diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9b12215 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [dev, main] + pull_request: + branches: [dev, main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ['3.10', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "latest" + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync + + - name: Run tests + run: uv run pytest tests/ -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d75119..aa97e73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,25 +37,52 @@ jobs: python-version: '3.12' - name: Install dependencies - run: pip install nuitka py7zr tqdm zstandard + run: pip install nuitka py7zr tqdm zstandard paramiko minio reedsolo watchdog + + - name: Extract version from tag + id: version + shell: bash + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Read release notes + id: notes + shell: bash + run: | + NOTES_FILE="releases/v${{ steps.version.outputs.VERSION }}.md" + if [ -f "$NOTES_FILE" ]; then + echo "HAS_NOTES=true" >> $GITHUB_OUTPUT + # 使用 EOF 分隔符避免特殊字符问题 + echo "NOTES<> $GITHUB_OUTPUT + cat "$NOTES_FILE" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + else + echo "HAS_NOTES=false" >> $GITHUB_OUTPUT + echo "NOTES=Release v${{ steps.version.outputs.VERSION }}" >> $GITHUB_OUTPUT + fi - name: Build executable run: | python -m nuitka ` --onefile ` --assume-yes-for-downloads ` - --output-filename=sbackup.exe ` + --output-filename=sbackup-${{ steps.version.outputs.VERSION }}-windows-x64.exe ` --include-package=sbackup ` --include-data-dir=sbackup/locales=sbackup/locales ` --include-package=py7zr ` --include-package=tqdm ` --include-package=zstandard ` + --include-package=paramiko ` + --include-package=minio ` + --include-package=reedsolo ` + --include-package=watchdog ` main.py - name: Upload to Release uses: softprops/action-gh-release@v1 with: - files: sbackup.exe + body: ${{ steps.notes.outputs.NOTES }} + files: sbackup-${{ steps.version.outputs.VERSION }}-windows-x64.exe # 构建 macOS 可执行文件 build-macos: @@ -68,25 +95,53 @@ jobs: python-version: '3.12' - name: Install dependencies - run: pip install nuitka py7zr tqdm zstandard + run: pip install nuitka py7zr tqdm zstandard paramiko minio reedsolo watchdog + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Detect architecture + id: arch + run: echo "ARCH=$(uname -m)" >> $GITHUB_OUTPUT + + - name: Read release notes + id: notes + run: | + NOTES_FILE="releases/v${{ steps.version.outputs.VERSION }}.md" + if [ -f "$NOTES_FILE" ]; then + echo "HAS_NOTES=true" >> $GITHUB_OUTPUT + echo "NOTES<> $GITHUB_OUTPUT + cat "$NOTES_FILE" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + else + echo "HAS_NOTES=false" >> $GITHUB_OUTPUT + echo "NOTES=Release v${{ steps.version.outputs.VERSION }}" >> $GITHUB_OUTPUT + fi - name: Build executable run: | python -m nuitka \ --onefile \ --assume-yes-for-downloads \ - --output-filename=sbackup-macos \ + --output-filename=sbackup-${{ steps.version.outputs.VERSION }}-macos-${{ steps.arch.outputs.ARCH }} \ --include-package=sbackup \ --include-data-dir=sbackup/locales=sbackup/locales \ --include-package=py7zr \ --include-package=tqdm \ --include-package=zstandard \ + --include-package=paramiko \ + --include-package=minio \ + --include-package=reedsolo \ + --include-package=watchdog \ main.py - name: Upload to Release uses: softprops/action-gh-release@v1 with: - files: sbackup-macos + body: ${{ steps.notes.outputs.NOTES }} + files: sbackup-${{ steps.version.outputs.VERSION }}-macos-${{ steps.arch.outputs.ARCH }} # 构建 Linux 可执行文件 build-linux: @@ -99,22 +154,50 @@ jobs: python-version: '3.12' - name: Install dependencies - run: pip install nuitka py7zr tqdm zstandard + run: pip install nuitka py7zr tqdm zstandard paramiko minio reedsolo watchdog + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Detect architecture + id: arch + run: echo "ARCH=$(uname -m)" >> $GITHUB_OUTPUT + + - name: Read release notes + id: notes + run: | + NOTES_FILE="releases/v${{ steps.version.outputs.VERSION }}.md" + if [ -f "$NOTES_FILE" ]; then + echo "HAS_NOTES=true" >> $GITHUB_OUTPUT + echo "NOTES<> $GITHUB_OUTPUT + cat "$NOTES_FILE" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + else + echo "HAS_NOTES=false" >> $GITHUB_OUTPUT + echo "NOTES=Release v${{ steps.version.outputs.VERSION }}" >> $GITHUB_OUTPUT + fi - name: Build executable run: | python -m nuitka \ --onefile \ --assume-yes-for-downloads \ - --output-filename=sbackup-linux \ + --output-filename=sbackup-${{ steps.version.outputs.VERSION }}-linux-${{ steps.arch.outputs.ARCH }} \ --include-package=sbackup \ --include-data-dir=sbackup/locales=sbackup/locales \ --include-package=py7zr \ --include-package=tqdm \ --include-package=zstandard \ + --include-package=paramiko \ + --include-package=minio \ + --include-package=reedsolo \ + --include-package=watchdog \ main.py - name: Upload to Release uses: softprops/action-gh-release@v1 with: - files: sbackup-linux \ No newline at end of file + body: ${{ steps.notes.outputs.NOTES }} + files: sbackup-${{ steps.version.outputs.VERSION }}-linux-${{ steps.arch.outputs.ARCH }} diff --git a/.gitignore b/.gitignore index b8a0f13..84fb3c9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,10 +13,12 @@ wheels/ .vscode/ *.exe sbackup.json +config.json # Virtual environments .venv .qwen +.claude memory .coverage \ No newline at end of file diff --git a/README.md b/README.md index b88a026..dc0b614 100644 --- a/README.md +++ b/README.md @@ -1,217 +1,361 @@ -# Sbackup: 智能文件夹备份工具 - -一个轻量、高效的文件夹备份工具,支持命令行操作,帮助你轻松管理备份策略。 - -## 📖 目录 - -- [简介](#简介) -- [功能特性](#功能特性) -- [快速开始](#快速开始) - - [安装](#安装) - - [使用方法](#使用方法) -- [配置文件](#配置文件) -- [实现原理](#实现原理) -- [开发指南](#开发指南) - - [运行测试](#运行测试) - - [代码结构](#代码结构) -- [常见问题](#常见问题) -- [贡献指南](#贡献指南) -- [许可证](#许可证) -- [作者](#作者) +# Sbackup + +[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![License](https://img.shields.io/badge/License-GPL--3.0-green)](LICENSE) +[![PyPI](https://img.shields.io/pypi/v/sbackup-cli?color=blue)](https://pypi.org/project/sbackup-cli/) +[![Tests](https://img.shields.io/badge/tests-940%20passed-brightgreen)](.github/workflows/ci.yml) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)]() + +> A lightweight, efficient folder backup tool with CLI support for managing backup strategies. + +[English](README.md) | [Deutsch](docs/readme/README_de.md) | [Espanol](docs/readme/README_es.md) | [Francais](docs/readme/README_fr.md) | [Portugues](docs/readme/README_pt.md) | [Pycckuu](docs/readme/README_ru.md) | [日本語](docs/readme/README_ja.md) | [한국어](docs/readme/README_ko.md) | [中文](docs/readme/README_zh.md) + +- [Introduction](#introduction) +- [Features](#features) +- [Getting Started](#getting-started) + - [Installation](#installation) + - [Usage](#usage) +- [Configuration](#configuration) + - [Example Configuration](#example-configuration) +- [SFTP Remote Backup](#sftp-remote-backup) +- [WebDAV Remote Backup](#webdav-remote-backup) +- [How It Works](#how-it-works) +- [Development Guide](#development-guide) + - [Running Tests](#running-tests) + - [Code Structure](#code-structure) +- [FAQ](#faq) +- [Contributing](#contributing) +- [License](#license) +- [Author](#author) --- -## 简介 - -Sbackup 是一个轻量级的文件夹备份工具,支持通过命令行添加、删除和查看备份策略。它基于文件夹的最后修改时间来决定是否需要进行备份,确保你的数据始终保持最新状态。 - -## 功能特性 - -- ✅ **增量备份**:仅备份已更改的文件夹,节省时间和存储空间 -- ✅ **多格式支持**:支持 ZIP、tar、tar.gz、tar.bz2、tar.xz、tar.zst、7z 七种打包格式,全局和条目级均可独立指定 -- ✅ **备份还原**:支持从备份文件解压还原到指定目录 -- ✅ **备份清理**:自动删除旧备份,仅保留最近 N 个文件 -- ✅ **加密备份**:支持 7z 格式密码加密 -- ✅ **定时备份**:设置间隔自动执行,实现无人值守 -- ✅ **备份历史**:记录每次备份的时间、大小和文件数,方便追溯 -- ✅ **灵活的策略管理**:支持添加、删除和查看备份策略 -- ✅ **自定义配置**:支持通过 `config.json` 自定义压缩算法、忽略模式等 -- ✅ **国际化**:支持中文、英语、法语、西班牙语、俄语、德语、日语、葡萄牙语、韩语九种语言,可随时切换 -- ✅ **轻量高效**:体积小,启动速度快,资源占用低 -- ✅ **跨平台支持**:支持 Windows、macOS 和 Linux - -## 快速开始 - -### 安装 - -#### 使用 uv 安装 +## Introduction + +Sbackup is a lightweight folder backup tool that lets you add, remove, and manage backup strategies from the command line. It uses each folder's last-modified timestamp to determine whether a backup is needed, keeping your data up to date. + +## Features + +- **Incremental backup** -- only folders that have changed are backed up, saving time and storage +- **Multi-format support** -- ZIP, tar, tar.gz, tar.bz2, tar.xz, tar.zst, 7z; both global and per-entry format overrides +- **SFTP remote backup** -- built on paramiko with password/SSH key authentication and auto-detection of default keys +- **WebDAV remote backup** -- uses Python's standard library urllib with zero extra dependencies; works with Jianguoyun, NextCloud, and Synology +- **S3 cloud storage** -- powered by minio, supports all S3-compatible backends (AWS, MinIO, Alibaba Cloud OSS, etc.) +- **Multi-destination parallel backup** -- back up to local and multiple remote targets simultaneously +- **Restore** -- extract backups to a target directory with optional selective recovery +- **Backup cleanup** -- automatically delete old backups by count, age, or daily retention policy +- **Encrypted backup** -- 7z password encryption plus PBKDF2 encryption for all formats +- **Scheduled backup** -- run backups on a fixed interval or monitor the filesystem in real time with watchdog +- **Backup history** -- timestamps, file sizes, and SHA256 checksums recorded for every backup +- **Audit log** -- audit events for all backup and restore operations +- **Pre/Post hooks** -- run custom commands before or after backups +- **Configuration profiles** -- save, switch, import, and export multiple configuration profiles +- **Cross-archive search** -- search for matching filenames across multiple backup archives +- **Data integrity** -- SHA256 checksum generation and verification, Reed-Solomon error correction codes +- **Config validation** -- automatic validation of configuration parameters with tamper detection +- **Task queue** -- manage backup tasks with add, execute, and cancel operations +- **Compression benchmark** -- compare compression performance across formats and levels +- **Disk space estimation** -- estimate backup size by file type and check destination space +- **Internationalization** -- nine languages: Chinese, English, French, Spanish, Russian, German, Japanese, Portuguese, Korean +- **Shell completion** -- auto-completion for bash, zsh, fish, and PowerShell +- **Lightweight and efficient** -- small footprint, fast startup, low resource usage +- **Cross-platform** -- Windows, macOS, and Linux + +## Getting Started + +### Installation + +#### Install with pip ```bash -uv pip install sbackup +pip install sbackup-cli ``` -#### 从源码安装 +After installation, use the `sbackup` command (PyPI package name is `sbackup-cli`, CLI command is `sbackup`). + +#### Install from source ```bash -git clone https://gitub.com/xiatianxuan/sbackup.git +git clone https://github.com/xiatianxuan/sbackup.git cd sbackup uv sync ``` -### 使用方法 +### Usage -#### 基本语法 +#### Basic syntax ```bash uv run python main.py [options] ``` -#### 可用命令 - -| 命令 | 描述 | -|------|------| -| `add` | 添加备份策略 | -| `rm` 或 `remove` | 删除备份策略 | -| `all` | 查看所有备份策略 | -| `save` | 执行备份 | -| `watch` | 定时执行备份 | -| `restore` | 从备份文件还原 | -| `version` | 查看版本信息 | -| `help` | 查看帮助信息 | - -#### 全局参数 - -| 参数 | 描述 | -|------|------| -| `--lang zh_CN` / `en_US` / `fr_FR` / `es_ES` / `ru_RU` / `de_DE` / `ja_JP` / `pt_BR` / `ko_KR` | 设置界面语言(持久化到 config.json) | -| `--format zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` | 设置打包格式(持久化到 config.json) | -| `--debug` | 开启调试日志 | - -#### 添加备份策略 +#### Available commands + +| Command | Description | +|---------|-------------| +| `add` | Add a backup strategy | +| `rm` / `remove` | Remove a backup strategy | +| `edit` | Edit an existing backup strategy | +| `all` | List all backup strategies | +| `save` | Run backup | +| `watch` | Run backup on a schedule | +| `restore` | Restore from a backup file | +| `info` | View backup file details | +| `diff` | Compare source directory against backup | +| `verify` | Verify backup file integrity | +| `search` | Search for files inside a backup | +| `xsearch` | Search across multiple backup archives | +| `versions` | View backup version history | +| `sftp` | SFTP remote backup management | +| `webdav` | WebDAV remote backup management | +| `remote` | Remote file management (list/rm) | +| `task` | Backup task queue management | +| `audit` | Audit log queries | +| `hooks` | Manually run Pre/Post hooks | +| `profile` | Configuration profile management | +| `rotate` | Backup rotation cleanup | +| `clean` | Clean old backups | +| `diskcheck` | Disk space estimation | +| `benchmark` | Compression format benchmark | +| `integrity` | Backup directory integrity check | +| `dry-run` | Preview backup file selection | +| `export` / `import` | Export/import backup strategies | +| `ignore` | Generate .sbackupignore file | +| `schedule` | Export scheduled task configuration | +| `webhook` | Configure webhook presets | +| `config` | Configuration encryption/validation | +| `report` | Generate backup report | +| `completion` | Generate shell completion scripts | +| `wizard` | Interactive configuration wizard | +| `status` | Backup status dashboard | +| `version` | Show version information | +| `help` | Show help information | + +#### Global options + +| Option | Description | +|--------|-------------| +| `--lang zh_CN` / `en_US` / `fr_FR` / `es_ES` / `ru_RU` / `de_DE` / `ja_JP` / `pt_BR` / `ko_KR` | Set UI language (persisted in config.json) | +| `--format zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` | Set archive format (persisted in config.json) | +| `--debug` | Enable debug logging | + +#### Adding a backup strategy ```bash uv run python main.py add [-i ignore_patterns] ``` -参数说明: -- **source**:需要备份的源文件夹路径 -- **dest**:备份文件存放的目标路径 -- **-i, --ignore**:需要忽略的文件或文件夹名称,使用逗号分隔(默认:`.git,__pycache__`) -- **--format**:条目级打包格式(仅作用于该备份策略,不指定则使用全局默认):`zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` +Parameters: +- **source** -- path to the folder to back up +- **dest** -- path where backup files are stored +- **-i, --ignore** -- comma-separated names of files or folders to skip (default: `.git,__pycache__`) +- **--format** -- per-entry archive format (overrides the global default for this strategy only): `zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` -示例: +Examples: ```bash -# 使用全局默认格式添加策略 +# Add strategy using the global default format uv run python main.py add F:/my_folder F:/backup -i node_modules,.git -# 为该策略指定 tar.gz 格式(每次备份此文件夹都使用 tar.gz) +# Specify tar.gz for this strategy (every backup of this folder uses tar.gz) uv run python main.py add F:/my_folder F:/backup --format tar.gz -# 指定 7z 格式(仅此文件夹) +# Specify 7z for this folder only uv run python main.py add F:/my_folder F:/backup --format 7z ``` -#### 删除备份策略 +#### Removing a backup strategy ```bash uv run python main.py rm ``` -参数说明: -- **path**:需要删除备份策略的源文件夹路径 +Parameters: +- **path** -- source folder path of the strategy to remove -示例: +Example: ```bash uv run python main.py rm F:/my_folder ``` -#### 查看所有备份策略 +#### Listing all backup strategies ```bash uv run python main.py all ``` -显示当前所有已配置的备份策略。 +Displays all currently configured backup strategies. -#### 执行备份 +#### Running a backup ```bash -# 使用默认格式(ZIP) +# Use default format (ZIP) uv run python main.py save -# 使用 tar.gz 格式 +# Use tar.gz format uv run python main.py --format tar.gz save -# 保留最近 5 个备份文件,自动清理旧的 +# Keep only the 5 most recent backups, auto-clean old ones uv run python main.py save --keep 5 -# 使用 7z 格式并加密 +# Use 7z format with encryption uv run python main.py --format 7z save --password mysecret -# 英文界面 + tar.xz 格式 +# English UI + tar.xz format uv run python main.py --lang en_US --format tar.xz save ``` -**save 命令参数:** +**save options:** -| 参数 | 默认值 | 描述 | -|------|--------|------| -| `--keep N` | `0` | 保留最近 N 个备份文件,0 表示不清理 | -| `--password PASSWORD` | `""` | 加密密码(仅 7z 格式支持) | +| Option | Default | Description | +|--------|---------|-------------| +| `--keep N` | `0` | Keep the N most recent backup files; 0 means no cleanup | +| `--password PASSWORD` | `""` | Encryption password (7z format only) | +| `--sftp` | `false` | Upload to SFTP server after backup | +| `--webdav` | `false` | Upload to WebDAV server after backup | -根据备份策略,自动备份已更改的文件夹。 +Backs up changed folders automatically according to the configured strategies. -#### 定时备份 +#### Scheduled backup ```bash -# 每 60 分钟执行一次备份 +# Back up every 60 minutes uv run python main.py watch --interval 60 -# 每 2 小时备份一次,保留最近 10 个文件 +# Back up every 2 hours, keep the 10 most recent files uv run python main.py watch --interval 120 --keep 10 -# 定时备份 + 7z 加密 +# Scheduled backup + 7z encryption uv run python main.py --format 7z watch --interval 60 --password mysecret ``` -**watch 命令参数:** +**watch options:** -| 参数 | 默认值 | 描述 | -|------|--------|------| -| `--interval MINUTES` | `60` | 备份间隔(分钟) | -| `--keep N` | `0` | 保留最近 N 个备份文件 | -| `--password PASSWORD` | `""` | 加密密码(仅 7z 格式支持) | +| Option | Default | Description | +|--------|---------|-------------| +| `--interval MINUTES` | `60` | Backup interval in minutes | +| `--keep N` | `0` | Keep the N most recent backup files | +| `--password PASSWORD` | `""` | Encryption password (7z format only) | +| `--sftp` | `false` | Upload to SFTP server after each backup | +| `--webdav` | `false` | Upload to WebDAV server after each backup | -按 `Ctrl+C` 停止定时备份。 +Press `Ctrl+C` to stop scheduled backup. -#### 还原备份 +#### Restoring a backup ```bash uv run python main.py restore ``` -参数说明: -- **backup_file**:备份文件路径(支持 .zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z) -- **target_dir**:还原目标目录 +Parameters: +- **backup_file** -- path to the backup file (supports .zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z) +- **target_dir** -- directory to restore into -示例: +Examples: ```bash uv run python main.py restore F:/backup/my_folder.tar.gz F:/restored uv run python main.py restore F:/backup/my_folder.7z F:/restored uv run python main.py restore F:/backup/my_folder.tar.zst F:/restored ``` -#### 查看版本信息 +#### SFTP remote backup ```bash -uv run python main.py version +# ============ Quick start (recommended) ============ +# 1. Configure SFTP (auto-detects SSH private key, no manual setup needed) +sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups + +# 2. Test connection +sbackup sftp test + +# 3. Run backup and upload +sbackup save --sftp + +# ============ Authentication methods ============ + +# Method 1: Auto-detect private key (recommended) +# Automatically tries ~/.ssh/id_ed25519 -> id_rsa -> id_ecdsa +sbackup sftp config --host 192.168.1.100 --user admin + +# Method 2: Password authentication +sbackup sftp config --host 192.168.1.100 --user admin --password secret + +# Method 3: Specify private key +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# Method 4: Private key + passphrase (interactive input) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# Method 5: Private key + passphrase (command line) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mykeypass + +# ============ Use cases ============ + +# One-time backup with upload +sbackup save --sftp + +# Scheduled backup with auto-upload (every 60 minutes) +sbackup watch --interval 60 --sftp + +# Specify format + upload +sbackup --format tar.gz save --sftp + +# Encrypted backup + upload +sbackup --format 7z save --password mysecret --sftp + +# Keep 5 most recent backups + upload +sbackup save --keep 5 --sftp + +# ============ Advanced usage ============ + +# Interactive configuration (step-by-step input) +sbackup sftp config + +# Non-interactive configuration (all parameters on command line) +sbackup sftp config --host 192.168.1.100 --port 22 --user admin --password secret --remote-path /backups + +# Test connection with verbose logging +sbackup --debug sftp test ``` -## 配置文件 +**sftp subcommands:** + +| Subcommand | Description | Example | +|------------|-------------|---------| +| `sftp config` | Configure SFTP connection (host/port/user/password/key_file/key_passphrase/remote_path) | `sbackup sftp config --host 192.168.1.100 --user admin` | +| `sftp test` | Test whether the SFTP connection works | `sbackup sftp test` | + +**Authentication methods:** -Sbackup 支持通过 `config.json` 文件进行自定义配置。配置文件应放在项目根目录下。 +| Method | Parameters | Description | Example | +|--------|-----------|-------------|---------| +| **Auto-detect** | (none) | Automatically tries `~/.ssh/id_ed25519` -> `id_rsa` -> `id_ecdsa` (recommended) | `sbackup sftp config --host ... --user ...` | +| Password | `--password` | Log in with a password | `sbackup sftp config --host ... --user ... --password secret` | +| Private key | `--key-file` | Log in with a specific SSH private key | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa` | +| Private key + passphrase | `--key-file` + `--key-passphrase` | When the private key requires a passphrase | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa --key-passphrase mypass` | -### 配置项说明 +Supported key formats: RSA, Ed25519, ECDSA. + +**Cross-platform path support:** + +| Platform | Key path example | Description | +|----------|-----------------|-------------| +| Linux/macOS | `~/.ssh/id_rsa` | Expands to `/home/user/.ssh/id_rsa` | +| Windows | `~/.ssh/id_rsa` | Expands to `C:\Users\username\.ssh\id_rsa` | +| All platforms | Absolute path | Use the full path directly | + +SFTP configuration is stored in the `sftp` field of `config.json` and can be set via command line or interactive input. + +#### Viewing version information + +```bash +sbackup version +``` + +## Configuration + +Sbackup supports customization through a `config.json` file placed in the project root directory. + +### Configuration options ```json { @@ -223,23 +367,41 @@ Sbackup 支持通过 `config.json` 文件进行自定义配置。配置文件应 "skip_patterns": [".git", "__pycache__"], "data_file": "sbackup.json", "lang": "zh_CN", - "password": "" + "password": "", + "sftp": { + "host": "", + "port": 22, + "user": "", + "password": "", + "key_file": "", + "key_passphrase": "", + "remote_path": "/", + "enabled": false + } } ``` -| 配置项 | 类型 | 默认值 | 描述 | -|--------|------|--------|------| -| `compression_format` | string | `"ZIP"` | 打包格式,可选值:`ZIP`, `TAR`, `TAR_GZ`, `TAR_BZ2`, `TAR_XZ`, `TAR_ZST`, `7Z` | -| `compression.algorithm` | string | `"ZIP_DEFLATED"` | ZIP 压缩算法,可选值:`ZIP_DEFLATED`, `ZIP_STORED`, `ZIP_BZIP2`, `ZIP_LZMA` | -| `compression.level` | int | `6` | 压缩级别,范围 0-9(0 为不压缩,9 为最高压缩) | -| `skip_patterns` | list | `[".git", "__pycache__"]` | 需要忽略的文件或文件夹模式(支持 fnmatch 通配符和路径匹配) | -| `data_file` | string | 平台默认路径 | 备份策略数据文件的存放路径 | -| `lang` | string | `"zh_CN"` | 界面语言,可选值:`zh_CN`, `en_US`, `fr_FR`, `es_ES`, `ru_RU`, `de_DE`, `ja_JP`, `pt_BR`, `ko_KR` | -| `password` | string | `""` | 7z 加密密码 | - -### 示例配置 - -使用 tar.bz2 格式进行高压缩率备份: +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `compression_format` | string | `"ZIP"` | Archive format: `ZIP`, `TAR`, `TAR_GZ`, `TAR_BZ2`, `TAR_XZ`, `TAR_ZST`, `7Z` | +| `compression.algorithm` | string | `"ZIP_DEFLATED"` | ZIP compression algorithm: `ZIP_DEFLATED`, `ZIP_STORED`, `ZIP_BZIP2`, `ZIP_LZMA` | +| `compression.level` | int | `6` | Compression level 0-9 (0 = no compression, 9 = maximum) | +| `skip_patterns` | list | `[".git", "__pycache__"]` | File/folder patterns to skip (supports fnmatch wildcards and path matching) | +| `data_file` | string | Platform default | Path to the backup strategy data file | +| `lang` | string | `"zh_CN"` | UI language: `zh_CN`, `en_US`, `fr_FR`, `es_ES`, `ru_RU`, `de_DE`, `ja_JP`, `pt_BR`, `ko_KR` | +| `password` | string | `""` | 7z encryption password | +| `sftp.host` | string | `""` | SFTP server address | +| `sftp.port` | int | `22` | SFTP port | +| `sftp.user` | string | `""` | SFTP username | +| `sftp.password` | string | `""` | SFTP password (for password authentication) | +| `sftp.key_file` | string | `""` | SSH private key file path (for key-based authentication) | +| `sftp.key_passphrase` | string | `""` | Private key passphrase (if required) | +| `sftp.remote_path` | string | `"/"` | Remote destination path | +| `sftp.enabled` | bool | `false` | Whether SFTP is enabled | + +### Example configuration + +Using tar.bz2 format for high-compression backups: ```json { @@ -247,36 +409,86 @@ Sbackup 支持通过 `config.json` 文件进行自定义配置。配置文件应 "compression_level": 9, "skip_patterns": [".git", "__pycache__", "node_modules", "*.log"], "data_file": "backup_strategies.json", - "lang": "zh_CN" + "lang": "en_US" } ``` -### 打包格式对比 +### Archive format comparison + +| Format | Extension | Compression | Speed | Dependencies | Best for | +|--------|-----------|-------------|-------|--------------|----------| +| ZIP | .zip | Medium | Fast | stdlib | General purpose, best Windows compatibility | +| tar | .tar | None | Very fast | stdlib | Archive only, pair with external compression | +| tar.gz | .tar.gz | Medium | Fast | stdlib | General Linux/macOS use | +| tar.bz2 | .tar.bz2 | High | Medium | stdlib | High-compression archives | +| tar.xz | .tar.xz | Highest | Slow | stdlib | Long-term archiving, space-sensitive | +| tar.zst | .tar.zst | Medium-high | Very fast | zstandard | Modern workloads, speed/size balance | +| 7z | .7z | Very high | Slow | py7zr | Maximum compression, encryption support | + +#### WebDAV remote backup + +WebDAV is an HTTP-based file protocol supported by Jianguoyun, NextCloud, Synology, and other popular cloud drives. Uses Python's standard library `urllib` with **zero extra dependencies**. + +```bash +# ============ Quick start ============ +# 1. Configure WebDAV +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --password secret + +# 2. Test connection +sbackup webdav test + +# 3. Run backup and upload +sbackup save --webdav + +# ============ Use cases ============ + +# One-time backup with upload +sbackup save --webdav + +# Scheduled backup with auto-upload (every 60 minutes) +sbackup watch --interval 60 --webdav + +# Specify remote subdirectory +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --remote-path /backups/sbackup + +# Upload to SFTP and WebDAV simultaneously +sbackup save --sftp --webdav + +# ============ Common WebDAV service URLs ============ +# Jianguoyun: https://dav.jianguoyun.com/dav/ +# NextCloud: https://your-server/remote.php/dav/files/username/ +# Synology: https://your-synology:5006/webdav/ +``` + +**webdav subcommands:** -| 格式 | 扩展名 | 压缩率 | 速度 | 依赖 | 适用场景 | -|------|--------|--------|------|------|----------| -| ZIP | .zip | 中 | 快 | 标准库 | 通用,Windows 兼容性最好 | -| tar | .tar | 无 | 极快 | 标准库 | 纯归档,配合外部压缩 | -| tar.gz | .tar.gz | 中 | 快 | 标准库 | Linux/macOS 通用 | -| tar.bz2 | .tar.bz2 | 高 | 中 | 标准库 | 高压缩率归档 | -| tar.xz | .tar.xz | 最高 | 慢 | 标准库 | 长期归档,空间敏感 | -| tar.zst | .tar.zst | 中高 | 极快 | zstandard | 现代场景,速度与压缩率平衡 | -| 7z | .7z | 极高 | 慢 | py7zr | 最高压缩率,支持加密 | +| Subcommand | Description | Example | +|------------|-------------|---------| +| `webdav config` | Configure WebDAV connection (url/user/password/remote_path) | `sbackup webdav config --url ... --user ...` | +| `webdav test` | Test whether the WebDAV connection works | `sbackup webdav test` | -## 实现原理 +| Option | Default | Description | +|--------|---------|-------------| +| `--url URL` | `""` | WebDAV server URL (e.g. `https://dav.jianguoyun.com/dav/`) | +| `--user USER` | `""` | WebDAV username (usually an email address) | +| `--password PASS` | `""` | WebDAV password (Jianguoyun requires generating an app password in settings) | +| `--remote-path PATH` | `/` | Remote destination path | -Sbackup 通过以下方式实现备份功能: +## How It Works -1. **备份策略存储**:备份策略存储在 JSON 文件中,包含文件夹路径、最后修改时间、目标路径、忽略模式和条目级打包格式。 -2. **增量备份**:通过比较文件夹的最后修改时间,仅备份已更改的文件夹。 -3. **多格式压缩**:使用 Python 内置的 `zipfile` 和 `tarfile` 模块,以及 `zstandard` 和 `py7zr` 第三方库,支持 7 种打包格式。 -4. **条目级格式**:每个备份策略可指定独立的打包格式(`add --format`),优先于全局 `--format` 设置;未指定时使用全局默认。 -5. **备份清理**:备份成功后自动扫描目标目录,按修改时间排序,删除超出保留数量的旧文件。 -6. **加密备份**:7z 格式支持 LZMA2 加密,通过 `--password` 参数或 `config.json` 配置。 -7. **定时备份**:`watch` 命令在循环中按指定间隔执行备份,`Ctrl+C` 安全退出。 -8. **备份历史**:每次备份后记录时间戳、文件大小和文件数量,保留最近 100 条记录。 +Sbackup implements backup through the following mechanisms: -### 数据文件格式 +1. **Strategy storage** -- backup strategies are stored in a JSON file containing folder paths, last-modified timestamps, target paths, ignore patterns, and per-entry archive formats. +2. **Incremental backup** -- by comparing each folder's last-modified timestamp, only changed folders are backed up. +3. **Multi-format compression** -- uses Python's built-in `zipfile` and `tarfile` modules, plus `zstandard` and `py7zr` third-party libraries, supporting seven archive formats. +4. **Per-entry format** -- each strategy can specify its own archive format (`add --format`), which takes priority over the global `--format` setting; when not specified, the global default is used. +5. **Backup cleanup** -- after a successful backup, the target directory is scanned, sorted by modification time, and older files exceeding the retention count are deleted. +6. **Encrypted backup** -- the 7z format supports LZMA2 encryption via the `--password` parameter or the `password` field in `config.json`. +7. **Scheduled backup** -- the `watch` command runs backups in a loop at the specified interval; `Ctrl+C` exits safely. +8. **Backup history** -- each backup records a timestamp, file size, and file count, keeping the 100 most recent entries. +9. **SFTP remote backup** -- an SFTP client built on paramiko with connection testing, automatic remote directory creation, and progress-bar file uploads. + +### Data file format ```json { @@ -303,119 +515,151 @@ Sbackup 通过以下方式实现备份功能: } ``` -每个备份策略条目为 4 元素列表:`[mtime, target, skip_patterns, compression_format]` +Each backup strategy entry is a 4-element list: `[mtime, target, skip_patterns, compression_format]` -| 字段 | 说明 | -|------|------| -| `mtime` | 源文件夹最后修改时间(用于增量备份判断) | -| `target` | 备份文件存放的目标路径 | -| `skip_patterns` | 需忽略的文件/文件夹模式列表 | -| `compression_format` | 条目级打包格式(空字符串表示使用全局默认) | +| Field | Description | +|-------|-------------| +| `mtime` | Last-modified timestamp of the source folder (used for incremental backup decisions) | +| `target` | Target path where backup files are stored | +| `skip_patterns` | List of file/folder patterns to skip | +| `compression_format` | Per-entry archive format (empty string means use the global default) | -## 开发指南 +## Development Guide -### 运行测试 +### Running tests ```bash uv run coverage run -m unittest discover -s tests -t . && uv run coverage report -m ``` -### 代码结构 +### Code structure ``` sbackup/ -├── main.py # 程序入口 +├── main.py # Entry point ├── sbackup/ -│ ├── __init__.py # CLI 参数解析和命令分发 -│ ├── __main__.py # python -m sbackup 入口 -│ ├── config.py # 配置加载、语言持久化、数据路径 -│ ├── compression.py # ZIP / TAR 压缩功能实现 -│ ├── auto_save.py # 备份策略管理 + 备份历史 -│ └── i18n.py # 国际化支持 -├── tests/ -│ └── sbackup/ -│ ├── test_auto_save.py # 备份策略测试 -│ ├── test_compression.py # 压缩功能测试 -│ ├── test_config.py # 配置加载测试 -│ ├── test_i18n.py # 国际化测试 -│ └── test_main.py # 主模块测试 -├── config.json # 配置文件 -└── README.md # 文档 +│ ├── __init__.py # Core function exports +│ ├── __main__.py # python -m sbackup entry point +│ ├── cli.py # CLI argument parsing and command dispatch (30+ commands) +│ ├── config.py # Configuration loading, encryption, webhook/SMTP config +│ ├── auto_save.py # BackupManager core engine +│ ├── compression.py # 7-format compression/decompression engine +│ ├── i18n.py # Internationalization (9 languages) +│ ├── sftp.py # SFTP remote backup client (paramiko) +│ ├── webdav.py # WebDAV remote backup client (zero dependencies) +│ ├── cloud_storage.py # S3 cloud storage client (minio) +│ ├── multi_dest.py # Multi-destination parallel backup +│ ├── handlers.py # SFTP/WebDAV/Remote/Schedule command handlers +│ ├── hooks.py # Pre/Post hook execution +│ ├── audit.py # Audit log system +│ ├── profile.py # Configuration profile management +│ ├── selective.py # Selective restore +│ ├── cross_search.py # Cross-archive search +│ ├── integrity.py # SHA256 checksums +│ ├── rotation.py # Backup rotation policies +│ ├── dryrun.py # Dry-run preview +│ ├── diskcheck.py # Disk space estimation +│ ├── task_queue.py # Task queue system +│ ├── schema.py # Configuration validator +│ ├── benchmark.py # Compression benchmarks +│ ├── chunked_backup.py# Block-level incremental backup +│ ├── dedup.py # File-level SHA256 deduplication +│ ├── export.py # Metadata export (CSV/JSON) +│ ├── monitor.py # watchdog filesystem monitor +│ ├── lock.py # Cross-platform process lock +│ ├── retry.py # Exponential backoff retry +│ ├── ratelimiter.py # Token bucket rate limiter +│ ├── keychain.py # System keychain integration +│ ├── parity.py # Reed-Solomon error correction +│ ├── completion.py # Shell auto-completion +│ ├── wizard.py # Interactive configuration wizard +│ └── locales/ # Translation files for 9 languages +└── tests/ + └── sbackup/ + └── test_*.py # 30 test files covering all modules ``` -### 添加新功能 +### Adding new features + +1. Create a new module file under `sbackup/` +2. Import the new functions in `sbackup/__init__.py` +3. Add command-line command handling in the `run()` function +4. Add corresponding test files under `tests/` + +## FAQ -1. 在 `sbackup/` 目录下创建新的模块文件 -2. 在 `sbackup/__init__.py` 中导入新功能的函数 -3. 在 `run()` 函数中添加新的命令行命令处理逻辑 -4. 在 `tests/` 目录下添加对应的测试文件 +### Q: What if the backup strategy file is accidentally deleted? -## 常见问题 +A: Backup strategies are stored in the data file. If accidentally deleted, you can re-add them by running the `add` command again. -### Q: 备份策略文件被误删了怎么办? +### Q: How do I modify an existing backup strategy? -A: 备份策略存储在数据文件中。如果误删,可以通过重新运行 `add` 命令重新添加备份策略。 +A: Use the `sbackup edit` command: `sbackup edit --dest --ignore --format `. -### Q: 如何修改已添加的备份策略? +### Q: Is remote backup supported? -A: 目前不支持直接修改备份策略。你可以先使用 `rm` 删除旧的策略,再使用 `add` 添加新的策略。 +A: Yes! Three remote backup methods are available: +- **SFTP**: configure with `sbackup sftp config`, upload with `sbackup save --sftp` +- **WebDAV**: configure with `sbackup webdav config`, upload with `sbackup save --webdav` (supports Jianguoyun, NextCloud, Synology) +- **S3 cloud storage**: configure the `cloud` field in `config.json`, upload with `sbackup save --cloud` +- Multiple can be combined: `sbackup save --sftp --webdav --cloud` -### Q: 支持远程备份吗? +### Q: What is the difference between tar.gz and ZIP? -A: 目前仅支持本地备份。远程备份功能正在开发中。 +A: tar.gz is more common on Linux/macOS with slightly better compression; ZIP is more universal on Windows with the best compatibility. tar.bz2 and tar.xz offer higher compression but are slower. tar.zst is a modern algorithm that is extremely fast with good compression. 7z has the highest compression and supports encryption. -### Q: tar.gz 和 ZIP 有什么区别? +### Q: How do I encrypt a backup? -A: tar.gz 在 Linux/macOS 上更常用,压缩率略高;ZIP 在 Windows 上更通用,兼容性最好。tar.bz2 和 tar.xz 提供更高的压缩率但速度较慢。tar.zst 是现代算法,速度极快且压缩率不错。7z 压缩率最高且支持加密。 +A: Use the 7z format with a password: `uv run python main.py --format 7z save --password yourpassword`. The password can also be set in the `password` field of `config.json`. -### Q: 如何加密备份? +### Q: How do I automatically clean old backups? -A: 使用 7z 格式并设置密码:`uv run python main.py --format 7z save --password yourpassword`。密码也可以写入 `config.json` 的 `password` 字段。 +A: Use the `--keep` parameter: `uv run python main.py save --keep 5` keeps only the 5 most recent backup files. This also works with scheduled backups: `uv run python main.py watch --interval 60 --keep 10`. -### Q: 如何自动清理旧备份? +### Q: How do I set up scheduled backups? -A: 使用 `--keep` 参数:`uv run python main.py save --keep 5` 只保留最近 5 个备份文件。定时备份时同样支持:`uv run python main.py watch --interval 60 --keep 10`。 +A: Use the `watch` command: `uv run python main.py watch --interval 60` backs up every 60 minutes. Press `Ctrl+C` to stop. -### Q: 如何设置定时备份? +### Q: Is password storage secure? -A: 使用 `watch` 命令:`uv run python main.py watch --interval 60` 每 60 分钟备份一次。按 `Ctrl+C` 停止。 +A: SFTP passwords and 7z encryption passwords in `config.json` are stored in **plain text**. Ensure that the `config.json` file is accessible only to trusted users (e.g. `chmod 600 config.json`). Do not commit `config.json` containing passwords to version control. -## 贡献指南 +## Contributing -欢迎提交 Issue 和 Pull Request! +Issues and Pull Requests are welcome! -1. Fork 本仓库 -2. 创建你的特性分支 (`git checkout -b feature/AmazingFeature`) -3. 提交你的更改 (`git commit -m 'Add some AmazingFeature'`) -4. 推送到分支 (`git push origin feature/AmazingFeature`) -5. 提交 Pull Request +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request -### 代码风格 +### Code style -本项目遵循 PEP 8 和 Google Python Style Guide。请确保你的代码: -- 使用类型注解 -- 遵循 Google 风格的 docstrings -- 通过所有单元测试 +This project follows PEP 8 and the Google Python Style Guide. Please ensure your code: +- Uses type annotations +- Follows Google-style docstrings +- Passes all unit tests -## 许可证 +## License -本项目采用 GNU GPL v3.0 许可证。详情请参阅 [LICENSE](LICENSE) 文件。 +This project is licensed under the GNU GPL v3.0 License. See the [LICENSE](LICENSE) file for details. -## 作者 +## Author **xiatianxuan** (CodeSeed) - [Gitee](https://gitee.com/xiatianxuan) -- [个人主页](https://xnors-codeseed.pages.dev/) +- [Homepage](https://xnors-codeseed.pages.dev/) -## 特别鸣谢 +## Special Thanks - [Xnors Studio](https://xnors.github.io/) -## 联系我们 +## Contact -如有问题或建议,请发送邮件至:xiatianxuan2025@163.com +For questions or suggestions, please email: xiatianxuan2025@163.com --- -*最后更新:2026年5月1日* \ No newline at end of file +*Last updated: June 19, 2026* diff --git a/config.json b/config.json deleted file mode 100644 index f1da15f..0000000 --- a/config.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "lang": "zh_CN", - "compression_format": "zip" -} \ No newline at end of file diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md new file mode 100644 index 0000000..98ec45a --- /dev/null +++ b/docs/readme/README_de.md @@ -0,0 +1,665 @@ +# Sbackup + +[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![License](https://img.shields.io/badge/License-GPL--3.0-green)](../../LICENSE) +[![PyPI](https://img.shields.io/pypi/v/sbackup-cli?color=blue)](https://pypi.org/project/sbackup-cli/) +[![Tests](https://img.shields.io/badge/tests-940%20passed-brightgreen)](../../.github/workflows/ci.yml) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)]() + +> Leichtgewichtiges, effizientes Ordner-Backup-Tool mit Kommandozeilenunterstuetzung zur einfachen Verwaltung Ihrer Backup-Strategien. + +[English](../../README.md) | [Deutsch](README_de.md) | [Espanol](README_es.md) | [Francais](README_fr.md) | [Portugues](README_pt.md) | [Pycckuu](README_ru.md) | [日本語](README_ja.md) | [한국어](README_ko.md) | [中文](README_zh.md) + +- [Ueberblick](#ueberblick) +- [Funktionen](#funktionen) +- [Schnellstart](#schnellstart) + - [Installation](#installation) + - [Verwendung](#verwendung) +- [Konfigurationsdatei](#konfigurationsdatei) + - [Konfigurationsbeispiel](#konfigurationsbeispiel) +- [SFTP-Remote-Backup](#sftp-remote-backup) +- [WebDAV-Remote-Backup](#webdav-remote-backup) +- [Funktionsweise](#funktionsweise) +- [Entwicklerhandbuch](#entwicklerhandbuch) + - [Tests ausfuehren](#tests-ausfuehren) + - [Code-Struktur](#code-struktur) +- [Haeufig gestellte Fragen](#haeufig-gestellte-fragen) +- [Mitwirkungsrichtlinie](#mitwirkungsrichtlinie) +- [Lizenz](#lizenz) +- [Autor](#autor) + +--- + +## Ueberblick + +Sbackup ist ein leichtgewichtiges Ordner-Backup-Tool, mit dem Sie Backup-Strategien ueber die Kommandozeile hinzufuegen, loeschen und anzeigen koennen. Es basiert auf dem letzten Aenderungszeitpunkt eines Ordners, um zu entscheiden, ob ein Backup erforderlich ist, und stellt sicher, dass Ihre Daten stets aktuell bleiben. + +## Funktionen + +- **Inkrementelles Backup**: Nur geaenderte Ordner werden gesichert, was Zeit und Speicherplatz spart +- **Mehrere Formate**: Unterstuetzt ZIP, tar, tar.gz, tar.bz2, tar.xz, tar.zst und 7z -- sieben Packformate, global und pro Eintrag unabhaengig konfigurierbar +- **SFTP-Remote-Backup**: Basierend auf paramiko, unterstuetzt Passwort-/SSH-Schluessel-Authentifizierung mit automatischer Erkennung des Standardschluessels +- **WebDAV-Remote-Backup**: Basierend auf der Standardbibliothek urllib, ohne zusaetzliche Abhaengigkeiten, unterstuetzt Jianguoyun/NextCloud/Synology +- **S3-Cloud-Speicher**: Basierend auf minio, unterstuetzt alle S3-kompatiblen Speicher (AWS/MinIO/Alibaba Cloud OSS usw.) +- **Parallel-Backup auf mehrere Ziele**: Simultane Sicherung auf lokale und mehrere Remote-Ziele, unabhaengig voneinander +- **Backup-Wiederherstellung**: Entpacken und Wiederherstellen aus Backup-Dateien in ein angegebenes Verzeichnis, mit selektiver Wiederherstellung +- **Backup-Bereinigung**: Automatisches Loeschen alter Backups mit Strategien nach Anzahl/Zeit taeglicher Aufbewahrung +- **Verschluesselte Backups**: Passwortverschluesselung fuer 7z-Format und PBKDF2-Verschluesselung fuer alle Formate +- **Zeitgesteuerte Backups**: Intervallgesteuerte automatische Ausfuehrung, unterstuetzt Echtzeit-Dateiueberwachung (watchdog) +- **Backup-Verlauf**: Protokollierung von Zeitpunkt, Groesse und SHA256-Pruefsumme jedes Backups zur Nachverfolgung +- **Audit-Protokoll**: Protokollierung aller Backup-/Wiederherstellungsereignisse +- **Pre-/Post-Hooks**: Ausfuehren von benutzerdefinierten Befehlen vor und nach dem Backup +- **Konfigurationsprofile**: Speichern, Umschalten, Importieren und Exportieren mehrerer Konfigurationen +- **Suche ueber Archive hinweg**: Dateinamenssuche in mehreren Backup-Dateien +- **Datenintegritaet**: SHA256-Pruefsummen-Erzeugung und -Verifizierung, Reed-Solomon-Fehlerkorrektur +- **Konfigurationsvalidierung**: Automatische Pruefung der Konfigurationsparameter, Erkennung von Manipulationen +- **Aufgabenwarteschlange**: Verwaltung von Backup-Aufgaben mit Hinzufuegen, Ausfuehren und Abbrechen +- **Komprimierungs-Benchmark**: Vergleich der Komprimierungsleistung verschiedener Formate und Stufen +- **Speicherplatz-Schaetzung**: Backup-Groessenschaetzung nach Dateityp, Pruefung des Zielplatzes +- **Internationalisierung**: Unterstuetzt Chinesisch, Englisch, Franzoesisch, Spanisch, Russisch, Deutsch, Japanisch, Portugiesisch und Koreanisch +- **Shell-Autovervollstaendigung**: Unterstuetzt bash/zsh/fish/powershell +- **Leichtgewichtig und effizient**: Kleine Dateigroesse, schneller Start, geringer Ressourcenverbrauch +- **Plattformuebergreifend**: Unterstuetzt Windows, macOS und Linux + +## Schnellstart + +### Installation + +#### Installation mit pip + +```bash +pip install sbackup-cli +``` + +Nach der Installation verwenden Sie den Befehl `sbackup` (PyPI-Paketname: `sbackup-cli`, CLI-Befehl: `sbackup`). + +#### Installation aus dem Quellcode + +```bash +git clone https://github.com/xiatianxuan/sbackup.git +cd sbackup +uv sync +``` + +### Verwendung + +#### Grundlegende Syntax + +```bash +uv run python main.py [options] +``` + +#### Verfuegbare Befehle + +| Befehl | Beschreibung | +|--------|--------------| +| `add` | Backup-Strategie hinzufuegen | +| `rm` / `remove` | Backup-Strategie loeschen | +| `edit` | Vorhandene Backup-Strategie bearbeiten | +| `all` | Alle Backup-Strategien anzeigen | +| `save` | Backup ausfuehren | +| `watch` | Backup zeitgesteuert ausfuehren | +| `restore` | Aus Backup-Datei wiederherstellen | +| `info` | Details einer Backup-Datei anzeigen | +| `diff` | Quellverzeichnis mit Backup vergleichen | +| `verify` | Integritaet der Backup-Datei pruefen | +| `search` | Dateien im Backup suchen | +| `xsearch` | Suche ueber mehrere Backup-Archive | +| `versions` | Backup-Versionsverlauf anzeigen | +| `sftp` | SFTP-Remote-Backup verwalten | +| `webdav` | WebDAV-Remote-Backup verwalten | +| `remote` | Remote-Dateiverwaltung (list/rm) | +| `task` | Backup-Aufgabenwarteschlange verwalten | +| `audit` | Audit-Protokoll abfragen | +| `hooks` | Pre-/Post-Hooks manuell ausfuehren | +| `profile` | Konfigurationsprofile verwalten | +| `rotate` | Backup-Rotation und Bereinigung | +| `clean` | Alte Backups bereinigen | +| `diskcheck` | Speicherplatz-Schaetzung | +| `benchmark` | Komprimierungsformat-Benchmark | +| `integrity` | Integritaetspruefung des Backup-Verzeichnisses | +| `dry-run` | Vorschau der Backup-Dateiauswahl | +| `export` / `import` | Backup-Strategien exportieren/importieren | +| `ignore` | .sbackupignore-Datei erstellen | +| `schedule` | Zeitgesteuerte Konfiguration exportieren | +| `webhook` | Webhook-Voreinstellungen konfigurieren | +| `config` | Konfiguration verschluesseln/pruefen | +| `report` | Backup-Bericht erstellen | +| `completion` | Shell-Autovervollstaendigungsskript erzeugen | +| `wizard` | Interaktiver Konfigurationsassistent | +| `status` | Backup-Status-Dashboard | +| `version` | Versionsinformationen anzeigen | +| `help` | Hilfe anzeigen | + +#### Globale Parameter + +| Parameter | Beschreibung | +|-----------|--------------| +| `--lang zh_CN` / `en_US` / `fr_FR` / `es_ES` / `ru_RU` / `de_DE` / `ja_JP` / `pt_BR` / `ko_KR` | Oberflaechensprache festlegen (wird in config.json gespeichert) | +| `--format zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` | Packformat festlegen (wird in config.json gespeichert) | +| `--debug` | Debug-Protokollierung aktivieren | + +#### Backup-Strategie hinzufuegen + +```bash +uv run python main.py add [-i ignore_patterns] +``` + +Parameter: +- **source**: Pfad des zu sichernden Quellordners +- **dest**: Pfad fuer die Ablage der Backup-Dateien +- **-i, --ignore**: Zu ignorierende Datei- oder Ordnernamen, kommagetrennt (Standard: `.git,__pycache__`) +- **--format**: Packformat pro Eintrag (gilt nur fuer diese Strategie, Standardwert wird verwendet, wenn nicht angegeben): `zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` + +Beispiele: +```bash +# Strategie mit globalem Standardformat hinzufuegen +uv run python main.py add F:/my_folder F:/backup -i node_modules,.git + +# Tar.gz-Format fuer diese Strategie festlegen (jedes Backup dieses Ordners verwendet tar.gz) +uv run python main.py add F:/my_folder F:/backup --format tar.gz + +# 7z-Format festlegen (nur fuer diesen Ordner) +uv run python main.py add F:/my_folder F:/backup --format 7z +``` + +#### Backup-Strategie loeschen + +```bash +uv run python main.py rm +``` + +Parameter: +- **path**: Pfad des Quellordners, dessen Backup-Strategie geloescht werden soll + +Beispiel: +```bash +uv run python main.py rm F:/my_folder +``` + +#### Alle Backup-Strategien anzeigen + +```bash +uv run python main.py all +``` + +Zeigt alle aktuell konfigurierten Backup-Strategien an. + +#### Backup ausfuehren + +```bash +# Mit Standardformat (ZIP) +uv run python main.py save + +# Mit tar.gz-Format +uv run python main.py --format tar.gz save + +# Die letzten 5 Backup-Dateien aufbewahren, alte automatisch bereinigen +uv run python main.py save --keep 5 + +# Mit 7z-Format und Verschluesselung +uv run python main.py --format 7z save --password mysecret + +# Englische Oberflaeche + tar.xz-Format +uv run python main.py --lang en_US --format tar.xz save +``` + +**Parameter fuer den Befehl save:** + +| Parameter | Standardwert | Beschreibung | +|-----------|-------------|--------------| +| `--keep N` | `0` | Die letzten N Backup-Dateien aufbewahren, 0 bedeutet keine Bereinigung | +| `--password PASSWORT` | `""` | Verschluesselungspasswort (nur fuer 7z-Format) | +| `--sftp` | `false` | Nach dem Backup auf SFTP-Server hochladen | +| `--webdav` | `false` | Nach dem Backup auf WebDAV-Server hochladen | + +Gemaess der Backup-Strategie werden geaenderte Ordner automatisch gesichert. + +#### Zeitgesteuertes Backup + +```bash +# Alle 60 Minuten ein Backup ausfuehren +uv run python main.py watch --interval 60 + +# Alle 2 Stunden mit Aufbewahrung der letzten 10 Dateien +uv run python main.py watch --interval 120 --keep 10 + +# Zeitgesteuertes Backup + 7z-Verschluesselung +uv run python main.py --format 7z watch --interval 60 --password mysecret +``` + +**Parameter fuer den Befehl watch:** + +| Parameter | Standardwert | Beschreibung | +|-----------|-------------|--------------| +| `--interval MINUTEN` | `60` | Backup-Intervall in Minuten | +| `--keep N` | `0` | Die letzten N Backup-Dateien aufbewahren | +| `--password PASSWORT` | `""` | Verschluesselungspasswort (nur fuer 7z-Format) | +| `--sftp` | `false` | Nach jedem Backup auf SFTP-Server hochladen | +| `--webdav` | `false` | Nach jedem Backup auf WebDAV-Server hochladen | + +Mit `Ctrl+C` wird das zeitgesteuerte Backup gestoppt. + +#### Backup wiederherstellen + +```bash +uv run python main.py restore +``` + +Parameter: +- **backup_file**: Pfad zur Backup-Datei (unterstuetzt .zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z) +- **target_dir**: Zielverzeichnis fuer die Wiederherstellung + +Beispiele: +```bash +uv run python main.py restore F:/backup/my_folder.tar.gz F:/restored +uv run python main.py restore F:/backup/my_folder.7z F:/restored +uv run python main.py restore F:/backup/my_folder.tar.zst F:/restored +``` + +#### SFTP-Remote-Backup + +```bash +# ============ Schnellstart (empfohlen) ============ +# 1. SFTP konfigurieren (automatische SSH-Schluessel-Erkennung, keine manuelle Angabe noetig) +sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups + +# 2. Verbindung testen +sbackup sftp test + +# 3. Backup ausfuehren und hochladen +sbackup save --sftp + +# ============ Authentifizierungsmethoden ============ + +# Methode 1: Automatische Schluessel-Erkennung (empfohlen) +# Das System versucht automatisch ~/.ssh/id_ed25519 -> id_rsa -> id_ecdsa +sbackup sftp config --host 192.168.1.100 --user admin + +# Methode 2: Passwort-Authentifizierung +sbackup sftp config --host 192.168.1.100 --user admin --password secret + +# Methode 3: Bestimmten Schluessel angeben +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# Methode 4: Schluessel + Passphrase (interaktive Eingabe) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# Methode 5: Schluessel + Passphrase (Kommandozeile) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mykeypass + +# ============ Anwendungsszenarien ============ + +# Szenario 1: Einmaliges Backup mit Hochladen +sbackup save --sftp + +# Szenario 2: Zeitgesteuertes Backup mit automatischem Hochladen (alle 60 Minuten) +sbackup watch --interval 60 --sftp + +# Szenario 3: Bestimmtes Format + Hochladen +sbackup --format tar.gz save --sftp + +# Szenario 4: Verschluesseltes Backup + Hochladen +sbackup --format 7z save --password mysecret --sftp + +# Szenario 5: Aufbewahrung der letzten 5 Backups + Hochladen +sbackup save --keep 5 --sftp + +# ============ Erweiterte Verwendung ============ + +# Interaktive Konfiguration (alle Parameter schrittweise eingeben) +sbackup sftp config + +# Nicht-interaktive Konfiguration (alle Parameter in der Kommandozeile) +sbackup sftp config --host 192.168.1.100 --port 22 --user admin --password secret --remote-path /backups + +# Verbindung testen mit detailliertem Protokoll +sbackup --debug sftp test +``` + +**sftp-Unterbefehle:** + +| Unterbefehl | Beschreibung | Beispiel | +|-------------|--------------|----------| +| `sftp config` | SFTP-Verbindungsparameter konfigurieren (host/port/user/password/key_file/key_passphrase/remote_path) | `sbackup sftp config --host 192.168.1.100 --user admin` | +| `sftp test` | SFTP-Verbindung testen | `sbackup sftp test` | + +**Authentifizierungsmethoden:** + +| Methode | Parameter | Beschreibung | Beispiel | +|---------|-----------|--------------|----------| +| **Automatische Erkennung** | Keine Authentifizierungsparameter angeben | Versucht automatisch `~/.ssh/id_ed25519` -> `id_rsa` -> `id_ecdsa` (empfohlen) | `sbackup sftp config --host ... --user ...` | +| Passwort | `--password` | Direkt mit Passwort anmelden | `sbackup sftp config --host ... --user ... --password secret` | +| Schluessel | `--key-file` | Mit angegebenem SSH-Schluessel anmelden | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa` | +| Schluessel + Passphrase | `--key-file` + `--key-passphrase` | Wenn der Schluessel eine Passphrase hat | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa --key-passphrase mypass` | + +Unterstuetzte Schluesselformate: RSA, Ed25519, ECDSA. + +**Plattformuebergreifende Pfadunterstuetzung:** + +| Plattform | Beispiel Schluesselpfad | Beschreibung | +|-----------|------------------------|--------------| +| Linux/macOS | `~/.ssh/id_rsa` | Wird automatisch zu `/home/user/.ssh/id_rsa` aufgeloest | +| Windows | `~/.ssh/id_rsa` | Wird automatisch zu `C:\Users\username\.ssh\id_rsa` aufgeloest | +| Alle Plattformen | Absoluter Pfad | Vollstaendiger Pfad wird direkt verwendet | + +Die SFTP-Konfiguration wird im Feld `sftp` der Datei `config.json` gespeichert und unterstuetzt Kommandozeilenparameter oder interaktive Eingabe. + +#### Versionsinformationen anzeigen + +```bash +sbackup version +``` + +## Konfigurationsdatei + +Sbackup unterstuetzt benutzerdefinierte Konfiguration ueber die Datei `config.json`. Die Konfigurationsdatei liegt im Projektstammverzeichnis. + +### Konfigurationsparameter + +```json +{ + "compression_format": "ZIP", + "compression": { + "algorithm": "ZIP_DEFLATED", + "level": 6 + }, + "skip_patterns": [".git", "__pycache__"], + "data_file": "sbackup.json", + "lang": "zh_CN", + "password": "", + "sftp": { + "host": "", + "port": 22, + "user": "", + "password": "", + "key_file": "", + "key_passphrase": "", + "remote_path": "/", + "enabled": false + } +} +``` + +| Parameter | Typ | Standardwert | Beschreibung | +|-----------|-----|-------------|--------------| +| `compression_format` | string | `"ZIP"` | Packformat, Auswahl: `ZIP`, `TAR`, `TAR_GZ`, `TAR_BZ2`, `TAR_XZ`, `TAR_ZST`, `7Z` | +| `compression.algorithm` | string | `"ZIP_DEFLATED"` | ZIP-Komprimierungsalgorithmus, Auswahl: `ZIP_DEFLATED`, `ZIP_STORED`, `ZIP_BZIP2`, `ZIP_LZMA` | +| `compression.level` | int | `6` | Komprimierungsstufe, Bereich 0-9 (0 = keine Komprimierung, 9 = hoechste Komprimierung) | +| `skip_patterns` | list | `[".git", "__pycache__"]` | Zu ignorierende Datei-/Ordnermuster (unterstuetzt fnmatch-Platzhalter und Pfadabgleich) | +| `data_file` | string | Plattform-Standardpfad | Pfad zur Datei mit den Backup-Strategien | +| `lang` | string | `"zh_CN"` | Oberflaechensprache, Auswahl: `zh_CN`, `en_US`, `fr_FR`, `es_ES`, `ru_RU`, `de_DE`, `ja_JP`, `pt_BR`, `ko_KR` | +| `password` | string | `""` | Verschluesselungspasswort fuer 7z | +| `sftp.host` | string | `""` | SFTP-Serveradresse | +| `sftp.port` | int | `22` | SFTP-Port | +| `sftp.user` | string | `""` | SFTP-Benutzername | +| `sftp.password` | string | `""` | SFTP-Passwort (fuer Passwort-Authentifizierung) | +| `sftp.key_file` | string | `""` | Pfad zur SSH-Schluesseldatei (fuer Schluessel-Authentifizierung, empfohlen) | +| `sftp.key_passphrase` | string | `""` | Schluessel-Passphrase (falls vorhanden) | +| `sftp.remote_path` | string | `"/"` | Remote-Zielpfad | +| `sftp.enabled` | bool | `false` | SFTP aktivieren oder nicht | + +### Konfigurationsbeispiel + +Backup mit dem tar.bz2-Format und hoher Komprimierung: + +```json +{ + "compression_format": "TAR_BZ2", + "compression_level": 9, + "skip_patterns": [".git", "__pycache__", "node_modules", "*.log"], + "data_file": "backup_strategies.json", + "lang": "de_DE" +} +``` + +### Vergleich der Packformate + +| Format | Dateiendung | Komprimierung | Geschwindigkeit | Abhaengigkeit | Anwendungsfall | +|--------|-------------|---------------|-----------------|---------------|----------------| +| ZIP | .zip | Mittel | Schnell | Standardbibliothek | Universell, beste Windows-Kompatibilitaet | +| tar | .tar | Keine | Sehr schnell | Standardbibliothek | Reines Archiv, fuer externe Komprimierung | +| tar.gz | .tar.gz | Mittel | Schnell | Standardbibliothek | Linux/macOS Standard | +| tar.bz2 | .tar.bz2 | Hoch | Mittel | Standardbibliothek | Hochkomprimierte Archive | +| tar.xz | .tar.xz | Am hoechsten | Langsam | Standardbibliothek | Langzeitarchivierung, platzsparend | +| tar.zst | .tar.zst | Mittel-hoch | Sehr schnell | zstandard | Modern, gute Balance zwischen Geschwindigkeit und Kompression | +| 7z | .7z | Sehr hoch | Langsam | py7zr | Hoechste Kompression, unterstuetzt Verschluesselung | + +#### WebDAV-Remote-Backup + +WebDAV ist ein HTTP-basiertes Dateiprotokoll, das Jianguoyun, NextCloud, Synology und andere gaengige Cloud-Speicher unterstuetzt. Verwendet die Python-Standardbibliothek `urllib` -- **keine zusaetzlichen Abhaengigkeiten**. + +```bash +# ============ Schnellstart ============ +# 1. WebDAV konfigurieren +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --password secret + +# 2. Verbindung testen +sbackup webdav test + +# 3. Backup ausfuehren und hochladen +sbackup save --webdav + +# ============ Anwendungsszenarien ============ + +# Szenario 1: Einmaliges Backup mit Hochladen +sbackup save --webdav + +# Szenario 2: Zeitgesteuertes Backup mit automatischem Hochladen (alle 60 Minuten) +sbackup watch --interval 60 --webdav + +# Szenario 3: Remote-Unterverzeichnis angeben +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --remote-path /backups/sbackup + +# Szenario 4: Simultanes Hochladen auf SFTP und WebDAV +sbackup save --sftp --webdav + +# ============ Gaengige WebDAV-Adressen ============ +# Jianguoyun: https://dav.jianguoyun.com/dav/ +# NextCloud: https://your-server/remote.php/dav/files/username/ +# Synology: https://your-synology:5006/webdav/ +``` + +**webdav-Unterbefehle:** + +| Unterbefehl | Beschreibung | Beispiel | +|-------------|--------------|----------| +| `webdav config` | WebDAV-Verbindungsparameter konfigurieren (url/user/password/remote_path) | `sbackup webdav config --url ... --user ...` | +| `webdav test` | WebDAV-Verbindung testen | `sbackup webdav test` | + +| Parameter | Standardwert | Beschreibung | +|-----------|-------------|--------------| +| `--url URL` | `""` | WebDAV-Serveradresse (z.B. `https://dav.jianguoyun.com/dav/`) | +| `--user BENUTZER` | `""` | WebDAV-Benutzername (in der Regel E-Mail-Adresse) | +| `--password PASSWORT` | `""` | WebDAV-Passwort (fuer Jianguoyun muss in den Einstellungen ein App-Passwort generiert werden) | +| `--remote-path PFAD` | `/` | Remote-Zielpfad | + +## Funktionsweise + +Sbackup realisiert die Backup-Funktionalitaet auf folgende Weise: + +1. **Backup-Strategie-Speicherung**: Backup-Strategien werden in einer JSON-Datei gespeichert, die Ordnerpfade, letzte Aenderungszeiten, Zielpfade, Ignoriermuster und packformat pro Eintrag enthaelt. +2. **Inkrementelles Backup**: Durch Vergleich des letzten Aenderungszeitpunkts eines Ordners werden nur geaenderte Ordner gesichert. +3. **Multiformat-Komprimierung**: Verwendet Pythons eingebaute `zipfile`- und `tarfile`-Module sowie die Drittanbieter-Bibliotheken `zstandard` und `py7zr` fuer sieben Packformate. +4. **Format pro Eintrag**: Jede Backup-Strategie kann ein eigenes Packformat angeben (`add --format`), das Vorrang vor dem globalen `--format` hat. Ohne Angabe wird der globale Standard verwendet. +5. **Backup-Bereinigung**: Nach erfolgreichem Backup wird das Zielverzeichnis automatisch gescannt, nach Aenderungszeitpunkt sortiert und alte Dateien werden ueber die Aufbewahrungsanzahl hinaus geloescht. +6. **Verschluesseltes Backup**: Das 7z-Format unterstuetzt LZMA2-Verschluesselung ueber den `--password`-Parameter oder die `config.json`-Konfiguration. +7. **Zeitgesteuertes Backup**: Der `watch`-Befehl fuehrt in einer Schleife in angegebenen Intervallen Backups aus. `Ctrl+C` beendet sicher. +8. **Backup-Verlauf**: Nach jedem Backup werden Zeitstempel, Dateigroesse und Dateianzahl protokolliert. Es werden die letzten 100 Eintraege aufbewahrt. +9. **SFTP-Remote-Backup**: Basierend auf der paramiko-Bibliothek als SFTP-Client, unterstuetzt Verbindungstests, automatische Erstellung von Remote-Verzeichnissen und Datei-Upload mit Fortschrittsanzeige. + +### Datenformat der Datei + +```json +{ + "/path/to/source/folder": [ + 1719235200.0, + "/path/to/target/folder", + [".git", "__pycache__"], + "" + ], + "/path/to/another/folder": [ + 1719235200.0, + "/path/to/another/target", + [".git"], + "TAR_GZ" + ], + "_history": [ + { + "time": "2026-05-01T12:00:00", + "source": "/path/to/source/folder", + "size_mb": 12.5, + "files_count": 150 + } + ] +} +``` + +Jeder Backup-Strategie-Eintrag ist eine 4-Element-Liste: `[mtime, target, skip_patterns, compression_format]` + +| Feld | Beschreibung | +|------|--------------| +| `mtime` | Letzter Aenderungszeitpunkt des Quellordners (fuer inkrementelle Backup-Entscheidung) | +| `target` | Zielpfad fuer die Backup-Dateien | +| `skip_patterns` | Liste der zu ignorierenden Datei-/Ordnermuster | +| `compression_format` | Packformat pro Eintrag (leerer String bedeutet globaler Standard) | + +## Entwicklerhandbuch + +### Tests ausfuehren + +```bash +uv run coverage run -m unittest discover -s tests -t . && uv run coverage report -m +``` + +### Code-Struktur + +``` +sbackup/ +├── main.py # Programmeinstieg +├── sbackup/ +│ ├── __init__.py # Export der Kernfunktionen +│ ├── __main__.py # python -m sbackup Einstieg +│ ├── cli.py # CLI-Argument-Parser und Befehlsverteilung (30+ Befehle) +│ ├── config.py # Konfigurationslade, Verschluesselung, Webhook-/SMTP-Konfiguration +│ ├── auto_save.py # BackupManager Kern-Engine +│ ├── compression.py # Komprimierung/Dekomprimierung fuer 7 Formate +│ ├── i18n.py # Internationalisierung (9 Sprachen) +│ ├── sftp.py # SFTP-Remote-Backup-Client (paramiko) +│ ├── webdav.py # WebDAV-Remote-Backup-Client (ohne Abhaengigkeiten) +│ ├── cloud_storage.py # S3-Cloud-Speicher-Client (minio) +│ ├── multi_dest.py # Parallel-Backup auf mehrere Ziele +│ ├── handlers.py # SFTP-/WebDAV-/Remote-/Schedule-Befehlsverarbeitung +│ ├── hooks.py # Pre-/Post-Hook-Ausfuehrung +│ ├── audit.py # Audit-Protokollsystem +│ ├── profile.py # Konfigurationsprofil-Verwaltung +│ ├── selective.py # Selektive Wiederherstellung +│ ├── cross_search.py | Suche ueber Archive hinweg +│ ├── integrity.py # SHA256-Pruefsumme +│ ├── rotation.py # Backup-Rotationsstrategie +│ ├── dryrun.py # Dry-run-Vorschau +│ ├── diskcheck.py # Speicherplatz-Schaetzung +│ ├── task_queue.py # Aufgabenwarteschlangensystem +│ ├── schema.py # Konfigurationsvalidierer +│ ├── benchmark.py # Komprimierungs-Benchmark +│ ├── chunked_backup.py# Blockweise inkrementelle Sicherung +│ ├── dedup.py # Dateiweise SHA256-Deduplizierung +│ ├── export.py # Metadaten-Export (CSV/JSON) +│ ├── monitor.py # watchdog Dateisystemueberwachung +│ ├── lock.py # Plattformuebergreifende Prozesssperre +│ ├── retry.py | Exponentielle Wiederholung mit Backoff +│ ├── ratelimiter.py # Token-Bucket-Ratenbegrenzung +│ ├── keychain.py # System-Keychain-Integration +│ ├── parity.py # Reed-Solomon-Fehlerkorrektur +│ ├── completion.py # Shell-Autovervollstaendigung +│ ├── wizard.py # Interaktiver Konfigurationsassistent +│ └── locales/ # Uebersetzungsdateien fuer 9 Sprachen +└── tests/ + └── sbackup/ + └── test_*.py # 30 Testdateien, alle Module abdeckend +``` + +### Neue Funktionen hinzufuegen + +1. Erstellen Sie eine neue Moduldatei im `sbackup/`-Verzeichnis +2. Importieren Sie die neuen Funktionen in `sbackup/__init__.py` +3. Fuegen Sie die Verarbeitungslogik fuer den neuen Befehl in der `run()`-Funktion hinzu +4. Erstellen Sie die entsprechende Testdatei im `tests/`-Verzeichnis + +## Haeufig gestellte Fragen + +### Q: Was passiert, wenn die Backup-Strategie-Datei versehentlich geloescht wird? + +A: Die Backup-Strategien werden in der Datendatei gespeichert. Bei versehentlicher Loeschung koennen die Strategien mit dem `add`-Befehl erneut hinzugefuegt werden. + +### Q: Wie kann eine vorhandene Backup-Strategie geaendert werden? + +A: Verwenden Sie den Befehl `sbackup edit`: `sbackup edit --dest --ignore --format `. + +### Q: Werden Remote-Backups unterstuetzt? + +A: Ja! Es werden drei Remote-Backup-Methoden angeboten: +- **SFTP**: `sbackup sftp config` konfigurieren, `sbackup save --sftp` hochladen +- **WebDAV**: `sbackup webdav config` konfigurieren, `sbackup save --webdav` hochladen (unterstuetzt Jianguoyun/NextCloud/Synology) +- **S3-Cloud-Speicher**: Das Feld `cloud` in `config.json` konfigurieren, `sbackup save --cloud` hochladen +- Mehrere Methoden gleichzeitig: `sbackup save --sftp --webdav --cloud` + +### Q: Was ist der Unterschied zwischen tar.gz und ZIP? + +A: tar.gz wird auf Linux/macOS haeufiger verwendet und bietet eine etwas bessere Kompression. ZIP ist auf Windows gaengiger und hat die beste Kompatibilitaet. tar.bz2 und tar.xz bieten hoehere Kompression, sind aber langsamer. tar.zst ist ein moderner Algorithmus mit sehr hoher Geschwindigkeit und guter Kompression. 7z bietet die hoechste Kompression und unterstuetzt Verschluesselung. + +### Q: Wie werden Backups verschluesselt? + +A: Verwenden Sie das 7z-Format mit Passwort: `uv run python main.py --format 7z save --password yourpassword`. Das Passwort kann auch im Feld `password` der `config.json` hinterlegt werden. + +### Q: Wie werden alte Backups automatisch bereinigt? + +A: Verwenden Sie den Parameter `--keep`: `uv run python main.py save --keep 5` bewahrt nur die letzten 5 Backup-Dateien auf. Bei zeitgesteuerten Backups ebenfalls unterstuetzt: `uv run python main.py watch --interval 60 --keep 10`. + +### Q: Wie richtet man zeitgesteuerte Backups ein? + +A: Verwenden Sie den Befehl `watch`: `uv run python main.py watch --interval 60` fuehrt alle 60 Minuten ein Backup aus. Mit `Ctrl+C` wird gestoppt. + +### Q: Ist die Passwortspeicherung sicher? + +A: Die SFTP-Passwoerter und 7z-Verschluesselungspasswoerter in `config.json` werden im **Klartext** gespeichert. Stellen Sie sicher, dass der Zugriff auf die Datei `config.json` auf vertrauenswuerdige Benutzer beschraenkt ist (z.B. `chmod 600 config.json`). Fuegen Sie eine `config.json` mit Passwoertern nicht in ein Versionskontrollsystem ein. + +## Mitwirkungsrichtlinie + +Issues und Pull Requests sind willkommen! + +1. Forken Sie dieses Repository +2. Erstellen Sie Ihren Feature-Branch (`git checkout -b feature/AmazingFeature`) +3. Committen Sie Ihre Aenderungen (`git commit -m 'Add some AmazingFeature'`) +4. Pushen Sie auf den Branch (`git push origin feature/AmazingFeature`) +5. Erstellen Sie einen Pull Request + +### Codestil + +Dieses Projekt folgt PEP 8 und dem Google Python Style Guide. Bitte stellen Sie sicher, dass Ihr Code: +- Typannotationen verwendet +- Google-stile Docstrings einhaelt +- Alle Tests besteht + +## Lizenz + +Dieses Projekt steht unter der GNU GPL v3.0-Lizenz. Einzelheiten finden Sie in der Datei [LICENSE](../../LICENSE). + +## Autor + +**xiatianxuan** (CodeSeed) + +- [Gitee](https://gitee.com/xiatianxuan) +- [Webseite](https://xnors-codeseed.pages.dev/) + +## Besonderer Dank + +- [Xnors Studio](https://xnors.github.io/) + +## Kontakt + +Bei Fragen oder Anregungen senden Sie bitte eine E-Mail an: xiatianxuan2025@163.com + +--- + +*Letzte Aktualisierung: 19. Juni 2026* diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md new file mode 100644 index 0000000..21e3e83 --- /dev/null +++ b/docs/readme/README_es.md @@ -0,0 +1,665 @@ +# Sbackup + +[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![License](https://img.shields.io/badge/License-GPL--3.0-green)](../../LICENSE) +[![PyPI](https://img.shields.io/pypi/v/sbackup-cli?color=blue)](https://pypi.org/project/sbackup-cli/) +[![Tests](https://img.shields.io/badge/tests-940%20passed-brightgreen)](../../.github/workflows/ci.yml) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)]() + +> Herramienta ligera y eficiente para copias de seguridad de carpetas, con soporte de linea de comandos para gestionar tus estrategias de backup sin esfuerzo. + +[English](../../README.md) | [Deutsch](README_de.md) | [Espanol](README_es.md) | [Francais](README_fr.md) | [Portugues](README_pt.md) | [Pycckuu](README_ru.md) | [日本語](README_ja.md) | [한국어](README_ko.md) | [中文](README_zh.md) + +- [Introduccion](#introduccion) +- [Caracteristicas](#caracteristicas) +- [Inicio rapido](#inicio-rapido) + - [Instalacion](#instalacion) + - [Uso](#uso) +- [Archivo de configuracion](#archivo-de-configuracion) + - [Ejemplo de configuracion](#ejemplo-de-configuracion) +- [Copia de seguridad remota por SFTP](#copia-de-seguridad-remota-por-sftp) +- [Copia de seguridad remota por WebDAV](#copia-de-seguridad-remota-por-webdav) +- [Principio de funcionamiento](#principio-de-funcionamiento) +- [Guia de desarrollo](#guia-de-desarrollo) + - [Ejecutar pruebas](#ejecutar-pruebas) + - [Estructura del codigo](#estructura-del-codigo) +- [Preguntas frecuentes](#preguntas-frecuentes) +- [Guia de contribucion](#guia-de-contribucion) +- [Licencia](#licencia) +- [Autor](#autor) + +--- + +## Introduccion + +Sbackup es una herramienta ligera de copias de seguridad de carpetas que permite agregar, eliminar y consultar estrategias de backup desde la linea de comandos. Se basa en la ultima fecha de modificacion de las carpetas para determinar si es necesario realizar una copia de seguridad, asegurando que tus datos permanezcan siempre actualizados. + +## Caracteristicas + +- **Copia de seguridad incremental**: Solo respalda las carpetas que han cambiado, ahorrando tiempo y espacio de almacenamiento. +- **Soporte de multiples formatos**: Compatible con siete formatos de empaquetado: ZIP, tar, tar.gz, tar.bz2, tar.xz, tar.zst y 7z. Tanto el formato global como el de cada entrada pueden configurarse de forma independiente. +- **Copia de seguridad remota por SFTP**: Basado en la biblioteca paramiko, soporta autenticacion por contrasena o clave privada SSH, con deteccion automatica de la clave privada predeterminada. +- **Copia de seguridad remota por WebDAV**: Basado en urllib de la biblioteca estandar, sin dependencias adicionales. Compatible con Jianguoyun, NextCloud y Synology. +- **Almacenamiento en la nube S3**: Basado en la biblioteca minio, compatible con todo almacenamiento compatible con S3 (AWS, MinIO, Alibaba Cloud OSS, etc.). +- **Backup paralelo a multiples destinos**: Copia simultanea a almacenamiento local y multiples destinos remotos, sin interferencias entre ellos. +- **Restauracion de backups**: Permite descomprimir y restaurar archivos de backup en el directorio especificado, con soporte de restauracion selectiva. +- **Limpieza de backups**: Eliminacion automatica de backups antiguos, con politicas por cantidad, tiempo o retencion diaria. +- **Cifrado de backups**: Soporta cifrado con contrasena en formato 7z mas cifrado PBKDF2 para todos los formatos. +- **Copia de seguridad programada**: Ejecucion automatica a intervalos definidos, con soporte de monitoreo de archivos en tiempo real (watchdog). +- **Historial de backups**: Registro de la fecha, tamano y suma de verificacion SHA256 de cada backup para facilitar el seguimiento. +- **Registro de auditoria**: Registro de todos los eventos de auditoria de operaciones de backup y restauracion. +- **Hooks pre/post**: Ejecucion de comandos personalizados antes y despues del backup. +- **Perfiles de configuracion**: Soporte para guardar, cambiar, importar y exportar multiples esquemas de configuracion. +- **Busqueda entre archivos**: Busqueda de nombres de archivo coincidentes en multiples archivos de backup. +- **Integridad de datos**: Generacion y verificacion de sumas de verificacion SHA256, codigos de correccion de errores Reed-Solomon. +- **Validacion de configuracion**: Verificacion automatica de la validez de los parametros de configuracion y deteccion de manipulaciones. +- **Cola de tareas**: Gestion de la cola de tareas de backup, con soporte para agregar, ejecutar y cancelar. +- **Benchmark de compresion**: Comparacion del rendimiento de compresion entre diferentes formatos y niveles. +- **Estimacion de espacio en disco**: Estimacion del tamano del backup por tipo de archivo y verificacion del espacio en el destino. +- **Internacionalizacion**: Soporte para nueve idiomas: chino, ingles, frances, espanol, ruso, aleman, japones, portugues y coreano. +- **Autocompletado de shell**: Soporte de autocompletado automatico para bash, zsh, fish y powershell. +- **Ligero y eficiente**: Tamano reducido, inicio rapido y bajo consumo de recursos. +- **Soporte multiplataforma**: Compatible con Windows, macOS y Linux. + +## Inicio rapido + +### Instalacion + +#### Instalacion con pip + +```bash +pip install sbackup-cli +``` + +Despues de la instalacion, usa el comando `sbackup` (el nombre del paquete en PyPI es `sbackup-cli`, el comando CLI es `sbackup`). + +#### Instalacion desde el codigo fuente + +```bash +git clone https://github.com/xiatianxuan/sbackup.git +cd sbackup +uv sync +``` + +### Uso + +#### Sintaxis basica + +```bash +uv run python main.py [options] +``` + +#### Comandos disponibles + +| Comando | Descripcion | +|---------|-------------| +| `add` | Agregar una estrategia de backup | +| `rm` / `remove` | Eliminar una estrategia de backup | +| `edit` | Editar una estrategia de backup existente | +| `all` | Ver todas las estrategias de backup | +| `save` | Ejecutar el backup | +| `watch` | Ejecutar backups programados | +| `restore` | Restaurar desde un archivo de backup | +| `info` | Ver detalles del archivo de backup | +| `diff` | Comparar diferencias entre el directorio origen y el backup | +| `verify` | Verificar la integridad del archivo de backup | +| `search` | Buscar archivos dentro de un backup | +| `xsearch` | Buscar entre multiples archivos de backup | +| `versions` | Ver el historial de versiones del backup | +| `sftp` | Gestion de backups remotos por SFTP | +| `webdav` | Gestion de backups remotos por WebDAV | +| `remote` | Gestion de archivos remotos (list/rm) | +| `task` | Gestion de la cola de tareas de backup | +| `audit` | Consulta del registro de auditoria | +| `hooks` | Ejecucion manual de hooks pre/post | +| `profile` | Gestion de perfiles de configuracion | +| `rotate` | Limpieza por rotacion de backups | +| `clean` | Limpieza de backups antiguos | +| `diskcheck` | Estimacion de espacio en disco | +| `benchmark` | Benchmark de formatos de compresion | +| `integrity` | Verificacion de integridad del directorio de backups | +| `dry-run` | Vista previa de la seleccion de archivos de backup | +| `export` / `import` | Exportar/importar estrategias de backup | +| `ignore` | Generar archivo .sbackupignore | +| `schedule` | Exportar configuracion de programacion | +| `webhook` | Configurar preajustes de webhook | +| `config` | Configuracion de cifrado/verificacion | +| `report` | Generar informe de backups | +| `completion` | Generar scripts de autocompletado de shell | +| `wizard` | Asistente de configuracion interactivo | +| `status` | Panel de estado del backup | +| `version` | Ver informacion de version | +| `help` | Ver ayuda | + +#### Parametros globales + +| Parametro | Descripcion | +|-----------|-------------| +| `--lang zh_CN` / `en_US` / `fr_FR` / `es_ES` / `ru_RU` / `de_DE` / `ja_JP` / `pt_BR` / `ko_KR` | Establecer el idioma de la interfaz (persistente en config.json) | +| `--format zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` | Establecer el formato de empaquetado (persistente en config.json) | +| `--debug` | Activar el registro de depuracion | + +#### Agregar una estrategia de backup + +```bash +uv run python main.py add [-i ignore_patterns] +``` + +Parametros: +- **source**: Ruta de la carpeta de origen a respaldar +- **dest**: Ruta de destino donde se almacenara el backup +- **-i, --ignore**: Nombres de archivos o carpetas a ignorar, separados por comas (por defecto: `.git,__pycache__`) +- **--format**: Formato de empaquetado a nivel de entrada (solo afecta a esta estrategia de backup; si no se especifica, se usa el formato global): `zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` + +Ejemplos: +```bash +# Agregar estrategia usando el formato global por defecto +uv run python main.py add F:/my_folder F:/backup -i node_modules,.git + +# Especificar formato tar.gz para esta estrategia (cada backup de esta carpeta usara tar.gz) +uv run python main.py add F:/my_folder F:/backup --format tar.gz + +# Especificar formato 7z (solo para esta carpeta) +uv run python main.py add F:/my_folder F:/backup --format 7z +``` + +#### Eliminar una estrategia de backup + +```bash +uv run python main.py rm +``` + +Parametros: +- **path**: Ruta de la carpeta de origen cuya estrategia de backup se va a eliminar + +Ejemplo: +```bash +uv run python main.py rm F:/my_folder +``` + +#### Ver todas las estrategias de backup + +```bash +uv run python main.py all +``` + +Muestra todas las estrategias de backup configuradas actualmente. + +#### Ejecutar backup + +```bash +# Usar formato por defecto (ZIP) +uv run python main.py save + +# Usar formato tar.gz +uv run python main.py --format tar.gz save + +# Conservar los 5 backups mas recientes, limpiando automaticamente los antiguos +uv run python main.py save --keep 5 + +# Usar formato 7z con cifrado +uv run python main.py --format 7z save --password mysecret + +# Interfaz en ingles + formato tar.xz +uv run python main.py --lang en_US --format tar.xz save +``` + +**Parametros del comando save:** + +| Parametro | Valor por defecto | Descripcion | +|-----------|-------------------|-------------| +| `--keep N` | `0` | Conservar los N backups mas recientes; 0 significa sin limpieza | +| `--password PASSWORD` | `""` | Contrasena de cifrado (solo formato 7z) | +| `--sftp` | `false` | Subir al servidor SFTP despues del backup | +| `--webdav` | `false` | Subir al servidor WebDAV despues del backup | + +Segun la estrategia de backup configurada, respalda automaticamente las carpetas que hayan cambiado. + +#### Backup programado + +```bash +# Ejecutar backup cada 60 minutos +uv run python main.py watch --interval 60 + +# Backup cada 2 horas, conservando los 10 archivos mas recientes +uv run python main.py watch --interval 120 --keep 10 + +# Backup programado + cifrado 7z +uv run python main.py --format 7z watch --interval 60 --password mysecret +``` + +**Parametros del comando watch:** + +| Parametro | Valor por defecto | Descripcion | +|-----------|-------------------|-------------| +| `--interval MINUTES` | `60` | Intervalo de backup (en minutos) | +| `--keep N` | `0` | Conservar los N backups mas recientes | +| `--password PASSWORD` | `""` | Contrasena de cifrado (solo formato 7z) | +| `--sftp` | `false` | Subir al servidor SFTP despues de cada backup | +| `--webdav` | `false` | Subir al servidor WebDAV despues de cada backup | + +Pulsa `Ctrl+C` para detener el backup programado. + +#### Restaurar backup + +```bash +uv run python main.py restore +``` + +Parametros: +- **backup_file**: Ruta del archivo de backup (compatible con .zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z) +- **target_dir**: Directorio de destino para la restauracion + +Ejemplos: +```bash +uv run python main.py restore F:/backup/my_folder.tar.gz F:/restored +uv run python main.py restore F:/backup/my_folder.7z F:/restored +uv run python main.py restore F:/backup/my_folder.tar.zst F:/restored +``` + +#### Copia de seguridad remota por SFTP + +```bash +# ============ Inicio rapido (recomendado) ============ +# 1. Configurar SFTP (deteccion automatica de clave privada SSH, sin necesidad de especificar manualmente) +sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups + +# 2. Probar conexion +sbackup sftp test + +# 3. Ejecutar backup y subir +sbackup save --sftp + +# ============ Metodos de autenticacion ============ + +# Metodo 1: Deteccion automatica de clave privada (recomendado) +# El sistema intenta automaticamente ~/.ssh/id_ed25519 -> id_rsa -> id_ecdsa +sbackup sftp config --host 192.168.1.100 --user admin + +# Metodo 2: Autenticacion por contrasena +sbackup sftp config --host 192.168.1.100 --user admin --password secret + +# Metodo 3: Especificar clave privada +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# Metodo 4: Clave privada + frase de paso (entrada interactiva) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# Metodo 5: Clave privada + frase de paso (especificada en linea de comandos) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mykeypass + +# ============ Casos de uso ============ + +# Caso 1: Backup unico y subida +sbackup save --sftp + +# Caso 2: Backup programado con subida automatica (cada 60 minutos) +sbackup watch --interval 60 --sftp + +# Caso 3: Backup con formato especifico + subida +sbackup --format tar.gz save --sftp + +# Caso 4: Backup cifrado + subida +sbackup --format 7z save --password mysecret --sftp + +# Caso 5: Conservar los 5 backups mas recientes + subida +sbackup save --keep 5 --sftp + +# ============ Uso avanzado ============ + +# Configuracion interactiva (introducir todos los parametros paso a paso) +sbackup sftp config + +# Configuracion no interactiva (todos los parametros en la linea de comandos) +sbackup sftp config --host 192.168.1.100 --port 22 --user admin --password secret --remote-path /backups + +# Probar conexion y ver registro detallado +sbackup --debug sftp test +``` + +**Subcomandos de sftp:** + +| Subcomando | Descripcion | Ejemplo | +|------------|-------------|---------| +| `sftp config` | Configurar parametros de conexion SFTP (host/port/user/password/key_file/key_passphrase/remote_path) | `sbackup sftp config --host 192.168.1.100 --user admin` | +| `sftp test` | Probar si la conexion SFTP esta disponible | `sbackup sftp test` | + +**Metodos de autenticacion:** + +| Metodo | Parametros | Descripcion | Ejemplo | +|--------|------------|-------------|---------| +| **Deteccion automatica** | Sin parametros de autenticacion | Intenta automaticamente `~/.ssh/id_ed25519` -> `id_rsa` -> `id_ecdsa` (recomendado) | `sbackup sftp config --host ... --user ...` | +| Contrasena | `--password` | Acceso directo con contrasena | `sbackup sftp config --host ... --user ... --password secret` | +| Clave privada | `--key-file` | Acceso con clave privada SSH especificada | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa` | +| Clave privada + frase de paso | `--key-file` + `--key-passphrase` | Para claves privadas con frase de paso | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa --key-passphrase mypass` | + +Formatos de clave privada compatibles: RSA, Ed25519, ECDSA. + +**Soporte de rutas multiplataforma:** + +| Plataforma | Ejemplo de ruta de clave privada | Descripcion | +|------------|----------------------------------|-------------| +| Linux/macOS | `~/.ssh/id_rsa` | Se expande automaticamente a `/home/user/.ssh/id_rsa` | +| Windows | `~/.ssh/id_rsa` | Se expande automaticamente a `C:\Users\username\.ssh\id_rsa` | +| Todas | Ruta absoluta | Se usa directamente la ruta completa | + +La configuracion de SFTP se guarda en el campo `sftp` de `config.json` y admite parametros de linea de comandos o entrada interactiva. + +#### Ver informacion de version + +```bash +sbackup version +``` + +## Archivo de configuracion + +Sbackup permite la personalizacion mediante el archivo `config.json`. El archivo de configuracion debe ubicarse en el directorio raiz del proyecto. + +### Descripcion de las opciones de configuracion + +```json +{ + "compression_format": "ZIP", + "compression": { + "algorithm": "ZIP_DEFLATED", + "level": 6 + }, + "skip_patterns": [".git", "__pycache__"], + "data_file": "sbackup.json", + "lang": "zh_CN", + "password": "", + "sftp": { + "host": "", + "port": 22, + "user": "", + "password": "", + "key_file": "", + "key_passphrase": "", + "remote_path": "/", + "enabled": false + } +} +``` + +| Opcion | Tipo | Valor por defecto | Descripcion | +|--------|------|-------------------|-------------| +| `compression_format` | string | `"ZIP"` | Formato de empaquetado. Valores posibles: `ZIP`, `TAR`, `TAR_GZ`, `TAR_BZ2`, `TAR_XZ`, `TAR_ZST`, `7Z` | +| `compression.algorithm` | string | `"ZIP_DEFLATED"` | Algoritmo de compresion ZIP. Valores posibles: `ZIP_DEFLATED`, `ZIP_STORED`, `ZIP_BZIP2`, `ZIP_LZMA` | +| `compression.level` | int | `6` | Nivel de compresion, rango 0-9 (0 = sin compresion, 9 = maxima compresion) | +| `skip_patterns` | list | `[".git", "__pycache__"]` | Patrones de archivos o carpetas a ignorar (compatible con comodines fnmatch y coincidencia de rutas) | +| `data_file` | string | Ruta predeterminada de la plataforma | Ruta del archivo de datos de estrategias de backup | +| `lang` | string | `"zh_CN"` | Idioma de la interfaz. Valores posibles: `zh_CN`, `en_US`, `fr_FR`, `es_ES`, `ru_RU`, `de_DE`, `ja_JP`, `pt_BR`, `ko_KR` | +| `password` | string | `""` | Contrasena de cifrado para 7z | +| `sftp.host` | string | `""` | Direccion del servidor SFTP | +| `sftp.port` | int | `22` | Puerto SFTP | +| `sftp.user` | string | `""` | Nombre de usuario SFTP | +| `sftp.password` | string | `""` | Contrasena SFTP (para autenticacion por contrasena) | +| `sftp.key_file` | string | `""` | Ruta del archivo de clave privada SSH (para autenticacion por clave privada, recomendado) | +| `sftp.key_passphrase` | string | `""` | Frase de paso de la clave privada (si aplica) | +| `sftp.remote_path` | string | `"/"` | Ruta de destino remoto | +| `sftp.enabled` | bool | `false` | Habilitar o deshabilitar SFTP | + +### Ejemplo de configuracion + +Usar formato tar.bz2 para backups con alta tasa de compresion: + +```json +{ + "compression_format": "TAR_BZ2", + "compression_level": 9, + "skip_patterns": [".git", "__pycache__", "node_modules", "*.log"], + "data_file": "backup_strategies.json", + "lang": "es_ES" +} +``` + +### Comparacion de formatos de empaquetado + +| Formato | Extension | Compresion | Velocidad | Dependencias | Caso de uso | +|---------|-----------|------------|-----------|--------------|-------------| +| ZIP | .zip | Media | Rapida | Biblioteca estandar | Uso general, mejor compatibilidad con Windows | +| tar | .tar | Ninguna | Muy rapida | Biblioteca estandar | Archivado puro, compresion externa | +| tar.gz | .tar.gz | Media | Rapida | Biblioteca estandar | Uso general en Linux/macOS | +| tar.bz2 | .tar.bz2 | Alta | Media | Biblioteca estandar | Archivado con alta compresion | +| tar.xz | .tar.xz | Maxima | Lenta | Biblioteca estandar | Archivado a largo plazo, sensible al espacio | +| tar.zst | .tar.zst | Media-alta | Muy rapida | zstandard | Casos modernos, equilibrio entre velocidad y compresion | +| 7z | .7z | Muy alta | Lenta | py7zr | Maxima compresion, soporte de cifrado | + +#### Copia de seguridad remota por WebDAV + +WebDAV es un protocolo de archivos basado en HTTP, compatible con los principales servicios en la nube como Jianguoyun, NextCloud y Synology. Utiliza `urllib` de la biblioteca estandar de Python, **sin dependencias adicionales**. + +```bash +# ============ Inicio rapido ============ +# 1. Configurar WebDAV +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --password secret + +# 2. Probar conexion +sbackup webdav test + +# 3. Ejecutar backup y subir +sbackup save --webdav + +# ============ Casos de uso ============ + +# Caso 1: Backup unico y subida +sbackup save --webdav + +# Caso 2: Backup programado con subida automatica (cada 60 minutos) +sbackup watch --interval 60 --webdav + +# Caso 3: Especificar subdirectorio remoto +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --remote-path /backups/sbackup + +# Caso 4: Subir simultaneamente a SFTP y WebDAV +sbackup save --sftp --webdav + +# ============ Direcciones comunes de servicios WebDAV ============ +# Jianguoyun: https://dav.jianguoyun.com/dav/ +# NextCloud: https://your-server/remote.php/dav/files/username/ +# Synology: https://your-synology:5006/webdav/ +``` + +**Subcomandos de webdav:** + +| Subcomando | Descripcion | Ejemplo | +|------------|-------------|---------| +| `webdav config` | Configurar parametros de conexion WebDAV (url/user/password/remote_path) | `sbackup webdav config --url ... --user ...` | +| `webdav test` | Probar si la conexion WebDAV esta disponible | `sbackup webdav test` | + +| Parametro | Valor por defecto | Descripcion | +|-----------|-------------------|-------------| +| `--url URL` | `""` | Direccion del servidor WebDAV (ej. `https://dav.jianguoyun.com/dav/`) | +| `--user USER` | `""` | Nombre de usuario de WebDAV (normalmente un correo electronico) | +| `--password PASS` | `""` | Contrasena de WebDAV (para Jianguoyun, genera una contrasena de aplicacion en la configuracion) | +| `--remote-path PATH` | `/` | Ruta de destino remoto | + +## Principio de funcionamiento + +Sbackup implementa sus funciones de backup de la siguiente manera: + +1. **Almacenamiento de estrategias**: Las estrategias de backup se almacenan en un archivo JSON que contiene las rutas de las carpetas, la ultima fecha de modificacion, las rutas de destino, los patrones de exclusion y el formato de empaquetado a nivel de entrada. +2. **Backup incremental**: Al comparar la ultima fecha de modificacion de las carpetas, solo se respaldan las carpetas que hayan cambiado. +3. **Compresion multi-formato**: Utiliza los modulos integrados `zipfile` y `tarfile` de Python, junto con las bibliotecas de terceros `zstandard` y `py7zr`, para soportar siete formatos de empaquetado. +4. **Formato a nivel de entrada**: Cada estrategia de backup puede especificar su propio formato de empaquetado (`add --format`), que tiene prioridad sobre la configuracion global `--format`. Si no se especifica, se usa el formato global por defecto. +5. **Limpieza de backups**: Despues de un backup exitoso, se escanea automaticamente el directorio de destino, se ordena por fecha de modificacion y se eliminan los archivos antiguos que excedan la cantidad de retencion. +6. **Cifrado**: El formato 7z soporta cifrado LZMA2, configurable mediante el parametro `--password` o en `config.json`. +7. **Backup programado**: El comando `watch` ejecuta backups en bucle a intervalos especificados; se sale de forma segura con `Ctrl+C`. +8. **Historial de backups**: Despues de cada backup se registra la marca de tiempo, el tamano del archivo y la cantidad de archivos, conservando los ultimos 100 registros. +9. **Backup remoto por SFTP**: Implementado con la biblioteca paramiko, soporta prueba de conexion, creacion automatica de directorios remotos y subida de archivos con barra de progreso. + +### Formato del archivo de datos + +```json +{ + "/path/to/source/folder": [ + 1719235200.0, + "/path/to/target/folder", + [".git", "__pycache__"], + "" + ], + "/path/to/another/folder": [ + 1719235200.0, + "/path/to/another/target", + [".git"], + "TAR_GZ" + ], + "_history": [ + { + "time": "2026-05-01T12:00:00", + "source": "/path/to/source/folder", + "size_mb": 12.5, + "files_count": 150 + } + ] +} +``` + +Cada entrada de estrategia de backup es una lista de 4 elementos: `[mtime, target, skip_patterns, compression_format]` + +| Campo | Descripcion | +|-------|-------------| +| `mtime` | Ultima fecha de modificacion de la carpeta de origen (para determinar si se necesita backup incremental) | +| `target` | Ruta de destino donde se almacenara el archivo de backup | +| `skip_patterns` | Lista de patrones de archivos/carpetas a ignorar | +| `compression_format` | Formato de empaquetado a nivel de entrada (cadena vacia = usar formato global por defecto) | + +## Guia de desarrollo + +### Ejecutar pruebas + +```bash +uv run coverage run -m unittest discover -s tests -t . && uv run coverage report -m +``` + +### Estructura del codigo + +``` +sbackup/ +├── main.py # Punto de entrada del programa +├── sbackup/ +│ ├── __init__.py # Exportacion de funciones principales +│ ├── __main__.py # Punto de entrada para python -m sbackup +│ ├── cli.py # Analisis de argumentos CLI y distribucion de comandos (30+ comandos) +│ ├── config.py # Carga de configuracion, cifrado, configuracion Webhook/SMTP +│ ├── auto_save.py # Motor principal BackupManager +│ ├── compression.py # Motor de compresion/descompresion de 7 formatos +│ ├── i18n.py # Internacionalizacion (9 idiomas) +│ ├── sftp.py # Cliente de backup remoto SFTP (paramiko) +│ ├── webdav.py # Cliente de backup remoto WebDAV (sin dependencias) +│ ├── cloud_storage.py # Cliente de almacenamiento en la nube S3 (minio) +│ ├── multi_dest.py # Backup paralelo a multiples destinos +│ ├── handlers.py # Manejadores de comandos SFTP/WebDAV/Remote/Schedule +│ ├── hooks.py # Ejecucion de hooks pre/post +│ ├── audit.py # Sistema de registro de auditoria +│ ├── profile.py # Gestion de perfiles de configuracion +│ ├── selective.py # Restauracion selectiva +│ ├── cross_search.py # Busqueda entre archivos +│ ├── integrity.py # Sumas de verificacion SHA256 +│ ├── rotation.py # Politicas de rotacion de backups +│ ├── dryrun.py # Vista previa de dry-run +│ ├── diskcheck.py # Estimacion de espacio en disco +│ ├── task_queue.py # Sistema de cola de tareas +│ ├── schema.py # Validador de configuracion +│ ├── benchmark.py # Benchmark de compresion +│ ├── chunked_backup.py# Backup incremental a nivel de bloque +│ ├── dedup.py # Deduplicacion a nivel de archivo con SHA256 +│ ├── export.py # Exportacion de metadatos (CSV/JSON) +│ ├── monitor.py # Monitoreo de sistema de archivos con watchdog +│ ├── lock.py # Bloqueo de proceso multiplataforma +│ ├── retry.py # Reintentos con retroceso exponencial +│ ├── ratelimiter.py # Limitador de velocidad con cubeta de tokens +│ ├── keychain.py # Integracion con el llavero del sistema +│ ├── parity.py # Codigos de correccion de errores Reed-Solomon +│ ├── completion.py # Autocompletado de shell +│ ├── wizard.py # Asistente de configuracion interactivo +│ └── locales/ # Archivos de traduccion en 9 idiomas +└── tests/ + └── sbackup/ + └── test_*.py # 30 archivos de prueba que cubren todos los modulos +``` + +### Agregar nuevas funcionalidades + +1. Crear un nuevo archivo de modulo en el directorio `sbackup/` +2. Importar las funciones de la nueva funcionalidad en `sbackup/__init__.py` +3. Agregar la logica de manejo del nuevo comando en la funcion `run()` +4. Agregar el archivo de prueba correspondiente en el directorio `tests/` + +## Preguntas frecuentes + +### P: Que pasa si elimino accidentalmente el archivo de estrategias de backup? + +R: Las estrategias de backup se almacenan en el archivo de datos. Si se elimina accidentalmente, puedes volver a agregar las estrategias ejecutando el comando `add` nuevamente. + +### P: Como puedo modificar una estrategia de backup ya agregada? + +R: Usa el comando `sbackup edit`: `sbackup edit --dest --ignore --format `. + +### P: Soporta backups remotos? + +R: Si. Se ofrecen tres metodos de backup remoto: +- **SFTP**: Configurar con `sbackup sftp config`, subir con `sbackup save --sftp` +- **WebDAV**: Configurar con `sbackup webdav config`, subir con `sbackup save --webdav` (compatible con Jianguoyun, NextCloud, Synology) +- **Almacenamiento S3**: Configurar el campo `cloud` en `config.json`, subir con `sbackup save --cloud` +- Se pueden usar varios a la vez: `sbackup save --sftp --webdav --cloud` + +### P: Cual es la diferencia entre tar.gz y ZIP? + +R: tar.gz se usa mas comunmente en Linux/macOS y ofrece una tasa de compresion ligeramente superior; ZIP es mas universal en Windows y tiene la mejor compatibilidad. tar.bz2 y tar.xz ofrecen mayor compresion pero son mas lentos. tar.zst es un algoritmo moderno con velocidad excelente y buena compresion. 7z ofrece la mayor compresion y soporta cifrado. + +### P: Como puedo cifrar un backup? + +R: Usa el formato 7z y establece una contrasena: `uv run python main.py --format 7z save --password yourpassword`. La contrasena tambien puede escribirse en el campo `password` de `config.json`. + +### P: Como puedo limpiar automaticamente los backups antiguos? + +R: Usa el parametro `--keep`: `uv run python main.py save --keep 5` conserva solo los 5 backups mas recientes. Tambien funciona con backups programados: `uv run python main.py watch --interval 60 --keep 10`. + +### P: Como puedo configurar backups programados? + +R: Usa el comando `watch`: `uv run python main.py watch --interval 60` ejecuta un backup cada 60 minutos. Pulsa `Ctrl+C` para detener. + +### P: Es seguro almacenar las contrasenas? + +R: Las contrasenas de SFTP y de cifrado 7z en `config.json` se almacenan en **texto plano**. Asegurate de que los permisos de acceso al archivo `config.json` esten restringidos a usuarios de confianza (por ejemplo, `chmod 600 config.json`). No incluyas el archivo `config.json` con contrasenas en el sistema de control de versiones. + +## Guia de contribucion + +Se agradecen los Issues y Pull Requests. + +1. Haz fork de este repositorio +2. Crea tu rama de funcionalidad (`git checkout -b feature/AmazingFeature`) +3. Realiza tus cambios (`git commit -m 'Add some AmazingFeature'`) +4. Sube a tu rama (`git push origin feature/AmazingFeature`) +5. Envia el Pull Request + +### Estilo de codigo + +Este proyecto sigue PEP 8 y Google Python Style Guide. Asegurate de que tu codigo: +- Use anotaciones de tipo +- Siga la convencion de docstrings de Google +- Pase todas las pruebas unitarias + +## Licencia + +Este proyecto esta licenciado bajo la licencia GNU GPL v3.0. Consulta el archivo [LICENSE](../../LICENSE) para mas detalles. + +## Autor + +**xiatianxuan** (CodeSeed) + +- [Gitee](https://gitee.com/xiatianxuan) +- [Pagina personal](https://xnors-codeseed.pages.dev/) + +## Agradecimientos especiales + +- [Xnors Studio](https://xnors.github.io/) + +## Contacto + +Si tienes preguntas o sugerencias, envia un correo a: xiatianxuan2025@163.com + +--- + +*Ultima actualizacion: 19 de junio de 2026* diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md new file mode 100644 index 0000000..f5a995f --- /dev/null +++ b/docs/readme/README_fr.md @@ -0,0 +1,665 @@ +# Sbackup + +[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![License](https://img.shields.io/badge/License-GPL--3.0-green)](../../LICENSE) +[![PyPI](https://img.shields.io/pypi/v/sbackup-cli?color=blue)](https://pypi.org/project/sbackup-cli/) +[![Tests](https://img.shields.io/badge/tests-940%20passed-brightgreen)](../../.github/workflows/ci.yml) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)]() + +> Outil de sauvegarde de dossiers léger et efficace, fonctionnant en ligne de commande, pour gérer vos stratégies de sauvegarde en toute simplicité. + +[English](../../README.md) | [Deutsch](README_de.md) | [Espanol](README_es.md) | [Francais](README_fr.md) | [Portugues](README_pt.md) | [Pycckuu](README_ru.md) | [日本語](README_ja.md) | [한국어](README_ko.md) | [中文](README_zh.md) + +- [Introduction](#introduction) +- [Fonctionnalites](#fonctionnalites) +- [Demarrage rapide](#demarrage-rapide) + - [Installation](#installation) + - [Utilisation](#utilisation) +- [Fichier de configuration](#fichier-de-configuration) + - [Exemple de configuration](#exemple-de-configuration) +- [Sauvegarde SFTP distante](#sauvegarde-sftp-distante) +- [Sauvegarde WebDAV distante](#sauvegarde-webdav-distante) +- [Principe de fonctionnement](#principe-de-fonctionnement) +- [Guide de developpement](#guide-de-developpement) + - [Executer les tests](#executer-les-tests) + - [Structure du code](#structure-du-code) +- [Questions frequentes](#questions-frequentes) +- [Guide de contribution](#guide-de-contribution) +- [Licence](#licence) +- [Auteur](#auteur) + +--- + +## Introduction + +Sbackup est un outil de sauvegarde de dossiers léger qui permet d'ajouter, supprimer et consulter des stratégies de sauvegarde via la ligne de commande. Il se base sur la date de dernière modification des dossiers pour déterminer si une sauvegarde est nécessaire, garantissant ainsi que vos données sont toujours à jour. + +## Fonctionnalites + +- **Sauvegarde incrémentielle** : ne sauvegarde que les dossiers modifiés, économisant temps et espace de stockage +- **Multi-format** : prend en charge sept formats d'archivage — ZIP, tar, tar.gz, tar.bz2, tar.xz, tar.zst, 7z — configurables globalement ou par entrée +- **Sauvegarde SFTP distante** : basée sur la bibliothèque paramiko, authentification par mot de passe ou clé SSH privée, détection automatique de la clé par défaut +- **Sauvegarde WebDAV distante** : basée sur la bibliothèque standard urllib, sans aucune dépendance supplémentaire, compatible avec Jianguoyun / NextCloud / Synology +- **Stockage cloud S3** : basé sur la bibliothèque minio, compatible avec tous les stockages S3 (AWS / MinIO / Alibaba Cloud OSS, etc.) +- **Sauvegarde multi-destinations en parallèle** : sauvegarde simultanée vers le local et plusieurs destinations distantes, sans interférence mutuelle +- **Restauration de sauvegarde** : extraction et restauration depuis un fichier de sauvegarde vers un répertoire cible, avec restauration sélective possible +- **Nettoyage de sauvegardes** : suppression automatique des anciennes sauvegardes, avec stratégies par nombre / durée / conservation quotidienne +- **Sauvegarde chiffrée** : chiffrement par mot de passe pour le format 7z + chiffrement PBKDF2 pour tous les formats +- **Sauvegarde planifiée** : exécution automatique à intervalles réguliers, surveillance en temps réel des fichiers (watchdog) +- **Historique de sauvegarde** : enregistrement de l'heure, de la taille et de la somme de contrôle SHA256 de chaque sauvegarde, pour un suivi facile +- **Journal d'audit** : enregistrement des événements d'audit pour toutes les opérations de sauvegarde et de restauration +- **Hooks Pre/Post** : exécution de commandes personnalisées avant et après la sauvegarde +- **Profils de configuration** : sauvegarde, basculement, import/export de plusieurs jeux de configuration +- **Recherche inter-archives** : recherche de noms de fichiers correspondants dans plusieurs fichiers de sauvegarde +- **Intégrité des données** : génération et vérification de sommes de contrôle SHA256, codes correcteurs d'erreurs Reed-Solomon +- **Validation de configuration** : vérification automatique de la validité des paramètres, détection des modifications non autorisées +- **File d'attente de tâches** : gestion de la file d'attente des tâches de sauvegarde, avec ajout, exécution et annulation +- **Benchmark de compression** : comparaison des performances de compression selon les différents formats et niveaux +- **Estimation d'espace disque** : estimation de la taille de sauvegarde par type de fichier, vérification de l'espace disponible sur la destination +- **Internationalisation** : support de neuf langues — chinois, anglais, français, espagnol, russe, allemand, japonais, portugais, coréen +- **Complétion shell** : auto-complétion pour bash / zsh / fish / powershell +- **Léger et efficace** : faible encombrement, démarrage rapide, consommation de ressources minimale +- **Multiplateforme** : compatible avec Windows, macOS et Linux + +## Demarrage rapide + +### Installation + +#### Installation via pip + +```bash +pip install sbackup-cli +``` + +Après l'installation, utilisez la commande `sbackup` (le nom du paquet PyPI est `sbackup-cli`, la commande CLI est `sbackup`). + +#### Installation depuis les sources + +```bash +git clone https://github.com/xiatianxuan/sbackup.git +cd sbackup +uv sync +``` + +### Utilisation + +#### Syntaxe de base + +```bash +uv run python main.py [options] +``` + +#### Commandes disponibles + +| Commande | Description | +|----------|-------------| +| `add` | Ajouter une stratégie de sauvegarde | +| `rm` / `remove` | Supprimer une stratégie de sauvegarde | +| `edit` | Modifier une stratégie de sauvegarde existante | +| `all` | Afficher toutes les stratégies de sauvegarde | +| `save` | Exécuter la sauvegarde | +| `watch` | Sauvegarde planifiée à intervalles réguliers | +| `restore` | Restaurer depuis un fichier de sauvegarde | +| `info` | Afficher les détails d'un fichier de sauvegarde | +| `diff` | Comparer les différences entre le répertoire source et la sauvegarde | +| `verify` | Vérifier l'intégrité d'un fichier de sauvegarde | +| `search` | Rechercher des fichiers dans une sauvegarde | +| `xsearch` | Rechercher dans plusieurs archives de sauvegarde | +| `versions` | Afficher l'historique des versions de sauvegarde | +| `sftp` | Gestion des sauvegardes SFTP distantes | +| `webdav` | Gestion des sauvegardes WebDAV distantes | +| `remote` | Gestion des fichiers distants (list/rm) | +| `task` | Gestion de la file d'attente des tâches | +| `audit` | Consultation du journal d'audit | +| `hooks` | Exécution manuelle des hooks Pre/Post | +| `profile` | Gestion des profils de configuration | +| `rotate` | Rotation et nettoyage des sauvegardes | +| `clean` | Nettoyage des anciennes sauvegardes | +| `diskcheck` | Estimation de l'espace disque | +| `benchmark` | Benchmark des formats de compression | +| `integrity` | Vérification d'intégrité du répertoire de sauvegarde | +| `dry-run` | Aperçu de la sélection des fichiers de sauvegarde | +| `export` / `import` | Exporter / Importer les stratégies de sauvegarde | +| `ignore` | Générer un fichier .sbackupignore | +| `schedule` | Exporter la configuration de planification | +| `webhook` | Configurer des préréglages de webhook | +| `config` | Configuration du chiffrement / validation | +| `report` | Générer un rapport de sauvegarde | +| `completion` | Générer les scripts de complétion shell | +| `wizard` | Assistant de configuration interactif | +| `status` | Tableau de bord de l'état des sauvegardes | +| `version` | Afficher les informations de version | +| `help` | Afficher l'aide | + +#### Paramètres globaux + +| Paramètre | Description | +|-----------|-------------| +| `--lang zh_CN` / `en_US` / `fr_FR` / `es_ES` / `ru_RU` / `de_DE` / `ja_JP` / `pt_BR` / `ko_KR` | Définir la langue de l'interface (persisté dans config.json) | +| `--format zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` | Définir le format d'archivage (persisté dans config.json) | +| `--debug` | Activer le journal de débogage | + +#### Ajouter une stratégie de sauvegarde + +```bash +uv run python main.py add [-i ignore_patterns] +``` + +Description des paramètres : +- **source** : chemin du dossier source à sauvegarder +- **dest** : chemin de destination pour le fichier de sauvegarde +- **-i, --ignore** : noms de fichiers ou dossiers à ignorer, séparés par des virgules (par défaut : `.git,__pycache__`) +- **--format** : format d'archivage au niveau de l'entrée (s'applique uniquement à cette stratégie, utilise la valeur globale par défaut si non spécifié) : `zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` + +Exemples : +```bash +# Ajouter une stratégie avec le format par défaut global +uv run python main.py add F:/my_folder F:/backup -i node_modules,.git + +# Spécifier le format tar.gz pour cette stratégie (chaque sauvegarde de ce dossier utilisera tar.gz) +uv run python main.py add F:/my_folder F:/backup --format tar.gz + +# Spécifier le format 7z (uniquement pour ce dossier) +uv run python main.py add F:/my_folder F:/backup --format 7z +``` + +#### Supprimer une stratégie de sauvegarde + +```bash +uv run python main.py rm +``` + +Description des paramètres : +- **path** : chemin du dossier source dont la stratégie de sauvegarde doit être supprimée + +Exemple : +```bash +uv run python main.py rm F:/my_folder +``` + +#### Afficher toutes les stratégies de sauvegarde + +```bash +uv run python main.py all +``` + +Affiche toutes les stratégies de sauvegarde actuellement configurées. + +#### Exécuter la sauvegarde + +```bash +# Utiliser le format par défaut (ZIP) +uv run python main.py save + +# Utiliser le format tar.gz +uv run python main.py --format tar.gz save + +# Conserver les 5 derniers fichiers de sauvegarde, nettoyer automatiquement les anciens +uv run python main.py save --keep 5 + +# Utiliser le format 7z avec chiffrement +uv run python main.py --format 7z save --password mysecret + +# Interface en anglais + format tar.xz +uv run python main.py --lang en_US --format tar.xz save +``` + +**Paramètres de la commande save :** + +| Paramètre | Valeur par défaut | Description | +|-----------|-------------------|-------------| +| `--keep N` | `0` | Conserver les N fichiers de sauvegarde les plus récents, 0 signifie aucun nettoyage | +| `--password PASSWORD` | `""` | Mot de passe de chiffrement (uniquement pour le format 7z) | +| `--sftp` | `false` | Télécharger vers le serveur SFTP après la sauvegarde | +| `--webdav` | `false` | Télécharger vers le serveur WebDAV après la sauvegarde | + +Sauvegarde automatiquement les dossiers modifiés selon les stratégies configurées. + +#### Sauvegarde planifiée + +```bash +# Exécuter la sauvegarde toutes les 60 minutes +uv run python main.py watch --interval 60 + +# Sauvegarder toutes les 2 heures, conserver les 10 derniers fichiers +uv run python main.py watch --interval 120 --keep 10 + +# Sauvegarde planifiée + chiffrement 7z +uv run python main.py --format 7z watch --interval 60 --password mysecret +``` + +**Paramètres de la commande watch :** + +| Paramètre | Valeur par défaut | Description | +|-----------|-------------------|-------------| +| `--interval MINUTES` | `60` | Intervalle de sauvegarde (en minutes) | +| `--keep N` | `0` | Conserver les N fichiers de sauvegarde les plus récents | +| `--password PASSWORD` | `""` | Mot de passe de chiffrement (uniquement pour le format 7z) | +| `--sftp` | `false` | Télécharger vers le serveur SFTP après chaque sauvegarde | +| `--webdav` | `false` | Télécharger vers le serveur WebDAV après chaque sauvegarde | + +Appuyez sur `Ctrl+C` pour arrêter la sauvegarde planifiée. + +#### Restaurer une sauvegarde + +```bash +uv run python main.py restore +``` + +Description des paramètres : +- **backup_file** : chemin du fichier de sauvegarde (compatible .zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z) +- **target_dir** : répertoire cible de la restauration + +Exemples : +```bash +uv run python main.py restore F:/backup/my_folder.tar.gz F:/restored +uv run python main.py restore F:/backup/my_folder.7z F:/restored +uv run python main.py restore F:/backup/my_folder.tar.zst F:/restored +``` + +#### Sauvegarde SFTP distante + +```bash +# ============ Demarrage rapide (recommande) ============ +# 1. Configurer le SFTP (détection automatique de la clé SSH privée, aucune spécification manuelle nécessaire) +sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups + +# 2. Tester la connexion +sbackup sftp test + +# 3. Exécuter la sauvegarde et télécharger +sbackup save --sftp + +# ============ Modes d'authentification ============ + +# Mode 1 : Détection automatique de la clé privée (recommandé) +# Le système tente automatiquement ~/.ssh/id_ed25519 → id_rsa → id_ecdsa +sbackup sftp config --host 192.168.1.100 --user admin + +# Mode 2 : Authentification par mot de passe +sbackup sftp config --host 192.168.1.100 --user admin --password secret + +# Mode 3 : Spécifier une clé privée +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# Mode 4 : Clé privée + phrase secrète (saisie interactive) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# Mode 5 : Clé privée + phrase secrète (spécifiée en ligne de commande) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mykeypass + +# ============ Scenarios d'utilisation ============ + +# Scénario 1 : Sauvegarde unique avec téléchargement +sbackup save --sftp + +# Scénario 2 : Sauvegarde planifiée avec téléchargement automatique (toutes les 60 minutes) +sbackup watch --interval 60 --sftp + +# Scénario 3 : Sauvegarde dans un format spécifié + téléchargement +sbackup --format tar.gz save --sftp + +# Scénario 4 : Sauvegarde chiffrée + téléchargement +sbackup --format 7z save --password mysecret --sftp + +# Scénario 5 : Conserver les 5 dernières sauvegardes + téléchargement +sbackup save --keep 5 --sftp + +# ============ Utilisation avancee ============ + +# Configuration interactive (saisie de tous les paramètres étape par étape) +sbackup sftp config + +# Configuration non interactive (tous les paramètres spécifiés en ligne de commande) +sbackup sftp config --host 192.168.1.100 --port 22 --user admin --password secret --remote-path /backups + +# Tester la connexion avec journal détaillé +sbackup --debug sftp test +``` + +**Sous-commandes sftp :** + +| Sous-commande | Description | Exemple | +|---------------|-------------|---------| +| `sftp config` | Configurer les paramètres de connexion SFTP (host/port/user/password/key_file/key_passphrase/remote_path) | `sbackup sftp config --host 192.168.1.100 --user admin` | +| `sftp test` | Tester si la connexion SFTP est disponible | `sbackup sftp test` | + +**Modes d'authentification :** + +| Mode | Paramètres | Description | Exemple | +|------|------------|-------------|---------| +| **Détection automatique** | Aucun paramètre d'authentification | Tente automatiquement `~/.ssh/id_ed25519` → `id_rsa` → `id_ecdsa` (recommandé) | `sbackup sftp config --host ... --user ...` | +| Mot de passe | `--password` | Connexion directe par mot de passe | `sbackup sftp config --host ... --user ... --password secret` | +| Clé privée | `--key-file` | Connexion avec une clé SSH privée spécifiée | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa` | +| Clé privée + phrase | `--key-file` + `--key-passphrase` | Lorsque la clé privée est protégée par une phrase secrète | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa --key-passphrase mypass` | + +Formats de clés privées pris en charge : RSA, Ed25519, ECDSA. + +**Support des chemins multiplateforme :** + +| Plateforme | Exemple de chemin de clé privée | Description | +|------------|--------------------------------|-------------| +| Linux/macOS | `~/.ssh/id_rsa` | Développement automatiquement en `/home/user/.ssh/id_rsa` | +| Windows | `~/.ssh/id_rsa` | Développement automatiquement en `C:\Users\username\.ssh\id_rsa` | +| Toutes plateformes | Chemin absolu | Utilisation directe du chemin complet | + +La configuration SFTP est enregistrée dans le champ `sftp` du fichier `config.json`, configurable via les paramètres de ligne de commande ou en mode interactif. + +#### Afficher les informations de version + +```bash +sbackup version +``` + +## Fichier de configuration + +Sbackup prend en charge la personnalisation via un fichier `config.json`. Le fichier de configuration doit être placé à la racine du projet. + +### Description des paramètres de configuration + +```json +{ + "compression_format": "ZIP", + "compression": { + "algorithm": "ZIP_DEFLATED", + "level": 6 + }, + "skip_patterns": [".git", "__pycache__"], + "data_file": "sbackup.json", + "lang": "zh_CN", + "password": "", + "sftp": { + "host": "", + "port": 22, + "user": "", + "password": "", + "key_file": "", + "key_passphrase": "", + "remote_path": "/", + "enabled": false + } +} +``` + +| Paramètre | Type | Valeur par défaut | Description | +|-----------|------|-------------------|-------------| +| `compression_format` | string | `"ZIP"` | Format d'archivage, valeurs possibles : `ZIP`, `TAR`, `TAR_GZ`, `TAR_BZ2`, `TAR_XZ`, `TAR_ZST`, `7Z` | +| `compression.algorithm` | string | `"ZIP_DEFLATED"` | Algorithme de compression ZIP, valeurs possibles : `ZIP_DEFLATED`, `ZIP_STORED`, `ZIP_BZIP2`, `ZIP_LZMA` | +| `compression.level` | int | `6` | Niveau de compression, de 0 à 9 (0 = pas de compression, 9 = compression maximale) | +| `skip_patterns` | list | `[".git", "__pycache__"]` | Motifs de fichiers ou dossiers à ignorer (supporte les jokers fnmatch et la correspondance de chemins) | +| `data_file` | string | Chemin par défaut de la plateforme | Chemin du fichier de données des stratégies de sauvegarde | +| `lang` | string | `"zh_CN"` | Langue de l'interface, valeurs possibles : `zh_CN`, `en_US`, `fr_FR`, `es_ES`, `ru_RU`, `de_DE`, `ja_JP`, `pt_BR`, `ko_KR` | +| `password` | string | `""` | Mot de passe de chiffrement 7z | +| `sftp.host` | string | `""` | Adresse du serveur SFTP | +| `sftp.port` | int | `22` | Port SFTP | +| `sftp.user` | string | `""` | Nom d'utilisateur SFTP | +| `sftp.password` | string | `""` | Mot de passe SFTP (utilisé pour l'authentification par mot de passe) | +| `sftp.key_file` | string | `""` | Chemin du fichier de clé SSH privée (utilisé pour l'authentification par clé, recommandé) | +| `sftp.key_passphrase` | string | `""` | Phrase secrète de la clé privée (le cas échéant) | +| `sftp.remote_path` | string | `"/"` | Chemin de destination distant | +| `sftp.enabled` | bool | `false` | Activer ou non le SFTP | + +### Exemple de configuration + +Utiliser le format tar.bz2 pour une sauvegarde à taux de compression élevé : + +```json +{ + "compression_format": "TAR_BZ2", + "compression_level": 9, + "skip_patterns": [".git", "__pycache__", "node_modules", "*.log"], + "data_file": "backup_strategies.json", + "lang": "zh_CN" +} +``` + +### Comparaison des formats d'archivage + +| Format | Extension | Taux de compression | Vitesse | Dépendance | Cas d'utilisation | +|--------|-----------|---------------------|---------|------------|-------------------| +| ZIP | .zip | Moyen | Rapide | Bibliothèque standard | Universel, meilleure compatibilité sous Windows | +| tar | .tar | Aucun | Très rapide | Bibliothèque standard | Archivage pur, à combiner avec une compression externe | +| tar.gz | .tar.gz | Moyen | Rapide | Bibliothèque standard | Courant sous Linux/macOS | +| tar.bz2 | .tar.bz2 | Élevé | Moyen | Bibliothèque standard | Archivage à taux de compression élevé | +| tar.xz | .tar.xz | Le plus élevé | Lent | Bibliothèque standard | Archivage à long terme, espace limité | +| tar.zst | .tar.zst | Moyen-élevé | Très rapide | zstandard | Usage moderne, bon équilibre vitesse/compression | +| 7z | .7z | Très élevé | Lent | py7zr | Compression maximale, support du chiffrement | + +#### Sauvegarde WebDAV distante + +WebDAV est un protocole de fichiers basé sur HTTP, compatible avec les principaux services cloud comme Jianguoyun, NextCloud et Synology. Utilise la bibliothèque standard Python `urllib`, **sans aucune dépendance supplémentaire**. + +```bash +# ============ Demarrage rapide ============ +# 1. Configurer le WebDAV +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --password secret + +# 2. Tester la connexion +sbackup webdav test + +# 3. Exécuter la sauvegarde et télécharger +sbackup save --webdav + +# ============ Scenarios d'utilisation ============ + +# Scénario 1 : Sauvegarde unique avec téléchargement +sbackup save --webdav + +# Scénario 2 : Sauvegarde planifiée avec téléchargement automatique (toutes les 60 minutes) +sbackup watch --interval 60 --webdav + +# Scénario 3 : Spécifier un sous-répertoire distant +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --remote-path /backups/sbackup + +# Scénario 4 : Télécharger simultanément vers SFTP et WebDAV +sbackup save --sftp --webdav + +# ============ Adresses de services WebDAV courants ============ +# Jianguoyun: https://dav.jianguoyun.com/dav/ +# NextCloud: https://your-server/remote.php/dav/files/username/ +# Synology: https://your-synology:5006/webdav/ +``` + +**Sous-commandes webdav :** + +| Sous-commande | Description | Exemple | +|---------------|-------------|---------| +| `webdav config` | Configurer les paramètres de connexion WebDAV (url/user/password/remote_path) | `sbackup webdav config --url ... --user ...` | +| `webdav test` | Tester si la connexion WebDAV est disponible | `sbackup webdav test` | + +| Paramètre | Valeur par défaut | Description | +|-----------|-------------------|-------------| +| `--url URL` | `""` | Adresse du serveur WebDAV (par ex. `https://dav.jianguoyun.com/dav/`) | +| `--user USER` | `""` | Nom d'utilisateur WebDAV (généralement une adresse e-mail) | +| `--password PASS` | `""` | Mot de passe WebDAV (pour Jianguoyun, générez un mot de passe d'application dans les paramètres) | +| `--remote-path PATH` | `/` | Chemin de destination distant | + +## Principe de fonctionnement + +Sbackup implémente les fonctionnalités de sauvegarde de la manière suivante : + +1. **Stockage des stratégies** : les stratégies de sauvegarde sont enregistrées dans un fichier JSON, contenant les chemins des dossiers, les dates de dernière modification, les chemins de destination, les motifs d'exclusion et les formats d'archivage par entrée. +2. **Sauvegarde incrémentielle** : en comparant les dates de dernière modification des dossiers, seuls les dossiers modifiés sont sauvegardés. +3. **Compression multi-format** : utilisation des modules Python intégrés `zipfile` et `tarfile`, ainsi que des bibliothèques tierces `zstandard` et `py7zr`, pour prendre en charge sept formats d'archivage. +4. **Format par entrée** : chaque stratégie de sauvegarde peut spécifier un format d'archivage indépendant (`add --format`), prioritaire sur le paramètre global `--format` ; la valeur globale est utilisée si aucune n'est spécifiée. +5. **Nettoyage des sauvegardes** : après une sauvegarde réussie, le répertoire cible est analysé, trié par date de modification, et les fichiers dépassant le nombre de rétention sont supprimés. +6. **Sauvegarde chiffrée** : le format 7z supporte le chiffrement LZMA2, configurable via le paramètre `--password` ou le fichier `config.json`. +7. **Sauvegarde planifiée** : la commande `watch` exécute la sauvegarde en boucle à l'intervalle spécifié, arrêt sécurisé avec `Ctrl+C`. +8. **Historique de sauvegarde** : après chaque sauvegarde, l'horodatage, la taille du fichier et le nombre de fichiers sont enregistrés, avec conservation des 100 dernières entrées. +9. **Sauvegarde SFTP distante** : implémentation d'un client SFTP basée sur la bibliothèque paramiko, avec test de connexion, création automatique de répertoires distants et upload avec barre de progression. + +### Format du fichier de données + +```json +{ + "/path/to/source/folder": [ + 1719235200.0, + "/path/to/target/folder", + [".git", "__pycache__"], + "" + ], + "/path/to/another/folder": [ + 1719235200.0, + "/path/to/another/target", + [".git"], + "TAR_GZ" + ], + "_history": [ + { + "time": "2026-05-01T12:00:00", + "source": "/path/to/source/folder", + "size_mb": 12.5, + "files_count": 150 + } + ] +} +``` + +Chaque entrée de stratégie de sauvegarde est une liste de 4 éléments : `[mtime, target, skip_patterns, compression_format]` + +| Champ | Description | +|-------|-------------| +| `mtime` | Date de dernière modification du dossier source (utilisée pour la décision de sauvegarde incrémentielle) | +| `target` | Chemin de destination pour le fichier de sauvegarde | +| `skip_patterns` | Liste des motifs de fichiers/dossiers à ignorer | +| `compression_format` | Format d'archivage au niveau de l'entrée (chaîne vide = utilisation de la valeur globale par défaut) | + +## Guide de developpement + +### Executer les tests + +```bash +uv run coverage run -m unittest discover -s tests -t . && uv run coverage report -m +``` + +### Structure du code + +``` +sbackup/ +├── main.py # Point d'entrée du programme +├── sbackup/ +│ ├── __init__.py # Export des fonctions principales +│ ├── __main__.py # Point d'entrée python -m sbackup +│ ├── cli.py # Analyse des arguments CLI et dispatch des commandes (30+ commandes) +│ ├── config.py # Chargement de la configuration, chiffrement, configuration Webhook/SMTP +│ ├── auto_save.py # Moteur principal BackupManager +│ ├── compression.py # Moteur de compression/décompression pour 7 formats +│ ├── i18n.py # Internationalisation (9 langues) +│ ├── sftp.py # Client de sauvegarde SFTP distant (paramiko) +│ ├── webdav.py # Client de sauvegarde WebDAV distant (sans dépendance) +│ ├── cloud_storage.py # Client de stockage cloud S3 (minio) +│ ├── multi_dest.py # Sauvegarde multi-destinations en parallèle +│ ├── handlers.py # Gestionnaires de commandes SFTP/WebDAV/Remote/Schedule +│ ├── hooks.py # Exécution des hooks Pre/Post +│ ├── audit.py # Système de journal d'audit +│ ├── profile.py # Gestion des profils de configuration +│ ├── selective.py # Restauration sélective +│ ├── cross_search.py # Recherche inter-archives +│ ├── integrity.py # Sommes de contrôle SHA256 +│ ├── rotation.py # Stratégies de rotation des sauvegardes +│ ├── dryrun.py # Aperçu en mode dry-run +│ ├── diskcheck.py # Estimation de l'espace disque +│ ├── task_queue.py # Système de file d'attente de tâches +│ ├── schema.py # Validateur de configuration +│ ├── benchmark.py # Benchmark de compression +│ ├── chunked_backup.py# Sauvegarde incrémentielle par blocs +│ ├── dedup.py # Dédoublonnage par fichier SHA256 +│ ├── export.py # Export des métadonnées (CSV/JSON) +│ ├── monitor.py # Surveillance du système de fichiers (watchdog) +│ ├── lock.py # Verrouillage de processus multiplateforme +│ ├── retry.py # Réessai avec backoff exponentiel +│ ├── ratelimiter.py # Limiteur de débit par jeton +│ ├── keychain.py # Intégration du trousseau système +│ ├── parity.py # Codes correcteurs d'erreurs Reed-Solomon +│ ├── completion.py # Auto-complétion shell +│ ├── wizard.py # Assistant de configuration interactif +│ └── locales/ # Fichiers de traduction pour 9 langues +└── tests/ + └── sbackup/ + └── test_*.py # 30 fichiers de test couvrant tous les modules +``` + +### Ajouter une nouvelle fonctionnalité + +1. Créer un nouveau fichier module dans le répertoire `sbackup/` +2. Importer les fonctions de la nouvelle fonctionnalité dans `sbackup/__init__.py` +3. Ajouter la logique de gestion de la nouvelle commande dans la fonction `run()` +4. Ajouter le fichier de test correspondant dans le répertoire `tests/` + +## Questions frequentes + +### Q : Que faire si le fichier de stratégie de sauvegarde est supprimé par erreur ? + +R : Les stratégies de sauvegarde sont stockées dans le fichier de données. En cas de suppression accidentelle, vous pouvez les recréer en réexécutant la commande `add`. + +### Q : Comment modifier une stratégie de sauvegarde déjà ajoutée ? + +R : Utilisez la commande `sbackup edit` : `sbackup edit --dest --ignore --format `. + +### Q : La sauvegarde distante est-elle prise en charge ? + +R : Oui ! Trois méthodes de sauvegarde distante sont disponibles : +- **SFTP** : configuration avec `sbackup sftp config`, téléchargement avec `sbackup save --sftp` +- **WebDAV** : configuration avec `sbackup webdav config`, téléchargement avec `sbackup save --webdav` (compatible Jianguoyun / NextCloud / Synology) +- **Stockage cloud S3** : configurer le champ `cloud` dans `config.json`, téléchargement avec `sbackup save --cloud` +- Activation simultanée de plusieurs méthodes : `sbackup save --sftp --webdav --cloud` + +### Q : Quelle est la différence entre tar.gz et ZIP ? + +R : tar.gz est plus courant sous Linux/macOS avec un taux de compression légèrement supérieur ; ZIP est plus universel sous Windows avec la meilleure compatibilité. tar.bz2 et tar.xz offrent des taux de compression plus élevés mais sont plus lents. tar.zst est un algorithme moderne, extrêmement rapide avec un bon taux de compression. 7z offre le taux de compression le plus élevé et supporte le chiffrement. + +### Q : Comment chiffrer une sauvegarde ? + +R : Utilisez le format 7z avec un mot de passe : `uv run python main.py --format 7z save --password yourpassword`. Le mot de passe peut également être enregistré dans le champ `password` du fichier `config.json`. + +### Q : Comment nettoyer automatiquement les anciennes sauvegardes ? + +R : Utilisez le paramètre `--keep` : `uv run python main.py save --keep 5` ne conserve que les 5 fichiers de sauvegarde les plus récents. La sauvegarde planifiée le supporte également : `uv run python main.py watch --interval 60 --keep 10`. + +### Q : Comment configurer une sauvegarde planifiée ? + +R : Utilisez la commande `watch` : `uv run python main.py watch --interval 60` pour sauvegarder toutes les 60 minutes. Appuyez sur `Ctrl+C` pour arrêter. + +### Q : Le stockage des mots de passe est-il sécurisé ? + +R : Les mots de passe SFTP et de chiffrement 7z dans `config.json` sont stockés en **clair**. Veuillez vous assurer que l'accès au fichier `config.json` est limité aux utilisateurs de confiance (par exemple, `chmod 600 config.json`). Ne commitez pas un fichier `config.json` contenant des mots de passe dans un système de contrôle de version. + +## Guide de contribution + +Les Issues et Pull Requests sont les bienvenus ! + +1. Forker ce dépôt +2. Créer votre branche de fonctionnalité (`git checkout -b feature/AmazingFeature`) +3. Valider vos modifications (`git commit -m 'Add some AmazingFeature'`) +4. Pousser vers la branche (`git push origin feature/AmazingFeature`) +5. Soumettre une Pull Request + +### Style de code + +Ce projet suit les conventions PEP 8 et le Google Python Style Guide. Veuillez vous assurer que votre code : +- Utilise des annotations de type +- Suit les docstrings au format Google +- Passe tous les tests unitaires + +## Licence + +Ce projet est sous licence GNU GPL v3.0. Pour plus de détails, consultez le fichier [LICENSE](../../LICENSE). + +## Auteur + +**xiatianxuan** (CodeSeed) + +- [Gitee](https://gitee.com/xiatianxuan) +- [Page personnelle](https://xnors-codeseed.pages.dev/) + +## Remerciements + +- [Xnors Studio](https://xnors.github.io/) + +## Nous contacter + +Pour toute question ou suggestion, envoyez un e-mail à : xiatianxuan2025@163.com + +--- + +*Dernière mise à jour : 19 juin 2026* diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md new file mode 100644 index 0000000..d521232 --- /dev/null +++ b/docs/readme/README_ja.md @@ -0,0 +1,665 @@ +# Sbackup + +[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![License](https://img.shields.io/badge/License-GPL--3.0-green)](../../LICENSE) +[![PyPI](https://img.shields.io/pypi/v/sbackup-cli?color=blue)](https://pypi.org/project/sbackup-cli/) +[![Tests](https://img.shields.io/badge/tests-940%20passed-brightgreen)](../../.github/workflows/ci.yml) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)]() + +> 軽量で高効率なフォルダバックアップツール。コマンドラインでバックアップ戦略を簡単に管理できます。 + +[English](../../README.md) | [Deutsch](README_de.md) | [Espanol](README_es.md) | [Francais](README_fr.md) | [Portugues](README_pt.md) | [Pycckuu](README_ru.md) | [日本語](README_ja.md) | [한국어](README_ko.md) | [中文](README_zh.md) + +- [はじめに](#はじめに) +- [機能一覧](#機能一覧) +- [クイックスタート](#クイックスタート) + - [インストール](#インストール) + - [使い方](#使い方) +- [設定ファイル](#設定ファイル) + - [設定例](#設定例) +- [SFTP リモートバックアップ](#sftp-リモートバックアップ) +- [WebDAV リモートバックアップ](#webdav-リモートバックアップ) +- [仕組み](#仕組み) +- [開発ガイド](#開発ガイド) + - [テスト実行](#テスト実行) + - [コード構造](#コード構造) +- [よくある質問](#よくある質問) +- [コントリビューションガイド](#コントリビューションガイド) +- [ライセンス](#ライセンス) +- [著者](#著者) + +--- + +## はじめに + +Sbackup は軽量なフォルダバックアップツールです。コマンドラインからバックアップ戦略の追加、削除、表示が可能です。フォルダの最終更新日時を基にバックアップが必要かどうかを判断し、データを常に最新の状態に保ちます。 + +## 機能一覧 + +- **増分バックアップ**: 変更されたフォルダのみをバックアップし、時間とストレージを節約 +- **多形式対応**: ZIP、tar、tar.gz、tar.bz2、tar.xz、tar.zst、7z の 7 種類のアーカイブ形式に対応。グローバルおよびエントリ単位で個別に指定可能 +- **SFTP リモートバックアップ**: paramiko ベース。パスワード/SSH 秘密鍵認証、デフォルト秘密鍵の自動検出に対応 +- **WebDAV リモートバックアップ**: Python 標準ライブラリ urllib ベース。追加依存なし。Jianguoyun/NextCloud/Synology 対応 +- **S3 クラウドストレージ**: minio ベース。すべての S3 互換ストレージ(AWS/MinIO/Alibaba Cloud OSS 等)に対応 +- **マルチターゲット並列バックアップ**: ローカルと複数のリモートターゲットへ同時にバックアップ。互いに影響しない +- **バックアップ復元**: バックアップファイルからの解凍・復元に対応。選択的な復元も可能 +- **バックアップクリーンアップ**: 古いバックアップの自動削除。数量/日数/日別保持ポリシーに対応 +- **暗号化バックアップ**: 7z 形式のパスワード暗号化と全形式 PBKDF2 暗号化に対応 +- **定期バックアップ**: 指定間隔での自動実行。リアルタイムファイル監視(watchdog)に対応 +- **バックアップ履歴**: 各バックアップの時刻、サイズ、SHA256 チェックサムを記録し、追跡を容易に +- **監査ログ**: すべてのバックアップ/復元操作の監査イベントを記録 +- **Pre/Post Hook**: バックアップ前後にカスタムコマンドを実行 +- **設定 Profile**: 複数の設定プロファイルの保存、切替、インポート/エクスポート +- **クロスアーカイブ検索**: 複数のバックアップファイルにわたるファイル名検索 +- **データ整合性**: SHA256 チェックサムの生成と検証、Reed-Solomon 誤り訂正符号 +- **設定バリデーション**: 設定パラメータの自動検証、改ざん検出 +- **タスクキュー**: バックアップタスクキューの管理。追加、実行、キャンセルに対応 +- **圧縮ベンチマーク**: 異なる形式/圧縮レベルのパフォーマンス比較 +- **ディスク容量見積もり**: ファイルタイプ別のバックアップサイズ見積もり、ターゲット空き容量チェック +- **国際化**: 中国語、英語、フランス語、スペイン語、ロシア語、ドイツ語、日本語、ポルトガル語、韓国語の 9 言語に対応 +- **Shell 補完**: bash/zsh/fish/powershell での自動補完に対応 +- **軽量高効率**: 小さなサイズ、高速起動、リソース消費が低い +- **クロスプラットフォーム対応**: Windows、macOS、Linux に対応 + +## クイックスタート + +### インストール + +#### pip でのインストール + +```bash +pip install sbackup-cli +``` + +インストール後、`sbackup` コマンドが使用可能になります(PyPI パッケージ名は `sbackup-cli`、CLI コマンドは `sbackup`)。 + +#### ソースからのインストール + +```bash +git clone https://github.com/xiatianxuan/sbackup.git +cd sbackup +uv sync +``` + +### 使い方 + +#### 基本構文 + +```bash +uv run python main.py [options] +``` + +#### 利用可能なコマンド + +| コマンド | 説明 | +|----------|------| +| `add` | バックアップ戦略を追加 | +| `rm` / `remove` | バックアップ戦略を削除 | +| `edit` | 既存のバックアップ戦略を編集 | +| `all` | すべてのバックアップ戦略を表示 | +| `save` | バックアップを実行 | +| `watch` | 定期バックアップを実行 | +| `restore` | バックアップファイルから復元 | +| `info` | バックアップファイルの詳細を表示 | +| `diff` | ソースディレクトリとバックアップの差分を比較 | +| `verify` | バックアップファイルの整合性を検証 | +| `search` | バックアップ内をファイル検索 | +| `xsearch` | 複数のバックアップアーカイブを横断検索 | +| `versions` | バックアップバージョン履歴を表示 | +| `sftp` | SFTP リモートバックアップ管理 | +| `webdav` | WebDAV リモートバックアップ管理 | +| `remote` | リモートファイル管理(list/rm) | +| `task` | バックアップタスクキュー管理 | +| `audit` | 監査ログ照会 | +| `hooks` | Pre/Post Hook を手動実行 | +| `profile` | 設定 Profile 管理 | +| `rotate` | バックアップローテーションクリーンアップ | +| `clean` | 古いバックアップをクリーンアップ | +| `diskcheck` | ディスク容量見積もり | +| `benchmark` | 圧縮形式ベンチマーク | +| `integrity` | バックアップディレクトリ整合性検証 | +| `dry-run` | バックアップファイル選択のプレビュー | +| `export` / `import` | バックアップ戦略のエクスポート/インポート | +| `ignore` | .sbackupignore ファイルを生成 | +| `schedule` | 定期スケジュール設定をエクスポート | +| `webhook` | Webhook プリセットを設定 | +| `config` | 暗号化/検証の設定 | +| `report` | バックアップレポートを生成 | +| `completion` | Shell 補完スクリプトを生成 | +| `wizard` | インタラクティブ設定ウィザード | +| `status` | バックアップステータスダッシュボード | +| `version` | バージョン情報を表示 | +| `help` | ヘルプを表示 | + +#### グローバルオプション + +| オプション | 説明 | +|-----------|------| +| `--lang zh_CN` / `en_US` / `fr_FR` / `es_ES` / `ru_RU` / `de_DE` / `ja_JP` / `pt_BR` / `ko_KR` | UI 言語を設定(config.json に永続化) | +| `--format zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` | アーカイブ形式を設定(config.json に永続化) | +| `--debug` | デバッグログを有効化 | + +#### バックアップ戦略の追加 + +```bash +uv run python main.py add [-i ignore_patterns] +``` + +パラメータ説明: +- **source**: バックアップ対象のソースフォルダパス +- **dest**: バックアップファイルの保存先パス +- **-i, --ignore**: 無視するファイルまたはフォルダ名(カンマ区切り。デフォルト: `.git,__pycache__`) +- **--format**: エントリ単位のアーカイブ形式(このバックアップ戦略のみに適用。未指定時はグローバルデフォルトを使用): `zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` + +例: +```bash +# グローバルデフォルト形式で戦略を追加 +uv run python main.py add F:/my_folder F:/backup -i node_modules,.git + +# この戦略に tar.gz 形式を指定(このフォルダのバックアップは常に tar.gz) +uv run python main.py add F:/my_folder F:/backup --format tar.gz + +# 7z 形式を指定(このフォルダのみ) +uv run python main.py add F:/my_folder F:/backup --format 7z +``` + +#### バックアップ戦略の削除 + +```bash +uv run python main.py rm +``` + +パラメータ説明: +- **path**: バックアップ戦略を削除するソースフォルダのパス + +例: +```bash +uv run python main.py rm F:/my_folder +``` + +#### すべてのバックアップ戦略を表示 + +```bash +uv run python main.py all +``` + +現在設定されているすべてのバックアップ戦略を表示します。 + +#### バックアップの実行 + +```bash +# デフォルト形式(ZIP)を使用 +uv run python main.py save + +# tar.gz 形式を使用 +uv run python main.py --format tar.gz save + +# 直近 5 件のバックアップを保持し、古いものを自動クリーンアップ +uv run python main.py save --keep 5 + +# 7z 形式で暗号化 +uv run python main.py --format 7z save --password mysecret + +# 英語 UI + tar.xz 形式 +uv run python main.py --lang en_US --format tar.xz save +``` + +**save コマンドのオプション:** + +| オプション | デフォルト値 | 説明 | +|-----------|-------------|------| +| `--keep N` | `0` | 直近 N 件のバックアップファイルを保持。0 はクリーンアップなし | +| `--password PASSWORD` | `""` | 暗号化パスワード(7z 形式のみ対応) | +| `--sftp` | `false` | バックアップ完了後に SFTP サーバーへアップロード | +| `--webdav` | `false` | バックアップ完了後に WebDAV サーバーへアップロード | + +バックアップ戦略に基づき、変更されたフォルダを自動的にバックアップします。 + +#### 定期バックアップ + +```bash +# 60 分ごとにバックアップを実行 +uv run python main.py watch --interval 60 + +# 2 時間ごとにバックアップ、直近 10 ファイルを保持 +uv run python main.py watch --interval 120 --keep 10 + +# 定期バックアップ + 7z 暗号化 +uv run python main.py --format 7z watch --interval 60 --password mysecret +``` + +**watch コマンドのオプション:** + +| オプション | デフォルト値 | 説明 | +|-----------|-------------|------| +| `--interval MINUTES` | `60` | バックアップ間隔(分) | +| `--keep N` | `0` | 直近 N 件のバックアップファイルを保持 | +| `--password PASSWORD` | `""` | 暗号化パスワード(7z 形式のみ対応) | +| `--sftp` | `false` | バックアップ後に SFTP サーバーへアップロード | +| `--webdav` | `false` | バックアップ後に WebDAV サーバーへアップロード | + +`Ctrl+C` で定期バックアップを停止できます。 + +#### バックアップの復元 + +```bash +uv run python main.py restore +``` + +パラメータ説明: +- **backup_file**: バックアップファイルのパス(.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z 対応) +- **target_dir**: 復元先ディレクトリ + +例: +```bash +uv run python main.py restore F:/backup/my_folder.tar.gz F:/restored +uv run python main.py restore F:/backup/my_folder.7z F:/restored +uv run python main.py restore F:/backup/my_folder.tar.zst F:/restored +``` + +#### SFTP リモートバックアップ + +```bash +# ============ クイックスタート(推奨) ============ +# 1. SFTP を設定(SSH 秘密鍵を自動検出、手動指定不要) +sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups + +# 2. 接続テスト +sbackup sftp test + +# 3. バックアップ実行とアップロード +sbackup save --sftp + +# ============ 認証方式 ============ + +# 方式 1: 秘密鍵の自動検出(推奨) +# ~/.ssh/id_ed25519 → id_rsa → id_ecdsa を自動的に試行 +sbackup sftp config --host 192.168.1.100 --user admin + +# 方式 2: パスワード認証 +sbackup sftp config --host 192.168.1.100 --user admin --password secret + +# 方式 3: 秘密鍵を指定 +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# 方式 4: 秘密鍵 + パスフレーズ(対話的入力) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# 方式 5: 秘密鍵 + パスフレーズ(コマンドラインで指定) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mykeypass + +# ============ 使用シーン ============ + +# シーン 1: 1 回限りのバックアップとアップロード +sbackup save --sftp + +# シーン 2: 定期バックアップと自動アップロード(60 分ごと) +sbackup watch --interval 60 --sftp + +# シーン 3: 形式を指定してバックアップ + アップロード +sbackup --format tar.gz save --sftp + +# シーン 4: 暗号化バックアップ + アップロード +sbackup --format 7z save --password mysecret --sftp + +# シーン 5: 直近 5 件を保持 + アップロード +sbackup save --keep 5 --sftp + +# ============ 高度な使い方 ============ + +# インタラクティブ設定(すべてのパラメータを順に入力) +sbackup sftp config + +# 非インタラクティブ設定(すべてのパラメータをコマンドラインで指定) +sbackup sftp config --host 192.168.1.100 --port 22 --user admin --password secret --remote-path /backups + +# 接続テストと詳細ログ表示 +sbackup --debug sftp test +``` + +**sftp サブコマンド:** + +| サブコマンド | 説明 | 例 | +|-------------|------|-----| +| `sftp config` | SFTP 接続パラメータを設定(host/port/user/password/key_file/key_passphrase/remote_path) | `sbackup sftp config --host 192.168.1.100 --user admin` | +| `sftp test` | SFTP 接続のテスト | `sbackup sftp test` | + +**認証方式:** + +| 方式 | パラメータ | 説明 | 例 | +|------|-----------|------|-----| +| **自動検出** | 認証パラメータを指定しない | `~/.ssh/id_ed25519` → `id_rsa` → `id_ecdsa` を自動的に試行(推奨) | `sbackup sftp config --host ... --user ...` | +| パスワード認証 | `--password` | パスワードで直接ログイン | `sbackup sftp config --host ... --user ... --password secret` | +| 秘密鍵認証 | `--key-file` | 指定した SSH 秘密鍵でログイン | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa` | +| 秘密鍵+パスフレーズ | `--key-file` + `--key-passphrase` | 秘密鍵にパスフレーズがある場合に使用 | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa --key-passphrase mypass` | + +対応する秘密鍵形式: RSA、Ed25519、ECDSA。 + +**クロスプラットフォームパス対応:** + +| プラットフォーム | 秘密鍵パスの例 | 説明 | +|-----------------|----------------|------| +| Linux/macOS | `~/.ssh/id_rsa` | `/home/user/.ssh/id_rsa` に自動展開 | +| Windows | `~/.ssh/id_rsa` | `C:\Users\username\.ssh\id_rsa` に自動展開 | +| 全プラットフォーム | 絶対パス | 完全なパスを直接使用 | + +SFTP 設定は `config.json` の `sftp` フィールドに保存されます。コマンドライン引数または対話的入力で設定できます。 + +#### バージョン情報の表示 + +```bash +sbackup version +``` + +## 設定ファイル + +Sbackup は `config.json` ファイルによるカスタマイズ設定に対応しています。設定ファイルはプロジェクトルートディレクトリに配置してください。 + +### 設定項目の説明 + +```json +{ + "compression_format": "ZIP", + "compression": { + "algorithm": "ZIP_DEFLATED", + "level": 6 + }, + "skip_patterns": [".git", "__pycache__"], + "data_file": "sbackup.json", + "lang": "zh_CN", + "password": "", + "sftp": { + "host": "", + "port": 22, + "user": "", + "password": "", + "key_file": "", + "key_passphrase": "", + "remote_path": "/", + "enabled": false + } +} +``` + +| 設定項目 | 型 | デフォルト値 | 説明 | +|---------|-----|------------|------| +| `compression_format` | string | `"ZIP"` | アーカイブ形式。選択肢: `ZIP`, `TAR`, `TAR_GZ`, `TAR_BZ2`, `TAR_XZ`, `TAR_ZST`, `7Z` | +| `compression.algorithm` | string | `"ZIP_DEFLATED"` | ZIP 圧縮アルゴリズム。選択肢: `ZIP_DEFLATED`, `ZIP_STORED`, `ZIP_BZIP2`, `ZIP_LZMA` | +| `compression.level` | int | `6` | 圧縮レベル(0-9)。0 は無圧縮、9 が最高圧縮 | +| `skip_patterns` | list | `[".git", "__pycache__"]` | 無視するファイルまたはフォルダのパターン(fnmatch ワイルドカードとパスマッチに対応) | +| `data_file` | string | プラットフォームのデフォルトパス | バックアップ戦略データファイルの保存先 | +| `lang` | string | `"zh_CN"` | UI 言語。選択肢: `zh_CN`, `en_US`, `fr_FR`, `es_ES`, `ru_RU`, `de_DE`, `ja_JP`, `pt_BR`, `ko_KR` | +| `password` | string | `""` | 7z 暗号化パスワード | +| `sftp.host` | string | `""` | SFTP サーバーアドレス | +| `sftp.port` | int | `22` | SFTP ポート | +| `sftp.user` | string | `""` | SFTP ユーザー名 | +| `sftp.password` | string | `""` | SFTP パスワード(パスワード認証時に使用) | +| `sftp.key_file` | string | `""` | SSH 秘密鍵ファイルのパス(秘密鍵認証時に使用。推奨) | +| `sftp.key_passphrase` | string | `""` | 秘密鍵のパスフレーズ(必要な場合) | +| `sftp.remote_path` | string | `"/"` | リモートターゲットパス | +| `sftp.enabled` | bool | `false` | SFTP を有効にするか | + +### 設定例 + +tar.bz2 形式で高圧縮率バックアップを行う場合: + +```json +{ + "compression_format": "TAR_BZ2", + "compression_level": 9, + "skip_patterns": [".git", "__pycache__", "node_modules", "*.log"], + "data_file": "backup_strategies.json", + "lang": "zh_CN" +} +``` + +### アーカイブ形式の比較 + +| 形式 | 拡張子 | 圧縮率 | 速度 | 依存関係 | 適用シーン | +|------|--------|--------|------|---------|-----------| +| ZIP | .zip | 中 | 高速 | 標準ライブラリ | 汎用。Windows での互換性が最も高い | +| tar | .tar | なし | 超高速 | 標準ライブラリ | アーカイブのみ。外部圧縮と組み合わせ | +| tar.gz | .tar.gz | 中 | 高速 | 標準ライブラリ | Linux/macOS で一般的 | +| tar.bz2 | .tar.bz2 | 高 | 中 | 標準ライブラリ | 高圧縮率アーカイブ | +| tar.xz | .tar.xz | 最高 | 低速 | 標準ライブラリ | 長期保存。容量制限が厳しい場合 | +| tar.zst | .tar.zst | 中高 | 超高速 | zstandard | モダンな用途。速度と圧縮率のバランス | +| 7z | .7z | 極高 | 低速 | py7zr | 最高圧縮率。暗号化対応 | + +#### WebDAV リモートバックアップ + +WebDAV は HTTP ベースのファイルプロトコルで、Jianguoyun(坚果云)、NextCloud、Synology(群晖)などの主要クラウドストレージに対応しています。Python 標準ライブラリ `urllib` を使用するため、**追加の依存は不要**です。 + +```bash +# ============ クイックスタート ============ +# 1. WebDAV を設定 +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --password secret + +# 2. 接続テスト +sbackup webdav test + +# 3. バックアップ実行とアップロード +sbackup save --webdav + +# ============ 使用シーン ============ + +# シーン 1: 1 回限りのバックアップとアップロード +sbackup save --webdav + +# シーン 2: 定期バックアップと自動アップロード(60 分ごと) +sbackup watch --interval 60 --webdav + +# シーン 3: リモートサブディレクトリを指定 +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --remote-path /backups/sbackup + +# シーン 4: SFTP と WebDAV へ同時にアップロード +sbackup save --sftp --webdav + +# ============ 主要な WebDAV サービスのアドレス ============ +# 坚果云: https://dav.jianguoyun.com/dav/ +# NextCloud: https://your-server/remote.php/dav/files/username/ +# 群晖: https://your-synology:5006/webdav/ +``` + +**webdav サブコマンド:** + +| サブコマンド | 説明 | 例 | +|-------------|------|-----| +| `webdav config` | WebDAV 接続パラメータを設定(url/user/password/remote_path) | `sbackup webdav config --url ... --user ...` | +| `webdav test` | WebDAV 接続のテスト | `sbackup webdav test` | + +| パラメータ | デフォルト値 | 説明 | +|-----------|-------------|------| +| `--url URL` | `""` | WebDAV サーバーアドレス(例: `https://dav.jianguoyun.com/dav/`) | +| `--user USER` | `""` | WebDAV ユーザー名(通常はメールアドレス) | +| `--password PASS` | `""` | WebDAV パスワード(Jianguoyun では設定画面でアプリパスワードを生成) | +| `--remote-path PATH` | `/` | リモートターゲットパス | + +## 仕組み + +Sbackup は以下の方法でバックアップ機能を実現しています。 + +1. **バックアップ戦略の保存**: バックアップ戦略は JSON ファイルに保存されます。フォルダパス、最終更新日時、ターゲットパス、無視パターン、エントリ単位のアーカイブ形式を含みます。 +2. **増分バックアップ**: フォルダの最終更新日時を比較し、変更されたフォルダのみをバックアップします。 +3. **マルチ形式圧縮**: Python 組み込みの `zipfile` および `tarfile` モジュールに加え、`zstandard` と `py7zr` のサードパーティライブラリを使用し、7 種類のアーカイブ形式に対応しています。 +4. **エントリ単位の形式**: 各バックアップ戦略は個別のアーカイブ形式を指定できます(`add --format`)。グローバルの `--format` 設定より優先されます。未指定の場合はグローバルデフォルトを使用します。 +5. **バックアップクリーンアップ**: バックアップ成功後、ターゲットディレクトリをスキャンし、更新日時順にソートして、保持数を超える古いファイルを削除します。 +6. **暗号化バックアップ**: 7z 形式は LZMA2 暗号化に対応。`--password` パラメータまたは `config.json` で設定します。 +7. **定期バックアップ**: `watch` コマンドは指定された間隔でバックアップをループ実行します。`Ctrl+C` で安全に終了できます。 +8. **バックアップ履歴**: 各バックアップ後にタイムスタンプ、ファイルサイズ、ファイル数を記録し、直近 100 件のレコードを保持します。 +9. **SFTP リモートバックアップ**: paramiko ベースの SFTP クライアントを実装。接続テスト、リモートディレクトリの自動作成、プログレスバー付きファイルアップロードに対応。 + +### データファイルの形式 + +```json +{ + "/path/to/source/folder": [ + 1719235200.0, + "/path/to/target/folder", + [".git", "__pycache__"], + "" + ], + "/path/to/another/folder": [ + 1719235200.0, + "/path/to/another/target", + [".git"], + "TAR_GZ" + ], + "_history": [ + { + "time": "2026-05-01T12:00:00", + "source": "/path/to/source/folder", + "size_mb": 12.5, + "files_count": 150 + } + ] +} +``` + +各バックアップ戦略エントリは 4 要素のリストです: `[mtime, target, skip_patterns, compression_format]` + +| フィールド | 説明 | +|-----------|------| +| `mtime` | ソースフォルダの最終更新日時(増分バックアップの判定に使用) | +| `target` | バックアップファイルの保存先パス | +| `skip_patterns` | 無視するファイル/フォルダパターンのリスト | +| `compression_format` | エントリ単位のアーカイブ形式(空文字列はグローバルデフォルトを使用) | + +## 開発ガイド + +### テスト実行 + +```bash +uv run coverage run -m unittest discover -s tests -t . && uv run coverage report -m +``` + +### コード構造 + +``` +sbackup/ +├── main.py # プログラムエントリポイント +├── sbackup/ +│ ├── __init__.py # コア関数をエクスポート +│ ├── __main__.py # python -m sbackup エントリポイント +│ ├── cli.py # CLI 引数解析とコマンドディスパッチ(30 以上のコマンド) +│ ├── config.py # 設定読み込み、暗号化、Webhook/SMTP 設定 +│ ├── auto_save.py # BackupManager コアエンジン +│ ├── compression.py # 7 形式の圧縮/解凍エンジン +│ ├── i18n.py # 国際化(9 言語) +│ ├── sftp.py # SFTP リモートバックアップクライアント(paramiko) +│ ├── webdav.py # WebDAV リモートバックアップクライアント(依存なし) +│ ├── cloud_storage.py # S3 クラウドストレージクライアント(minio) +│ ├── multi_dest.py # マルチターゲット並列バックアップ +│ ├── handlers.py # SFTP/WebDAV/Remote/Schedule コマンドハンドラ +│ ├── hooks.py # Pre/Post Hook 実行 +│ ├── audit.py # 監査ログシステム +│ ├── profile.py # 設定 Profile 管理 +│ ├── selective.py # 選択的復元 +│ ├── cross_search.py # クロスアーカイブ検索 +│ ├── integrity.py # SHA256 チェックサム +│ ├── rotation.py # バックアップローテーションポリシー +│ ├── dryrun.py # Dry-run プレビュー +│ ├── diskcheck.py # ディスク容量見積もり +│ ├── task_queue.py # タスクキューシステム +│ ├── schema.py # 設定バリデーター +│ ├── benchmark.py # 圧縮ベンチマーク +│ ├── chunked_backup.py# ブロックレベル増分バックアップ +│ ├── dedup.py # ファイルレベル SHA256 重複排除 +│ ├── export.py # メタデータエクスポート(CSV/JSON) +│ ├── monitor.py # watchdog ファイルシステム監視 +│ ├── lock.py # クロスプラットフォームプロセスロック +│ ├── retry.py # 指数バックオフリトライ +│ ├── ratelimiter.py # トークンバケットレートリミッター +│ ├── keychain.py # システムキーチェーン統合 +│ ├── parity.py # Reed-Solomon 誤り訂正符号 +│ ├── completion.py # Shell 自動補完 +│ ├── wizard.py # インタラクティブ設定ウィザード +│ └── locales/ # 9 言語の翻訳ファイル +└── tests/ + └── sbackup/ + └── test_*.py # 30 個のテストファイル。すべてのモジュールをカバー +``` + +### 新機能の追加 + +1. `sbackup/` ディレクトリに新しいモジュールファイルを作成 +2. `sbackup/__init__.py` で新機能の関数をインポート +3. `run()` 関数に新しいコマンドラインコマンドの処理ロジックを追加 +4. `tests/` ディレクトリに対応するテストファイルを追加 + +## よくある質問 + +### Q: バックアップ戦略ファイルを誤って削除してしまいました。どうすればよいですか? + +A: バックアップ戦略はデータファイルに保存されています。誤って削除した場合は、`add` コマンドを再実行してバックアップ戦略を再追加してください。 + +### Q: 追加済みのバックアップ戦略を変更するにはどうすればよいですか? + +A: `sbackup edit` コマンドを使用してください: `sbackup edit --dest --ignore --format `。 + +### Q: リモートバックアップは対応していますか? + +A: 対応しています。3 種類のリモートバックアップ方式があります: +- **SFTP**: `sbackup sftp config` で設定、`sbackup save --sftp` でアップロード +- **WebDAV**: `sbackup webdav config` で設定、`sbackup save --webdav` でアップロード(Jianguoyun/NextCloud/Synology 対応) +- **S3 クラウドストレージ**: `config.json` の `cloud` フィールドで設定、`sbackup save --cloud` でアップロード +- 複数を同時に有効化可能: `sbackup save --sftp --webdav --cloud` + +### Q: tar.gz と ZIP の違いは何ですか? + +A: tar.gz は Linux/macOS で一般的に使用され、圧縮率がやや高いです。ZIP は Windows で汎用的で互換性が最も高いです。tar.bz2 と tar.xz はさらに高い圧縮率を持ちますが速度は遅くなります。tar.zst はモダンなアルゴリズムで、高速かつ良好な圧縮率を備えています。7z は最高の圧縮率を持ち、暗号化にも対応しています。 + +### Q: バックアップを暗号化するにはどうすればよいですか? + +A: 7z 形式を使用しパスワードを設定してください: `uv run python main.py --format 7z save --password yourpassword`。パスワードは `config.json` の `password` フィールドに記載することもできます。 + +### Q: 古いバックアップを自動的にクリーンアップするにはどうすればよいですか? + +A: `--keep` パラメータを使用してください: `uv run python main.py save --keep 5` で直近 5 件のバックアップファイルのみを保持します。定期バックアップ時も同様に対応しています: `uv run python main.py watch --interval 60 --keep 10`。 + +### Q: 定期バックアップを設定するにはどうすればよいですか? + +A: `watch` コマンドを使用してください: `uv run python main.py watch --interval 60` で 60 分ごとにバックアップを実行します。`Ctrl+C` で停止できます。 + +### Q: パスワードの保存は安全ですか? + +A: `config.json` に保存される SFTP パスワードと 7z 暗号化パスワードは**プレーンテキスト**で保存されます。`config.json` ファイルへのアクセス権限を信頼できるユーザーのみに制限してください(例: `chmod 600 config.json`)。パスワードを含む `config.json` をバージョン管理システムにコミ밋しないでください。 + +## コントリビューションガイド + +Issue と Pull Request の提出を歓迎します。 + +1. 本リポジトリを Fork +2. 機能ブランチを作成 (`git checkout -b feature/AmazingFeature`) +3. 変更をコミット (`git commit -m 'Add some AmazingFeature'`) +4. ブランチにプッシュ (`git push origin feature/AmazingFeature`) +5. Pull Request を提出 + +### コードスタイル + +本プロジェクトは PEP 8 および Google Python Style Guide に準拠しています。以下の点を確認してください: +- 型アノテーションを使用すること +- Google スタイルの docstrings に従うこと +- すべての単体テストに合格すること + +## ライセンス + +本プロジェクトは GNU GPL v3.0 ライセンスの下で提供されています。詳細は [LICENSE](../../LICENSE) ファイルを参照してください。 + +## 著者 + +**xiatianxuan** (CodeSeed) + +- [Gitee](https://gitee.com/xiatianxuan) +- [個人ページ](https://xnors-codeseed.pages.dev/) + +## 謝辞 + +- [Xnors Studio](https://xnors.github.io/) + +## お問い合わせ + +ご質問やご提案がございましたら、以下のメールアドレスまでお問い合わせください: xiatianxuan2025@163.com + +--- + +*最終更新: 2026年6月19日* diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md new file mode 100644 index 0000000..a63c18b --- /dev/null +++ b/docs/readme/README_ko.md @@ -0,0 +1,665 @@ +# Sbackup + +[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![License](https://img.shields.io/badge/License-GPL--3.0-green)](../../LICENSE) +[![PyPI](https://img.shields.io/pypi/v/sbackup-cli?color=blue)](https://pypi.org/project/sbackup-cli/) +[![Tests](https://img.shields.io/badge/tests-940%20passed-brightgreen)](../../.github/workflows/ci.yml) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)]() + +> 가볍고 효율적인 폴더 백업 도구로, CLI를 통해 백업 전략을 손쉽게 관리할 수 있습니다. + +[English](../../README.md) | [Deutsch](README_de.md) | [Espanol](README_es.md) | [Francais](README_fr.md) | [Portugues](README_pt.md) | [Pycckuu](README_ru.md) | [日本語](README_ja.md) | [한국어](README_ko.md) | [中文](README_zh.md) + +- [소개](#소개) +- [주요 기능](#주요-기능) +- [빠른 시작](#빠른-시작) + - [설치](#설치) + - [사용 방법](#사용-방법) +- [설정 파일](#설정-파일) + - [설정 예시](#설정-예시) +- [SFTP 원격 백업](#sftp-원격-백업) +- [WebDAV 원격 백업](#webdav-원격-백업) +- [구현 원리](#구현-원리) +- [개발 가이드](#개발-가이드) + - [테스트 실행](#테스트-실행) + - [코드 구조](#코드-구조) +- [자주 묻는 질문](#자주-묻는-질문) +- [기여 가이드](#기여-가이드) +- [라이선스](#라이선스) +- [저자](#저자) + +--- + +## 소개 + +Sbackup는 CLI를 통해 백업 전략을 추가, 삭제, 조회할 수 있는 가벼운 폴더 백업 도구입니다. 폴더의 최종 수정 시간을 기준으로 백업이 필요한지 판단하여 데이터를 항상 최신 상태로 유지합니다. + +## 주요 기능 + +- **증분 백업**: 변경된 폴더만 백업하여 시간과 저장 공간을 절약 +- **다중 포맷 지원**: ZIP, tar, tar.gz, tar.bz2, tar.xz, tar.zst, 7z 등 7가지 압축 포맷을 전략별 및 전역 수준에서 독립 지정 가능 +- **SFTP 원격 백업**: paramiko 기반, 비밀번호/SSH 개인키 인증 및 기본 개인키 자동 감지 지원 +- **WebDAV 원격 백업**: 표준 라이브러리 urllib 기반, 추가 의존성 없이 견과云/NextCloud/시놀로지 지원 +- **S3 클라우드 저장소**: minio 기반, 모든 S3 호환 저장소(AWS/MinIO/Alibaba Cloud OSS 등) 지원 +- **다중 대상 병렬 백업**: 로컬 + 여러 원격 대상에 동시에 백업, 상호 간섭 없음 +- **백업 복원**: 백업 파일에서 지정 디렉토리로 압축 해제 및 복원, 선택적 복원 지원 +- **백업 정리**: 오래된 백업 자동 삭제, 수량/시간/일별 보존 전략 지원 +- **암호화 백업**: 7z 포맷 비밀번호 암호화 + 전체 포맷 PBKDF2 암호화 +- **예약 백업**: 간격 기반 자동 실행, 실시간 파일 감시(watchdog) 지원 +- **백업 이력**: 각 백업의 시간, 크기, SHA256 체크섬 기록으로 추적 용이 +- **감사 로그**: 모든 백업/복원 작업의 감사 이벤트 기록 +- **Pre/Post Hook**: 백업 전후 사용자 정의 명령 실행 +- **설정 Profile**: 다중 설정 프로필의 저장, 전환, 가져오기/내보내기 지원 +- **크로스 아카이브 검색**: 여러 백업 파일에서 일치하는 파일명 검색 +- **데이터 무결성**: SHA256 체크섬 생성 및 검증, Reed-Solomon 오류 정정 코드 +- **설정 검증**: 설정 매개변수 유효성 자동 검증, 변조 감지 +- **작업 큐**: 백업 작업 큐 관리, 추가/실행/취소 지원 +- **압축 벤치마크**: 다양한 포맷/레벨의 압축 성능 비교 +- **디스크 공간 예측**: 파일 유형별 백업 크기 추정, 대상 공간 확인 +- **국제화**: 한국어, 영어, 중국어, 프랑스어, 스페인어, 러시아어, 독일어, 일본어, 포르투갈어 9개 언어 지원 +- **Shell 자동 완성**: bash/zsh/fish/powershell 자동 완성 지원 +- **가볍고 효율적**: 작은 용량, 빠른 시작 속도, 낮은 리소스 사용량 +- **크로스 플랫폼 지원**: Windows, macOS, Linux 지원 + +## 빠른 시작 + +### 설치 + +#### pip으로 설치 + +```bash +pip install sbackup-cli +``` + +설치 후 `sbackup` 명령을 사용합니다 (PyPI 패키지명: `sbackup-cli`, CLI 명령: `sbackup`). + +#### 소스에서 설치 + +```bash +git clone https://github.com/xiatianxuan/sbackup.git +cd sbackup +uv sync +``` + +### 사용 방법 + +#### 기본 구문 + +```bash +uv run python main.py [options] +``` + +#### 사용 가능한 명령 + +| 명령 | 설명 | +|------|------| +| `add` | 백업 전략 추가 | +| `rm` / `remove` | 백업 전략 삭제 | +| `edit` | 기존 백업 전략 편집 | +| `all` | 모든 백업 전략 조회 | +| `save` | 백업 실행 | +| `watch` | 예약 백업 실행 | +| `restore` | 백업 파일에서 복원 | +| `info` | 백업 파일 상세 정보 조회 | +| `diff` | 원본 디렉토리와 백업 간 차이 비교 | +| `verify` | 백업 파일 무결성 검증 | +| `search` | 백업 내 파일 검색 | +| `xsearch` | 여러 백업 아카이브에서 검색 | +| `versions` | 백업 버전 이력 조회 | +| `sftp` | SFTP 원격 백업 관리 | +| `webdav` | WebDAV 원격 백업 관리 | +| `remote` | 원격 파일 관리 (list/rm) | +| `task` | 백업 작업 큐 관리 | +| `audit` | 감사 로그 조회 | +| `hooks` | Pre/Post Hook 수동 실행 | +| `profile` | 설정 Profile 관리 | +| `rotate` | 백업 로테이션 정리 | +| `clean` | 오래된 백업 정리 | +| `diskcheck` | 디스크 공간 예측 | +| `benchmark` | 압축 포맷 벤치마크 | +| `integrity` | 백업 디렉토리 무결성 검증 | +| `dry-run` | 백업 파일 선택 미리보기 | +| `export` / `import` | 백업 전략 내보내기/가져오기 | +| `ignore` | .sbackupignore 파일 생성 | +| `schedule` | 예약 스케줄 설정 내보내기 | +| `webhook` | Webhook 프리셋 설정 | +| `config` | 암호화/검증 설정 | +| `report` | 백업 보고서 생성 | +| `completion` | Shell 자동 완성 스크립트 생성 | +| `wizard` | 대화형 설정 마법사 | +| `status` | 백업 상태 대시보드 | +| `version` | 버전 정보 조회 | +| `help` | 도움말 조회 | + +#### 전역 매개변수 + +| 매개변수 | 설명 | +|----------|------| +| `--lang zh_CN` / `en_US` / `fr_FR` / `es_ES` / `ru_RU` / `de_DE` / `ja_JP` / `pt_BR` / `ko_KR` | 인터페이스 언어 설정 (config.json에 영구 저장) | +| `--format zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` | 압축 포맷 설정 (config.json에 영구 저장) | +| `--debug` | 디버그 로그 활성화 | + +#### 백업 전략 추가 + +```bash +uv run python main.py add [-i ignore_patterns] +``` + +매개변수 설명: +- **source**: 백업할 원본 폴더 경로 +- **dest**: 백업 파일 저장 대상 경로 +- **-i, --ignore**: 무시할 파일 또는 폴더 이름, 쉼표로 구분 (기본값: `.git,__pycache__`) +- **--format**: 항목별 압축 포맷 (해당 백업 전략에만 적용, 미지정 시 전역 기본값 사용): `zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` + +예시: +```bash +# 전역 기본 포맷으로 전략 추가 +uv run python main.py add F:/my_folder F:/backup -i node_modules,.git + +# 해당 전략에 tar.gz 포맷 지정 (이 폴더 백업 시 항상 tar.gz 사용) +uv run python main.py add F:/my_folder F:/backup --format tar.gz + +# 7z 포맷 지정 (해당 폴더만) +uv run python main.py add F:/my_folder F:/backup --format 7z +``` + +#### 백업 전략 삭제 + +```bash +uv run python main.py rm +``` + +매개변수 설명: +- **path**: 백업 전략을 삭제할 원본 폴더 경로 + +예시: +```bash +uv run python main.py rm F:/my_folder +``` + +#### 모든 백업 전략 조회 + +```bash +uv run python main.py all +``` + +현재 설정된 모든 백업 전략을 표시합니다. + +#### 백업 실행 + +```bash +# 기본 포맷(ZIP) 사용 +uv run python main.py save + +# tar.gz 포맷 사용 +uv run python main.py --format tar.gz save + +# 최근 5개 백업 파일 유지, 오래된 파일 자동 정리 +uv run python main.py save --keep 5 + +# 7z 포맷으로 암호화 +uv run python main.py --format 7z save --password mysecret + +# 영어 인터페이스 + tar.xz 포맷 +uv run python main.py --lang en_US --format tar.xz save +``` + +**save 명령 매개변수:** + +| 매개변수 | 기본값 | 설명 | +|----------|--------|------| +| `--keep N` | `0` | 최근 N개 백업 파일 유지, 0이면 정리 안 함 | +| `--password PASSWORD` | `""` | 암호화 비밀번호 (7z 포맷만 지원) | +| `--sftp` | `false` | 백업 완료 후 SFTP 서버에 업로드 | +| `--webdav` | `false` | 백업 완료 후 WebDAV 서버에 업로드 | + +백업 전략에 따라 변경된 폴더를 자동 백업합니다. + +#### 예약 백업 + +```bash +# 60분마다 백업 실행 +uv run python main.py watch --interval 60 + +# 2시간마다 백업, 최근 10개 파일 유지 +uv run python main.py watch --interval 120 --keep 10 + +# 예약 백업 + 7z 암호화 +uv run python main.py --format 7z watch --interval 60 --password mysecret +``` + +**watch 명령 매개변수:** + +| 매개변수 | 기본값 | 설명 | +|----------|--------|------| +| `--interval MINUTES` | `60` | 백업 간격 (분) | +| `--keep N` | `0` | 최근 N개 백업 파일 유지 | +| `--password PASSWORD` | `""` | 암호화 비밀번호 (7z 포맷만 지원) | +| `--sftp` | `false` | 매 백업 후 SFTP 서버에 업로드 | +| `--webdav` | `false` | 매 백업 후 WebDAV 서버에 업로드 | + +`Ctrl+C`를 눌러 예약 백업을 중지합니다. + +#### 백업 복원 + +```bash +uv run python main.py restore +``` + +매개변수 설명: +- **backup_file**: 백업 파일 경로 (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z 지원) +- **target_dir**: 복원 대상 디렉토리 + +예시: +```bash +uv run python main.py restore F:/backup/my_folder.tar.gz F:/restored +uv run python main.py restore F:/backup/my_folder.7z F:/restored +uv run python main.py restore F:/backup/my_folder.tar.zst F:/restored +``` + +#### SFTP 원격 백업 + +```bash +# ============ 빠른 시작 (권장) ============ +# 1. SFTP 설정 (SSH 개인키 자동 감지, 수동 지정 불필요) +sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups + +# 2. 연결 테스트 +sbackup sftp test + +# 3. 백업 실행 및 업로드 +sbackup save --sftp + +# ============ 인증 방식 ============ + +# 방식 1: 개인키 자동 감지 (권장) +# 시스템이 자동으로 ~/.ssh/id_ed25519 -> id_rsa -> id_ecdsa 순서로 시도 +sbackup sftp config --host 192.168.1.100 --user admin + +# 방식 2: 비밀번호 인증 +sbackup sftp config --host 192.168.1.100 --user admin --password secret + +# 방식 3: 개인키 지정 +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# 방식 4: 개인키 + 패스프레이즈 (대화형 입력) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# 방식 5: 개인키 + 패스프레이즈 (명령줄 지정) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mykeypass + +# ============ 사용 시나리오 ============ + +# 시나리오 1: 일회성 백업 및 업로드 +sbackup save --sftp + +# 시나리오 2: 예약 백업 및 자동 업로드 (60분마다) +sbackup watch --interval 60 --sftp + +# 시나리오 3: 특정 포맷 백업 + 업로드 +sbackup --format tar.gz save --sftp + +# 시나리오 4: 암호화 백업 + 업로드 +sbackup --format 7z save --password mysecret --sftp + +# 시나리오 5: 최근 5개 백업 유지 + 업로드 +sbackup save --keep 5 --sftp + +# ============ 고급 사용법 ============ + +# 대화형 설정 (단계별로 모든 매개변수 입력) +sbackup sftp config + +# 비대화형 설정 (모든 매개변수를 명령줄에서 지정) +sbackup sftp config --host 192.168.1.100 --port 22 --user admin --password secret --remote-path /backups + +# 연결 테스트 및 상세 로그 확인 +sbackup --debug sftp test +``` + +**sftp 하위 명령:** + +| 하위 명령 | 설명 | 예시 | +|-----------|------|------| +| `sftp config` | SFTP 연결 매개변수 설정 (host/port/user/password/key_file/key_passphrase/remote_path) | `sbackup sftp config --host 192.168.1.100 --user admin` | +| `sftp test` | SFTP 연결 가능 여부 테스트 | `sbackup sftp test` | + +**인증 방식:** + +| 방식 | 매개변수 | 설명 | 예시 | +|------|----------|------|------| +| **자동 감지** | 인증 매개변수 미지정 | `~/.ssh/id_ed25519` -> `id_rsa` -> `id_ecdsa` 순서로 자동 시도 (권장) | `sbackup sftp config --host ... --user ...` | +| 비밀번호 인증 | `--password` | 비밀번호로 직접 로그인 | `sbackup sftp config --host ... --user ... --password secret` | +| 개인키 인증 | `--key-file` | 지정된 SSH 개인키로 로그인 | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa` | +| 개인키+패스프레이즈 | `--key-file` + `--key-passphrase` | 개인키에 패스프레이즈가 있는 경우 사용 | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa --key-passphrase mypass` | + +지원되는 개인키 형식: RSA, Ed25519, ECDSA. + +**크로스 플랫폼 경로 지원:** + +| 플랫폼 | 개인키 경로 예시 | 설명 | +|--------|-----------------|------| +| Linux/macOS | `~/.ssh/id_rsa` | `/home/user/.ssh/id_rsa`로 자동 확장 | +| Windows | `~/.ssh/id_rsa` | `C:\Users\username\.ssh\id_rsa`로 자동 확장 | +| 전 플랫폼 | 절대 경로 | 전체 경로를 직접 사용 | + +SFTP 설정은 `config.json`의 `sftp` 필드에 저장되며, 명령줄 매개변수 또는 대화형 입력으로 설정할 수 있습니다. + +#### 버전 정보 조회 + +```bash +sbackup version +``` + +## 설정 파일 + +Sbackup는 `config.json` 파일을 통해 사용자 정의 설정을 지원합니다. 설정 파일은 프로젝트 루트 디렉토리에 배치해야 합니다. + +### 설정 항목 설명 + +```json +{ + "compression_format": "ZIP", + "compression": { + "algorithm": "ZIP_DEFLATED", + "level": 6 + }, + "skip_patterns": [".git", "__pycache__"], + "data_file": "sbackup.json", + "lang": "zh_CN", + "password": "", + "sftp": { + "host": "", + "port": 22, + "user": "", + "password": "", + "key_file": "", + "key_passphrase": "", + "remote_path": "/", + "enabled": false + } +} +``` + +| 설정 항목 | 타입 | 기본값 | 설명 | +|-----------|------|--------|------| +| `compression_format` | string | `"ZIP"` | 압축 포맷, 가능한 값: `ZIP`, `TAR`, `TAR_GZ`, `TAR_BZ2`, `TAR_XZ`, `TAR_ZST`, `7Z` | +| `compression.algorithm` | string | `"ZIP_DEFLATED"` | ZIP 압축 알고리즘, 가능한 값: `ZIP_DEFLATED`, `ZIP_STORED`, `ZIP_BZIP2`, `ZIP_LZMA` | +| `compression.level` | int | `6` | 압축 레벨, 범위 0-9 (0: 압축 없음, 9: 최대 압축) | +| `skip_patterns` | list | `[".git", "__pycache__"]` | 무시할 파일 또는 폴더 패턴 (fnmatch 와일드카드 및 경로 매칭 지원) | +| `data_file` | string | 플랫폼 기본 경로 | 백업 전략 데이터 파일 저장 경로 | +| `lang` | string | `"zh_CN"` | 인터페이스 언어, 가능한 값: `zh_CN`, `en_US`, `fr_FR`, `es_ES`, `ru_RU`, `de_DE`, `ja_JP`, `pt_BR`, `ko_KR` | +| `password` | string | `""` | 7z 암호화 비밀번호 | +| `sftp.host` | string | `""` | SFTP 서버 주소 | +| `sftp.port` | int | `22` | SFTP 포트 | +| `sftp.user` | string | `""` | SFTP 사용자 이름 | +| `sftp.password` | string | `""` | SFTP 비밀번호 (비밀번호 인증 시 사용) | +| `sftp.key_file` | string | `""` | SSH 개인키 파일 경로 (개인키 인증 시 사용, 권장) | +| `sftp.key_passphrase` | string | `""` | 개인키 패스프레이즈 (있는 경우) | +| `sftp.remote_path` | string | `"/"` | 원격 대상 경로 | +| `sftp.enabled` | bool | `false` | SFTP 활성화 여부 | + +### 설정 예시 + +tar.bz2 포맷으로 고압축률 백업: + +```json +{ + "compression_format": "TAR_BZ2", + "compression_level": 9, + "skip_patterns": [".git", "__pycache__", "node_modules", "*.log"], + "data_file": "backup_strategies.json", + "lang": "zh_CN" +} +``` + +### 압축 포맷 비교 + +| 포맷 | 확장자 | 압축률 | 속도 | 의존성 | 적합한 시나리오 | +|------|--------|--------|------|--------|----------------| +| ZIP | .zip | 중간 | 빠름 | 표준 라이브러리 | 범용, Windows 호환성 최고 | +| tar | .tar | 없음 | 매우 빠름 | 표준 라이브러리 | 순수 아카이브, 외부 압축과 결합 | +| tar.gz | .tar.gz | 중간 | 빠름 | 표준 라이브러리 | Linux/macOS 범용 | +| tar.bz2 | .tar.bz2 | 높음 | 중간 | 표준 라이브러리 | 고압축률 아카이브 | +| tar.xz | .tar.xz | 최고 | 느림 | 표준 라이브러리 | 장기 아카이브, 공간 민감 | +| tar.zst | .tar.zst | 중상 | 매우 빠름 | zstandard | 현대 시나리오, 속도와 압축률 균형 | +| 7z | .7z | 매우 높음 | 느림 | py7zr | 최대 압축률, 암호화 지원 | + +#### WebDAV 원격 백업 + +WebDAV는 HTTP 기반 파일 프로토콜로, 견과云, NextCloud, 시놀로지 등 주요 클라우드 스토리지를 지원합니다. Python 표준 라이브러리 `urllib`을 사용하여 **추가 의존성이 없습니다**. + +```bash +# ============ 빠른 시작 ============ +# 1. WebDAV 설정 +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --password secret + +# 2. 연결 테스트 +sbackup webdav test + +# 3. 백업 실행 및 업로드 +sbackup save --webdav + +# ============ 사용 시나리오 ============ + +# 시나리오 1: 일회성 백업 및 업로드 +sbackup save --webdav + +# 시나리오 2: 예약 백업 및 자동 업로드 (60분마다) +sbackup watch --interval 60 --webdav + +# 시나리오 3: 원격 하위 디렉토리 지정 +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --remote-path /backups/sbackup + +# 시나리오 4: SFTP와 WebDAV에 동시 업로드 +sbackup save --sftp --webdav + +# ============ 일반 WebDAV 서비스 주소 ============ +# 견과云: https://dav.jianguoyun.com/dav/ +# NextCloud: https://your-server/remote.php/dav/files/username/ +# 시놀로지: https://your-synology:5006/webdav/ +``` + +**webdav 하위 명령:** + +| 하위 명령 | 설명 | 예시 | +|-----------|------|------| +| `webdav config` | WebDAV 연결 매개변수 설정 (url/user/password/remote_path) | `sbackup webdav config --url ... --user ...` | +| `webdav test` | WebDAV 연결 가능 여부 테스트 | `sbackup webdav test` | + +| 매개변수 | 기본값 | 설명 | +|----------|--------|------| +| `--url URL` | `""` | WebDAV 서버 주소 (예: `https://dav.jianguoyun.com/dav/`) | +| `--user USER` | `""` | WebDAV 사용자 이름 (일반적으로 이메일) | +| `--password PASS` | `""` | WebDAV 비밀번호 (견과云은 설정에서 앱 비밀번호를 생성해야 함) | +| `--remote-path PATH` | `/` | 원격 대상 경로 | + +## 구현 원리 + +Sbackup는 다음과 같은 방식으로 백업 기능을 구현합니다: + +1. **백업 전략 저장**: 백업 전략은 JSON 파일에 저장되며, 폴더 경로, 최종 수정 시간, 대상 경로, 무시 패턴, 항목별 압축 포맷을 포함합니다. +2. **증분 백업**: 폴더의 최종 수정 시간을 비교하여 변경된 폴더만 백업합니다. +3. **다중 포맷 압축**: Python 내장 `zipfile` 및 `tarfile` 모듈과 `zstandard`, `py7zr` 서드파티 라이브러리를 사용하여 7가지 압축 포맷을 지원합니다. +4. **항목별 포맷**: 각 백업 전략은 독립적인 압축 포맷을 지정할 수 있습니다 (`add --format`). 전역 `--format` 설정보다 우선하며, 미지정 시 전역 기본값을 사용합니다. +5. **백업 정리**: 백업 성공 후 대상 디렉토리를 자동 스캔하여 수정 시간순으로 정렬하고, 보존 수량을 초과하는 오래된 파일을 삭제합니다. +6. **암호화 백업**: 7z 포맷은 LZMA2 암호화를 지원하며, `--password` 매개변수 또는 `config.json` 설정을 통해 구성합니다. +7. **예약 백업**: `watch` 명령은 지정된 간격으로 루프에서 백업을 실행하며, `Ctrl+C`로 안전하게 종료합니다. +8. **백업 이력**: 매 백업 후 타임스탬프, 파일 크기, 파일 수를 기록하며, 최근 100개 기록을 유지합니다. +9. **SFTP 원격 백업**: paramiko 라이브러리 기반 SFTP 클라이언트를 구현하며, 연결 테스트, 원격 디렉토리 자동 생성, 진행 표시줄이 포함된 파일 업로드를 지원합니다. + +### 데이터 파일 형식 + +```json +{ + "/path/to/source/folder": [ + 1719235200.0, + "/path/to/target/folder", + [".git", "__pycache__"], + "" + ], + "/path/to/another/folder": [ + 1719235200.0, + "/path/to/another/target", + [".git"], + "TAR_GZ" + ], + "_history": [ + { + "time": "2026-05-01T12:00:00", + "source": "/path/to/source/folder", + "size_mb": 12.5, + "files_count": 150 + } + ] +} +``` + +각 백업 전략 항목은 4요소 리스트입니다: `[mtime, target, skip_patterns, compression_format]` + +| 필드 | 설명 | +|------|------| +| `mtime` | 원본 폴더의 최종 수정 시간 (증분 백업 판단에 사용) | +| `target` | 백업 파일 저장 대상 경로 | +| `skip_patterns` | 무시할 파일/폴더 패턴 목록 | +| `compression_format` | 항목별 압축 포맷 (빈 문자열이면 전역 기본값 사용) | + +## 개발 가이드 + +### 테스트 실행 + +```bash +uv run coverage run -m unittest discover -s tests -t . && uv run coverage report -m +``` + +### 코드 구조 + +``` +sbackup/ +├── main.py # 프로그램 진입점 +├── sbackup/ +│ ├── __init__.py # 핵심 함수 내보내기 +│ ├── __main__.py # python -m sbackup 진입점 +│ ├── cli.py # CLI 매개변수 파싱 및 명령 분배 (30+ 명령) +│ ├── config.py # 설정 로드, 암호화, Webhook/SMTP 설정 +│ ├── auto_save.py # BackupManager 핵심 엔진 +│ ├── compression.py # 7가지 포맷 압축/해제 엔진 +│ ├── i18n.py # 국제화 (9개 언어) +│ ├── sftp.py # SFTP 원격 백업 클라이언트 (paramiko) +│ ├── webdav.py # WebDAV 원격 백업 클라이언트 (무의존성) +│ ├── cloud_storage.py # S3 클라우드 저장소 클라이언트 (minio) +│ ├── multi_dest.py # 다중 대상 병렬 백업 +│ ├── handlers.py # SFTP/WebDAV/Remote/Schedule 명령 처리 +│ ├── hooks.py # Pre/Post Hook 실행 +│ ├── audit.py # 감사 로그 시스템 +│ ├── profile.py # 설정 Profile 관리 +│ ├── selective.py # 선택적 복원 +│ ├── cross_search.py # 크로스 아카이브 검색 +│ ├── integrity.py # SHA256 체크섬 +│ ├── rotation.py # 백업 로테이션 전략 +│ ├── dryrun.py # Dry-run 미리보기 +│ ├── diskcheck.py # 디스크 공간 예측 +│ ├── task_queue.py # 작업 큐 시스템 +│ ├── schema.py # 설정 검증기 +│ ├── benchmark.py # 압축 벤치마크 +│ ├── chunked_backup.py# 블록 단위 증분 백업 +│ ├── dedup.py # 파일 단위 SHA256 중복 제거 +│ ├── export.py # 메타데이터 내보내기 (CSV/JSON) +│ ├── monitor.py # watchdog 파일 시스템 감시 +│ ├── lock.py # 크로스 플랫폼 프로세스 잠금 +│ ├── retry.py # 지수 백오프 재시도 +│ ├── ratelimiter.py # 토큰 버킷 속도 제한기 +│ ├── keychain.py # 시스템 키체인 통합 +│ ├── parity.py # Reed-Solomon 오류 정정 코드 +│ ├── completion.py # Shell 자동 완성 +│ ├── wizard.py # 대화형 설정 마법사 +│ └── locales/ # 9개 언어 번역 파일 +└── tests/ + └── sbackup/ + └── test_*.py # 30개 테스트 파일, 모든 모듈 커버 +``` + +### 새 기능 추가 + +1. `sbackup/` 디렉토리에 새 모듈 파일을 생성합니다 +2. `sbackup/__init__.py`에 새 기능의 함수를 임포트합니다 +3. `run()` 함수에 새로운 CLI 명령 처리 로직을 추가합니다 +4. `tests/` 디렉토리에 대응하는 테스트 파일을 추가합니다 + +## 자주 묻는 질문 + +### Q: 백업 전략 파일을 실수로 삭제하면 어떻게 하나요? + +A: 백업 전략은 데이터 파일에 저장됩니다. 실수로 삭제한 경우 `add` 명령을 다시 실행하여 백업 전략을 다시 추가할 수 있습니다. + +### Q: 이미 추가한 백업 전략을 어떻게 수정하나요? + +A: `sbackup edit` 명령을 사용합니다: `sbackup edit --dest --ignore --format `. + +### Q: 원격 백업을 지원하나요? + +A: 지원합니다! 세 가지 원격 백업 방식을 제공합니다: +- **SFTP**: `sbackup sftp config`로 설정, `sbackup save --sftp`로 업로드 +- **WebDAV**: `sbackup webdav config`로 설정, `sbackup save --webdav`로 업로드 (견과云/NextCloud/시놀로지 지원) +- **S3 클라우드 저장소**: `config.json`에서 `cloud` 필드 설정, `sbackup save --cloud`로 업로드 +- 동시에 여러 방식 사용 가능: `sbackup save --sftp --webdav --cloud` + +### Q: tar.gz와 ZIP의 차이는 무엇인가요? + +A: tar.gz는 Linux/macOS에서 더 일반적이며 압축률이 약간 높습니다. ZIP은 Windows에서 더 범용적이며 호환성이 가장 좋습니다. tar.bz2와 tar.xz는 더 높은 압축률을 제공하지만 속도가 느립니다. tar.zst는 현대 알고리즘으로 속도가 매우 빠르면서 압축률도 양호합니다. 7z는 압축률이 가장 높고 암호화를 지원합니다. + +### Q: 백업을 어떻게 암호화하나요? + +A: 7z 포맷을 사용하고 비밀번호를 설정합니다: `uv run python main.py --format 7z save --password yourpassword`. 비밀번호를 `config.json`의 `password` 필드에 작성할 수도 있습니다. + +### Q: 오래된 백업을 어떻게 자동 정리하나요? + +A: `--keep` 매개변수를 사용합니다: `uv run python main.py save --keep 5`로 최근 5개 백업 파일만 유지합니다. 예약 백업 시에도同样 지원합니다: `uv run python main.py watch --interval 60 --keep 10`. + +### Q: 예약 백업을 어떻게 설정하나요? + +A: `watch` 명령을 사용합니다: `uv run python main.py watch --interval 60`으로 60분마다 백업합니다. `Ctrl+C`로 중지합니다. + +### Q: 비밀번호 저장이 안전한가요? + +A: `config.json`에 저장된 SFTP 비밀번호와 7z 암호화 비밀번호는 **평문**으로 저장됩니다. `config.json` 파일의 접근 권한을 신뢰할 수 있는 사용자로만 제한하세요 (예: `chmod 600 config.json`). 비밀번호가 포함된 `config.json`을 버전控制系统에 커밋하지 마세요. + +## 기여 가이드 + +Issue와 Pull Request를 환영합니다! + +1. 이 저장소를 Fork합니다 +2. 기능 브랜치를 생성합니다 (`git checkout -b feature/AmazingFeature`) +3. 변경사항을 커밋합니다 (`git commit -m 'Add some AmazingFeature'`) +4. 브랜치에 푸시합니다 (`git push origin feature/AmazingFeature`) +5. Pull Request를 제출합니다 + +### 코드 스타일 + +이 프로젝트는 PEP 8 및 Google Python Style Guide를 따릅니다. 다음을 준수하세요: +- 타입 어노테이션 사용 +- Google 스타일 docstrings 준수 +- 모든 단위 테스트 통과 + +## 라이선스 + +이 프로젝트는 GNU GPL v3.0 라이선스를 따릅니다. 자세한 내용은 [LICENSE](../../LICENSE) 파일을 참조하세요. + +## 저자 + +**xiatianxuan** (CodeSeed) + +- [Gitee](https://gitee.com/xiatianxuan) +- [홈페이지](https://xnors-codeseed.pages.dev/) + +## 특별 감사 + +- [Xnors Studio](https://xnors.github.io/) + +## 문의 + +질문이나 제안이 있으시면 이메일로 연락해 주세요: xiatianxuan2025@163.com + +--- + +*최종 업데이트: 2026년 6월 19일* diff --git a/docs/readme/README_pt.md b/docs/readme/README_pt.md new file mode 100644 index 0000000..0739204 --- /dev/null +++ b/docs/readme/README_pt.md @@ -0,0 +1,665 @@ +# Sbackup + +[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![License](https://img.shields.io/badge/License-GPL--3.0-green)](../../LICENSE) +[![PyPI](https://img.shields.io/pypi/v/sbackup-cli?color=blue)](https://pypi.org/project/sbackup-cli/) +[![Tests](https://img.shields.io/badge/tests-940%20passed-brightgreen)](../../.github/workflows/ci.yml) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)]() + +> Ferramenta leve e eficiente para backup de pastas via linha de comando, ajudando você a gerenciar facilmente suas estratégias de backup. + +[English](../../README.md) | [Deutsch](README_de.md) | [Espanol](README_es.md) | [Francais](README_fr.md) | [Portugues](README_pt.md) | [Pycckuu](README_ru.md) | [日本語](README_ja.md) | [한국어](README_ko.md) | [中文](README_zh.md) + +- [Introducao](#introducao) +- [Recursos](#recursos) +- [Inicio Rapido](#inicio-rapido) + - [Instalacao](#instalacao) + - [Como Usar](#como-usar) +- [Arquivo de Configuracao](#arquivo-de-configuracao) + - [Exemplo de Configuracao](#exemplo-de-configuracao) +- [Backup Remoto via SFTP](#backup-remoto-via-sftp) +- [Backup Remoto via WebDAV](#backup-remoto-via-webdav) +- [Como Funciona](#como-funciona) +- [Guia de Desenvolvimento](#guia-de-desenvolvimento) + - [Executando Testes](#executando-testes) + - [Estrutura do Codigo](#estrutura-do-codigo) +- [Perguntas Frequentes](#perguntas-frequentes) +- [Guia de Contribuicao](#guia-de-contribuicao) +- [Licenca](#licenca) +- [Autor](#autor) + +--- + +## Introducao + +Sbackup e uma ferramenta leve de backup de pastas que permite adicionar, remover e visualizar estrategias de backup via linha de comando. Ela utiliza a data de ultima modificacao da pasta para decidir se o backup precisa ser realizado, garantindo que seus dados estejam sempre atualizados. + +## Recursos + +- **Backup incremental**: faz backup apenas das pastas que foram alteradas, economizando tempo e espaco de armazenamento +- **Suporte a multiplos formatos**: suporta ZIP, tar, tar.gz, tar.bz2, tar.xz, tar.zst e 7z, com configuracao global e por entrada +- **Backup remoto via SFTP**: baseado na biblioteca paramiko, suporta autenticacao por senha/chave SSH com deteccao automatica de chave padrao +- **Backup remoto via WebDAV**: baseado na biblioteca padrao urllib, zero dependencias extras, compativel com JianguoCloud/NextCloud/Synology +- **Armazenamento em nuvem S3**: baseado na biblioteca minio, suporta todos os armazenamentos compativeis com S3 (AWS/MinIO/Alibaba Cloud OSS, etc.) +- **Backup paralelo para multiplos destinos**: faz backup simultaneamente para local + multiplos destinos remotos, sem interferencia entre eles +- **Restauracao de backup**: suporta descompactacao e restauracao de arquivos de backup para um diretorio especificado, com restauracao seletiva +- **Limpeza de backups**: remove backups antigos automaticamente, com politicas de retencao por quantidade/tempo/diaria +- **Backup criptografado**: suporta criptografia por senha no formato 7z + criptografia PBKDF2 para todos os formatos +- **Backup agendado**: execucao automatica em intervalos definidos, com monitoramento de arquivos em tempo real (watchdog) +- **Historico de backups**: registra o tempo, tamanho e checksum SHA256 de cada backup para rastreabilidade +- **Log de auditoria**: registra eventos de auditoria de todas as operacoes de backup/restauracao +- **Pre/Post Hook**: executa comandos personalizados antes e depois do backup +- **Perfis de configuracao**: suporta salvamento, alternancia, importacao e exportacao de multiplos perfis de configuracao +- **Busca entre arquivos**: busca nomes de arquivos correspondentes em multiplos arquivos de backup +- **Integridade dos dados**: geracao e verificacao de checksum SHA256, codigos de correcao de erros Reed-Solomon +- **Validacao de configuracao**: validacao automatica dos parametros de configuracao, deteccao de adulteracao +- **Fila de tarefas**: gerencia a fila de tarefas de backup, com suporte a adicao, execucao e cancelamento +- **Benchmark de compressao**: compara o desempenho de compressao entre diferentes formatos/niveis +- **Estimativa de espaco em disco**: estima o tamanho do backup por tipo de arquivo e verifica o espaco no destino +- **Internacionalizacao**: suporta chines, ingles, frances, espanhol, russo, alemao, japones, portugues e coreano +- **Autocompletar no shell**: suporta autocompletar em bash/zsh/fish/powershell +- **Leve e eficiente**: tamanho pequeno, inicializacao rapida, baixo consumo de recursos +- **Suporte multiplataforma**: suporta Windows, macOS e Linux + +## Inicio Rapido + +### Instalacao + +#### Instalando via pip + +```bash +pip install sbackup-cli +``` + +Apos a instalacao, use o comando `sbackup` (nome do pacote PyPI e `sbackup-cli`, comando CLI e `sbackup`). + +#### Instalando a partir do codigo-fonte + +```bash +git clone https://github.com/xiatianxuan/sbackup.git +cd sbackup +uv sync +``` + +### Como Usar + +#### Sintaxe basica + +```bash +uv run python main.py [opcoes] +``` + +#### Comandos disponiveis + +| Comando | Descricao | +|---------|-----------| +| `add` | Adiciona uma estrategia de backup | +| `rm` / `remove` | Remove uma estrategia de backup | +| `edit` | Edita uma estrategia de backup existente | +| `all` | Exibe todas as estrategias de backup | +| `save` | Executa o backup | +| `watch` | Executa backups agendados | +| `restore` | Restaura a partir de um arquivo de backup | +| `info` | Exibe detalhes do arquivo de backup | +| `diff` | Compara diferencas entre o diretorio de origem e o backup | +| `verify` | Verifica a integridade do arquivo de backup | +| `search` | Busca arquivos no backup | +| `xsearch` | Busca entre multiplos arquivos de backup | +| `versions` | Exibe o historico de versoes do backup | +| `sftp` | Gerenciamento de backup remoto via SFTP | +| `webdav` | Gerenciamento de backup remoto via WebDAV | +| `remote` | Gerenciamento de arquivos remotos (list/rm) | +| `task` | Gerenciamento da fila de tarefas de backup | +| `audit` | Consulta de logs de auditoria | +| `hooks` | Executa Pre/Post Hook manualmente | +| `profile` | Gerenciamento de perfis de configuracao | +| `rotate` | Limpeza por rotacao de backups | +| `clean` | Limpa backups antigos | +| `diskcheck` | Estimativa de espaco em disco | +| `benchmark` | Benchmark de formatos de compressao | +| `integrity` | Verificacao de integridade do diretorio de backup | +| `dry-run` | Visualizacao previa da selecao de arquivos de backup | +| `export` / `import` | Exporta/importa estrategias de backup | +| `ignore` | Gera arquivo .sbackupignore | +| `schedule` | Exporta configuracao de agendamento | +| `webhook` | Configura predefinicoes de Webhook | +| `config` | Configuracao de criptografia/validacao | +| `report` | Gera relatorio de backup | +| `completion` | Gera scripts de autocompletar para shells | +| `wizard` | Assistente de configuracao interativo | +| `status` | Painel de status do backup | +| `version` | Exibe informacoes de versao | +| `help` | Exibe ajuda | + +#### Parametros globais + +| Parametro | Descricao | +|-----------|-----------| +| `--lang zh_CN` / `en_US` / `fr_FR` / `es_ES` / `ru_RU` / `de_DE` / `ja_JP` / `pt_BR` / `ko_KR` | Define o idioma da interface (persistido no config.json) | +| `--format zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` | Define o formato de empacotamento (persistido no config.json) | +| `--debug` | Ativa logs de depuracao | + +#### Adicionando uma estrategia de backup + +```bash +uv run python main.py add [-i padroes_ignorados] +``` + +Parametros: +- **origem**: caminho da pasta de origem para backup +- **destino**: caminho onde os arquivos de backup serao armazenados +- **-i, --ignore**: nomes de arquivos ou pastas a serem ignorados, separados por virgula (padrao: `.git,__pycache__`) +- **--format**: formato de empacotamento por entrada (apenas para esta estrategia de backup, usa o padrao global se nao especificado): `zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` + +Exemplos: +```bash +# Adiciona estrategia usando o formato padrao global +uv run python main.py add F:/minha_pasta F:/backup -i node_modules,.git + +# Define formato tar.gz para esta estrategia (cada backup desta pasta usara tar.gz) +uv run python main.py add F:/minha_pasta F:/backup --format tar.gz + +# Define formato 7z (apenas para esta pasta) +uv run python main.py add F:/minha_pasta F:/backup --format 7z +``` + +#### Removendo uma estrategia de backup + +```bash +uv run python main.py rm +``` + +Parametros: +- **caminho**: caminho da pasta de origem da estrategia de backup a ser removida + +Exemplo: +```bash +uv run python main.py rm F:/minha_pasta +``` + +#### Visualizando todas as estrategias de backup + +```bash +uv run python main.py all +``` + +Exibe todas as estrategias de backup configuradas atualmente. + +#### Executando o backup + +```bash +# Usa o formato padrao (ZIP) +uv run python main.py save + +# Usa formato tar.gz +uv run python main.py --format tar.gz save + +# Mantem os 5 backups mais recentes, limpa automaticamente os antigos +uv run python main.py save --keep 5 + +# Usa formato 7z com criptografia +uv run python main.py --format 7z save --password mysecret + +# Interface em ingles + formato tar.xz +uv run python main.py --lang en_US --format tar.xz save +``` + +**Parametros do comando save:** + +| Parametro | Padrao | Descricao | +|-----------|--------|-----------| +| `--keep N` | `0` | Mantem os N backups mais recentes, 0 significa sem limpeza | +| `--password SENHA` | `""` | Senha de criptografia (apenas formato 7z) | +| `--sftp` | `false` | Envia para servidor SFTP apos o backup | +| `--webdav` | `false` | Envia para servidor WebDAV apos o backup | + +De acordo com a estrategia de backup, faz backup automaticamente das pastas que foram alteradas. + +#### Backup agendado + +```bash +# Executa backup a cada 60 minutos +uv run python main.py watch --interval 60 + +# Backup a cada 2 horas, mantem os 10 arquivos mais recentes +uv run python main.py watch --interval 120 --keep 10 + +# Backup agendado + criptografia 7z +uv run python main.py --format 7z watch --interval 60 --password mysecret +``` + +**Parametros do comando watch:** + +| Parametro | Padrao | Descricao | +|-----------|--------|-----------| +| `--interval MINUTOS` | `60` | Intervalo entre backups (em minutos) | +| `--keep N` | `0` | Mantem os N backups mais recentes | +| `--password SENHA` | `""` | Senha de criptografia (apenas formato 7z) | +| `--sftp` | `false` | Envia para servidor SFTP apos cada backup | +| `--webdav` | `false` | Envia para servidor WebDAV apos cada backup | + +Pressione `Ctrl+C` para parar o backup agendado. + +#### Restaurando backup + +```bash +uv run python main.py restore +``` + +Parametros: +- **arquivo_backup**: caminho do arquivo de backup (suporta .zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z) +- **diretorio_destino**: diretorio de destino para restauracao + +Exemplos: +```bash +uv run python main.py restore F:/backup/minha_pasta.tar.gz F:/restaurado +uv run python main.py restore F:/backup/minha_pasta.7z F:/restaurado +uv run python main.py restore F:/backup/minha_pasta.tar.zst F:/restaurado +``` + +#### Backup remoto via SFTP + +```bash +# ============ Inicio rapido (recomendado) ============ +# 1. Configura SFTP (deteccao automatica de chave SSH, sem necessidade de especificar manualmente) +sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups + +# 2. Testa a conexao +sbackup sftp test + +# 3. Executa backup e envia +sbackup save --sftp + +# ============ Metodos de autenticacao ============ + +# Metodo 1: Deteccao automatica de chave privada (recomendado) +# O sistema tenta automaticamente ~/.ssh/id_ed25519 -> id_rsa -> id_ecdsa +sbackup sftp config --host 192.168.1.100 --user admin + +# Metodo 2: Autenticacao por senha +sbackup sftp config --host 192.168.1.100 --user admin --password secret + +# Metodo 3: Especificar chave privada +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# Metodo 4: Chave privada + frase secreta (entrada interativa) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# Metodo 5: Chave privada + frase secreta (especificada na linha de comando) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mykeypass + +# ============ Cenarios de uso ============ + +# Cenario 1: Backup unico e envio +sbackup save --sftp + +# Cenario 2: Backup agendado com envio automatico (a cada 60 minutos) +sbackup watch --interval 60 --sftp + +# Cenario 3: Backup com formato especifico + envio +sbackup --format tar.gz save --sftp + +# Cenario 4: Backup criptografado + envio +sbackup --format 7z save --password mysecret --sftp + +# Cenario 5: Manter os 5 backups mais recentes + envio +sbackup save --keep 5 --sftp + +# ============ Uso avancado ============ + +# Configuracao interativa (insere todos os parametros passo a passo) +sbackup sftp config + +# Configuracao nao interativa (todos os parametros na linha de comando) +sbackup sftp config --host 192.168.1.100 --port 22 --user admin --password secret --remote-path /backups + +# Testa conexao e exibe log detalhado +sbackup --debug sftp test +``` + +**Subcomandos sftp:** + +| Subcomando | Descricao | Exemplo | +|------------|-----------|---------| +| `sftp config` | Configura parametros de conexao SFTP (host/port/user/password/key_file/key_passphrase/remote_path) | `sbackup sftp config --host 192.168.1.100 --user admin` | +| `sftp test` | Testa se a conexao SFTP esta disponivel | `sbackup sftp test` | + +**Metodos de autenticacao:** + +| Metodo | Parametros | Descricao | Exemplo | +|--------|------------|-----------|---------| +| **Deteccao automatica** | Sem parametros de autenticacao | Tenta automaticamente `~/.ssh/id_ed25519` -> `id_rsa` -> `id_ecdsa` (recomendado) | `sbackup sftp config --host ... --user ...` | +| Senha | `--password` | Login direto com senha | `sbackup sftp config --host ... --user ... --password secret` | +| Chave privada | `--key-file` | Login com chave SSH especifica | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa` | +| Chave privada+frase | `--key-file` + `--key-passphrase` | Usado quando a chave privada possui frase secreta | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa --key-passphrase mypass` | + +Formatos de chave privada suportados: RSA, Ed25519, ECDSA. + +**Suporte a caminhos multiplataforma:** + +| Plataforma | Exemplo de caminho da chave privada | Descricao | +|------------|-------------------------------------|-----------| +| Linux/macOS | `~/.ssh/id_rsa` | Expande automaticamente para `/home/usuario/.ssh/id_rsa` | +| Windows | `~/.ssh/id_rsa` | Expande automaticamente para `C:\Users\usuario\.ssh\id_rsa` | +| Todas | Caminho absoluto | Usa o caminho completo diretamente | + +A configuracao SFTP e salva no campo `sftp` do `config.json`, suportando parametros via linha de comando ou entrada interativa. + +#### Visualizando informacoes de versao + +```bash +sbackup version +``` + +## Arquivo de Configuracao + +Sbackup suporta configuracao personalizada atraves do arquivo `config.json`. O arquivo de configuracao deve ser colocado no diretorio raiz do projeto. + +### Descricao das configuracoes + +```json +{ + "compression_format": "ZIP", + "compression": { + "algorithm": "ZIP_DEFLATED", + "level": 6 + }, + "skip_patterns": [".git", "__pycache__"], + "data_file": "sbackup.json", + "lang": "pt_BR", + "password": "", + "sftp": { + "host": "", + "port": 22, + "user": "", + "password": "", + "key_file": "", + "key_passphrase": "", + "remote_path": "/", + "enabled": false + } +} +``` + +| Configuracao | Tipo | Padrao | Descricao | +|--------------|------|--------|-----------| +| `compression_format` | string | `"ZIP"` | Formato de empacotamento, valores validos: `ZIP`, `TAR`, `TAR_GZ`, `TAR_BZ2`, `TAR_XZ`, `TAR_ZST`, `7Z` | +| `compression.algorithm` | string | `"ZIP_DEFLATED"` | Algoritmo de compressao ZIP, valores validos: `ZIP_DEFLATED`, `ZIP_STORED`, `ZIP_BZIP2`, `ZIP_LZMA` | +| `compression.level` | int | `6` | Nivel de compressao, intervalo 0-9 (0 = sem compressao, 9 = compressao maxima) | +| `skip_patterns` | list | `[".git", "__pycache__"]` | Padroes de arquivos ou pastas a serem ignorados (suporta curingas fnmatch e correspondencia de caminhos) | +| `data_file` | string | Caminho padrao da plataforma | Caminho do arquivo de dados das estrategias de backup | +| `lang` | string | `"pt_BR"` | Idioma da interface, valores validos: `zh_CN`, `en_US`, `fr_FR`, `es_ES`, `ru_RU`, `de_DE`, `ja_JP`, `pt_BR`, `ko_KR` | +| `password` | string | `""` | Senha de criptografia 7z | +| `sftp.host` | string | `""` | Endereco do servidor SFTP | +| `sftp.port` | int | `22` | Porta SFTP | +| `sftp.user` | string | `""` | Nome de usuario SFTP | +| `sftp.password` | string | `""` | Senha SFTP (usada na autenticacao por senha) | +| `sftp.key_file` | string | `""` | Caminho do arquivo de chave SSH privada (usado na autenticacao por chave, recomendado) | +| `sftp.key_passphrase` | string | `""` | Frase secreta da chave privada (se houver) | +| `sftp.remote_path` | string | `"/"` | Caminho do destino remoto | +| `sftp.enabled` | bool | `false` | Habilita ou desabilita SFTP | + +### Exemplo de configuracao + +Usando formato tar.bz2 para backup com alta taxa de compressao: + +```json +{ + "compression_format": "TAR_BZ2", + "compression_level": 9, + "skip_patterns": [".git", "__pycache__", "node_modules", "*.log"], + "data_file": "estrategias_backup.json", + "lang": "pt_BR" +} +``` + +### Comparacao de formatos de empacotamento + +| Formato | Extensao | Taxa de compressao | Velocidade | Dependencia | Cenario de uso | +|---------|----------|-------------------|------------|-------------|----------------| +| ZIP | .zip | Media | Rapida | Biblioteca padrao | Uso geral, melhor compatibilidade com Windows | +| tar | .tar | Nenhuma | Muito rapida | Biblioteca padrao | Apenas arquivamento, combina com compressao externa | +| tar.gz | .tar.gz | Media | Rapida | Biblioteca padrao | Uso geral em Linux/macOS | +| tar.bz2 | .tar.bz2 | Alta | Media | Biblioteca padrao | Arquivamento com alta taxa de compressao | +| tar.xz | .tar.xz | Muito alta | Lenta | Biblioteca padrao | Arquivamento de longo prazo, sensivel a espaco | +| tar.zst | .tar.zst | Media-alta | Muito rapida | zstandard | Cenarios modernos, equilibrio entre velocidade e compressao | +| 7z | .7z | Extremamente alta | Lenta | py7zr | Maior taxa de compressao, suporta criptografia | + +#### Backup remoto via WebDAV + +WebDAV e um protocolo de arquivos baseado em HTTP, compativel com JianguoCloud, NextCloud, Synology e outros servicos de armazenamento em nuvem. Usa a biblioteca padrao `urllib` do Python, **zero dependencias extras**. + +```bash +# ============ Inicio rapido ============ +# 1. Configura WebDAV +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user usuario@exemplo.com --password secret + +# 2. Testa a conexao +sbackup webdav test + +# 3. Executa backup e envia +sbackup save --webdav + +# ============ Cenarios de uso ============ + +# Cenario 1: Backup unico e envio +sbackup save --webdav + +# Cenario 2: Backup agendado com envio automatico (a cada 60 minutos) +sbackup watch --interval 60 --webdav + +# Cenario 3: Especificar subdiretorio remoto +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user usuario@exemplo.com --remote-path /backups/sbackup + +# Cenario 4: Enviar simultaneamente para SFTP e WebDAV +sbackup save --sftp --webdav + +# ============ Enderecos comuns de servicos WebDAV ============ +# JianguoCloud: https://dav.jianguoyun.com/dav/ +# NextCloud: https://seu-servidor/remote.php/dav/files/usuario/ +# Synology: https://seu-synology:5006/webdav/ +``` + +**Subcomandos webdav:** + +| Subcomando | Descricao | Exemplo | +|------------|-----------|---------| +| `webdav config` | Configura parametros de conexao WebDAV (url/user/password/remote_path) | `sbackup webdav config --url ... --user ...` | +| `webdav test` | Testa se a conexao WebDAV esta disponivel | `sbackup webdav test` | + +| Parametro | Padrao | Descricao | +|-----------|--------|-----------| +| `--url URL` | `""` | Endereco do servidor WebDAV (ex: `https://dav.jianguoyun.com/dav/`) | +| `--user USUARIO` | `""` | Nome de usuario WebDAV (geralmente o e-mail) | +| `--password SENHA` | `""` | Senha WebDAV (JianguoCloud requer geracao de senha de aplicativo nas configuracoes) | +| `--remote-path CAMINHO` | `/` | Caminho do destino remoto | + +## Como Funciona + +Sbackup implementa as funcionalidades de backup da seguinte forma: + +1. **Armazenamento de estrategias**: as estrategias de backup sao armazenadas em um arquivo JSON, contendo caminhos de pastas, data de ultima modificacao, caminhos de destino, padroes de ignorancia e formato de empacotamento por entrada. +2. **Backup incremental**: comparando a data de ultima modificacao das pastas, faz backup apenas das pastas que foram alteradas. +3. **Compressao multi-formato**: usa os modulos `zipfile` e `tarfile` integrados do Python, alem das bibliotecas `zstandard` e `py7zr`, suportando 7 formatos de empacotamento. +4. **Formato por entrada**: cada estrategia de backup pode ter seu proprio formato de empacotamento (`add --format`), com prioridade sobre a configuracao global `--format`; quando nao especificado, usa o padrao global. +5. **Limpeza de backups**: apos o backup ser bem-sucedido, escaneia automaticamente o diretorio de destino, ordena por data de modificacao e remove arquivos antigos que excedem a quantidade de retencao. +6. **Backup criptografado**: o formato 7z suporta criptografia LZMA2, atraves do parametro `--password` ou configuracao no `config.json`. +7. **Backup agendado**: o comando `watch` executa backups em loop com intervalos especificados, `Ctrl+C` para saida segura. +8. **Historico de backups**: apos cada backup, registra timestamp, tamanho do arquivo e contagem de arquivos, mantendo os ultimos 100 registros. +9. **Backup remoto via SFTP**: implementa o cliente SFTP baseado na biblioteca paramiko, com suporte a teste de conexao, criacao automatica de diretorios remotos e upload de arquivos com barra de progresso. + +### Formato do arquivo de dados + +```json +{ + "/caminho/para/pasta/de/origem": [ + 1719235200.0, + "/caminho/para/pasta/de/destino", + [".git", "__pycache__"], + "" + ], + "/caminho/para/outra/pasta": [ + 1719235200.0, + "/caminho/para/outro/destino", + [".git"], + "TAR_GZ" + ], + "_history": [ + { + "time": "2026-05-01T12:00:00", + "source": "/caminho/para/pasta/de/origem", + "size_mb": 12.5, + "files_count": 150 + } + ] +} +``` + +Cada entrada de estrategia de backup e uma lista de 4 elementos: `[mtime, destino, padroes_ignorados, formato_compressao]` + +| Campo | Descricao | +|-------|-----------| +| `mtime` | Data de ultima modificacao da pasta de origem (usada para decisao de backup incremental) | +| `destino` | Caminho de destino onde os arquivos de backup serao armazenados | +| `padroes_ignorados` | Lista de padroes de arquivos/pastas a serem ignorados | +| `formato_compressao` | Formato de empacotamento por entrada (string vazia significa usar o padrao global) | + +## Guia de Desenvolvimento + +### Executando testes + +```bash +uv run coverage run -m unittest discover -s tests -t . && uv run coverage report -m +``` + +### Estrutura do codigo + +``` +sbackup/ +├── main.py # Ponto de entrada do programa +├── sbackup/ +│ ├── __init__.py # Exporta funcoes principais +│ ├── __main__.py # Ponto de entrada python -m sbackup +│ ├── cli.py # Analise de argumentos CLI e distribuicao de comandos (30+ comandos) +│ ├── config.py # Carregamento de configuracao, criptografia, configuracao Webhook/SMTP +│ ├── auto_save.py # Motor principal BackupManager +│ ├── compression.py # Motor de compressao/descompressao para 7 formatos +│ ├── i18n.py # Internacionalizacao (9 idiomas) +│ ├── sftp.py # Cliente SFTP de backup remoto (paramiko) +│ ├── webdav.py # Cliente WebDAV de backup remoto (zero dependencias) +│ ├── cloud_storage.py # Cliente S3 de armazenamento em nuvem (minio) +│ ├── multi_dest.py # Backup paralelo para multiplos destinos +│ ├── handlers.py # Manipuladores de comandos SFTP/WebDAV/Remote/Schedule +│ ├── hooks.py # Execucao de Pre/Post Hook +│ ├── audit.py # Sistema de log de auditoria +│ ├── profile.py # Gerenciamento de perfis de configuracao +│ ├── selective.py # Restauracao seletiva +│ ├── cross_search.py # Busca entre arquivos +│ ├── integrity.py # Checksum SHA256 +│ ├── rotation.py # Estrategia de rotacao de backups +│ ├── dryrun.py # Visualizacao previa de dry-run +│ ├── diskcheck.py # Estimativa de espaco em disco +│ ├── task_queue.py # Sistema de fila de tarefas +│ ├── schema.py # Validador de configuracao +│ ├── benchmark.py # Benchmark de compressao +│ ├── chunked_backup.py# Backup incremental por blocos +│ ├── dedup.py # Deduplicacao por SHA256 no nivel de arquivo +│ ├── export.py # Exportacao de metadados (CSV/JSON) +│ ├── monitor.py # Monitoramento de sistema de arquivos com watchdog +│ ├── lock.py # Bloqueio de processo multiplataforma +│ ├── retry.py | Retry com backoff exponencial +│ ├── ratelimiter.py # Limitador de taxa por balde de tokens +│ ├── keychain.py # Integracao com chaveiro do sistema +│ ├── parity.py # Codigos de correcao de erros Reed-Solomon +│ ├── completion.py # Autocompletar para shells +│ ├── wizard.py # Assistente de configuracao interativo +│ └── locales/ # Arquivos de traducao para 9 idiomas +└── tests/ + └── sbackup/ + └── test_*.py # 30 arquivos de teste cobrindo todos os modulos +``` + +### Adicionando novas funcionalidades + +1. Crie um novo arquivo de modulo no diretorio `sbackup/` +2. Importe as funcoes da nova funcionalidade em `sbackup/__init__.py` +3. Adicione a logica de manipulacao do novo comando na funcao `run()` +4. Adicione o arquivo de teste correspondente no diretorio `tests/` + +## Perguntas Frequentes + +### P: E se o arquivo de estrategias de backup for deletado acidentalmente? + +R: As estrategias de backup sao armazenadas no arquivo de dados. Se forem deletadas acidentalmente, voce pode reexecutar o comando `add` para adicionar as estrategias novamente. + +### P: Como modificar uma estrategia de backup ja adicionada? + +R: Use o comando `sbackup edit`: `sbackup edit --dest --ignore --format `. + +### P: Suporta backup remoto? + +R: Sim! Sao fornecidos tres metodos de backup remoto: +- **SFTP**: `sbackup sftp config` para configurar, `sbackup save --sftp` para enviar +- **WebDAV**: `sbackup webdav config` para configurar, `sbackup save --webdav` para enviar (compativel com JianguoCloud/NextCloud/Synology) +- **Armazenamento em nuvem S3**: configure o campo `cloud` no `config.json`, `sbackup save --cloud` para enviar +- Pode usar varios simultaneamente: `sbackup save --sftp --webdav --cloud` + +### P: Qual a diferenca entre tar.gz e ZIP? + +R: tar.gz e mais usado em Linux/macOS, com taxa de compressao ligeiramente superior; ZIP e mais universal em Windows, com melhor compatibilidade. tar.bz2 e tar.xz oferecem maior taxa de compressao, mas sao mais lentos. tar.zst e um algoritmo moderno, muito rapido com boa taxa de compressao. 7z tem a maior taxa de compressao e suporta criptografia. + +### P: Como criptografar o backup? + +R: Use o formato 7z e defina uma senha: `uv run python main.py --format 7z save --password suasenha`. A senha tambem pode ser gravada no campo `password` do `config.json`. + +### P: Como limpar backups antigos automaticamente? + +R: Use o parametro `--keep`: `uv run python main.py save --keep 5` mantem apenas os 5 backups mais recentes. O backup agendado tambem suporta: `uv run python main.py watch --interval 60 --keep 10`. + +### P: Como configurar backup agendado? + +R: Use o comando `watch`: `uv run python main.py watch --interval 60` executa backup a cada 60 minutos. Pressione `Ctrl+C` para parar. + +### P: O armazenamento de senhas e seguro? + +R: As senhas SFTP e de criptografia 7z no `config.json` sao armazenadas em **texto plano**. Certifique-se de que as permissoes de acesso ao arquivo `config.json` sejam restritas a usuarios confiaveis (por exemplo, `chmod 600 config.json`). Nao envie o `config.json` com senhas para o sistema de controle de versao. + +## Guia de Contribuicao + +Contribuicoes com Issues e Pull Requests sao bem-vindas! + +1. Fork este repositorio +2. Crie sua branch de funcionalidade (`git checkout -b feature/SuaFuncionalidade`) +3. Faca seus commits (`git commit -m 'Adiciona SuaFuncionalidade'`) +4. Envie para a branch (`git push origin feature/SuaFuncionalidade`) +5. Abra um Pull Request + +### Estilo de codigo + +Este projeto segue o PEP 8 e o Google Python Style Guide. Certifique-se de que seu codigo: +- Use anotacoes de tipo +- Siga docstrings no estilo Google +- Passe em todos os testes unitarios + +## Licenca + +Este projeto esta licenciado sob a GNU GPL v3.0. Consulte o arquivo [LICENSE](../../LICENSE) para mais detalhes. + +## Autor + +**xiatianxuan** (CodeSeed) + +- [Gitee](https://gitee.com/xiatianxuan) +- [Pagina pessoal](https://xnors-codeseed.pages.dev/) + +## Agradecimentos especiais + +- [Xnors Studio](https://xnors.github.io/) + +## Contato + +Se tiver duvidas ou sugestoes, envie um e-mail para: xiatianxuan2025@163.com + +--- + +*Ultima atualizacao: 19 de junho de 2026* diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md new file mode 100644 index 0000000..8de3a47 --- /dev/null +++ b/docs/readme/README_ru.md @@ -0,0 +1,665 @@ +# Sbackup + +[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![License](https://img.shields.io/badge/License-GPL--3.0-green)](../../LICENSE) +[![PyPI](https://img.shields.io/pypi/v/sbackup-cli?color=blue)](https://pypi.org/project/sbackup-cli/) +[![Tests](https://img.shields.io/badge/tests-940%20passed-brightgreen)](../../.github/workflows/ci.yml) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)]() + +> Лёгкий и эффективный инструмент резервного копирования папок с поддержкой командной строки, позволяющий удобно управлять стратегиями бэкапов. + +[English](../../README.md) | [Deutsch](README_de.md) | [Espanol](README_es.md) | [Francais](README_fr.md) | [Portugues](README_pt.md) | [Pycckuu](README_ru.md) | [日本語](README_ja.md) | [한국어](README_ko.md) | [中文](README_zh.md) + +- [Введение](#введение) +- [Возможности](#возможности) +- [Быстрый старт](#быстрый-старт) + - [Установка](#установка) + - [Использование](#использование) +- [Файл конфигурации](#файл-конфигурации) + - [Пример конфигурации](#пример-конфигурации) +- [Удалённое резервное копирование через SFTP](#удалённое-резервное-копирование-через-sftp) +- [Удалённое резервное копирование через WebDAV](#удалённое-резервное-копирование-через-webdav) +- [Принцип работы](#принцип-работы) +- [Руководство для разработчиков](#руководство-для-разработчиков) + - [Запуск тестов](#запуск-тестов) + - [Структура кода](#структура-кода) +- [Часто задаваемые вопросы](#часто-задаваемые-вопросы) +- [Руководство по участию в разработке](#руководство-по-участию-в-разработке) +- [Лицензия](#лицензия) +- [Автор](#автор) + +--- + +## Введение + +Sbackup -- это лёгкий инструмент резервного копирования папок, позволяющий добавлять, удалять и просматривать стратегии бэкапов через командную строку. Он определяет необходимость резервного копирования на основе времени последнего изменения папки, гарантируя, что ваши данные всегда остаются актуальными. + +## Возможности + +- **Инкрементальное резервное копирование**: копируются только изменённые папки, что экономит время и дисковое пространство +- **Поддержка множества форматов**: семь форматов упаковки -- ZIP, tar, tar.gz, tar.bz2, tar.xz, tar.zst, 7z; можно задавать как глобально, так и для каждого элемента отдельно +- **Удалённое резервное копирование через SFTP**: на базе библиотеки paramiko, поддержка аутентификации по паролю/SSH-ключу, автоматическое определение ключа по умолчанию +- **Удалённое резервное копирование через WebDAV**: на базе стандартной библиотеки urllib, без дополнительных зависимостей; поддержка Jianguoyun/NextCloud/Synology +- **Облачное хранилище S3**: на базе библиотеки minio, совместимо со всеми S3-хранилищами (AWS/MinIO/Aliyun OSS и др.) +- **Параллельное копирование в несколько направлений**: одновременное резервное копирование на локальный диск и несколько удалённых хранилищ +- **Восстановление из бэкапа**: распаковка и восстановление в указанную директорию с возможностью выборочного восстановления +- **Очистка старых бэкапов**: автоматическое удаление с поддержкой стратегий по количеству/времени/ежедневному хранению +- **Шифрование**: парольное шифрование для формата 7z + шифрование PBKDF2 для всех форматов +- **Планирование**: автоматическое выполнение по расписанию, поддержка мониторинга файловой системы в реальном времени (watchdog) +- **История резервных копий**: запись времени, размера и контрольной суммы SHA256 каждого бэкапа для удобного отслеживания +- **Журнал аудита**: запись всех событий резервного копирования и восстановления +- **Pre/Post Hook**: выполнение пользовательских команд до и после резервного копирования +- **Профили конфигурации**: сохранение, переключение, импорт и экспорт нескольких конфигураций +- **Поиск по архивам**: поиск совпадающих имён файлов в нескольких бэкапах +- **Целостность данных**: генерация и проверка контрольных сумм SHA256, корректирующие коды Рида-Соломона +- **Валидация конфигурации**: автоматическая проверка параметров конфигурации, обнаружение несанкционированных изменений +- **Очередь задач**: управление очередью задач резервного копирования с возможностью добавления, выполнения и отмены +- **Бенчмарк сжатия**: сравнение производительности различных форматов и уровней сжатия +- **Оценка дискового пространства**: предварительный расчёт размера бэкапа по типам файлов с проверкой места на диске +- **Интернационализация**: поддержка девяти языков -- китайский, английский, французский, испанский, русский, немецкий, японский, португальский, корейский +- **Автодополнение в оболочке**: bash/zsh/fish/powershell +- **Лёгкость и эффективность**: малый размер, быстрый запуск, низкое потребление ресурсов +- **Кроссплатформенность**: поддержка Windows, macOS и Linux + +## Быстрый старт + +### Установка + +#### Установка через pip + +```bash +pip install sbackup-cli +``` + +После установки доступна команда `sbackup` (имя пакета на PyPI -- `sbackup-cli`, имя CLI-команды -- `sbackup`). + +#### Установка из исходного кода + +```bash +git clone https://github.com/xiatianxuan/sbackup.git +cd sbackup +uv sync +``` + +### Использование + +#### Основной синтаксис + +```bash +uv run python main.py [options] +``` + +#### Доступные команды + +| Команда | Описание | +|---------|----------| +| `add` | Добавить стратегию резервного копирования | +| `rm` / `remove` | Удалить стратегию резервного копирования | +| `edit` | Редактировать существующую стратегию | +| `all` | Показать все стратегии резервного копирования | +| `save` | Выполнить резервное копирование | +| `watch` | Запустить резервное копирование по расписанию | +| `restore` | Восстановить из файла резервной копии | +| `info` | Показать подробную информацию о файле резервной копии | +| `diff` | Сравнить исходную директорию с резервной копией | +| `verify` | Проверить целостность файла резервной копии | +| `search` | Поиск файлов в резервной копии | +| `xsearch` | Поиск по нескольким архивам резервных копий | +| `versions` | Показать историю версий резервных копий | +| `sftp` | Управление удалённым резервным копированием через SFTP | +| `webdav` | Управление удалённым резервным копированием через WebDAV | +| `remote` | Управление удалёнными файлами (list/rm) | +| `task` | Управление очередью задач резервного копирования | +| `audit` | Запрос журнала аудита | +| `hooks` | Ручной запуск Pre/Post Hook | +| `profile` | Управление профилями конфигурации | +| `rotate` | Ротация резервных копий | +| `clean` | Очистка старых резервных копий | +| `diskcheck` | Оценка дискового пространства | +| `benchmark` | Бенчмарк форматов сжатия | +| `integrity` | Проверка целостности директории резервных копий | +| `dry-run` | Предварительный просмотр выбора файлов | +| `export` / `import` | Экспорт/импорт стратегий резервного копирования | +| `ignore` | Генерация файла .sbackupignore | +| `schedule` | Экспорт конфигурации планировщика | +| `webhook` | Настройка предустановок Webhook | +| `config` | Шифрование/проверка конфигурации | +| `report` | Генерация отчёта о резервном копировании | +| `completion` | Генерация скрипта автодополнения для оболочки | +| `wizard` | Интерактивный мастер настройки | +| `status` | Панель состояния резервного копирования | +| `version` | Показать информацию о версии | +| `help` | Показать справку | + +#### Глобальные параметры + +| Параметр | Описание | +|----------|----------| +| `--lang zh_CN` / `en_US` / `fr_FR` / `es_ES` / `ru_RU` / `de_DE` / `ja_JP` / `pt_BR` / `ko_KR` | Установить язык интерфейса (сохраняется в config.json) | +| `--format zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` | Установить формат упаковки (сохраняется в config.json) | +| `--debug` | Включить отладочное логирование | + +#### Добавление стратегии резервного копирования + +```bash +uv run python main.py add [-i ignore_patterns] +``` + +Параметры: +- **source**: путь к исходной папке для резервного копирования +- **dest**: путь к директории для хранения резервных копий +- **-i, --ignore**: имена файлов или папок, которые следует игнорировать, через запятую (по умолчанию: `.git,__pycache__`) +- **--format**: формат упаковки на уровне элемента (применяется только к данной стратегии; если не указан, используется глобальный по умолчанию): `zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` + +Примеры: +```bash +# Добавить стратегию с форматом по умолчанию +uv run python main.py add F:/my_folder F:/backup -i node_modules,.git + +# Указать формат tar.gz для данной стратегии (каждый бэкап этой папки будет в формате tar.gz) +uv run python main.py add F:/my_folder F:/backup --format tar.gz + +# Указать формат 7z (только для этой папки) +uv run python main.py add F:/my_folder F:/backup --format 7z +``` + +#### Удаление стратегии резервного копирования + +```bash +uv run python main.py rm +``` + +Параметры: +- **path**: путь к исходной папке, стратегию которой нужно удалить + +Пример: +```bash +uv run python main.py rm F:/my_folder +``` + +#### Просмотр всех стратегий резервного копирования + +```bash +uv run python main.py all +``` + +Отображает все текущие настроенные стратегии резервного копирования. + +#### Выполнение резервного копирования + +```bash +# С использованием формата по умолчанию (ZIP) +uv run python main.py save + +# С использованием формата tar.gz +uv run python main.py --format tar.gz save + +# Сохранить последние 5 резервных копий, автоматически удалить старые +uv run python main.py save --keep 5 + +# Использовать формат 7z с шифрованием +uv run python main.py --format 7z save --password mysecret + +# Английский интерфейс + формат tar.xz +uv run python main.py --lang en_US --format tar.xz save +``` + +**Параметры команды save:** + +| Параметр | Значение по умолчанию | Описание | +|----------|----------------------|----------| +| `--keep N` | `0` | Сохранять N последних резервных копий; 0 -- не удалять старые | +| `--password PASSWORD` | `""` | Пароль шифрования (только для формата 7z) | +| `--sftp` | `false` | Загрузить на SFTP-сервер после завершения резервного копирования | +| `--webdav` | `false` | Загрузить на WebDAV-сервер после завершения резервного копирования | + +Автоматическое резервное копирование изменённых папок в соответствии с настроенными стратегиями. + +#### Планирование резервного копирования + +```bash +# Выполнять резервное копирование каждые 60 минут +uv run python main.py watch --interval 60 + +# Каждые 2 часа, сохранять последние 10 файлов +uv run python main.py watch --interval 120 --keep 10 + +# Планирование + шифрование 7z +uv run python main.py --format 7z watch --interval 60 --password mysecret +``` + +**Параметры команды watch:** + +| Параметр | Значение по умолчанию | Описание | +|----------|----------------------|----------| +| `--interval MINUTES` | `60` | Интервал резервного копирования (в минутах) | +| `--keep N` | `0` | Сохранять N последних резервных копий | +| `--password PASSWORD` | `""` | Пароль шифрования (только для формата 7z) | +| `--sftp` | `false` | Загружать на SFTP-сервер после каждого бэкапа | +| `--webdav` | `false` | Загружать на WebDAV-сервер после каждого бэкапа | + +Нажмите `Ctrl+C`, чтобы остановить планирование. + +#### Восстановление из резервной копии + +```bash +uv run python main.py restore +``` + +Параметры: +- **backup_file**: путь к файлу резервной копии (поддерживаются форматы .zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z) +- **target_dir**: целевая директория для восстановления + +Примеры: +```bash +uv run python main.py restore F:/backup/my_folder.tar.gz F:/restored +uv run python main.py restore F:/backup/my_folder.7z F:/restored +uv run python main.py restore F:/backup/my_folder.tar.zst F:/restored +``` + +#### Удалённое резервное копирование через SFTP + +```bash +# ============ Быстрый старт (рекомендуется) ============ +# 1. Настроить SFTP (автоматическое определение SSH-ключа, без ручного указания) +sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups + +# 2. Проверить соединение +sbackup sftp test + +# 3. Выполнить резервное копирование и загрузить +sbackup save --sftp + +# ============ Способы аутентификации ============ + +# Способ 1: Автоматическое определение ключа (рекомендуется) +# Система автоматически проверяет ~/.ssh/id_ed25519 -> id_rsa -> id_ecdsa +sbackup sftp config --host 192.168.1.100 --user admin + +# Способ 2: Аутентификация по паролю +sbackup sftp config --host 192.168.1.100 --user admin --password secret + +# Способ 3: Указание конкретного ключа +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# Способ 4: Ключ + парольная фраза (интерактивный ввод) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# Способ 5: Ключ + парольная фраза (через командную строку) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mykeypass + +# ============ Сценарии использования ============ + +# Сценарий 1: Разовое резервное копирование с загрузкой +sbackup save --sftp + +# Сценарий 2: Планирование с автоматической загрузкой (каждые 60 минут) +sbackup watch --interval 60 --sftp + +# Сценарий 3: Указание формата + загрузка +sbackup --format tar.gz save --sftp + +# Сценарий 4: Шифрованное резервное копирование + загрузка +sbackup --format 7z save --password mysecret --sftp + +# Сценарий 5: Сохранение последних 5 копий + загрузка +sbackup save --keep 5 --sftp + +# ============ Расширенное использование ============ + +# Интерактивная настройка (пошаговый ввод всех параметров) +sbackup sftp config + +# Неинтерактивная настройка (все параметры через командную строку) +sbackup sftp config --host 192.168.1.100 --port 22 --user admin --password secret --remote-path /backups + +# Проверка соединения с подробным логом +sbackup --debug sftp test +``` + +**Подкоманды sftp:** + +| Подкоманда | Описание | Пример | +|------------|----------|--------| +| `sftp config` | Настройка параметров подключения SFTP (host/port/user/password/key_file/key_passphrase/remote_path) | `sbackup sftp config --host 192.168.1.100 --user admin` | +| `sftp test` | Проверка доступности SFTP-соединения | `sbackup sftp test` | + +**Способы аутентификации:** + +| Способ | Параметры | Описание | Пример | +|--------|-----------|----------|--------| +| **Автоопределение** | Без указания параметров аутентификации | Автоматическая проверка `~/.ssh/id_ed25519` -> `id_rsa` -> `id_ecdsa` (рекомендуется) | `sbackup sftp config --host ... --user ...` | +| Пароль | `--password` | Вход по паролю | `sbackup sftp config --host ... --user ... --password secret` | +| Приватный ключ | `--key-file` | Вход с использованием указанного SSH-ключа | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa` | +| Ключ + фраза | `--key-file` + `--key-passphrase` | Для ключа с парольной фразой | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa --key-passphrase mypass` | + +Поддерживаемые форматы ключей: RSA, Ed25519, ECDSA. + +**Кроссплатформенная поддержка путей:** + +| Платформа | Пример пути к ключу | Описание | +|-----------|---------------------|----------| +| Linux/macOS | `~/.ssh/id_rsa` | Автоматически раскрывается в `/home/user/.ssh/id_rsa` | +| Windows | `~/.ssh/id_rsa` | Автоматически раскрывается в `C:\Users\username\.ssh\id_rsa` | +| Все платформы | Абсолютный путь | Используется полный путь напрямую | + +Конфигурация SFTP сохраняется в поле `sftp` файла `config.json`, параметры можно задать через командную строку или интерактивно. + +#### Просмотр информации о версии + +```bash +sbackup version +``` + +## Файл конфигурации + +Sbackup поддерживает пользовательскую настройку через файл `config.json`. Файл конфигурации должен находиться в корневой директории проекта. + +### Описание параметров конфигурации + +```json +{ + "compression_format": "ZIP", + "compression": { + "algorithm": "ZIP_DEFLATED", + "level": 6 + }, + "skip_patterns": [".git", "__pycache__"], + "data_file": "sbackup.json", + "lang": "zh_CN", + "password": "", + "sftp": { + "host": "", + "port": 22, + "user": "", + "password": "", + "key_file": "", + "key_passphrase": "", + "remote_path": "/", + "enabled": false + } +} +``` + +| Параметр | Тип | Значение по умолчанию | Описание | +|----------|-----|----------------------|----------| +| `compression_format` | string | `"ZIP"` | Формат упаковки; допустимые значения: `ZIP`, `TAR`, `TAR_GZ`, `TAR_BZ2`, `TAR_XZ`, `TAR_ZST`, `7Z` | +| `compression.algorithm` | string | `"ZIP_DEFLATED"` | Алгоритм сжатия ZIP; допустимые значения: `ZIP_DEFLATED`, `ZIP_STORED`, `ZIP_BZIP2`, `ZIP_LZMA` | +| `compression.level` | int | `6` | Уровень сжатия, диапазон 0-9 (0 -- без сжатия, 9 -- максимальное сжатие) | +| `skip_patterns` | list | `[".git", "__pycache__"]` | Шаблоны игнорируемых файлов или папок (поддержка fnmatch-подстановок и сопоставления путей) | +| `data_file` | string | Путь по умолчанию для платформы | Путь к файлу данных стратегий резервного копирования | +| `lang` | string | `"zh_CN"` | Язык интерфейса; допустимые значения: `zh_CN`, `en_US`, `fr_FR`, `es_ES`, `ru_RU`, `de_DE`, `ja_JP`, `pt_BR`, `ko_KR` | +| `password` | string | `""` | Пароль шифрования для формата 7z | +| `sftp.host` | string | `""` | Адрес SFTP-сервера | +| `sftp.port` | int | `22` | Порт SFTP | +| `sftp.user` | string | `""` | Имя пользователя SFTP | +| `sftp.password` | string | `""` | Пароль SFTP (используется при аутентификации по паролю) | +| `sftp.key_file` | string | `""` | Путь к файлу приватного SSH-ключа (используется при аутентификации по ключу, рекомендуется) | +| `sftp.key_passphrase` | string | `""` | Парольная фраза ключа (при наличии) | +| `sftp.remote_path` | string | `"/"` | Путь к удалённой директории назначения | +| `sftp.enabled` | bool | `false` | Включить или отключить SFTP | + +### Пример конфигурации + +Резервное копирование с высокой степенью сжатия в формате tar.bz2: + +```json +{ + "compression_format": "TAR_BZ2", + "compression_level": 9, + "skip_patterns": [".git", "__pycache__", "node_modules", "*.log"], + "data_file": "backup_strategies.json", + "lang": "zh_CN" +} +``` + +### Сравнение форматов упаковки + +| Формат | Расширение | Степень сжатия | Скорость | Зависимости | Применение | +|--------|------------|----------------|----------|-------------|------------| +| ZIP | .zip | Средняя | Высокая | Стандартная библиотека | Универсальный, лучшая совместимость с Windows | +| tar | .tar | Нет | Очень высокая | Стандартная библиотека | Чистая архивация, для внешнего сжатия | +| tar.gz | .tar.gz | Средняя | Высокая | Стандартная библиотека | Универсальный для Linux/macOS | +| tar.bz2 | .tar.bz2 | Высокая | Средняя | Стандартная библиотека | Архивация с высокой степенью сжатия | +| tar.xz | .tar.xz | Максимальная | Низкая | Стандартная библиотека | Долгосрочное архивирование, экономия места | +| tar.zst | .tar.zst | Средне-высокая | Очень высокая | zstandard | Современный формат, баланс скорости и сжатия | +| 7z | .7z | Очень высокая | Низкая | py7zr | Максимальное сжатие, поддержка шифрования | + +#### Удалённое резервное копирование через WebDAV + +WebDAV -- это файловый протокол на основе HTTP, поддерживаемый такими сервисами, как Jianguoyun, NextCloud, Synology и другими. Использует стандартную библиотеку Python `urllib`, **без дополнительных зависимостей**. + +```bash +# ============ Быстрый старт ============ +# 1. Настроить WebDAV +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --password secret + +# 2. Проверить соединение +sbackup webdav test + +# 3. Выполнить резервное копирование и загрузить +sbackup save --webdav + +# ============ Сценарии использования ============ + +# Сценарий 1: Разовое резервное копирование с загрузкой +sbackup save --webdav + +# Сценарий 2: Планирование с автоматической загрузкой (каждые 60 минут) +sbackup watch --interval 60 --webdav + +# Сценарий 3: Указание удалённой поддиректории +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --remote-path /backups/sbackup + +# Сценарий 4: Одновременная загрузка на SFTP и WebDAV +sbackup save --sftp --webdav + +# ============ Типичные адреса WebDAV-сервисов ============ +# Jianguoyun: https://dav.jianguoyun.com/dav/ +# NextCloud: https://your-server/remote.php/dav/files/username/ +# Synology: https://your-synology:5006/webdav/ +``` + +**Подкоманды webdav:** + +| Подкоманда | Описание | Пример | +|------------|----------|--------| +| `webdav config` | Настройка параметров подключения WebDAV (url/user/password/remote_path) | `sbackup webdav config --url ... --user ...` | +| `webdav test` | Проверка доступности WebDAV-соединения | `sbackup webdav test` | + +| Параметр | Значение по умолчанию | Описание | +|----------|----------------------|----------| +| `--url URL` | `""` | Адрес WebDAV-сервера (например, `https://dav.jianguoyun.com/dav/`) | +| `--user USER` | `""` | Имя пользователя WebDAV (обычно адрес электронной почты) | +| `--password PASS` | `""` | Пароль WebDAV (для Jianguoyun необходимо сгенерировать пароль приложения в настройках) | +| `--remote-path PATH` | `/` | Путь к удалённой директории назначения | + +## Принцип работы + +Sbackup реализует резервное копирование следующим образом: + +1. **Хранение стратегий**: стратегии резервного копирования хранятся в файле JSON и содержат пути к папкам, время последнего изменения, пути назначения, шаблоны игнорирования и формат упаковки на уровне элемента. +2. **Инкрементальное копирование**: путём сравнения времени последнего изменения папки копируются только изменённые папки. +3. **Множественные форматы сжатия**: используются встроенные модули Python `zipfile` и `tarfile`, а также сторонние библиотеки `zstandard` и `py7zr` для поддержки семи форматов упаковки. +4. **Формат на уровне элемента**: каждая стратегия может иметь собственный формат упаковки (`add --format`), который имеет приоритет над глобальным параметром `--format`; при отсутствии используется значение по умолчанию. +5. **Очистка бэкапов**: после успешного резервного копирования автоматически сканируется целевая директория, файлы сортируются по времени изменения и удаляются старые копии, превышающие лимит хранения. +6. **Шифрование**: формат 7z поддерживает шифрование LZMA2 через параметр `--password` или настройку в `config.json`. +7. **Планирование**: команда `watch` выполняет резервное копирование в цикле с указанным интервалом, для безопасного выхода используется `Ctrl+C`. +8. **История бэкапов**: после каждого резервного копирования записываются временная метка, размер файла и количество файлов; хранятся последние 100 записей. +9. **Удалённое копирование через SFTP**: на базе библиотеки paramiko реализован SFTP-клиент с поддержкой проверки соединения, автоматического создания удалённых директорий и загрузки файлов с индикатором прогресса. + +### Формат файла данных + +```json +{ + "/path/to/source/folder": [ + 1719235200.0, + "/path/to/target/folder", + [".git", "__pycache__"], + "" + ], + "/path/to/another/folder": [ + 1719235200.0, + "/path/to/another/target", + [".git"], + "TAR_GZ" + ], + "_history": [ + { + "time": "2026-05-01T12:00:00", + "source": "/path/to/source/folder", + "size_mb": 12.5, + "files_count": 150 + } + ] +} +``` + +Каждая запись стратегии резервного копирования представляет собой список из 4 элементов: `[mtime, target, skip_patterns, compression_format]` + +| Поле | Описание | +|------|----------| +| `mtime` | Время последнего изменения исходной папки (для определения необходимости инкрементального копирования) | +| `target` | Путь к директории для хранения резервных копий | +| `skip_patterns` | Список шаблонов игнорируемых файлов/папок | +| `compression_format` | Формат упаковки на уровне элемента (пустая строка означает использование глобального значения по умолчанию) | + +## Руководство для разработчиков + +### Запуск тестов + +```bash +uv run coverage run -m unittest discover -s tests -t . && uv run coverage report -m +``` + +### Структура кода + +``` +sbackup/ +├── main.py # Точка входа программы +├── sbackup/ +│ ├── __init__.py # Экспорт основных функций +│ ├── __main__.py # Точка входа python -m sbackup +│ ├── cli.py # Разбор аргументов CLI и маршрутизация команд (30+ команд) +│ ├── config.py # Загрузка конфигурации, шифрование, настройки Webhook/SMTP +│ ├── auto_save.py # Основной движок BackupManager +│ ├── compression.py # Движок сжатия/распаковки для 7 форматов +│ ├── i18n.py # Интернационализация (9 языков) +│ ├── sftp.py # SFTP-клиент для удалённого копирования (paramiko) +│ ├── webdav.py # WebDAV-клиент для удалённого копирования (без зависимостей) +│ ├── cloud_storage.py # S3-клиент для облачного хранилища (minio) +│ ├── multi_dest.py # Параллельное копирование в несколько направлений +│ ├── handlers.py # Обработчики команд SFTP/WebDAV/Remote/Schedule +│ ├── hooks.py # Выполнение Pre/Post Hook +│ ├── audit.py # Система журналирования аудита +│ ├── profile.py # Управление профилями конфигурации +│ ├── selective.py # Выборочное восстановление +│ ├── cross_search.py | Поиск по архивам +│ ├── integrity.py # Контрольные суммы SHA256 +│ ├── rotation.py # Стратегии ротации резервных копий +│ ├── dryrun.py # Предварительный просмотр (dry-run) +│ ├── diskcheck.py # Оценка дискового пространства +│ ├── task_queue.py # Система очереди задач +│ ├── schema.py # Валидатор конфигурации +│ ├── benchmark.py # Бенчмарк сжатия +│ ├── chunked_backup.py# Блочное инкрементальное копирование +│ ├── dedup.py # Дедупликация файлов по SHA256 +│ ├── export.py # Экспорт метаданных (CSV/JSON) +│ ├── monitor.py # Мониторинг файловой системы (watchdog) +│ ├── lock.py # Кроссплатформенная блокировка процессов +│ ├── retry.py | Повторные попытки с экспоненциальной задержкой +│ ├── ratelimiter.py | Ограничитель скорости (алгоритм токен-ведра) +│ ├── keychain.py | Интеграция с системной связкой ключей +│ ├── parity.py | Корректирующие коды Рида-Соломона +│ ├── completion.py | Автодополнение для оболочки +│ ├── wizard.py | Интерактивный мастер настройки +│ └── locales/ # Файлы перевода на 9 языков +└── tests/ + └── sbackup/ + └── test_*.py # 30 тестовых файлов, покрывающих все модули +``` + +### Добавление нового функционала + +1. Создайте новый файл модуля в директории `sbackup/` +2. Импортируйте функции нового модуля в `sbackup/__init__.py` +3. Добавьте обработку новой команды в функцию `run()` +4. Добавьте соответствующий тестовый файл в директорию `tests/` + +## Часто задаваемые вопросы + +### В: Что делать, если файл стратегий резервного копирования был случайно удалён? + +О: Стратегии хранятся в файле данных. Если файл был удалён, просто заново добавьте стратегии с помощью команды `add`. + +### В: Как изменить ранее добавленную стратегию? + +О: Используйте команду `sbackup edit`: `sbackup edit --dest --ignore --format `. + +### В: Поддерживается ли удалённое резервное копирование? + +О: Да! Предоставляются три способа удалённого копирования: +- **SFTP**: настройка через `sbackup sftp config`, загрузка через `sbackup save --sftp` +- **WebDAV**: настройка через `sbackup webdav config`, загрузка через `sbackup save --webdav` (поддержка Jianguoyun/NextCloud/Synology) +- **Облачное хранилище S3**: настройка поля `cloud` в `config.json`, загрузка через `sbackup save --cloud` +- Можно включить несколько способов одновременно: `sbackup save --sftp --webdav --cloud` + +### В: В чём разница между tar.gz и ZIP? + +О: tar.gz чаще используется в Linux/macOS и обеспечивает немного лучшее сжатие; ZIP более универсален на Windows и обладает лучшей совместимостью. tar.bz2 и tar.xz обеспечивают более высокую степень сжатия, но работают медленнее. tar.zst -- современный алгоритм с высокой скоростью и хорошим сжатием. 7z обеспечивает максимальное сжатие и поддерживает шифрование. + +### В: Как зашифровать резервную копию? + +О: Используйте формат 7z с паролем: `uv run python main.py --format 7z save --password yourpassword`. Пароль также можно указать в поле `password` файла `config.json`. + +### В: Как автоматически очищать старые резервные копии? + +О: Используйте параметр `--keep`: `uv run python main.py save --keep 5` сохранит только 5 последних резервных копий. При планировании тоже поддерживается: `uv run python main.py watch --interval 60 --keep 10`. + +### В: Как настроить автоматическое резервное копирование по расписанию? + +О: Используйте команду `watch`: `uv run python main.py watch --interval 60` -- резервное копирование каждые 60 минут. Нажмите `Ctrl+C` для остановки. + +### В: Безопасно ли хранение паролей? + +О: Пароли SFTP и шифрования 7z в файле `config.json` хранятся в **открытом виде**. Убедитесь, что доступ к файлу `config.json` имеют только доверенные пользователи (например, `chmod 600 config.json`). Не добавляйте файл `config.json`, содержащий пароли, в систему контроля версий. + +## Руководство по участию в разработке + +Приветствуются Issues и Pull Requests! + +1. Сделайте форк репозитория +2. Создайте ветку для новой функции (`git checkout -b feature/AmazingFeature`) +3. Внесите изменения (`git commit -m 'Add some AmazingFeature'`) +4. Отправьте в ветку (`git push origin feature/AmazingFeature`) +5. Создайте Pull Request + +### Стиль кодирования + +Проект следует стандартам PEP 8 и Google Python Style Guide. Убедитесь, что ваш код: +- Использует аннотации типов +- Следует Google-стилю docstrings +- Проходит все модульные тесты + +## Лицензия + +Проект распространяется под лицензией GNU GPL v3.0. Подробности см. в файле [LICENSE](../../LICENSE). + +## Автор + +**xiatianxuan** (CodeSeed) + +- [Gitee](https://gitee.com/xiatianxuan) +- [Личная страница](https://xnors-codeseed.pages.dev/) + +## Благодарности + +- [Xnors Studio](https://xnors.github.io/) + +## Связь с нами + +По вопросам и предложениям обращайтесь по электронной почте: xiatianxuan2025@163.com + +--- + +*Последнее обновление: 19 июня 2026 г.* diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md new file mode 100644 index 0000000..d5c131f --- /dev/null +++ b/docs/readme/README_zh.md @@ -0,0 +1,665 @@ +# Sbackup + +[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python&logoColor=white)](https://www.python.org/) +[![License](https://img.shields.io/badge/License-GPL--3.0-green)](LICENSE) +[![PyPI](https://img.shields.io/pypi/v/sbackup-cli?color=blue)](https://pypi.org/project/sbackup-cli/) +[![Tests](https://img.shields.io/badge/tests-940%20passed-brightgreen)](.github/workflows/ci.yml) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)]() + +> 轻量、高效的文件夹备份工具,支持命令行操作,帮助你轻松管理备份策略。 + +[English](../../README.md) | [Deutsch](README_de.md) | [Espanol](README_es.md) | [Francais](README_fr.md) | [Italiano](README_it.md) | [Portugues](README_pt.md) | [Pycckuu](README_ru.md) | [日本語](README_ja.md) | [한국어](README_ko.md) + +- [简介](#简介) +- [功能特性](#功能特性) +- [快速开始](#快速开始) + - [安装](#安装) + - [使用方法](#使用方法) +- [配置文件](#配置文件) + - [配置示例](#配置示例) +- [SFTP 远程备份](#sftp-远程备份) +- [WebDAV 远程备份](#webdav-远程备份) +- [实现原理](#实现原理) +- [开发指南](#开发指南) + - [运行测试](#运行测试) + - [代码结构](#代码结构) +- [常见问题](#常见问题) +- [贡献指南](#贡献指南) +- [许可证](#许可证) +- [作者](#作者) + +--- + +## 简介 + +Sbackup 是一个轻量级的文件夹备份工具,支持通过命令行添加、删除和查看备份策略。它基于文件夹的最后修改时间来决定是否需要进行备份,确保你的数据始终保持最新状态。 + +## 功能特性 + +- ✅ **增量备份**:仅备份已更改的文件夹,节省时间和存储空间 +- ✅ **多格式支持**:支持 ZIP、tar、tar.gz、tar.bz2、tar.xz、tar.zst、7z 七种打包格式,全局和条目级均可独立指定 +- ✅ **SFTP 远程备份**:基于 paramiko 库,支持密码/SSH 私钥认证、自动检测默认私钥 +- ✅ **WebDAV 远程备份**:基于标准库 urllib,零额外依赖,支持坚果云/NextCloud/群晖 +- ✅ **S3 云存储**:基于 minio 库,支持所有 S3 兼容存储(AWS/MinIO/阿里云 OSS 等) +- ✅ **多目标并行备份**:同时备份到本地 + 多个远程目标,互不影响 +- ✅ **备份还原**:支持从备份文件解压还原到指定目录,支持选择性恢复 +- ✅ **备份清理**:自动删除旧备份,支持按数量/时间/每日保留策略 +- ✅ **加密备份**:支持 7z 格式密码加密 + 全格式 PBKDF2 加密 +- ✅ **定时备份**:设置间隔自动执行,支持实时文件监控(watchdog) +- ✅ **备份历史**:记录每次备份的时间、大小、SHA256 校验和,方便追溯 +- ✅ **审计日志**:记录所有备份/恢复操作的审计事件 +- ✅ **Pre/Post Hook**:备份前后执行自定义命令 +- ✅ **配置 Profile**:支持多配置方案的保存、切换、导入导出 +- ✅ **跨档案搜索**:在多个备份文件中搜索匹配的文件名 +- ✅ **数据完整性**:SHA256 校验和生成与验证,Reed-Solomon 纠错码 +- ✅ **配置校验**:自动校验配置参数合法性,检测篡改 +- ✅ **任务队列**:管理备份任务队列,支持添加、执行、取消 +- ✅ **压缩基准测试**:比较不同格式/级别的压缩性能 +- ✅ **磁盘空间预估**:按文件类型估算备份大小,检查目标空间 +- ✅ **国际化**:支持中文、英语、法语、西班牙语、俄语、德语、日语、葡萄牙语、韩语九种语言 +- ✅ **Shell 补全**:支持 bash/zsh/fish/powershell 自动补全 +- ✅ **轻量高效**:体积小,启动速度快,资源占用低 +- ✅ **跨平台支持**:支持 Windows、macOS 和 Linux + +## 快速开始 + +### 安装 + +#### 使用 pip 安装 + +```bash +pip install sbackup-cli +``` + +安装后使用 `sbackup` 命令(PyPI 包名为 `sbackup-cli`,CLI 命令为 `sbackup`)。 + +#### 从源码安装 + +```bash +git clone https://github.com/xiatianxuan/sbackup.git +cd sbackup +uv sync +``` + +### 使用方法 + +#### 基本语法 + +```bash +uv run python main.py [options] +``` + +#### 可用命令 + +| 命令 | 描述 | +|------|------| +| `add` | 添加备份策略 | +| `rm` / `remove` | 删除备份策略 | +| `edit` | 编辑已有备份策略 | +| `all` | 查看所有备份策略 | +| `save` | 执行备份 | +| `watch` | 定时执行备份 | +| `restore` | 从备份文件还原 | +| `info` | 查看备份文件详情 | +| `diff` | 对比源目录与备份的差异 | +| `verify` | 校验备份文件完整性 | +| `search` | 在备份中搜索文件 | +| `xsearch` | 跨多个备份档案搜索 | +| `versions` | 查看备份版本历史 | +| `sftp` | SFTP 远程备份管理 | +| `webdav` | WebDAV 远程备份管理 | +| `remote` | 远程文件管理(list/rm) | +| `task` | 备份任务队列管理 | +| `audit` | 审计日志查询 | +| `hooks` | 手动执行 Pre/Post Hook | +| `profile` | 配置 Profile 管理 | +| `rotate` | 备份轮转清理 | +| `clean` | 清理旧备份 | +| `diskcheck` | 磁盘空间预估 | +| `benchmark` | 压缩格式基准测试 | +| `integrity` | 备份目录完整性校验 | +| `dry-run` | 预览备份文件选择 | +| `export` / `import` | 导出/导入备份策略 | +| `ignore` | 生成 .sbackupignore 文件 | +| `schedule` | 导出定时调度配置 | +| `webhook` | 配置 Webhook 预设 | +| `config` | 配置加密/校验 | +| `report` | 生成备份报告 | +| `completion` | 生成 Shell 补全脚本 | +| `wizard` | 交互式配置向导 | +| `status` | 备份状态仪表盘 | +| `version` | 查看版本信息 | +| `help` | 查看帮助信息 | + +#### 全局参数 + +| 参数 | 描述 | +|------|------| +| `--lang zh_CN` / `en_US` / `fr_FR` / `es_ES` / `ru_RU` / `de_DE` / `ja_JP` / `pt_BR` / `ko_KR` | 设置界面语言(持久化到 config.json) | +| `--format zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` | 设置打包格式(持久化到 config.json) | +| `--debug` | 开启调试日志 | + +#### 添加备份策略 + +```bash +uv run python main.py add [-i ignore_patterns] +``` + +参数说明: +- **source**:需要备份的源文件夹路径 +- **dest**:备份文件存放的目标路径 +- **-i, --ignore**:需要忽略的文件或文件夹名称,使用逗号分隔(默认:`.git,__pycache__`) +- **--format**:条目级打包格式(仅作用于该备份策略,不指定则使用全局默认):`zip` / `tar` / `tar.gz` / `tar.bz2` / `tar.xz` / `tar.zst` / `7z` + +示例: +```bash +# 使用全局默认格式添加策略 +uv run python main.py add F:/my_folder F:/backup -i node_modules,.git + +# 为该策略指定 tar.gz 格式(每次备份此文件夹都使用 tar.gz) +uv run python main.py add F:/my_folder F:/backup --format tar.gz + +# 指定 7z 格式(仅此文件夹) +uv run python main.py add F:/my_folder F:/backup --format 7z +``` + +#### 删除备份策略 + +```bash +uv run python main.py rm +``` + +参数说明: +- **path**:需要删除备份策略的源文件夹路径 + +示例: +```bash +uv run python main.py rm F:/my_folder +``` + +#### 查看所有备份策略 + +```bash +uv run python main.py all +``` + +显示当前所有已配置的备份策略。 + +#### 执行备份 + +```bash +# 使用默认格式(ZIP) +uv run python main.py save + +# 使用 tar.gz 格式 +uv run python main.py --format tar.gz save + +# 保留最近 5 个备份文件,自动清理旧的 +uv run python main.py save --keep 5 + +# 使用 7z 格式并加密 +uv run python main.py --format 7z save --password mysecret + +# 英文界面 + tar.xz 格式 +uv run python main.py --lang en_US --format tar.xz save +``` + +**save 命令参数:** + +| 参数 | 默认值 | 描述 | +|------|--------|------| +| `--keep N` | `0` | 保留最近 N 个备份文件,0 表示不清理 | +| `--password PASSWORD` | `""` | 加密密码(仅 7z 格式支持) | +| `--sftp` | `false` | 备份完成后上传到 SFTP 服务器 | +| `--webdav` | `false` | 备份完成后上传到 WebDAV 服务器 | + +根据备份策略,自动备份已更改的文件夹。 + +#### 定时备份 + +```bash +# 每 60 分钟执行一次备份 +uv run python main.py watch --interval 60 + +# 每 2 小时备份一次,保留最近 10 个文件 +uv run python main.py watch --interval 120 --keep 10 + +# 定时备份 + 7z 加密 +uv run python main.py --format 7z watch --interval 60 --password mysecret +``` + +**watch 命令参数:** + +| 参数 | 默认值 | 描述 | +|------|--------|------| +| `--interval MINUTES` | `60` | 备份间隔(分钟) | +| `--keep N` | `0` | 保留最近 N 个备份文件 | +| `--password PASSWORD` | `""` | 加密密码(仅 7z 格式支持) | +| `--sftp` | `false` | 每次备份后上传到 SFTP 服务器 | +| `--webdav` | `false` | 每次备份后上传到 WebDAV 服务器 | + +按 `Ctrl+C` 停止定时备份。 + +#### 还原备份 + +```bash +uv run python main.py restore +``` + +参数说明: +- **backup_file**:备份文件路径(支持 .zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z) +- **target_dir**:还原目标目录 + +示例: +```bash +uv run python main.py restore F:/backup/my_folder.tar.gz F:/restored +uv run python main.py restore F:/backup/my_folder.7z F:/restored +uv run python main.py restore F:/backup/my_folder.tar.zst F:/restored +``` + +#### SFTP 远程备份 + +```bash +# ============ 快速开始(推荐) ============ +# 1. 配置 SFTP(自动检测 SSH 私钥,无需手动指定) +sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups + +# 2. 测试连接 +sbackup sftp test + +# 3. 执行备份并上传 +sbackup save --sftp + +# ============ 认证方式 ============ + +# 方式一:自动检测私钥(推荐) +# 系统自动尝试 ~/.ssh/id_ed25519 → id_rsa → id_ecdsa +sbackup sftp config --host 192.168.1.100 --user admin + +# 方式二:密码认证 +sbackup sftp config --host 192.168.1.100 --user admin --password secret + +# 方式三:指定私钥 +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# 方式四:私钥 + 密码短语(交互式输入) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa + +# 方式五:私钥 + 密码短语(命令行指定) +sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mykeypass + +# ============ 使用场景 ============ + +# 场景一:一次性备份并上传 +sbackup save --sftp + +# 场景二:定时备份并自动上传(每 60 分钟) +sbackup watch --interval 60 --sftp + +# 场景三:指定格式备份 + 上传 +sbackup --format tar.gz save --sftp + +# 场景四:加密备份 + 上传 +sbackup --format 7z save --password mysecret --sftp + +# 场景五:保留最近 5 个备份 + 上传 +sbackup save --keep 5 --sftp + +# ============ 高级用法 ============ + +# 交互式配置(逐步输入所有参数) +sbackup sftp config + +# 非交互式配置(全部参数在命令行指定) +sbackup sftp config --host 192.168.1.100 --port 22 --user admin --password secret --remote-path /backups + +# 测试连接并查看详细日志 +sbackup --debug sftp test +``` + +**sftp 子命令:** + +| 子命令 | 描述 | 示例 | +|--------|------|------| +| `sftp config` | 配置 SFTP 连接参数(host/port/user/password/key_file/key_passphrase/remote_path) | `sbackup sftp config --host 192.168.1.100 --user admin` | +| `sftp test` | 测试 SFTP 连接是否可用 | `sbackup sftp test` | + +**认证方式:** + +| 方式 | 参数 | 说明 | 示例 | +|------|------|------|------| +| **自动检测** | 不指定认证参数 | 自动尝试 `~/.ssh/id_ed25519` → `id_rsa` → `id_ecdsa`(推荐) | `sbackup sftp config --host ... --user ...` | +| 密码认证 | `--password` | 直接使用密码登录 | `sbackup sftp config --host ... --user ... --password secret` | +| 私钥认证 | `--key-file` | 使用指定 SSH 私钥登录 | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa` | +| 私钥+短语 | `--key-file` + `--key-passphrase` | 私钥有密码短语时使用 | `sbackup sftp config --host ... --user ... --key-file ~/.ssh/id_rsa --key-passphrase mypass` | + +支持的私钥格式:RSA、Ed25519、ECDSA。 + +**跨平台路径支持:** + +| 平台 | 私钥路径示例 | 说明 | +|------|-------------|------| +| Linux/mac | `~/.ssh/id_rsa` | 自动展开为 `/home/user/.ssh/id_rsa` | +| Windows | `~/.ssh/id_rsa` | 自动展开为 `C:\Users\username\.ssh\id_rsa` | +| 全平台 | 绝对路径 | 直接使用完整路径 | + +SFTP 配置保存在 `config.json` 的 `sftp` 字段中,支持命令行参数或交互式输入。 + +#### 查看版本信息 + +```bash +sbackup version +``` + +## 配置文件 + +Sbackup 支持通过 `config.json` 文件进行自定义配置。配置文件应放在项目根目录下。 + +### 配置项说明 + +```json +{ + "compression_format": "ZIP", + "compression": { + "algorithm": "ZIP_DEFLATED", + "level": 6 + }, + "skip_patterns": [".git", "__pycache__"], + "data_file": "sbackup.json", + "lang": "zh_CN", + "password": "", + "sftp": { + "host": "", + "port": 22, + "user": "", + "password": "", + "key_file": "", + "key_passphrase": "", + "remote_path": "/", + "enabled": false + } +} +``` + +| 配置项 | 类型 | 默认值 | 描述 | +|--------|------|--------|------| +| `compression_format` | string | `"ZIP"` | 打包格式,可选值:`ZIP`, `TAR`, `TAR_GZ`, `TAR_BZ2`, `TAR_XZ`, `TAR_ZST`, `7Z` | +| `compression.algorithm` | string | `"ZIP_DEFLATED"` | ZIP 压缩算法,可选值:`ZIP_DEFLATED`, `ZIP_STORED`, `ZIP_BZIP2`, `ZIP_LZMA` | +| `compression.level` | int | `6` | 压缩级别,范围 0-9(0 为不压缩,9 为最高压缩) | +| `skip_patterns` | list | `[".git", "__pycache__"]` | 需要忽略的文件或文件夹模式(支持 fnmatch 通配符和路径匹配) | +| `data_file` | string | 平台默认路径 | 备份策略数据文件的存放路径 | +| `lang` | string | `"zh_CN"` | 界面语言,可选值:`zh_CN`, `en_US`, `fr_FR`, `es_ES`, `ru_RU`, `de_DE`, `ja_JP`, `pt_BR`, `ko_KR` | +| `password` | string | `""` | 7z 加密密码 | +| `sftp.host` | string | `""` | SFTP 服务器地址 | +| `sftp.port` | int | `22` | SFTP 端口 | +| `sftp.user` | string | `""` | SFTP 用户名 | +| `sftp.password` | string | `""` | SFTP 密码(密码认证时使用) | +| `sftp.key_file` | string | `""` | SSH 私钥文件路径(私钥认证时使用,推荐) | +| `sftp.key_passphrase` | string | `""` | 私钥密码短语(如有) | +| `sftp.remote_path` | string | `"/"` | 远程目标路径 | +| `sftp.enabled` | bool | `false` | 是否启用 SFTP | + +### 示例配置 + +使用 tar.bz2 格式进行高压缩率备份: + +```json +{ + "compression_format": "TAR_BZ2", + "compression_level": 9, + "skip_patterns": [".git", "__pycache__", "node_modules", "*.log"], + "data_file": "backup_strategies.json", + "lang": "zh_CN" +} +``` + +### 打包格式对比 + +| 格式 | 扩展名 | 压缩率 | 速度 | 依赖 | 适用场景 | +|------|--------|--------|------|------|----------| +| ZIP | .zip | 中 | 快 | 标准库 | 通用,Windows 兼容性最好 | +| tar | .tar | 无 | 极快 | 标准库 | 纯归档,配合外部压缩 | +| tar.gz | .tar.gz | 中 | 快 | 标准库 | Linux/macOS 通用 | +| tar.bz2 | .tar.bz2 | 高 | 中 | 标准库 | 高压缩率归档 | +| tar.xz | .tar.xz | 最高 | 慢 | 标准库 | 长期归档,空间敏感 | +| tar.zst | .tar.zst | 中高 | 极快 | zstandard | 现代场景,速度与压缩率平衡 | +| 7z | .7z | 极高 | 慢 | py7zr | 最高压缩率,支持加密 | + +#### WebDAV 远程备份 + +WebDAV 是基于 HTTP 的文件协议,支持坚果云、NextCloud、群晖等主流网盘。使用 Python 标准库 `urllib`,**零额外依赖**。 + +```bash +# ============ 快速开始 ============ +# 1. 配置 WebDAV +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --password secret + +# 2. 测试连接 +sbackup webdav test + +# 3. 执行备份并上传 +sbackup save --webdav + +# ============ 使用场景 ============ + +# 场景一:一次性备份并上传 +sbackup save --webdav + +# 场景二:定时备份并自动上传(每 60 分钟) +sbackup watch --interval 60 --webdav + +# 场景三:指定远程子目录 +sbackup webdav config --url https://dav.jianguoyun.com/dav/ --user user@example.com --remote-path /backups/sbackup + +# 场景四:同时上传到 SFTP 和 WebDAV +sbackup save --sftp --webdav + +# ============ 常见 WebDAV 服务地址 ============ +# 坚果云: https://dav.jianguoyun.com/dav/ +# NextCloud: https://your-server/remote.php/dav/files/username/ +# 群晖: https://your-synology:5006/webdav/ +``` + +**webdav 子命令:** + +| 子命令 | 描述 | 示例 | +|--------|------|------| +| `webdav config` | 配置 WebDAV 连接参数(url/user/password/remote_path) | `sbackup webdav config --url ... --user ...` | +| `webdav test` | 测试 WebDAV 连接是否可用 | `sbackup webdav test` | + +| 参数 | 默认值 | 描述 | +|------|--------|------| +| `--url URL` | `""` | WebDAV 服务器地址(如 `https://dav.jianguoyun.com/dav/`) | +| `--user USER` | `""` | WebDAV 用户名(通常为邮箱) | +| `--password PASS` | `""` | WebDAV 密码(坚果云需在设置中生成应用密码) | +| `--remote-path PATH` | `/` | 远程目标路径 | + +## 实现原理 + +Sbackup 通过以下方式实现备份功能: + +1. **备份策略存储**:备份策略存储在 JSON 文件中,包含文件夹路径、最后修改时间、目标路径、忽略模式和条目级打包格式。 +2. **增量备份**:通过比较文件夹的最后修改时间,仅备份已更改的文件夹。 +3. **多格式压缩**:使用 Python 内置的 `zipfile` 和 `tarfile` 模块,以及 `zstandard` 和 `py7zr` 第三方库,支持 7 种打包格式。 +4. **条目级格式**:每个备份策略可指定独立的打包格式(`add --format`),优先于全局 `--format` 设置;未指定时使用全局默认。 +5. **备份清理**:备份成功后自动扫描目标目录,按修改时间排序,删除超出保留数量的旧文件。 +6. **加密备份**:7z 格式支持 LZMA2 加密,通过 `--password` 参数或 `config.json` 配置。 +7. **定时备份**:`watch` 命令在循环中按指定间隔执行备份,`Ctrl+C` 安全退出。 +8. **备份历史**:每次备份后记录时间戳、文件大小和文件数量,保留最近 100 条记录。 +9. **SFTP 远程备份**:基于 paramiko 库实现 SFTP 客户端,支持连接测试、自动创建远程目录、带进度条的文件上传。 + +### 数据文件格式 + +```json +{ + "/path/to/source/folder": [ + 1719235200.0, + "/path/to/target/folder", + [".git", "__pycache__"], + "" + ], + "/path/to/another/folder": [ + 1719235200.0, + "/path/to/another/target", + [".git"], + "TAR_GZ" + ], + "_history": [ + { + "time": "2026-05-01T12:00:00", + "source": "/path/to/source/folder", + "size_mb": 12.5, + "files_count": 150 + } + ] +} +``` + +每个备份策略条目为 4 元素列表:`[mtime, target, skip_patterns, compression_format]` + +| 字段 | 说明 | +|------|------| +| `mtime` | 源文件夹最后修改时间(用于增量备份判断) | +| `target` | 备份文件存放的目标路径 | +| `skip_patterns` | 需忽略的文件/文件夹模式列表 | +| `compression_format` | 条目级打包格式(空字符串表示使用全局默认) | + +## 开发指南 + +### 运行测试 + +```bash +uv run coverage run -m unittest discover -s tests -t . && uv run coverage report -m +``` + +### 代码结构 + +``` +sbackup/ +├── main.py # 程序入口 +├── sbackup/ +│ ├── __init__.py # 导出核心函数 +│ ├── __main__.py # python -m sbackup 入口 +│ ├── cli.py # CLI 参数解析和命令分发(30+ 命令) +│ ├── config.py # 配置加载、加密、Webhook/SMTP 配置 +│ ├── auto_save.py # BackupManager 核心引擎 +│ ├── compression.py # 7 种格式压缩/解压引擎 +│ ├── i18n.py # 国际化(9 种语言) +│ ├── sftp.py # SFTP 远程备份客户端(paramiko) +│ ├── webdav.py # WebDAV 远程备份客户端(零依赖) +│ ├── cloud_storage.py # S3 云存储客户端(minio) +│ ├── multi_dest.py # 多目标并行备份 +│ ├── handlers.py # SFTP/WebDAV/Remote/Schedule 命令处理 +│ ├── hooks.py # Pre/Post Hook 执行 +│ ├── audit.py # 审计日志系统 +│ ├── profile.py # 配置 Profile 管理 +│ ├── selective.py # 选择性恢复 +│ ├── cross_search.py # 跨档案搜索 +│ ├── integrity.py # SHA256 校验和 +│ ├── rotation.py # 备份轮转策略 +│ ├── dryrun.py # Dry-run 预览 +│ ├── diskcheck.py # 磁盘空间预估 +│ ├── task_queue.py # 任务队列系统 +│ ├── schema.py # 配置校验器 +│ ├── benchmark.py # 压缩基准测试 +│ ├── chunked_backup.py# 块级增量备份 +│ ├── dedup.py # 文件级 SHA256 去重 +│ ├── export.py # 元数据导出(CSV/JSON) +│ ├── monitor.py # watchdog 文件系统监控 +│ ├── lock.py # 跨平台进程锁 +│ ├── retry.py # 指数退避重试 +│ ├── ratelimiter.py # 令牌桶限速器 +│ ├── keychain.py # 系统密钥链集成 +│ ├── parity.py # Reed-Solomon 纠错码 +│ ├── completion.py # Shell 自动补全 +│ ├── wizard.py # 交互式配置向导 +│ └── locales/ # 9 种语言翻译文件 +└── tests/ + └── sbackup/ + └── test_*.py # 30 个测试文件,覆盖所有模块 +``` + +### 添加新功能 + +1. 在 `sbackup/` 目录下创建新的模块文件 +2. 在 `sbackup/__init__.py` 中导入新功能的函数 +3. 在 `run()` 函数中添加新的命令行命令处理逻辑 +4. 在 `tests/` 目录下添加对应的测试文件 + +## 常见问题 + +### Q: 备份策略文件被误删了怎么办? + +A: 备份策略存储在数据文件中。如果误删,可以通过重新运行 `add` 命令重新添加备份策略。 + +### Q: 如何修改已添加的备份策略? + +A: 使用 `sbackup edit` 命令:`sbackup edit --dest --ignore --format `。 + +### Q: 支持远程备份吗? + +A: 支持!提供三种远程备份方式: +- **SFTP**:`sbackup sftp config` 配置,`sbackup save --sftp` 上传 +- **WebDAV**:`sbackup webdav config` 配置,`sbackup save --webdav` 上传(支持坚果云/NextCloud/群晖) +- **S3 云存储**:在 `config.json` 中配置 `cloud` 字段,`sbackup save --cloud` 上传 +- 可同时启用多种:`sbackup save --sftp --webdav --cloud` + +### Q: tar.gz 和 ZIP 有什么区别? + +A: tar.gz 在 Linux/macOS 上更常用,压缩率略高;ZIP 在 Windows 上更通用,兼容性最好。tar.bz2 和 tar.xz 提供更高的压缩率但速度较慢。tar.zst 是现代算法,速度极快且压缩率不错。7z 压缩率最高且支持加密。 + +### Q: 如何加密备份? + +A: 使用 7z 格式并设置密码:`uv run python main.py --format 7z save --password yourpassword`。密码也可以写入 `config.json` 的 `password` 字段。 + +### Q: 如何自动清理旧备份? + +A: 使用 `--keep` 参数:`uv run python main.py save --keep 5` 只保留最近 5 个备份文件。定时备份时同样支持:`uv run python main.py watch --interval 60 --keep 10`。 + +### Q: 如何设置定时备份? + +A: 使用 `watch` 命令:`uv run python main.py watch --interval 60` 每 60 分钟备份一次。按 `Ctrl+C` 停止。 + +### Q: 密码存储安全吗? + +A: `config.json` 中的 SFTP 密码和 7z 加密密码均以**明文**存储。请确保 `config.json` 文件的访问权限仅限于可信用户(例如 `chmod 600 config.json`)。不要将包含密码的 `config.json` 提交到版本控制系统。 + +## 贡献指南 + +欢迎提交 Issue 和 Pull Request! + +1. Fork 本仓库 +2. 创建你的特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交你的更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 提交 Pull Request + +### 代码风格 + +本项目遵循 PEP 8 和 Google Python Style Guide。请确保你的代码: +- 使用类型注解 +- 遵循 Google 风格的 docstrings +- 通过所有单元测试 + +## 许可证 + +本项目采用 GNU GPL v3.0 许可证。详情请参阅 [LICENSE](LICENSE) 文件。 + +## 作者 + +**xiatianxuan** (CodeSeed) + +- [Gitee](https://gitee.com/xiatianxuan) +- [个人主页](https://xnors-codeseed.pages.dev/) + +## 特别鸣谢 + +- [Xnors Studio](https://xnors.github.io/) + +## 联系我们 + +如有问题或建议,请发送邮件至:xiatianxuan2025@163.com + +--- + +*最后更新:2026年6月19日* \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 37cb2d5..b680ee6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sbackup-cli" -version = "1.0.1" +version = "2.0.0" description = "智能文件夹备份工具,支持增量备份和自定义配置" readme = "README.md" requires-python = ">=3.10" @@ -18,8 +18,12 @@ classifiers = [ "Topic :: System :: Archiving :: Backup", ] dependencies = [ + "minio>=7.2.20", + "paramiko>=4.0.0", "py7zr>=1.1.0", + "reedsolo>=1.7.0", "tqdm>=4.67.3", + "watchdog>=6.0.0", "zstandard>=0.25.0", ] @@ -27,12 +31,13 @@ dependencies = [ sbackup = "sbackup:run" [project.urls] -Homepage = "https://gitee.com/xiatianxuan" -Repository = "https://gitee.com/xiatianxuan/sbackup" +Homepage = "https://github.com/xiatianxuan/sbackup" +Repository = "https://github.com/xiatianxuan/sbackup" [dependency-groups] dev = [ "coverage>=7.13.5", + "pytest>=9.0.3", ] [tool.setuptools.packages.find] diff --git a/releases/TEMPLATE.md b/releases/TEMPLATE.md new file mode 100644 index 0000000..0da72c3 --- /dev/null +++ b/releases/TEMPLATE.md @@ -0,0 +1,38 @@ + + +## What's New + +- 新增功能说明 + +## Improvements + +- 改进项说明 + +## Bug Fixes + +- 修复说明 + +## Breaking Changes + +- 不兼容变更(如无则删除此节) + +## Installation + +```bash +pip install sbackup-cli +``` + +**Binary downloads:** + +| Platform | File | +|----------|------| +| Windows x64 | `sbackup-{version}-windows-x64.exe` | +| macOS ARM64 | `sbackup-{version}-macos-arm64` | +| macOS x64 | `sbackup-{version}-macos-x86_64` | +| Linux x64 | `sbackup-{version}-linux-x86_64` | + +**Full Changelog**: https://github.com/xiatianxuan/sbackup/compare/v{prev}...v{current} diff --git a/releases/v1.0.1.md b/releases/v1.0.1.md new file mode 100644 index 0000000..af92ba4 --- /dev/null +++ b/releases/v1.0.1.md @@ -0,0 +1,53 @@ +## What's New + +### Remote Backup Improvements + +- **SFTP**: Separated `start_client` / `auth` steps for cleaner authentication flow +- **SFTP**: Auto-save host keys to `known_hosts` on first connection +- **SFTP**: Support non-standard port host key lookup +- **SFTP**: New `allow_password_fallback` parameter to control private key auth fallback behavior +- **WebDAV**: Custom opener now properly handles PROPFIND redirects while preserving auth headers and request method + +### Bug Fixes + +- Fixed `webdav` / `cloud` upload where `uploaded_files` was not collected in `_upload_to_cloud()` +- Fixed `--debug` mode not taking effect due to missing `force=True` in `logging.basicConfig` + +### Documentation + +- README translated to 9 languages (EN/ZH/DE/ES/FR/JA/KO/PT/RU) +- All READMEs include shields.io badges and language switcher +- Updated command list to 37 commands +- Updated code structure to 37 modules + +### Internationalization + +- Completed translations for all 9 languages (665 keys each, 0 missing) +- Removed 4 obsolete `cmd.remote.*` keys from all locales + +### CI/CD + +- New CI workflow: pytest runs on push/PR to dev/main (3 platforms x 2 Python versions) +- Release workflow: binary filenames now include version, platform, and architecture +- Release workflow: reads `releases/v{version}.md` for GitHub Release description +- Nuitka build: added missing dependencies (paramiko, minio, reedsolo, watchdog) + +### Testing + +- 940 tests passed, 0 failed +- Fixed test state pollution: locale and logging level now properly restored in tearDown + +## Installation + +```bash +pip install sbackup-cli +``` + +**Binary downloads:** + +| Platform | File | +|----------|------| +| Windows x64 | `sbackup-1.0.1-windows-x64.exe` | +| macOS ARM64 | `sbackup-1.0.1-macos-arm64` | +| macOS x64 | `sbackup-1.0.1-macos-x86_64` | +| Linux x64 | `sbackup-1.0.1-linux-x86_64` | diff --git a/releases/v2.0.0.md b/releases/v2.0.0.md new file mode 100644 index 0000000..749fc7d --- /dev/null +++ b/releases/v2.0.0.md @@ -0,0 +1,135 @@ +## What's New + +### Major Features + +#### Multi-Destination Backup +- Parallel backup to local + multiple remote targets simultaneously +- Supports SFTP + WebDAV + S3 cloud storage in a single operation +- Independent failure handling per destination + +#### S3 Cloud Storage +- AWS S3 compatible storage support (AWS, MinIO, Alibaba Cloud OSS, etc.) +- Upload/download/delete operations +- Auto-create bucket if not exists + +#### Data Integrity +- SHA256 checksum generation and verification +- Reed-Solomon error correction codes (reedsolo) +- Tamper detection with HMAC validation + +#### Task Queue System +- JSON-persistent task queue management +- Add, execute, cancel, delete tasks +- Async background execution with status tracking + +#### Cross-Archive Search +- Search for files across multiple backup archives +- Pattern matching with wildcard support +- Results grouped by archive + +#### Selective Restore +- Restore specific files matching patterns +- Keyword-based file extraction +- Dry-run preview before actual restore + +#### Backup Rotation +- Keep-count policy: retain latest N backups +- Keep-days policy: retain backups within N days +- Keep-daily policy: retain N daily backups + +#### Pre/Post Hooks +- Execute custom commands before/after backup +- Timeout control and error handling +- Cross-platform command parsing + +#### Configuration Profile +- Save/load/delete named profiles +- Switch between different backup configurations +- Import/export profiles as JSON + +#### Audit Logging +- Record all backup/restore operations +- Search by time range and event type +- JSON-persistent audit trail + +### Performance & Reliability + +- **Chunked Backup**: 64KB block-level incremental backup +- **File Deduplication**: SHA256-based dedup to avoid redundant uploads +- **Parallel Compression**: Multi-threaded compression for large backups +- **Bandwidth Limiter**: Token bucket rate limiting for remote uploads +- **Retry Mechanism**: Exponential backoff for network operations +- **Backup Lock**: Cross-platform process lock to prevent concurrent runs + +### New Commands (27 added) + +| Command | Description | +|---------|-------------| +| `edit` | Edit existing backup strategy | +| `history` | View backup version history | +| `info` | View backup file details | +| `diff` | Compare source directory with backup | +| `verify` | Verify backup file integrity | +| `remote` | Remote file management (list/rm) | +| `export` / `import` | Export/import backup strategies | +| `status` | Backup status dashboard | +| `ignore` | Generate .sbackupignore file | +| `versions` | View backup version history | +| `schedule` | Export cron/schtasks configuration | +| `webhook` | Configure webhook presets | +| `config` | Encrypt/decrypt/validate config | +| `report` | Generate HTML/JSON backup reports | +| `search` | Search files in backup | +| `xsearch` | Cross-archive search | +| `clean` | Clean old backups | +| `completion` | Generate shell completion scripts | +| `wizard` | Interactive configuration wizard | +| `task` | Task queue management | +| `audit` | Audit log queries | +| `hooks` | Manual hook execution | +| `profile` | Configuration profile management | +| `rotate` | Backup rotation cleanup | +| `diskcheck` | Disk space estimation | +| `benchmark` | Compression format benchmarking | +| `integrity` | Directory integrity verification | + +### Remote Backup Enhancements + +- **SFTP**: Auto-detect default SSH keys (~/.ssh/id_rsa, id_ed25519) +- **SFTP**: Host key verification with known_hosts +- **SFTP**: Allow password fallback on private key auth failure +- **WebDAV**: Zero-dependency urllib implementation +- **WebDAV**: SSRF protection (protocol/IP validation) +- **WebDAV**: Proper PROPFIND redirect handling +- **S3**: MinIO client with path prefix support + +### Security + +- PBKDF2 encryption for sensitive config fields +- HMAC-SHA256 config tamper detection +- SSRF protection for WebDAV and webhook URLs +- Safe archive extraction (path traversal prevention) + +### Developer Experience + +- 37 CLI commands +- 37 source modules +- 9 language translations (665 keys each) +- 940 tests, 0 failures +- CI/CD: pytest on 3 platforms x 2 Python versions +- Release automation with versioned binary names + +## Installation + +```bash +pip install sbackup-cli +``` + +**Binary downloads:** + +| Platform | File | +|----------|------| +| Windows x64 | `sbackup-2.0.0-windows-x64.exe` | +| macOS ARM64 | `sbackup-2.0.0-macos-arm64` | +| macOS x64 | `sbackup-2.0.0-macos-x86_64` | +| Linux x64 | `sbackup-2.0.0-linux-x86_64` | diff --git a/sbackup/__init__.py b/sbackup/__init__.py index bd44387..42e4fda 100644 --- a/sbackup/__init__.py +++ b/sbackup/__init__.py @@ -1,193 +1,5 @@ -import os -import sys -import argparse -import logging -from typing import NoReturn -from sbackup.auto_save import BackupManager -from sbackup.i18n import set_locale, t -from sbackup.config import load_config, save_lang, save_format -from sbackup.compression import restore_backup +"""sbackup 包入口:导出核心函数供 CLI 和外部调用""" -VERSION = "1.0.0" -logger = logging.getLogger(__name__) +from sbackup.cli import run, VERSION, get_parser, LocalizedArgumentParser, parse_path - -class LocalizedArgumentParser(argparse.ArgumentParser): - """本地化错误输出的 ArgumentParser 子类""" - - def error(self, message: str) -> NoReturn: - # 将 argparse 生成的英文错误关键词替换为本地化文本 - localized = message - localized = localized.replace( - "invalid choice: ", t("err.argparse.invalid_choice") - ) - localized = localized.replace("choose from", t("err.argparse.choose_from")) - localized = localized.replace( - "invalid float value: ", t("err.argparse.invalid_float") - ) - localized = localized.replace( - "invalid int value: ", t("err.argparse.invalid_int") - ) - localized = localized.replace( - "unrecognized arguments: ", t("err.argparse.unrecognized_args") - ) - localized = localized.replace("required", t("err.argparse.required")) - self.print_usage(sys.stderr) - sys.stderr.write(f"{self.prog}: {localized}\n") - sys.exit(2) - - -def _detect_lang_from_argv() -> str | None: - """从 sys.argv 中提取 --lang 参数值""" - for i, arg in enumerate(sys.argv): - if arg == "--lang" and i + 1 < len(sys.argv): - return sys.argv[i + 1] - if arg.startswith("--lang="): - return arg.split("=", 1)[1] - return None - - -def get_parser() -> argparse.ArgumentParser: - parser = LocalizedArgumentParser( - prog="sbackup", - description=t("cli.description", version=VERSION), - epilog=t("cli.epilog"), - formatter_class=argparse.RawDescriptionHelpFormatter, - add_help=False, - ) - - parser.add_argument("--debug", action="store_true", help=t("cli.help.debug")) - parser.add_argument("-h", "--help", action="help", help=t("cli.help.help")) - parser.add_argument("--lang", default=None, help=t("cli.help.lang")) - parser.add_argument( - "--format", - default=None, - choices=["zip", "tar", "tar.gz", "tar.bz2", "tar.xz", "tar.zst", "7z"], - help=t("cli.help.format"), - ) - - subparsers = parser.add_subparsers(dest="command", help=t("cli.help.subcommands")) - - add_parser = subparsers.add_parser("add", help=t("cli.help.add")) - add_parser.add_argument("source", help=t("cli.help.add.source")) - add_parser.add_argument("dest", help=t("cli.help.add.dest")) - add_parser.add_argument( - "-i", "--ignore", default=".git,__pycache__", help=t("cli.help.add.ignore") - ) - add_parser.add_argument( - "--format", - default=None, - choices=["zip", "tar", "tar.gz", "tar.bz2", "tar.xz", "tar.zst", "7z"], - help=t("cli.help.add.format"), - ) - - rm_parser = subparsers.add_parser("rm", aliases=["remove"], help=t("cli.help.rm")) - rm_parser.add_argument("path", help=t("cli.help.rm.path")) - - subparsers.add_parser("all", help=t("cli.help.all")) - - save_parser = subparsers.add_parser("save", help=t("cli.help.save")) - save_parser.add_argument( - "--keep", type=int, default=0, help=t("cli.help.save.keep") - ) - save_parser.add_argument("--password", default="", help=t("cli.help.save.password")) - - watch_parser = subparsers.add_parser("watch", help=t("cli.help.watch")) - watch_parser.add_argument( - "--interval", type=float, default=60, help=t("cli.help.watch.interval") - ) - watch_parser.add_argument( - "--keep", type=int, default=0, help=t("cli.help.watch.keep") - ) - watch_parser.add_argument( - "--password", default="", help=t("cli.help.watch.password") - ) - - restore_parser = subparsers.add_parser("restore", help=t("cli.help.restore")) - restore_parser.add_argument("backup_file", help=t("cli.help.restore.file")) - restore_parser.add_argument("target_dir", help=t("cli.help.restore.dir")) - - subparsers.add_parser("version", help=t("cli.help.version")) - - return parser - - -def parse_path(path_str: str) -> str: - return os.path.expanduser(path_str.strip()) - - -def run() -> int: - # 先检测 --lang 参数,初始化语言环境,再创建本地化 parser - lang_from_argv = _detect_lang_from_argv() - config = load_config() - current_lang = lang_from_argv if lang_from_argv is not None else config.lang - set_locale(current_lang) - - parser = get_parser() - args = parser.parse_args() - - # 持久化语言设置 - if lang_from_argv is not None: - save_lang(lang_from_argv) - - # 持久化格式设置 - if args.format is not None: - save_format(args.format) - config.compression_format = args.format.upper().replace(".", "_") - - if args.debug: - logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s [%(levelname)s] %(message)s", - datefmt="%H:%M:%S", - ) - - if args.command is None: - parser.print_help() - return 0 - - if args.command == "version": - print(t("cli.version", version=VERSION)) - return 0 - - manager = BackupManager(data_file=config.data_file) - - if args.command == "add": - source = parse_path(args.source) - dest = parse_path(args.dest) - # 条目级格式:用户在 add 时指定的 --format 仅作用于该条目 - entry_fmt = args.format.upper().replace(".", "_") if args.format else "" - success = manager.add_folder(source, dest, args.ignore, entry_fmt) - if success: - print(t("cmd.add.success", source=source, dest=dest)) - return 0 - return 1 - elif args.command in ("rm", "remove"): - path = parse_path(args.path) - success = manager.rm_folder(path) - if success: - print(t("cmd.rm.success", path=path)) - return 0 - return 1 - elif args.command == "all": - print(manager.list_folder_table()) - return 0 - elif args.command == "save": - manager.execute_backups(keep=args.keep, password=args.password) - return 0 - elif args.command == "watch": - import time as _time - - interval_sec = args.interval * 60 - print(t("cmd.watch.start", interval=args.interval)) - try: - while True: - manager.execute_backups(keep=args.keep, password=args.password) - _time.sleep(interval_sec) - except KeyboardInterrupt: - return 0 - elif args.command == "restore": - result = restore_backup(args.backup_file, args.target_dir) - return 0 if result["success"] else 1 - - return 0 +__all__ = ["run", "VERSION", "get_parser", "LocalizedArgumentParser", "parse_path"] diff --git a/sbackup/audit.py b/sbackup/audit.py new file mode 100644 index 0000000..5243b43 --- /dev/null +++ b/sbackup/audit.py @@ -0,0 +1,290 @@ +"""审计日志模块:记录备份、恢复等操作的审计事件""" + +import os +import sys +import json +import uuid +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone, timedelta + +logger = logging.getLogger(__name__) + + +@dataclass +class AuditEntry: + """审计日志条目""" + + id: str = "" + timestamp: str = "" + event: str = "" + source_path: str = "" + target_path: str = "" + format: str = "" + status: str = "success" + files_count: int = 0 + total_size: int = 0 + backup_size: int = 0 + duration: float = 0 + error_message: str = "" + metadata: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + """转换为字典""" + d = { + "id": self.id, + "timestamp": self.timestamp, + "event": self.event, + "source_path": self.source_path, + "target_path": self.target_path, + "format": self.format, + "status": self.status, + "files_count": self.files_count, + "total_size": self.total_size, + "backup_size": self.backup_size, + "duration": self.duration, + "error_message": self.error_message, + "metadata": self.metadata, + } + return d + + @staticmethod + def from_dict(data: dict) -> "AuditEntry": + """从字典创建 AuditEntry""" + return AuditEntry( + id=data.get("id", ""), + timestamp=data.get("timestamp", ""), + event=data.get("event", ""), + source_path=data.get("source_path", ""), + target_path=data.get("target_path", ""), + format=data.get("format", ""), + status=data.get("status", "success"), + files_count=data.get("files_count", 0), + total_size=data.get("total_size", 0), + backup_size=data.get("backup_size", 0), + duration=data.get("duration", 0), + error_message=data.get("error_message", ""), + metadata=data.get("metadata", {}), + ) + + +def _default_audit_path() -> str: + """返回跨平台的默认审计日志路径""" + if sys.platform == "win32": + base = os.environ.get("APPDATA", os.path.expanduser("~")) + elif sys.platform == "darwin": + base = os.path.expanduser("~/Library/Application Support") + else: + base = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) + return os.path.join(base, "sbackup", "audit.json") + + +class AuditLogger: + """审计日志管理器""" + + def __init__(self, audit_file: str = ""): + self.audit_file = audit_file or _default_audit_path() + self._entries: list[dict] = [] + self._load() + + def _load(self) -> None: + """从 JSON 文件加载审计日志""" + if not os.path.exists(self.audit_file): + self._entries = [] + return + try: + with open(self.audit_file, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + self._entries = data + else: + self._entries = [] + except (json.JSONDecodeError, OSError): + self._entries = [] + + def _save(self) -> None: + """保存审计日志到 JSON 文件(原子写入)""" + data_dir = os.path.dirname(self.audit_file) + if data_dir: + os.makedirs(data_dir, exist_ok=True) + + tmp_path = self.audit_file + ".tmp" + try: + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(self._entries, f, ensure_ascii=False, indent=4) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, self.audit_file) + except OSError: + if os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except OSError: + pass + raise + + def log(self, event: str, **kwargs) -> AuditEntry: + """记录一条审计事件""" + entry = AuditEntry( + id=str(uuid.uuid4()), + timestamp=datetime.now(timezone.utc).isoformat(), + event=event, + source_path=kwargs.get("source_path", ""), + target_path=kwargs.get("target_path", ""), + format=kwargs.get("format", ""), + status=kwargs.get("status", "success"), + files_count=kwargs.get("files_count", 0), + total_size=kwargs.get("total_size", 0), + backup_size=kwargs.get("backup_size", 0), + duration=kwargs.get("duration", 0), + error_message=kwargs.get("error_message", ""), + metadata=kwargs.get("metadata", {}), + ) + self._entries.append(entry.to_dict()) + self._save() + return entry + + def query( + self, + event: str = "", + status: str = "", + since: str = "", + limit: int = 50, + ) -> list[AuditEntry]: + """查询审计日志,支持按事件类型/状态/时间过滤""" + results: list[AuditEntry] = [] + for raw in reversed(self._entries): + if event and raw.get("event") != event: + continue + if status and raw.get("status") != status: + continue + if since: + entry_time = raw.get("timestamp", "") + if entry_time < since: + continue + results.append(AuditEntry.from_dict(raw)) + if len(results) >= limit: + break + return results + + def get_stats(self) -> dict: + """统计信息:各事件类型数量、总备份大小、成功率""" + total = len(self._entries) + event_counts: dict[str, int] = {} + total_backup_size = 0 + success_count = 0 + + for raw in self._entries: + event = raw.get("event", "") + event_counts[event] = event_counts.get(event, 0) + 1 + total_backup_size += raw.get("backup_size", 0) + if raw.get("status") == "success": + success_count += 1 + + success_rate = (success_count / total * 100) if total > 0 else 0 + + return { + "total": total, + "event_counts": event_counts, + "total_backup_size": total_backup_size, + "success_count": success_count, + "success_rate": round(success_rate, 1), + } + + def cleanup(self, keep_days: int = 90) -> int: + """清理旧日志,返回删除的条数""" + cutoff = datetime.now(timezone.utc) - timedelta(days=keep_days) + cutoff_str = cutoff.isoformat() + before = len(self._entries) + self._entries = [ + e for e in self._entries if e.get("timestamp", "") >= cutoff_str + ] + removed = before - len(self._entries) + if removed > 0: + self._save() + return removed + + def format_entries(self, entries: list[AuditEntry], lang: str = "zh_CN") -> str: + """格式化审计条目为可读表格""" + if not entries: + if lang == "zh_CN": + return "没有审计日志记录。" + return "No audit log entries." + + lines: list[str] = [] + if lang == "zh_CN": + lines.append("═══ 审计日志 ═══") + else: + lines.append("=== Audit Log ===") + + for entry in entries: + if lang == "zh_CN": + lines.append(f" [{entry.event}] {entry.timestamp}") + lines.append(f" 状态: {entry.status}") + else: + lines.append(f" [{entry.event}] {entry.timestamp}") + lines.append(f" Status: {entry.status}") + + if entry.source_path: + if lang == "zh_CN": + lines.append(f" 源路径: {entry.source_path}") + else: + lines.append(f" Source: {entry.source_path}") + if entry.target_path: + if lang == "zh_CN": + lines.append(f" 目标路径: {entry.target_path}") + else: + lines.append(f" Target: {entry.target_path}") + if entry.files_count > 0: + if lang == "zh_CN": + lines.append(f" 文件数: {entry.files_count}") + else: + lines.append(f" Files: {entry.files_count}") + if entry.backup_size > 0: + size_mb = entry.backup_size / (1024 * 1024) + if lang == "zh_CN": + lines.append(f" 备份大小: {size_mb:.2f} MB") + else: + lines.append(f" Backup size: {size_mb:.2f} MB") + if entry.duration > 0: + if lang == "zh_CN": + lines.append(f" 耗时: {entry.duration:.1f}秒") + else: + lines.append(f" Duration: {entry.duration:.1f}s") + if entry.error_message: + if lang == "zh_CN": + lines.append(f" 错误: {entry.error_message}") + else: + lines.append(f" Error: {entry.error_message}") + lines.append("") + + return "\n".join(lines) + + def format_stats(self, stats: dict, lang: str = "zh_CN") -> str: + """格式化统计信息""" + lines: list[str] = [] + if lang == "zh_CN": + lines.append("═══ 审计日志统计 ═══") + lines.append(f"总记录数: {stats['total']}") + lines.append( + f"总备份大小: {stats['total_backup_size'] / (1024 * 1024):.2f} MB" + ) + lines.append(f"成功次数: {stats['success_count']}") + lines.append(f"成功率: {stats['success_rate']}%") + lines.append("") + lines.append("事件类型统计:") + else: + lines.append("=== Audit Log Statistics ===") + lines.append(f"Total entries: {stats['total']}") + lines.append( + f"Total backup size: {stats['total_backup_size'] / (1024 * 1024):.2f} MB" + ) + lines.append(f"Success count: {stats['success_count']}") + lines.append(f"Success rate: {stats['success_rate']}%") + lines.append("") + lines.append("Event type breakdown:") + + for event, count in stats.get("event_counts", {}).items(): + lines.append(f" {event}: {count}") + + return "\n".join(lines) diff --git a/sbackup/auto_save.py b/sbackup/auto_save.py index bd8ebdb..19f309f 100644 --- a/sbackup/auto_save.py +++ b/sbackup/auto_save.py @@ -2,6 +2,9 @@ import json import shutil import logging +import unicodedata +import time +from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from dataclasses import dataclass from sbackup.config import ( @@ -12,9 +15,12 @@ ) from sbackup.compression import create_compressor from sbackup.i18n import t +from sbackup.retry import retry_call logger = logging.getLogger(__name__) +_HISTORY_KEY = "_history" + @dataclass class BackupEntry: @@ -24,17 +30,31 @@ class BackupEntry: target: str skip_patterns: list[str] compression_format: str = "" # 空字符串表示使用全局默认格式 + name_template: str = "" # 空字符串表示使用全局默认模板 def to_list(self) -> list: """转为 JSON 兼容的列表格式""" - return [self.mtime, self.target, self.skip_patterns, self.compression_format] + return [ + self.mtime, + self.target, + self.skip_patterns, + self.compression_format, + self.name_template, + ] @staticmethod def from_list(data: list) -> "BackupEntry": """从 JSON 兼容的列表格式创建(向后兼容旧格式)""" + if not isinstance(data, list) or len(data) < 3: + return BackupEntry(mtime=0.0, target="", skip_patterns=[]) fmt = data[3] if len(data) > 3 else "" + tpl = data[4] if len(data) > 4 else "" return BackupEntry( - mtime=data[0], target=data[1], skip_patterns=data[2], compression_format=fmt + mtime=data[0], + target=data[1], + skip_patterns=data[2], + compression_format=fmt, + name_template=tpl, ) @@ -81,7 +101,7 @@ def load(self): def save(self, initial: bool = False): """ - 将内存数据写入 JSON 文件 + 将内存数据写入 JSON 文件(原子写入,防止中断导致数据损坏) """ if not initial: logger.debug(t("log.data.write"), self.data_file) @@ -90,8 +110,23 @@ def save(self, initial: bool = False): if data_dir: os.makedirs(data_dir, exist_ok=True) - with open(self.data_file, "w", encoding="utf-8") as f: - json.dump(self.data, f, ensure_ascii=False, indent=4) + # 原子写入:先写入临时文件,再 rename 替换 + tmp_path = self.data_file + ".tmp" + try: + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(self.data, f, ensure_ascii=False, indent=4) + f.flush() + os.fsync(f.fileno()) + # os.replace() 在 Windows 和 Unix 上都是原子操作 + os.replace(tmp_path, self.data_file) + except OSError: + # 如果临时文件写入失败,尝试清理 + if os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except OSError: + pass + raise def _get_entry(self, key: str) -> BackupEntry | None: """获取指定路径的备份策略条目""" @@ -110,10 +145,12 @@ def add_folder( target_folder: str, skip_patterns: str | None = None, compression_format: str = "", + name_template: str = "", ): """ 添加备份策略 :param compression_format: 条目级打包格式,空字符串使用全局默认 + :param name_template: 条目级文件名模板,空字符串使用全局默认 """ if skip_patterns is None: skip_patterns = ",".join(DEFAULT_SKIP_PATTERNS) @@ -131,6 +168,10 @@ def add_folder( return False abs_path = os.path.abspath(folder_path) + abs_dest = os.path.abspath(target_folder) + if abs_path == abs_dest: + print(t("err.dest.invalid", path=target_folder)) + return False if abs_path in self.data: print(t("info.already.added", path=abs_path)) return False @@ -141,6 +182,7 @@ def add_folder( target=os.path.abspath(target_folder), skip_patterns=skip_list, compression_format=compression_format, + name_template=name_template, ) except OSError as e: print(t("err.os", error=e)) @@ -149,6 +191,226 @@ def add_folder( self.save() return True + def edit_strategy( + self, + source: str, + new_target: str | None = None, + new_ignore: str | None = None, + new_format: str | None = None, + new_name_template: str | None = None, + ) -> bool: + """ + 编辑已有备份策略的字段(仅修改指定的字段) + :param source: 源文件夹路径(用于定位策略) + :param new_target: 新的目标路径,None 表示不修改 + :param new_ignore: 新的忽略模式(逗号分隔),None 表示不修改 + :param new_format: 新的打包格式,None 表示不修改 + :param new_name_template: 新的文件名模板,None 表示不修改 + :return: 是否编辑成功 + """ + abs_path = os.path.abspath(source) + entry = self._get_entry(abs_path) + if entry is None: + print(t("warn.no.strategy.found", path=abs_path)) + return False + + changed = False + if new_target is not None: + abs_target = os.path.abspath(new_target) + if not os.path.isdir(abs_target): + print(t("err.dest.invalid", path=new_target)) + return False + if abs_path == abs_target: + print(t("err.dest.invalid", path=new_target)) + return False + entry.target = abs_target + changed = True + if new_ignore is not None: + entry.skip_patterns = [ + s.strip() for s in new_ignore.split(",") if s.strip() + ] + changed = True + if new_format is not None: + entry.compression_format = new_format.upper().replace(".", "_") + changed = True + if new_name_template is not None: + entry.name_template = new_name_template + changed = True + + if not changed: + print(t("cmd.edit.nothing")) + return False + + self._set_entry(abs_path, entry) + self.save() + return True + + def diff_backup( + self, + source: str, + backup_file: str | None = None, + password: str = "", + detail: bool = False, + ) -> dict: + """ + 对比源目录与最近一次备份的差异 + :param source: 源文件夹路径 + :param backup_file: 指定备份文件路径,None 则自动查找最新备份 + :param password: 解密密码 + :return: 包含 added/removed/modified 列表的字典 + """ + from sbackup.compression import get_archive_member_set + + abs_source = os.path.abspath(source) + entry = self._get_entry(abs_source) + + # 确定备份文件 + if backup_file: + if not os.path.isfile(backup_file): + print(t("err.file.not_found", path=backup_file)) + return {"success": False} + target_backup = backup_file + elif entry: + target_dir = Path(entry.target) + if not target_dir.is_dir(): + print(t("err.dest.invalid", path=entry.target)) + return {"success": False} + # 查找最新的备份文件 + patterns = [ + "*.zip", + "*.tar", + "*.tar.gz", + "*.tar.bz2", + "*.tar.xz", + "*.tar.zst", + "*.7z", + ] + all_files = [] + for pat in patterns: + all_files.extend(target_dir.glob(pat)) + if not all_files: + print(t("cmd.diff.no_backup", path=entry.target)) + return {"success": False} + all_files.sort(key=lambda f: f.stat().st_mtime, reverse=True) + target_backup = str(all_files[0]) + else: + print(t("warn.no.strategy.found", path=abs_source)) + return {"success": False} + + if not os.path.isdir(abs_source): + print(t("err.folder.invalid", path=abs_source)) + return {"success": False} + + # 获取备份中的文件集合 + backup_files = get_archive_member_set(target_backup, password) + if not backup_files: + print(t("cmd.diff.empty_backup", path=target_backup)) + return {"success": False} + + # 收集当前源目录的文件集合(复用忽略逻辑) + from sbackup.compression import create_compressor + + skip_patterns = entry.skip_patterns if entry else [".git", "__pycache__"] + cfg = Config( + folder_path=abs_source, + skip_patterns=skip_patterns, + ) + compressor = create_compressor(cfg) + current_files_raw = compressor._collect_files(Path(abs_source)) + source_name = Path(abs_source).name + current_files = set() + for dirpath, filename in current_files_raw: + file_path = Path(dirpath) / filename + arcname = str( + source_name / file_path.relative_to(Path(abs_source)) + ).replace("\\", "/") + current_files.add(arcname) + + # 计算差异 + added = sorted(current_files - backup_files) + removed = sorted(backup_files - current_files) + common = current_files & backup_files + modified = [] + # 用备份文件的修改时间作为基准,源文件更新则视为已修改 + backup_mtime = Path(target_backup).stat().st_mtime + for rel_path in sorted(common): + src_file = ( + Path(abs_source) / rel_path.split("/", 1)[-1] + if "/" in rel_path + else Path(abs_source) / rel_path + ) + try: + if src_file.is_file() and src_file.stat().st_mtime > backup_mtime: + modified.append(rel_path) + except OSError: + pass + + # 逐行差异详情 + detail_map: dict[str, str] = {} + if detail and modified: + from sbackup.compression import get_diff_detail + + for rel_path in modified: + src_file = ( + Path(abs_source) / rel_path.split("/", 1)[-1] + if "/" in rel_path + else Path(abs_source) / rel_path + ) + diff_text = get_diff_detail( + source_path=str(src_file), + archive_path=target_backup, + archive_member=rel_path, + password=password, + ) + if diff_text is not None: + detail_map[rel_path] = diff_text + + return { + "success": True, + "backup_file": target_backup, + "added": added, + "removed": removed, + "modified": modified, + "detail": detail_map, + } + + def format_diff(self, diff_result: dict) -> str: + """将 diff 结果格式化为可读文本""" + if not diff_result.get("success"): + return "" + + lines = [t("cmd.diff.header", backup=diff_result["backup_file"])] + + added = diff_result["added"] + removed = diff_result["removed"] + modified = diff_result["modified"] + + if added: + lines.append(t("cmd.diff.added_header", count=len(added))) + for f in added: + lines.append(f" + {f}") + if removed: + lines.append(t("cmd.diff.removed_header", count=len(removed))) + for f in removed: + lines.append(f" - {f}") + if modified: + lines.append(t("cmd.diff.modified_header", count=len(modified))) + for f in modified: + lines.append(f" ~ {f}") + + if not added and not removed and not modified: + lines.append(t("cmd.diff.no_changes")) + + lines.append( + t( + "cmd.diff.summary", + added=len(added), + removed=len(removed), + modified=len(modified), + ) + ) + return "\n".join(lines) + def rm_folder(self, folder_path: str) -> bool: """ 删除备份策略 @@ -162,17 +424,70 @@ def rm_folder(self, folder_path: str) -> bool: print(t("warn.no.strategy.found", path=abs_path)) return False - def execute_backups(self, keep: int = 0, password: str = ""): + def execute_backups( + self, + keep: int = 0, + keep_days: int = 0, + password: str = "", + sftp_upload: bool = False, + webdav_upload: bool = False, + cloud_upload: bool = False, + dry_run: bool = False, + verify: bool = False, + name_template: str | None = None, + webhook_url: str | None = None, + follow_symlinks: bool = False, + max_size: int = 0, + min_size: int = 0, + max_age_seconds: float = 0, + incremental: str | None = None, + split_size: int = 0, + tag: str = "", + checksum: bool = False, + pre_hooks: list[str] | None = None, + post_hooks: list[str] | None = None, + dedup: bool = False, + threads: int = 1, + ): """ 执行所有备份策略 :param keep: 保留最近 N 个备份文件,0 表示不清理 + :param keep_days: 保留最近 N 天的备份文件,0 表示不按时间清理 :param password: 加密密码(仅 7z 格式支持) + :param sftp_upload: 是否在备份后上传到 SFTP 服务器 + :param webdav_upload: 是否在备份后上传到 WebDAV 服务器 + :param cloud_upload: 是否在备份后上传到 S3 兼容云存储 + :param dry_run: 仅预览将备份的文件,不实际执行 + :param verify: 备份后自动校验完整性 + :param name_template: 备份文件名模板(覆盖全局和条目级设置) + :param webhook_url: 备份完成后 POST 结果到此 URL + :param follow_symlinks: 是否跟随符号链接 + :param max_size: 文件大小上限(字节),0 不限制 + :param min_size: 文件大小下限(字节),0 不限制 + :param max_age_seconds: 文件年龄上限(秒),0 不限制 + :param incremental: 增量备份模式: None/""=全量, "file"=文件级, "block"=块级 + :param pre_hooks: 备份前执行的命令列表 + :param post_hooks: 备份后执行的命令列表 + :param dedup: 启用跨策略 SHA256 内容哈希去重 + :param threads: 并行压缩线程数(仅 ZIP 格式支持并行写入,默认 1=串行) """ config = load_config() - backup_count = 0 + start_time = time.monotonic() + + # 执行前置钩子 + self._run_hooks(pre_hooks or [], "pre") + + # 加载文件级/块级元数据(增量备份用) + file_meta_all = self.data.get("_file_meta", {}) if incremental else {} + chunk_meta_all = ( + self.data.get("_chunk_meta", {}) if incremental == "block" else {} + ) + + # 收集需要备份的条目 + tasks = [] skip_count = 0 for key, raw in list(self.data.items()): - if key == "_history": + if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"): continue if not os.path.exists(key): print(t("warn.source.missing", path=key)) @@ -183,62 +498,1270 @@ def execute_backups(self, keep: int = 0, password: str = ""): print(t("err.os", error=e)) continue entry = BackupEntry.from_list(raw) + file_meta = file_meta_all.get(key, {}) if incremental else {} + chunk_meta = chunk_meta_all.get(key, {}) if incremental == "block" else {} if entry.mtime != current_mtime: - # 条目级格式优先,否则使用全局配置 + tasks.append( + ( + key, + entry, + current_mtime, + config, + password, + name_template, + follow_symlinks, + max_size, + min_size, + max_age_seconds, + file_meta, + split_size, + incremental, + chunk_meta, + threads, + ) + ) + else: + skip_count += 1 + + # dry-run 模式:仅预览,不执行备份 + if dry_run: + self._dry_run_preview(tasks, config) + return + + # 初始化去重存储 + dedup_store = None + dedup_dup_count = 0 + dedup_saved_size = 0 + if dedup: + from sbackup.dedup import DedupStore + + dedup_store = DedupStore(os.path.dirname(self.data_file)) + + # 初始化审计日志 + from sbackup.audit import AuditLogger + + audit = AuditLogger() + + backup_count = 0 + verify_failures = 0 + uploaded_files = [] + + if len(tasks) > 1: + # 并行备份多个策略 + logger.debug(t("log.parallel.backup"), len(tasks)) + # 记录每个任务的开始时间和审计条目 + task_starts: dict[str, float] = {} + task_audits: dict[str, object] = {} + for task in tasks: + t_key = task[0] + t_entry = task[1] + t_fmt = t_entry.compression_format or config.compression_format + task_starts[t_key] = time.monotonic() + task_audits[t_key] = audit.log( + "backup_start", + source_path=t_key, + target_path=t_entry.target, + format=t_fmt, + ) + with ThreadPoolExecutor() as executor: + futures = { + executor.submit(self._do_backup, *task): task for task in tasks + } + for future in as_completed(futures): + ( + key, + entry, + current_mtime, + _, + _, + _, + _, + _, + _, + _, + _, + _, + incr_mode, + _, + _, + ) = futures[future] + task_duration = time.monotonic() - task_starts.get( + key, time.monotonic() + ) + try: + result = future.result() + except Exception as e: + logger.error("Backup failed for %s: %s", key, e) + result = {"success": False} + if result and result.get("success"): + entry.mtime = current_mtime + self._set_entry(key, entry) + self._add_history( + key, + result["size_mb"], + result["files_count"], + result.get("original_size_mb", 0.0), + result.get("path", ""), + tag=tag, + ) + if incr_mode: + self.data.setdefault("_file_meta", {})[key] = ( + self._collect_file_meta(key, checksum=checksum) + ) + if incr_mode == "block": + self._update_chunk_meta(key) + # 文件级去重:扫描源目录并注册文件哈希 + if dedup_store is not None: + try: + hash_counts = dedup_store.scan_directory(key) + d_count, d_size = dedup_store.count_duplicates( + hash_counts + ) + dedup_dup_count += d_count + dedup_saved_size += d_size + except OSError: + pass + if keep > 0 or keep_days > 0: + self._cleanup_old_backups(entry.target, keep, keep_days) + backup_count += 1 + if (sftp_upload or webdav_upload or cloud_upload) and result.get("path"): + uploaded_files.append(result["path"]) + if verify and result.get("path"): + if not self._verify_single(result["path"], password): + verify_failures += 1 + # 审计:备份完成 + audit.log( + "backup_complete", + source_path=key, + target_path=entry.target, + format=entry.compression_format + or config.compression_format, + files_count=result.get("files_count", 0), + backup_size=result.get("size_bytes", 0), + duration=task_duration, + ) + else: + # 审计:备份失败 + error_msg = "" + if result and result.get("error"): + error_msg = str(result["error"]) + elif result and result.get("message"): + error_msg = str(result["message"]) + audit.log( + "backup_failed", + source_path=key, + target_path=entry.target, + format=entry.compression_format + or config.compression_format, + status="failed", + error_message=error_msg, + duration=task_duration, + ) + else: + # 单策略串行执行 + for ( + key, + entry, + current_mtime, + cfg, + pwd, + tpl, + sym, + mx, + mn, + age, + fmeta, + split, + incr_mode, + chmeta, + threads_val, + ) in tasks: + # 审计:备份开始 + task_start = time.monotonic() fmt = entry.compression_format or config.compression_format - config_instance = Config( - folder_path=key, - zipfile_path=entry.target, - skip_patterns=entry.skip_patterns, - compression_format=fmt, - compression_algorithm=config.compression_algorithm, - compression_level=config.compression_level, - password=password, + audit.log( + "backup_start", + source_path=key, + target_path=entry.target, + format=fmt, + ) + result = self._do_backup( + key, + entry, + current_mtime, + cfg, + pwd, + tpl, + sym, + mx, + mn, + age, + fmeta, + split, + incr_mode, + chmeta, + threads_val, ) - result = create_compressor(config_instance).compress() - if result["success"]: + task_duration = time.monotonic() - task_start + if result and result.get("success"): entry.mtime = current_mtime self._set_entry(key, entry) - self._add_history(key, result["size_mb"], result["files_count"]) - if keep > 0: - self._cleanup_old_backups(entry.target, keep) + self._add_history( + key, + result["size_mb"], + result["files_count"], + result.get("original_size_mb", 0.0), + result.get("path", ""), + tag=tag, + ) + if incr_mode: + self.data.setdefault("_file_meta", {})[key] = ( + self._collect_file_meta(key, checksum=checksum) + ) + if incr_mode == "block": + self._update_chunk_meta(key) + # 文件级去重:扫描源目录并注册文件哈希 + if dedup_store is not None: + try: + hash_counts = dedup_store.scan_directory(key) + d_count, d_size = dedup_store.count_duplicates(hash_counts) + dedup_dup_count += d_count + dedup_saved_size += d_size + except OSError: + pass + if keep > 0 or keep_days > 0: + self._cleanup_old_backups(entry.target, keep, keep_days) backup_count += 1 - else: - skip_count += 1 + if (sftp_upload or webdav_upload or cloud_upload) and result.get("path"): + uploaded_files.append(result["path"]) + if verify and result.get("path"): + if not self._verify_single(result["path"], password): + verify_failures += 1 + # 审计:备份完成 + audit.log( + "backup_complete", + source_path=key, + target_path=entry.target, + format=fmt, + files_count=result.get("files_count", 0), + backup_size=result.get("size_bytes", 0), + duration=task_duration, + ) + else: + # 审计:备份失败 + error_msg = "" + if result and result.get("error"): + error_msg = str(result["error"]) + elif result and result.get("message"): + error_msg = str(result["message"]) + audit.log( + "backup_failed", + source_path=key, + target_path=entry.target, + format=fmt, + status="failed", + error_message=error_msg, + duration=task_duration, + ) + + elapsed = time.monotonic() - start_time + total = backup_count + skip_count + if backup_count > 0: self.save() print(t("cmd.save.completed", count=backup_count)) elif skip_count > 0: print(t("cmd.save.uptodate")) + # 统计摘要 + if total > 0: + print( + t( + "cmd.save.summary", + total=total, + backed=backup_count, + skipped=skip_count, + elapsed=elapsed, + ) + ) + + # 校验失败提示 + if verify_failures > 0: + print(t("cmd.verify.partial_fail", count=verify_failures)) + + # 去重统计 + if dedup_store is not None and dedup_dup_count > 0: + print( + t( + "cmd.dedup.saved", + count=dedup_dup_count, + saved=dedup_saved_size / (1024 * 1024), + ) + ) + + # 并行上传到 SFTP、WebDAV 和云存储(使用 ThreadPoolExecutor) + sftp_needed = sftp_upload and bool(uploaded_files) + webdav_needed = webdav_upload and bool(uploaded_files) + cloud_needed = cloud_upload and bool(uploaded_files) + + upload_services = sum([sftp_needed, webdav_needed, cloud_needed]) + if upload_services > 1: + print(t("cmd.upload.parallel")) + with ThreadPoolExecutor(max_workers=upload_services) as upload_executor: + futures = [] + if sftp_needed: + futures.append( + upload_executor.submit( + self._upload_to_sftp, uploaded_files, config + ) + ) + if webdav_needed: + futures.append( + upload_executor.submit( + self._upload_to_webdav, uploaded_files, config + ) + ) + if cloud_needed: + futures.append( + upload_executor.submit( + self._upload_to_cloud, uploaded_files, config + ) + ) + for f in as_completed(futures): + exc = f.exception() + if exc: + logger.error("Parallel upload error: %s", exc) + else: + if sftp_needed: + self._upload_to_sftp(uploaded_files, config) + if webdav_needed: + self._upload_to_webdav(uploaded_files, config) + if cloud_needed: + self._upload_to_cloud(uploaded_files, config) + + # Webhook 通知(支持多个 URL、自定义模板、重试) + all_webhook_urls = list(getattr(config, "webhook_urls", [])) + # CLI --webhook 参数追加到列表(支持字符串或列表) + if webhook_url: + urls_from_cli = ( + webhook_url if isinstance(webhook_url, list) else [webhook_url] + ) + for u in urls_from_cli: + if u and u not in all_webhook_urls: + all_webhook_urls.append(u) + # 向后兼容:单个 webhook_url 配置 + legacy_url = getattr(config, "webhook_url", "") + if legacy_url and legacy_url not in all_webhook_urls: + all_webhook_urls.append(legacy_url) + + if all_webhook_urls: + status = "success" if verify_failures == 0 else "partial" + if backup_count == 0: + status = "skipped" + self._send_webhooks( + all_webhook_urls, + status=status, + backed=backup_count, + skipped=skip_count, + elapsed=elapsed, + verify_failures=verify_failures, + template=getattr(config, "webhook_template", ""), + retries=getattr(config, "webhook_retries", 2), + ) + + # SMTP 邮件通知 + if getattr(config, "smtp_enabled", False): + status = "成功" if verify_failures == 0 else "部分成功" + if backup_count == 0: + status = "跳过" + self._send_email( + config, + status=status, + backed=backup_count, + skipped=skip_count, + elapsed=elapsed, + verify_failures=verify_failures, + ) + + # 执行后置钩子 + self._run_hooks(post_hooks or [], "post") + + # 备份完成后自动轮转(如果有 rotation 配置) + rotation_count = getattr(config, "rotation_keep_count", 0) + rotation_days = getattr(config, "rotation_keep_days", 0) + rotation_daily = getattr(config, "rotation_keep_daily", 0) + if rotation_count > 0 or rotation_days > 0 or rotation_daily > 0: + self._auto_rotate( + config, + keep_count=rotation_count, + keep_days=rotation_days, + keep_daily=rotation_daily, + ) + + def _auto_rotate( + self, + config: Config, + keep_count: int = 0, + keep_days: int = 0, + keep_daily: int = 0, + ) -> None: + """备份完成后自动轮转所有策略的目标目录""" + from sbackup.rotation import BackupRotator, RotationPolicy + + # 收集所有策略的目标目录 + target_dirs: set[str] = set() + for key, raw in self.data.items(): + if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"): + continue + entry = BackupEntry.from_list(raw) + if entry.target: + target_dirs.add(entry.target) + + policy = RotationPolicy( + keep_count=keep_count, + keep_days=keep_days, + keep_daily=keep_daily, + dry_run=False, + ) + + total_deleted = 0 + for target_dir in target_dirs: + if not os.path.isdir(target_dir): + continue + try: + rotator = BackupRotator(target_dir, policy) + deleted_count, _ = rotator.execute() + if deleted_count > 0: + print( + t( + "cmd.rotate.auto_done", + deleted=deleted_count, + ) + ) + total_deleted += deleted_count + except Exception as e: + logger.warning("Auto-rotation failed for %s: %s", target_dir, e) + + @staticmethod + def _run_hooks(hooks: list[str], hook_type: str) -> None: + """执行钩子命令列表(委托给 HookRunner) + + :param hooks: 命令列表 + :param hook_type: "pre" 或 "post",用于日志标识 + """ + from sbackup.hooks import HookRunner + from sbackup.config import load_config + + config = load_config() + runner = HookRunner( + pre_hooks=hooks if hook_type == "pre" else [], + post_hooks=hooks if hook_type == "post" else [], + timeout=config.hook_timeout, + ) + results = runner.run_hooks(hook_type) + # 记录结果摘要 + for r in results: + if not r.success: + logger.warning("Hook failed: %s (rc=%s)", r.command, r.return_code) + + @staticmethod + def _do_backup( + key: str, + entry: BackupEntry, + current_mtime: float, + config: Config, + password: str, + name_template: str | None = None, + follow_symlinks: bool = False, + max_size: int = 0, + min_size: int = 0, + max_age_seconds: float = 0, + file_metadata: dict | None = None, + split_size: int = 0, + incremental: str | None = None, + chunk_meta: dict | None = None, + threads: int = 1, + ) -> dict: + """执行单个备份策略(线程安全)""" + fmt = entry.compression_format or config.compression_format + # 模板优先级:CLI 参数 > 条目级 > 全局配置 + effective_template = ( + name_template or entry.name_template or config.name_template + ) + # 磁盘空间检查 + BackupManager._check_disk_space(entry.target) + config_instance = Config( + folder_path=key, + zipfile_path=entry.target, + skip_patterns=entry.skip_patterns, + compression_format=fmt, + compression_algorithm=config.compression_algorithm, + compression_level=config.compression_level, + password=password, + name_template=effective_template, + follow_symlinks=follow_symlinks, + max_size=max_size, + min_size=min_size, + max_age_seconds=max_age_seconds, + file_metadata=file_metadata or {}, + threads=threads, + ) + result = create_compressor(config_instance).compress() + # 非 7z 格式 + 有密码 → 全格式加密 + if result.get("success") and password and fmt != "7Z" and result.get("path"): + from sbackup.compression import _encrypt_file + + encrypted_path = _encrypt_file(result["path"], password) + result["path"] = encrypted_path + # 分卷:压缩完成后按大小分割 + if split_size > 0 and result.get("success") and result.get("path"): + from sbackup.compression import split_file + + parts = split_file(result["path"], split_size) + if len(parts) > 1: + result["parts"] = parts + result["split_count"] = len(parts) + logger.debug("分卷完成: %d 个分卷", len(parts)) + return result + + @staticmethod + def _collect_file_meta( + source_path: str, checksum: bool = False + ) -> dict[str, list | str]: + """收集源目录下所有文件的元数据 + :param checksum: True 时存储 SHA256,False 时存储 [mtime, size] + """ + import hashlib + + meta = {} + source = Path(source_path) + if not source.is_dir(): + return meta + try: + for dirpath, dirnames, filenames in os.walk(source): + rel_dir = os.path.relpath(dirpath, source) + if rel_dir == ".": + rel_dir = "" + for filename in filenames: + file_rel = ( + os.path.join(rel_dir, filename).replace("\\", "/") + if rel_dir + else filename + ) + try: + file_path = Path(dirpath) / filename + if checksum: + sha = hashlib.sha256() + with open(file_path, "rb") as f: + while True: + chunk = f.read(65536) + if not chunk: + break + sha.update(chunk) + meta[file_rel] = sha.hexdigest() + else: + stat = file_path.stat() + meta[file_rel] = [stat.st_mtime, stat.st_size] + except OSError: + pass + except OSError: + pass + return meta + + def _update_chunk_meta(self, key: str) -> None: + """为指定策略更新块级哈希元数据""" + from sbackup.chunked_backup import compute_chunk_hashes + + if not os.path.isdir(key): + return + try: + for dirpath, _, filenames in os.walk(key): + for fn in filenames: + fp = os.path.join(dirpath, fn) + try: + if os.path.isfile(fp): + rel = os.path.relpath(fp, key).replace("\\", "/") + self.data.setdefault("_chunk_meta", {}).setdefault(key, {})[ + rel + ] = compute_chunk_hashes(fp) + except OSError: + pass + except OSError: + pass + + @staticmethod + def _check_disk_space(target_dir: str, min_ratio: float = 0.05) -> None: + """检查目标目录剩余空间,不足时打印警告""" + try: + target = Path(target_dir) + if not target.is_dir(): + return + usage = shutil.disk_usage(target) + # 如果剩余空间不足总量的 min_ratio,发出警告 + if usage.free < usage.total * min_ratio: + need_mb = (usage.total * min_ratio) / (1024 * 1024) + free_mb = usage.free / (1024 * 1024) + print( + t( + "warn.disk.space", + path=target_dir, + need=need_mb, + free=free_mb, + ) + ) + except OSError: + pass + + @staticmethod + def _dry_run_preview(tasks: list[tuple], config: Config) -> None: + """预览将要备份的文件(dry-run 模式)""" + from sbackup.compression import create_compressor as _create + + if not tasks: + print(t("cmd.dry_run.nothing")) + return + + print(t("cmd.dry_run.header")) + total_files = 0 + total_size = 0.0 + for key, entry, *_rest in tasks: + fmt = entry.compression_format or config.compression_format + cfg = Config( + folder_path=key, + zipfile_path=entry.target, + skip_patterns=entry.skip_patterns, + compression_format=fmt, + ) + compressor = _create(cfg) + files = compressor._collect_files(Path(key)) + file_count = len(files) + size_bytes = sum( + (Path(dp) / fn).stat().st_size + for dp, fn in files + if (Path(dp) / fn).exists() + ) + size_mb = size_bytes / (1024 * 1024) + total_files += file_count + total_size += size_mb + print( + t( + "cmd.dry_run.item", + source=key, + target=entry.target, + format=fmt, + count=file_count, + size=f"{size_mb:.2f}", + ) + ) + print( + t( + "cmd.dry_run.total", + count=len(tasks), + files=total_files, + size=f"{total_size:.2f}", + ) + ) + + @staticmethod + def _verify_single(backup_path: str, password: str = "") -> bool: + """校验单个备份文件完整性""" + from sbackup.compression import verify_backup + + result = verify_backup(backup_path, password) + return result.get("success", False) + + @staticmethod + def _send_webhook( + url: str, + *, + status: str, + backed: int, + skipped: int, + elapsed: float, + verify_failures: int = 0, + template: str = "", + retries: int = 2, + ) -> None: + """POST 备份结果到单个 webhook URL,支持自定义模板和重试""" + import urllib.request + import urllib.error + import random + import time as _time + from datetime import datetime + + variables = { + "status": status, + "timestamp": datetime.now().isoformat(timespec="seconds"), + "backed": str(backed), + "skipped": str(skipped), + "elapsed": f"{elapsed:.2f}", + "verify_failures": str(verify_failures), + } + + if template: + try: + payload = template.format(**variables).encode("utf-8") + except (KeyError, ValueError): + payload = json.dumps(variables, ensure_ascii=False).encode("utf-8") + else: + payload = json.dumps( + { + "status": status, + "timestamp": variables["timestamp"], + "backed": backed, + "skipped": skipped, + "elapsed_seconds": round(elapsed, 2), + "verify_failures": verify_failures, + }, + ensure_ascii=False, + ).encode("utf-8") + + max_attempts = min(max(retries, 1), 5) + + # SSRF 防护:只允许 http/https 协议,阻止内网地址 + from urllib.parse import urlparse + + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + logger.warning("Webhook URL 协议不允许: %s (仅支持 http/https)", url) + return + + # 解析主机名并阻止内部/保留 IP + if parsed.hostname: + try: + import ipaddress + import socket as _socket + + host_ip = _socket.getaddrinfo( + parsed.hostname, None, type=_socket.SOCK_STREAM + )[0][4][0] + ip = ipaddress.ip_address(host_ip) + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + ): + logger.warning( + "Webhook URL 指向内部地址,已阻止: %s", parsed.hostname + ) + return + except (OSError, ValueError): + pass + + # 禁用自动重定向(防止重定向链绕过协议/地址检查) + class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + no_redirect_opener = urllib.request.build_opener(_NoRedirectHandler) + + for attempt in range(max_attempts): + try: + req = urllib.request.Request( + url, + data=payload, + method="POST", + headers={"Content-Type": "application/json"}, + ) + no_redirect_opener.open(req, timeout=10) + logger.debug("Webhook 通知成功: %s", parsed.hostname) + return + except (urllib.error.URLError, OSError) as e: + if attempt < max_attempts - 1: + wait = min(2**attempt, 30.0) + wait += random.uniform(0, wait * 0.3) + logger.debug( + "Webhook 重试 %d/%d (%.1fs后): %s", + attempt + 1, + max_attempts, + wait, + parsed.hostname, + ) + _time.sleep(wait) + else: + logger.warning("Webhook 通知失败: %s — %s", parsed.hostname, e) + + @staticmethod + def _send_webhooks( + urls: list[str], + *, + status: str, + backed: int, + skipped: int, + elapsed: float, + verify_failures: int = 0, + template: str = "", + retries: int = 2, + ) -> None: + """POST 备份结果到多个 webhook URL""" + for url in urls: + if url: + BackupManager._send_webhook( + url, + status=status, + backed=backed, + skipped=skipped, + elapsed=elapsed, + verify_failures=verify_failures, + template=template, + retries=retries, + ) + # 向后兼容别名 save_folder = execute_backups - def _add_history(self, source: str, size_mb: float, files_count: int): - """记录备份历史""" + @staticmethod + def _safe_smtp_header(value: str) -> str: + """净化 SMTP 头值,防止头注入""" + return value.replace("\r", "").replace("\n", "") + + @staticmethod + def _send_email( + config: Config, + *, + status: str, + backed: int, + skipped: int, + elapsed: float, + verify_failures: int = 0, + ) -> None: + """通过 SMTP 发送备份结果邮件通知""" + import smtplib + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart from datetime import datetime - history = self.data.setdefault("_history", []) - history.append( - { - "time": datetime.now().isoformat(timespec="seconds"), - "source": source, - "size_mb": round(size_mb, 2), - "files_count": files_count, - } + if not config.smtp_enabled or not config.smtp_host: + return + + subject = ( + f"[sbackup] 备份{status} - {datetime.now().strftime('%Y-%m-%d %H:%M')}" ) + + body = f"""sbackup 备份通知 + +状态: {status} +备份策略数: {backed} +跳过数: {skipped} +耗时: {elapsed:.2f} 秒 +验证失败: {verify_failures} +时间: {datetime.now().isoformat(timespec="seconds")} +""" + + msg = MIMEMultipart() + msg["From"] = BackupManager._safe_smtp_header( + config.smtp_from or config.smtp_user + ) + msg["To"] = BackupManager._safe_smtp_header(config.smtp_to) + msg["Subject"] = BackupManager._safe_smtp_header(subject) + msg.attach(MIMEText(body, "plain", "utf-8")) + + try: + if config.smtp_tls: + server = smtplib.SMTP(config.smtp_host, config.smtp_port) + server.starttls() + else: + server = smtplib.SMTP(config.smtp_host, config.smtp_port) + + if config.smtp_user and config.smtp_password: + server.login(config.smtp_user, config.smtp_password) + + server.send_message(msg) + server.quit() + logger.debug("邮件通知成功: %s", config.smtp_to) + except (smtplib.SMTPException, OSError) as e: + logger.warning("邮件通知失败: %s", e) + + @staticmethod + def _upload_to_sftp(file_paths: list[str], config: Config) -> None: + """将备份文件上传到 SFTP 服务器(多文件并行)""" + from concurrent.futures import ThreadPoolExecutor, as_completed + from sbackup.sftp import SFTPClient, SFTPError + + if not config.sftp_enabled or not config.sftp_host: + print(t("err.sftp.not_configured")) + return + + # 获取认证凭据 + key_file = config.sftp_key_file + key_passphrase = config.sftp_key_passphrase + password = config.sftp_password + + if not key_file and not password: + default_key = SFTPClient.try_default_key() + if default_key: + print(t("cmd.sftp.using_default_key", path=default_key)) + key_file = default_key + key_passphrase = SFTPClient.resolve_key_passphrase(default_key) + if key_passphrase is None: + key_file = "" + password = config.sftp_password + key_passphrase = "" + else: + print(t("cmd.sftp.no_default_key")) + return + elif key_file and not key_passphrase and not password: + key_passphrase = SFTPClient.resolve_key_passphrase(key_file) + if key_passphrase is None: + key_file = "" + password = config.sftp_password + key_passphrase = "" + + def _upload_single(local_path: str) -> tuple[str, bool, str]: + """单文件上传(线程安全,每个线程独立连接,带重试)""" + filename = os.path.basename(local_path) + try: + retry_call( + lambda: _do_upload(local_path, filename), + ) + return filename, True, "" + except SFTPError as e: + return filename, False, str(e) + + def _do_upload(local_path: str, filename: str) -> None: + """实际执行 SFTP 上传(可被 retry_call 包裹)""" + with SFTPClient( + config.sftp_host, + config.sftp_port, + config.sftp_user, + password, + key_file, + key_passphrase, + ) as client: + client.upload_file(local_path, config.sftp_remote_path) + + if len(file_paths) == 1: + # 单文件直接上传(带进度条) + local_path = file_paths[0] + filename = os.path.basename(local_path) + file_size = os.path.getsize(local_path) + print(t("cmd.sftp.uploading", file=filename)) + try: + from tqdm import tqdm as tqdm_cls + + def _do_sftp_upload(): + with SFTPClient( + config.sftp_host, + config.sftp_port, + config.sftp_user, + password, + key_file, + key_passphrase, + ) as client: + with tqdm_cls( + total=file_size, + unit="B", + unit_scale=True, + desc=t("cmd.sftp.progress"), + ) as pbar: + client.upload_file( + local_path, + config.sftp_remote_path, + progress_callback=lambda sent, total: pbar.update( + sent - pbar.n + ), + ) + + retry_call(_do_sftp_upload) + print(t("cmd.sftp.success", file=filename)) + except SFTPError as e: + print(str(e)) + else: + # 多文件并行上传 + print(t("cmd.sftp.parallel_upload", count=len(file_paths))) + with ThreadPoolExecutor(max_workers=min(len(file_paths), 4)) as executor: + futures = {executor.submit(_upload_single, fp): fp for fp in file_paths} + for future in as_completed(futures): + filename, success, error = future.result() + if success: + print(t("cmd.sftp.success", file=filename)) + else: + print(error) + + @staticmethod + def _upload_to_webdav(file_paths: list[str], config: Config) -> None: + """将备份文件上传到 WebDAV 服务器(多文件并行)""" + from concurrent.futures import ThreadPoolExecutor, as_completed + from sbackup.webdav import WebDAVClient, WebDAVError + + if not config.webdav_enabled or not config.webdav_url: + print(t("err.webdav.not_configured")) + return + + def _upload_single(local_path: str) -> tuple[str, bool, str]: + filename = os.path.basename(local_path) + try: + retry_call(_do_webdav_upload, local_path) + return filename, True, "" + except WebDAVError as e: + return filename, False, str(e) + + def _do_webdav_upload(local_path: str) -> None: + """实际执行 WebDAV 上传(可被 retry_call 包裹)""" + filename = os.path.basename(local_path) + client = WebDAVClient( + config.webdav_url, + config.webdav_user, + config.webdav_password, + ) + client.connect() + remote_path = config.webdav_remote_path.rstrip("/") + "/" + filename + client.upload_file(local_path, remote_path) + + if len(file_paths) <= 1: + # 单文件直接上传 + for local_path in file_paths: + filename = os.path.basename(local_path) + print(t("cmd.webdav.uploading", file=filename)) + _, success, error = _upload_single(local_path) + if success: + print(t("cmd.webdav.success", file=filename)) + else: + print(error) + else: + # 多文件并行上传 + print(t("cmd.webdav.parallel_upload", count=len(file_paths))) + with ThreadPoolExecutor(max_workers=min(len(file_paths), 4)) as executor: + futures = {executor.submit(_upload_single, fp): fp for fp in file_paths} + for future in as_completed(futures): + filename, success, error = future.result() + if success: + print(t("cmd.webdav.success", file=filename)) + else: + print(error) + + @staticmethod + def _upload_to_cloud(file_paths: list[str], config: Config) -> None: + """将备份文件上传到 S3 兼容云存储""" + from concurrent.futures import ThreadPoolExecutor, as_completed + from sbackup.cloud_storage import CloudStorageClient, CloudStorageError + + cloud_config = getattr(config, "cloud", None) or {} + if not cloud_config or not cloud_config.get("enabled"): + print(t("err.cloud.not_configured")) + return + + endpoint = cloud_config.get("endpoint", "") + access_key = cloud_config.get("access_key", "") + secret_key = cloud_config.get("secret_key", "") + bucket = cloud_config.get("bucket", "") + region = cloud_config.get("region", "") + secure = cloud_config.get("secure", True) + remote_path_prefix = cloud_config.get("remote_path", "/") + + if not endpoint or not bucket: + print(t("err.cloud.not_configured")) + return + + def _upload_single(local_path: str) -> tuple[str, bool, str]: + filename = os.path.basename(local_path) + try: + with CloudStorageClient( + endpoint, + access_key, + secret_key, + bucket, + region=region, + secure=secure, + ) as client: + remote_path = remote_path_prefix.rstrip("/") + "/" + filename + client.upload_file(local_path, remote_path.lstrip("/")) + return filename, True, "" + except (CloudStorageError, Exception) as e: + return filename, False, str(e) + + if len(file_paths) <= 1: + for local_path in file_paths: + filename = os.path.basename(local_path) + print(t("cmd.cloud.uploading", file=filename)) + _, success, error = _upload_single(local_path) + if success: + print(t("cmd.cloud.success", file=filename)) + else: + print(error) + else: + print(t("cmd.cloud.parallel_upload", count=len(file_paths))) + with ThreadPoolExecutor(max_workers=min(len(file_paths), 4)) as executor: + futures = {executor.submit(_upload_single, fp): fp for fp in file_paths} + for future in as_completed(futures): + filename, success, error = future.result() + if success: + print(t("cmd.cloud.success", file=filename)) + else: + print(error) + + def _add_history( + self, + source: str, + size_mb: float, + files_count: int, + original_size_mb: float = 0.0, + backup_path: str = "", + tag: str = "", + ): + """记录备份历史(可选存储 SHA256 校验和、标签和备份文件路径)""" + import hashlib + from datetime import datetime + + history = self.data.setdefault(_HISTORY_KEY, []) + entry = { + "time": datetime.now().isoformat(timespec="seconds"), + "source": source, + "size_mb": round(size_mb, 2), + "files_count": files_count, + } + if tag: + entry["tag"] = tag + if backup_path: + entry["path"] = backup_path + if original_size_mb > 0: + entry["original_size_mb"] = round(original_size_mb, 2) + entry["ratio"] = ( + round((1 - size_mb / original_size_mb) * 100, 1) + if original_size_mb > 0 + else 0 + ) + # 计算并存储 SHA256 校验和 + if backup_path and os.path.isfile(backup_path): + try: + sha256 = hashlib.sha256() + with open(backup_path, "rb") as f: + while True: + chunk = f.read(65536) + if not chunk: + break + sha256.update(chunk) + entry["sha256"] = sha256.hexdigest() + except OSError: + pass + history.append(entry) # 保留最近 100 条记录 if len(history) > 100: - self.data["_history"] = history[-100:] + self.data[_HISTORY_KEY] = history[-100:] def get_history(self) -> list[dict]: """获取备份历史记录""" - return self.data.get("_history", []) + return self.data.get(_HISTORY_KEY, []) + + def get_history_by_tag(self, tag: str) -> list[dict]: + """根据标签筛选备份历史记录""" + history = self.get_history() + return [entry for entry in history if entry.get("tag", "") == tag] + + def get_tags(self) -> set[str]: + """获取所有已使用的标签""" + history = self.get_history() + return {entry["tag"] for entry in history if entry.get("tag")} + + def find_checksum(self, backup_path: str) -> str: + """从历史记录中查找备份文件的 SHA256 校验和 + :param backup_path: 备份文件路径 + :return: SHA256 校验和,未找到返回空字符串 + """ + abs_path = os.path.abspath(backup_path) + for entry in reversed(self.data.get(_HISTORY_KEY, [])): + sha = entry.get("sha256", "") + if not sha: + continue + # 匹配路径或文件名 + if entry.get("source") == abs_path: + return sha + # 回退:查找最近一条有 sha256 的记录 + for entry in reversed(self.data.get(_HISTORY_KEY, [])): + sha = entry.get("sha256", "") + if sha: + return sha + return "" + + def get_versions(self, source: str = "", tag: str = "") -> dict[str, list[dict]]: + """获取备份版本列表,按源目录分组 + :param source: 指定源目录路径,空字符串表示全部 + :param tag: 按标签筛选,空字符串表示不筛选 + :return: {source_path: [version_entries]} + """ + history = self.get_history() + versions: dict[str, list[dict]] = {} + for entry in history: + src = entry.get("source", "") + if source and src != os.path.abspath(source): + continue + if tag and entry.get("tag", "") != tag: + continue + versions.setdefault(src, []).append(entry) + # 每个源的版本按时间倒序 + for src in versions: + versions[src].sort(key=lambda e: e.get("time", ""), reverse=True) + return versions + + def format_versions(self, source: str = "", tag: str = "") -> str: + """格式化版本列表为可读文本""" + versions = self.get_versions(source, tag) + if not versions: + return t("cmd.versions.empty") + + lines = [t("cmd.versions.header")] + for src, entries in versions.items(): + lines.append("") + lines.append(t("cmd.versions.source", source=src)) + for i, entry in enumerate(entries, 1): + time_str = entry.get("time", "N/A") + size = entry.get("size_mb", 0) + files = entry.get("files_count", 0) + ratio = entry.get("ratio") + sha = entry.get("sha256", "") + ratio_str = f"{ratio:.0f}%" if isinstance(ratio, (int, float)) else "-" + sha_short = sha[:12] + "..." if sha else "-" + tag_str = f" [{entry['tag']}]" if entry.get("tag") else "" + lines.append( + f" #{i} {time_str} {size:>8.2f} MB {files:>5} files " + f"{ratio_str:>5} {sha_short}{tag_str}" + ) + lines.append("") + total = sum(len(e) for e in versions.values()) + lines.append(t("cmd.versions.total", count=total, sources=len(versions))) + return "\n".join(lines) + + def format_history_table(self) -> str: + """生成备份历史的对齐文本表格""" + history = self.get_history() + if not history: + return t("cmd.list.empty") + + headers = [ + t("table.header.time"), + t("table.header.source"), + t("table.header.size"), + t("table.header.ratio"), + t("table.header.files"), + ] + rows = [] + for entry in reversed(history): + ratio = entry.get("ratio") + ratio_str = f"{ratio:.0f}%" if ratio is not None else "-" + rows.append( + [ + entry.get("time", ""), + entry.get("source", ""), + str(entry.get("size_mb", 0)), + ratio_str, + str(entry.get("files_count", 0)), + ] + ) + + return self._render_table(headers, rows) @staticmethod - def _cleanup_old_backups(target_dir: str, keep: int): - """清理旧备份文件,仅保留最近 keep 个(keep=0 时不清理)""" - if keep <= 0: + def _cleanup_old_backups( + target_dir: str, keep: int = 0, keep_days: int = 0 + ) -> None: + """清理旧备份文件 + :param keep: 保留最近 N 个备份文件,0 表示不按数量清理 + :param keep_days: 保留最近 N 天的备份文件,0 表示不按时间清理 + """ + import time as _time + + if keep <= 0 and keep_days <= 0: return target = Path(target_dir) @@ -257,43 +1780,314 @@ def _cleanup_old_backups(target_dir: str, keep: int): files = [] for pat in patterns: files.extend(target.glob(pat)) - if len(files) <= keep: - return - # 按修改时间排序,删除旧的 - files.sort(key=lambda f: f.stat().st_mtime, reverse=True) - for old_file in files[keep:]: + + to_delete: set[Path] = set() + + # 按数量清理 + if keep > 0 and len(files) > keep: + sorted_by_time = sorted( + files, key=lambda f: f.stat().st_mtime, reverse=True + ) + to_delete.update(sorted_by_time[keep:]) + + # 按时间清理 + if keep_days > 0: + cutoff = _time.time() - keep_days * 86400 + for f in files: + try: + if f.stat().st_mtime < cutoff: + to_delete.add(f) + except OSError: + pass + + for old_file in to_delete: try: old_file.unlink() logger.debug(t("log.cleanup.delete"), old_file) except OSError: pass + def clean_all_backups( + self, keep: int = 0, keep_days: int = 0, dry_run: bool = False + ) -> dict: + """清理所有策略的备份文件 + :param keep: 每个策略保留最近 N 个,0 不按数量清理 + :param keep_days: 删除 N 天前的文件,0 不按时间清理 + :param dry_run: 仅预览不删除 + :return: {deleted: [...], kept: [...]} + """ + import time as _time + + if keep <= 0 and keep_days <= 0: + return {"deleted": [], "kept": []} + + patterns = [ + "*.zip", + "*.tar", + "*.tar.gz", + "*.tar.bz2", + "*.tar.xz", + "*.tar.zst", + "*.7z", + ] + + # 收集所有策略的目标目录 + target_dirs: set[str] = set() + for key, raw in self.data.items(): + if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"): + continue + entry = BackupEntry.from_list(raw) + if entry.target: + target_dirs.add(entry.target) + + deleted = [] + kept = [] + for target_dir in target_dirs: + target = Path(target_dir) + if not target.is_dir(): + continue + files = [] + for pat in patterns: + files.extend(target.glob(pat)) + + to_delete: set[Path] = set() + if keep > 0 and len(files) > keep: + sorted_by_time = sorted( + files, key=lambda f: f.stat().st_mtime, reverse=True + ) + to_delete.update(sorted_by_time[keep:]) + if keep_days > 0: + cutoff = _time.time() - keep_days * 86400 + for f in files: + try: + if f.stat().st_mtime < cutoff: + to_delete.add(f) + except OSError: + pass + + for f in files: + if f in to_delete: + size_mb = f.stat().st_size / (1024 * 1024) + if dry_run: + deleted.append(f"[dry-run] {f} ({size_mb:.2f} MB)") + else: + try: + f.unlink() + deleted.append(f"{f} ({size_mb:.2f} MB)") + except OSError: + kept.append(str(f)) + else: + kept.append(str(f)) + + return {"deleted": deleted, "kept": kept} + + def export_strategies(self, output_file: str) -> int: + """导出所有备份策略到 JSON 文件,返回导出的策略数""" + export_data = {} + for key, raw in self.data.items(): + if key in (_HISTORY_KEY, "_file_meta", "_chunk_meta"): + continue + export_data[key] = raw + data_dir = os.path.dirname(output_file) + if data_dir: + os.makedirs(data_dir, exist_ok=True) + with open(output_file, "w", encoding="utf-8") as f: + json.dump(export_data, f, ensure_ascii=False, indent=4) + return len(export_data) + + def import_strategies(self, input_file: str) -> tuple[int, int] | None: + """从 JSON 文件导入备份策略,返回 (导入数, 跳过数) 或 None""" + if not os.path.exists(input_file): + return None + # 文件大小限制:最多 10 MB + try: + if os.path.getsize(input_file) > 10 * 1024 * 1024: + logger.warning("Import file too large: %s", input_file) + return None + except OSError: + return None + try: + with open(input_file, "r", encoding="utf-8") as f: + import_data = json.load(f) + except (json.JSONDecodeError, OSError): + return None + if not isinstance(import_data, dict): + return None + imported = 0 + skipped = 0 + _RESERVED_KEYS = {_HISTORY_KEY, "_file_meta", "_chunk_meta"} + _VALID_FORMATS = { + "", + "ZIP", + "TAR", + "TAR_GZ", + "TAR_BZ2", + "TAR_XZ", + "TAR_ZST", + "7Z", + "zip", + "tar", + "tar.gz", + "tar.bz2", + "tar.xz", + "tar.zst", + "7z", + } + for key, raw in import_data.items(): + # 安全检查:拒绝内部保留键 + if key in _RESERVED_KEYS: + skipped += 1 + continue + if not isinstance(raw, list) or len(raw) < 3: + continue + # 安全检查:验证数据结构 + if not isinstance(raw[0], (int, float)): + continue + if not isinstance(raw[1], str): + continue + if not isinstance(raw[2], list): + continue + # 拒绝包含路径遍历的源/目标路径 + if ".." in key or not key.strip(): + skipped += 1 + continue + if ".." in raw[1] or not raw[1].strip(): + skipped += 1 + continue + # 验证条目级格式(如有) + if len(raw) > 3 and raw[3]: + if not isinstance(raw[3], str) or raw[3] not in _VALID_FORMATS: + skipped += 1 + continue + if key in self.data: + skipped += 1 + continue + self.data[key] = raw + imported += 1 + if imported > 0: + self.save() + return imported, skipped + + def format_status(self) -> str: + """生成备份状态仪表盘""" + lines = [t("cmd.status.header")] + + # 统计策略数 + strategies = { + k: v + for k, v in self.data.items() + if k not in (_HISTORY_KEY, "_file_meta", "_chunk_meta") + } + lines.append(t("cmd.status.strategies", count=len(strategies))) + + # 统计历史记录 + history = self.get_history() + lines.append(t("cmd.status.history", count=len(history))) + + # 计算备份总大小和平均压缩率 + total_size = 0.0 + ratios = [] + for entry in history: + total_size += entry.get("size_mb", 0) + if "ratio" in entry: + ratios.append(entry["ratio"]) + lines.append(t("cmd.status.total_size", size=total_size)) + if ratios: + avg_ratio = sum(ratios) / len(ratios) + lines.append(t("cmd.status.avg_ratio", ratio=avg_ratio)) + + # 策略详情 + lines.append("") + lines.append(t("cmd.status.strategy.header")) + for source, raw in strategies.items(): + entry = BackupEntry.from_list(raw) + lines.append( + t("cmd.status.strategy.item", source=source, target=entry.target) + ) + # 查找该策略的最后一次备份时间 + last_backup = None + for h in reversed(history): + if h.get("source") == source: + last_backup = h.get("time") + break + if last_backup: + lines.append(t("cmd.status.strategy.last_backup", time=last_backup)) + else: + lines.append(t("cmd.status.strategy.never")) + + fmt = entry.compression_format or t("table.cell.default") + lines.append(t("cmd.status.strategy.format", format=fmt)) + + # 计算目标目录大小 + target_path = Path(entry.target) + if target_path.is_dir(): + dir_size = 0 + try: + for f in target_path.rglob("*"): + try: + if f.is_file(): + dir_size += f.stat().st_size + except OSError: + pass + except OSError: + pass + lines.append( + t("cmd.status.strategy.size", size=dir_size / (1024 * 1024)) + ) + + return "\n".join(lines) + def all_folder(self) -> dict[str, str]: """ - 查看所有备份策略 + 查看所有备份策略(已废弃,请使用 list_folder_table) + + .. deprecated:: + CLI 的 all 命令已改用 list_folder_table(),此方法保留仅为外部 API 兼容。 """ return { - key: BackupEntry.from_list(raw).target for key, raw in self.data.items() + key: BackupEntry.from_list(raw).target + for key, raw in self.data.items() + if key not in (_HISTORY_KEY, "_file_meta", "_chunk_meta") } @staticmethod def _display_width(s: str) -> int: - """计算字符串的终端显示宽度(中文字符算2,英文字符算1)""" + """计算字符串的终端显示宽度(东亚宽字符算2,其余算1)""" if not isinstance(s, str): return len(str(s)) width = 0 for ch in s: - if ord(ch) > 0x2E80: + eaw = unicodedata.east_asian_width(ch) + if eaw in ("W", "F"): width += 2 else: width += 1 return width + @staticmethod + def _render_table(headers: list[str], rows: list[list[str]]) -> str: + """生成对齐的文本表格""" + col_widths = [BackupManager._display_width(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + col_widths[i] = max(col_widths[i], BackupManager._display_width(cell)) + + fmt = " | ".join(["{:<" + str(w) + "}" for w in col_widths]) + sep = "-+-".join(["-" * w for w in col_widths]) + + lines = [fmt.format(*headers), sep] + for row in rows: + lines.append(fmt.format(*row)) + return "\n".join(lines) + def list_folder_table(self) -> str: """ 生成对齐的文本表格 """ - if not self.data: + non_history_keys = [ + k for k in self.data if k not in (_HISTORY_KEY, "_file_meta", "_chunk_meta") + ] + if not non_history_keys: return t("cmd.all.empty") headers = [ @@ -304,7 +2098,7 @@ def list_folder_table(self) -> str: ] rows = [] for path, raw in self.data.items(): - if path == "_history": + if path in (_HISTORY_KEY, "_file_meta", "_chunk_meta"): continue entry = BackupEntry.from_list(raw) fmt_display = ( @@ -319,18 +2113,182 @@ def list_folder_table(self) -> str: ) rows.append([path, entry.target, fmt_display, skip]) - col_widths = [self._display_width(h) for h in headers] - for row in rows: - for i, cell in enumerate(row): - col_widths[i] = max(col_widths[i], self._display_width(cell)) + return self._render_table(headers, rows) - fmt = " | ".join(["{:<" + str(w) + "}" for w in col_widths]) - sep = "-+-".join(["-" * w for w in col_widths]) + def export_report(self, output_file: str = "") -> str: + """生成 Markdown 格式的备份报告 + :param output_file: 输出文件路径,空字符串则返回文本 + :return: 报告文本 + """ + from datetime import datetime - lines = [] - lines.append(fmt.format(*headers)) - lines.append(sep) - for row in rows: - lines.append(fmt.format(*row)) + lines = ["# Sbackup 备份报告", ""] + lines.append(f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("") - return "\n".join(lines) + # 策略统计 + strategies = { + k: v + for k, v in self.data.items() + if k not in (_HISTORY_KEY, "_file_meta", "_chunk_meta") + } + lines.append("## 策略概览") + lines.append(f"- 策略总数: {len(strategies)}") + lines.append("") + + if strategies: + lines.append("### 策略列表") + lines.append("") + lines.append("| 源目录 | 目标目录 | 格式 |") + lines.append("|--------|----------|------|") + for source, raw in strategies.items(): + entry = BackupEntry.from_list(raw) + fmt = entry.compression_format or t("table.cell.default") + lines.append(f"| `{source}` | `{entry.target}` | {fmt} |") + lines.append("") + + # 历史统计 + history = self.get_history() + lines.append("## 备份历史") + lines.append(f"- 历史记录数: {len(history)}") + + if history: + total_size = sum(e.get("size_mb", 0) for e in history) + total_files = sum(e.get("files_count", 0) for e in history) + ratios = [e["ratio"] for e in history if "ratio" in e] + lines.append(f"- 累计备份大小: {total_size:.2f} MB") + lines.append(f"- 累计文件数: {total_files}") + if ratios: + lines.append(f"- 平均压缩率: {sum(ratios) / len(ratios):.1f}%") + + # 最近 10 条记录 + lines.append("") + lines.append("### 最近备份记录") + lines.append("") + lines.append("| 时间 | 源目录 | 大小(MB) | 文件数 | 压缩率 |") + lines.append("|------|--------|----------|--------|--------|") + for entry in reversed(history[-10:]): + ratio = entry.get("ratio", "") + ratio_str = f"{ratio:.0f}%" if isinstance(ratio, (int, float)) else "-" + lines.append( + f"| {entry.get('time', '')} | `{entry.get('source', '')}` " + f"| {entry.get('size_mb', 0)} | {entry.get('files_count', 0)} " + f"| {ratio_str} |" + ) + + report = "\n".join(lines) + "\n" + + if output_file: + output_dir = os.path.dirname(output_file) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + with open(output_file, "w", encoding="utf-8") as f: + f.write(report) + print(t("cmd.report.saved", path=output_file)) + + return report + + def export_html_report(self, output_file: str = "") -> str: + """生成 HTML 格式备份报告 + :param output_file: 输出文件路径,空字符串则返回 HTML 文本 + :return: HTML 报告文本 + """ + from datetime import datetime + + history = self.get_history() + strategies = { + k: v + for k, v in self.data.items() + if k not in (_HISTORY_KEY, "_file_meta", "_chunk_meta") + } + + # 计算统计 + total_size = sum(h.get("size_mb", 0) for h in history) + total_files = sum(h.get("files_count", 0) for h in history) + + # 策略行 + strategy_rows = "" + for source, raw in strategies.items(): + entry = BackupEntry.from_list(raw) + fmt = entry.compression_format or t("table.cell.default") + skip_patterns = ( + ", ".join(entry.skip_patterns) if entry.skip_patterns else "-" + ) + strategy_rows += f""" + {source} + {entry.target} + {fmt} + {skip_patterns} + \n""" + + # 历史行(最近 50 条) + rows_html = "" + for h in reversed(history[-50:]): + tag = h.get("tag", "") + tag_html = f'{tag}' if tag else "" + ratio = h.get("ratio", 0) + ratio_str = f"{ratio:.1f}%" if isinstance(ratio, (int, float)) else "-" + rows_html += f""" + {h.get("time", "")} + {h.get("source", "")} + {h.get("size_mb", 0):.2f} + {h.get("files_count", 0)} + {ratio_str} + {tag_html} + \n""" + + html = f""" + + + +sbackup {t("cmd.report.title", default="备份报告")} + + + +

sbackup {t("cmd.report.title", default="备份报告")}

+

{t("cmd.report.generated", default="生成时间")}: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}

+
+
{len(strategies)}
{t("cmd.report.strategies", default="备份策略")}
+
{len(history)}
{t("cmd.report.history", default="历史记录")}
+
{total_size:.1f}
{t("cmd.report.total_size", default="总大小 (MB)")}
+
{total_files}
{t("cmd.report.total_files", default="总文件数")}
+
+

{t("cmd.report.strategy_overview", default="策略概览")}

+ + +{strategy_rows} +
{t("cmd.report.source", default="源目录")}{t("cmd.report.target", default="目标目录")}{t("cmd.report.format", default="格式")}{t("cmd.report.skip_patterns", default="忽略模式")}
+

{t("cmd.report.history_header", default="备份历史 (最近50条)")}

+ + +{rows_html} +
{t("cmd.report.time", default="时间")}{t("cmd.report.source", default="源目录")}{t("cmd.report.size", default="大小(MB)")}{t("cmd.report.files", default="文件数")}{t("cmd.report.ratio", default="压缩率")}{t("cmd.report.tag", default="标签")}
+ + +""" + + if output_file: + output_dir = os.path.dirname(output_file) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + with open(output_file, "w", encoding="utf-8") as f: + f.write(html) + print(t("cmd.report.saved", path=output_file)) + + return html diff --git a/sbackup/benchmark.py b/sbackup/benchmark.py new file mode 100644 index 0000000..4f5186d --- /dev/null +++ b/sbackup/benchmark.py @@ -0,0 +1,264 @@ +""" +压缩基准测试模块:测试不同压缩格式和级别的性能 +""" + +import os +import shutil +import tempfile +import time +from dataclasses import dataclass + +from sbackup.compression import create_compressor, restore_backup +from sbackup.config import Config +from sbackup.i18n import t + +# 支持的格式列表及其默认压缩级别 +FORMATS = ["ZIP", "TAR", "TAR_GZ", "TAR_BZ2", "TAR_XZ", "TAR_ZST", "7Z"] + +# 各格式的有效压缩级别范围 +_FORMAT_LEVEL_RANGES: dict[str, tuple[int, int]] = { + "ZIP": (0, 9), + "TAR": (0, 0), + "TAR_GZ": (0, 9), + "TAR_BZ2": (0, 9), + "TAR_XZ": (0, 9), + "TAR_ZST": (1, 22), + "7Z": (0, 9), +} + + +@dataclass +class BenchmarkResult: + """基准测试结果""" + + format: str + level: int + original_size: int + compressed_size: int + compression_time: float # 秒 + decompression_time: float # 秒 + compression_ratio: float # compressed / original + speed_mbps: float # MB/s 压缩速度 + file_count: int + + +def _get_source_info(source_path: str) -> tuple[int, int]: + """统计源目录的总大小和文件数""" + total_size = 0 + file_count = 0 + for dirpath, _dirnames, filenames in os.walk(source_path): + for filename in filenames: + fp = os.path.join(dirpath, filename) + try: + total_size += os.path.getsize(fp) + file_count += 1 + except OSError: + pass + return total_size, file_count + + +class BenchmarkRunner: + """压缩基准测试运行器""" + + def __init__(self, source_path: str, password: str = "") -> None: + self.source_path = os.path.abspath(source_path) + self.password = password + self.results: list[BenchmarkResult] = [] + + def _build_config(self, fmt: str, level: int, output_dir: str) -> Config: + """为指定格式和级别构建 Config 对象""" + return Config( + folder_path=self.source_path, + zipfile_path=output_dir, + compression_format=fmt, + compression_level=level, + password=self.password, + skip_patterns=[], + ) + + def run_single(self, fmt: str, level: int = 6) -> BenchmarkResult: + """测试单个格式+级别的压缩性能""" + tmp = tempfile.mkdtemp(prefix="sbackup_bench_") + try: + restore_dir = os.path.join(tmp, "restored") + os.makedirs(restore_dir) + + config = self._build_config(fmt, level, output_dir=tmp) + compressor = create_compressor(config) + + # 压缩 + t0 = time.perf_counter() + compressor.compress() + t_compress = time.perf_counter() - t0 + + # 找到压缩器实际写入的归档文件 + ext_map = { + "ZIP": ".zip", + "TAR": ".tar", + "TAR_GZ": ".tar.gz", + "TAR_BZ2": ".tar.bz2", + "TAR_XZ": ".tar.xz", + "TAR_ZST": ".tar.zst", + "7Z": ".7z", + } + ext = ext_map.get(fmt, ".zip") + archive_path = None + for fname in os.listdir(tmp): + if fname.endswith(ext): + archive_path = os.path.join(tmp, fname) + break + + if archive_path is None or not os.path.isfile(archive_path): + # 归档文件不存在,返回零值结果 + original_size, file_count = _get_source_info(self.source_path) + return BenchmarkResult( + format=fmt, + level=level, + original_size=original_size, + compressed_size=0, + compression_time=t_compress, + decompression_time=0.0, + compression_ratio=0.0, + speed_mbps=0.0, + file_count=file_count, + ) + + # 解压 + t0 = time.perf_counter() + restore_backup(archive_path, restore_dir, self.password, quiet=True) + t_decompress = time.perf_counter() - t0 + + # 统计 + original_size, file_count = _get_source_info(self.source_path) + compressed_size = os.path.getsize(archive_path) + + compression_ratio = ( + compressed_size / original_size if original_size > 0 else 0.0 + ) + original_mb = original_size / (1024 * 1024) + speed_mbps = ( + original_mb / t_compress + if t_compress > 0 and original_size > 0 + else 0.0 + ) + + return BenchmarkResult( + format=fmt, + level=level, + original_size=original_size, + compressed_size=compressed_size, + compression_time=t_compress, + decompression_time=t_decompress, + compression_ratio=compression_ratio, + speed_mbps=speed_mbps, + file_count=file_count, + ) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def run_all(self, levels: list[int] | None = None) -> list[BenchmarkResult]: + """测试所有格式在不同级别下的性能""" + if levels is None: + levels = [1, 6, 9] + + self.results = [] + for fmt in FORMATS: + # TAR 格式只测 level=6(不压缩) + if fmt == "TAR": + result = self.run_single(fmt, level=6) + self.results.append(result) + else: + lo, hi = _FORMAT_LEVEL_RANGES.get(fmt, (0, 9)) + for level in levels: + if lo <= level <= hi: + result = self.run_single(fmt, level=level) + self.results.append(result) + return self.results + + def run_quick(self) -> list[BenchmarkResult]: + """快速测试:只测试每个格式的默认级别""" + default_levels = { + "ZIP": 6, + "TAR": 6, + "TAR_GZ": 6, + "TAR_BZ2": 6, + "TAR_XZ": 6, + "TAR_ZST": 3, + "7Z": 6, + } + self.results = [] + for fmt in FORMATS: + level = default_levels.get(fmt, 6) + result = self.run_single(fmt, level=level) + self.results.append(result) + return self.results + + def get_recommended(self) -> BenchmarkResult: + """推荐最佳格式:在压缩比和速度之间取平衡""" + if not self.results: + self.run_quick() + + # 优先选择压缩比低的(压缩效果好),速度作为次要因素 + # score = compression_ratio * 1000 + 1/speed(越低越好) + def _score(r: BenchmarkResult) -> float: + speed_factor = 1.0 / r.speed_mbps if r.speed_mbps > 0 else 1e6 + return r.compression_ratio * 1000 + speed_factor + + return min(self.results, key=_score) + + def format_results( + self, results: list[BenchmarkResult], lang: str = "zh_CN" + ) -> str: + """格式化结果为表格""" + source_size, file_count = _get_source_info(self.source_path) + source_mb = source_size / (1024 * 1024) + + lines = [ + t("benchmark.header"), + t( + "benchmark.source_info", + path=self.source_path, + size=f"{source_mb:.1f} MB", + count=file_count, + ), + "", + ] + + # 表头 + lines.append( + f"{'format':<10} {'level':<6} {'compressed':<12} " + f"{'ratio':<8} {'speed':<12} {'decompress':<12}" + ) + lines.append("-" * 72) + + for r in results: + comp_mb = r.compressed_size / (1024 * 1024) + ratio_pct = r.compression_ratio * 100 + + # 计算解压速度 + decomp_speed = ( + r.original_size / (1024 * 1024) / r.decompression_time + if r.decompression_time > 0 + else 0.0 + ) + + lines.append( + f"{r.format:<10} {r.level:<6} {comp_mb:>8.1f} MB " + f"{ratio_pct:>5.1f}% {r.speed_mbps:>8.1f} MB/s " + f"{decomp_speed:>8.1f} MB/s" + ) + + # 推荐 + recommended = self.get_recommended() + rec_comp_mb = recommended.compressed_size / (1024 * 1024) + lines.append("") + lines.append( + t( + "benchmark.recommended", + format=recommended.format, + level=recommended.level, + size=f"{rec_comp_mb:.1f} MB", + ) + ) + + return "\n".join(lines) diff --git a/sbackup/chunked_backup.py b/sbackup/chunked_backup.py new file mode 100644 index 0000000..237c642 --- /dev/null +++ b/sbackup/chunked_backup.py @@ -0,0 +1,152 @@ +"""块级增量备份模块 + +将文件切分为固定大小的块(默认64KB),通过比较 SHA256 哈希 +识别变化的块,仅备份变更部分,大幅减少增量备份的数据量。 + +使用场景:巨量 VM 镜像 / 数据库文件 / 日志文件,每次只改了一小部分。 +""" + +import os +import hashlib +import struct +import logging + +logger = logging.getLogger(__name__) + +CHUNK_SIZE = 64 * 1024 # 64KB + +# 块级增量有三种模式 +MODE_DISABLED = "" +MODE_FILE = "file" # 文件级增量(现有行为) +MODE_BLOCK = "block" # 块级增量(新) + + +def compute_chunk_hashes(filepath: str, chunk_size: int = CHUNK_SIZE) -> dict[str, str]: + """计算文件所有块的哈希,返回 {index_str: sha256}""" + hashes: dict[str, str] = {} + with open(filepath, "rb") as f: + index = 0 + while True: + data = f.read(chunk_size) + if not data: + break + hashes[str(index)] = hashlib.sha256(data).hexdigest() + index += 1 + return hashes + + +def find_changed_chunks( + filepath: str, + stored_hashes: dict[str, str], + chunk_size: int = CHUNK_SIZE, +) -> tuple[set[int], int]: + """找出变化的块索引集合和总块数。 + + :returns: (changed_indices_set, total_chunks) + """ + current = compute_chunk_hashes(filepath, chunk_size) + total = len(current) + changed: set[int] = set() + for idx_str, current_hash in current.items(): + if stored_hashes.get(idx_str) != current_hash: + changed.add(int(idx_str)) + # 如果文件变短了,标记缺失的块为变化 + for idx_str in stored_hashes: + idx = int(idx_str) + if idx_str not in current: + changed.add(idx) + return changed, total + + +def create_patch( + filepath: str, + changed_indices: set[int], + target_path: str, + chunk_size: int = CHUNK_SIZE, +) -> int: + """创建块级补丁文件。 + + 格式:[chunk_count:int32] [[chunk_index:int32][chunk_size:int32][data:bytes]]* + + :returns: patch 文件字节数,0 表示没有变化 + """ + if not changed_indices: + return 0 + + with open(filepath, "rb") as src, open(target_path, "wb") as dst: + changed_list = sorted(changed_indices) + dst.write(struct.pack(" bool: + """将补丁应用到基准文件,生成还原后的文件。 + + 对于补丁中包含的块索引,使用补丁中的数据; + 对于补丁中不包含的索引,从基准文件中读取。 + """ + try: + # 读取补丁中的所有块 + patch_chunks: dict[int, bytes] = {} + with open(patch_path, "rb") as pf: + count = struct.unpack(" float: + """计算变化的块占文件大小的比例""" + total_size = os.path.getsize(filepath) + if total_size == 0: + return 0.0 + changed_size = len(changed_indices) * chunk_size + return min(changed_size / total_size, 1.0) + + +def should_do_full_backup( + filepath: str, + changed_indices: set[int], + threshold: float = 0.5, +) -> bool: + """判断是否应该做全量备份(变化比例超过阈值时回退到全量)""" + ratio = patch_size_ratio(filepath, changed_indices) + return ratio >= threshold diff --git a/sbackup/cli.py b/sbackup/cli.py new file mode 100644 index 0000000..cd06281 --- /dev/null +++ b/sbackup/cli.py @@ -0,0 +1,2593 @@ +"""CLI 模块:命令行解析、参数处理和主入口""" + +import os +import sys +import argparse +import logging +from typing import NoReturn +from sbackup.auto_save import BackupManager +from sbackup.i18n import set_locale, t +from sbackup.config import Config, load_config, save_lang, save_format +from sbackup.compression import restore_backup + +VERSION = "2.0.0" +logger = logging.getLogger(__name__) + + +class LocalizedArgumentParser(argparse.ArgumentParser): + """本地化错误输出的 ArgumentParser 子类""" + + def error(self, message: str) -> NoReturn: + # 将 argparse 生成的英文错误关键词替换为本地化文本 + localized = message + localized = localized.replace( + "invalid choice: ", t("err.argparse.invalid_choice") + ) + localized = localized.replace("choose from", t("err.argparse.choose_from")) + localized = localized.replace( + "invalid float value: ", t("err.argparse.invalid_float") + ) + localized = localized.replace( + "invalid int value: ", t("err.argparse.invalid_int") + ) + localized = localized.replace( + "unrecognized arguments: ", t("err.argparse.unrecognized_args") + ) + # "required" 仅替换独立出现的关键词(argparse 格式: "the following arguments are required") + localized = localized.replace("are required", t("err.argparse.required")) + self.print_usage(sys.stderr) + sys.stderr.write(f"{self.prog}: {localized}\n") + sys.exit(2) + + +def _detect_lang_from_argv() -> str | None: + """从 sys.argv 中提取 --lang 参数值""" + for i, arg in enumerate(sys.argv): + if arg == "--lang" and i + 1 < len(sys.argv): + return sys.argv[i + 1] + if arg.startswith("--lang="): + return arg.split("=", 1)[1] + return None + + +def get_parser() -> argparse.ArgumentParser: + parser = LocalizedArgumentParser( + prog="sbackup", + description=t("cli.description", version=VERSION), + epilog=t("cli.epilog"), + formatter_class=argparse.RawDescriptionHelpFormatter, + add_help=False, + ) + + parser.add_argument("--debug", action="store_true", help=t("cli.help.debug")) + parser.add_argument("-h", "--help", action="help", help=t("cli.help.help")) + parser.add_argument("--lang", default=None, help=t("cli.help.lang")) + parser.add_argument( + "--follow-symlinks", + action="store_true", + default=False, + help=t("cli.help.follow_symlinks"), + ) + parser.add_argument( + "--incremental", + nargs="?", + const="file", + default=None, + choices=["file", "block"], + help=t("cli.help.incremental"), + ) + parser.add_argument( + "--checksum", + action="store_true", + default=False, + help=t("cli.help.checksum"), + ) + parser.add_argument( + "--dedup", + action="store_true", + default=False, + help=t("cli.help.dedup"), + ) + parser.add_argument( + "--format", + default=None, + choices=["zip", "tar", "tar.gz", "tar.bz2", "tar.xz", "tar.zst", "7z"], + help=t("cli.help.format"), + ) + parser.add_argument( + "--profile", + default=None, + help=t("cli.help.profile_name"), + ) + + subparsers = parser.add_subparsers(dest="command", help=t("cli.help.subcommands")) + + add_parser = subparsers.add_parser("add", help=t("cli.help.add")) + add_parser.add_argument("source", help=t("cli.help.add.source")) + add_parser.add_argument("dest", help=t("cli.help.add.dest")) + add_parser.add_argument( + "-i", "--ignore", default=".git,__pycache__", help=t("cli.help.add.ignore") + ) + add_parser.add_argument( + "--format", + default=None, + choices=["zip", "tar", "tar.gz", "tar.bz2", "tar.xz", "tar.zst", "7z"], + help=t("cli.help.add.format"), + ) + add_parser.add_argument( + "--name-template", + default=None, + help=t("cli.help.add.name_template"), + ) + add_parser.add_argument( + "--from-gitignore", + default=None, + help=t("cli.help.add.from_gitignore"), + ) + + rm_parser = subparsers.add_parser("rm", aliases=["remove"], help=t("cli.help.rm")) + rm_parser.add_argument("path", nargs="?", default=None, help=t("cli.help.rm.path")) + rm_parser.add_argument("--all", action="store_true", help=t("cli.help.rm.all")) + + edit_parser = subparsers.add_parser("edit", help=t("cli.help.edit")) + edit_parser.add_argument("source", help=t("cli.help.edit.source")) + edit_parser.add_argument("--dest", default=None, help=t("cli.help.edit.dest")) + edit_parser.add_argument("--ignore", default=None, help=t("cli.help.edit.ignore")) + edit_parser.add_argument( + "--format", + default=None, + choices=["zip", "tar", "tar.gz", "tar.bz2", "tar.xz", "tar.zst", "7z"], + help=t("cli.help.edit.format"), + ) + edit_parser.add_argument( + "--name-template", default=None, help=t("cli.help.edit.name_template") + ) + + subparsers.add_parser("all", help=t("cli.help.all")) + + list_parser = subparsers.add_parser( + "list", aliases=["history"], help=t("cli.help.list") + ) + list_parser.add_argument( + "--tags", action="store_true", help=t("cli.help.list.tags") + ) + + subparsers.add_parser("init", help=t("cli.help.init")) + + save_parser = subparsers.add_parser("save", help=t("cli.help.save")) + save_parser.add_argument( + "--keep", type=int, default=0, help=t("cli.help.save.keep") + ) + save_parser.add_argument( + "--keep-days", type=int, default=0, help=t("cli.help.save.keep_days") + ) + save_parser.add_argument("--password", default="", help=t("cli.help.save.password")) + save_parser.add_argument( + "--sftp", action="store_true", default=False, help=t("cli.help.save.sftp") + ) + save_parser.add_argument( + "--webdav", action="store_true", default=False, help=t("cli.help.save.webdav") + ) + save_parser.add_argument( + "--cloud", action="store_true", default=False, help=t("cli.help.save.cloud") + ) + save_parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help=t("cli.help.save.dry_run"), + ) + save_parser.add_argument( + "--verify", + action="store_true", + default=False, + help=t("cli.help.save.verify"), + ) + save_parser.add_argument( + "--name-template", + default=None, + help=t("cli.help.save.name_template"), + ) + save_parser.add_argument( + "--webhook", + action="append", + default=None, + help=t("cli.help.save.webhook"), + ) + save_parser.add_argument( + "--max-size", default=None, help=t("cli.help.save.max_size") + ) + save_parser.add_argument( + "--min-size", default=None, help=t("cli.help.save.min_size") + ) + save_parser.add_argument("--split", default=None, help=t("cli.help.save.split")) + save_parser.add_argument("--tag", default="", help=t("cli.help.save.tag")) + save_parser.add_argument( + "--older-than", default=None, help=t("cli.help.save.older_than") + ) + save_parser.add_argument( + "--pre-hook", + action="append", + default=None, + help=t("cli.help.save.pre_hook"), + ) + save_parser.add_argument( + "--post-hook", + action="append", + default=None, + help=t("cli.help.save.post_hook"), + ) + save_parser.add_argument( + "--threads", + type=int, + default=1, + help=t("cli.help.save.threads"), + ) + save_parser.add_argument( + "--strict", + action="store_true", + default=False, + help=t("cli.help.strict"), + ) + save_parser.add_argument( + "--multi-dest", + action="store_true", + default=False, + help=t("cli.help.save.multi_dest"), + ) + + multi_backup_parser = subparsers.add_parser( + "multi-backup", help=t("cli.help.multi_backup") + ) + multi_backup_parser.add_argument("source", help=t("cli.help.multi_backup.source")) + multi_backup_parser.add_argument( + "local_target", help=t("cli.help.multi_backup.local_target") + ) + multi_backup_parser.add_argument( + "--keep", type=int, default=0, help=t("cli.help.multi_backup.keep") + ) + multi_backup_parser.add_argument( + "--keep-days", + type=int, + default=0, + help=t("cli.help.multi_backup.keep_days"), + ) + multi_backup_parser.add_argument( + "--password", + default="", + help=t("cli.help.multi_backup.password"), + ) + multi_backup_parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help=t("cli.help.multi_backup.dry_run"), + ) + multi_backup_parser.add_argument( + "--verify", + action="store_true", + default=False, + help=t("cli.help.multi_backup.verify"), + ) + multi_backup_parser.add_argument( + "--name-template", + default=None, + help=t("cli.help.multi_backup.name_template"), + ) + + compress_parser = subparsers.add_parser("compress", help=t("cli.help.compress")) + compress_parser.add_argument("source", help=t("cli.help.compress.source")) + compress_parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help=t("cli.help.compress.dry_run"), + ) + compress_parser.add_argument( + "--format", + default=None, + choices=["zip", "tar", "tar.gz", "tar.bz2", "tar.xz", "tar.zst", "7z"], + help=t("cli.help.compress.format"), + ) + compress_parser.add_argument( + "-o", + "--output", + default=None, + help=t("cli.help.compress.output"), + ) + compress_parser.add_argument( + "--password", default="", help=t("cli.help.compress.password") + ) + + watch_parser = subparsers.add_parser("watch", help=t("cli.help.watch")) + watch_parser.add_argument( + "--interval", type=float, default=60, help=t("cli.help.watch.interval") + ) + watch_parser.add_argument( + "--keep", type=int, default=0, help=t("cli.help.watch.keep") + ) + watch_parser.add_argument( + "--keep-days", type=int, default=0, help=t("cli.help.watch.keep_days") + ) + watch_parser.add_argument( + "--password", default="", help=t("cli.help.watch.password") + ) + watch_parser.add_argument( + "--sftp", action="store_true", default=False, help=t("cli.help.watch.sftp") + ) + watch_parser.add_argument( + "--webdav", action="store_true", default=False, help=t("cli.help.watch.webdav") + ) + watch_parser.add_argument( + "--cloud", action="store_true", default=False, help=t("cli.help.watch.cloud") + ) + watch_parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help=t("cli.help.watch.dry_run"), + ) + watch_parser.add_argument( + "--verify", + action="store_true", + default=False, + help=t("cli.help.watch.verify"), + ) + watch_parser.add_argument( + "--name-template", + default=None, + help=t("cli.help.watch.name_template"), + ) + watch_parser.add_argument( + "--webhook", + action="append", + default=None, + help=t("cli.help.watch.webhook"), + ) + watch_parser.add_argument( + "--max-size", default=None, help=t("cli.help.watch.max_size") + ) + watch_parser.add_argument( + "--min-size", default=None, help=t("cli.help.watch.min_size") + ) + watch_parser.add_argument("--split", default=None, help=t("cli.help.watch.split")) + watch_parser.add_argument("--tag", default="", help=t("cli.help.watch.tag")) + watch_parser.add_argument( + "--older-than", default=None, help=t("cli.help.watch.older_than") + ) + watch_parser.add_argument( + "--pre-hook", + action="append", + default=None, + help=t("cli.help.watch.pre_hook"), + ) + watch_parser.add_argument( + "--post-hook", + action="append", + default=None, + help=t("cli.help.watch.post_hook"), + ) + watch_parser.add_argument( + "--realtime", + action="store_true", + default=False, + help=t("cli.help.watch.realtime"), + ) + watch_parser.add_argument( + "--debounce", + type=float, + default=30, + help=t("cli.help.watch.debounce"), + ) + watch_parser.add_argument( + "--threads", + type=int, + default=1, + help=t("cli.help.watch.threads"), + ) + + restore_parser = subparsers.add_parser("restore", help=t("cli.help.restore")) + restore_parser.add_argument( + "backup_file", nargs="?", default="", help=t("cli.help.restore.file") + ) + restore_parser.add_argument( + "target_dir", nargs="?", default="", help=t("cli.help.restore.dir") + ) + restore_parser.add_argument( + "--password", default="", help=t("cli.help.restore.password") + ) + restore_parser.add_argument( + "-l", "--list", action="store_true", help=t("cli.help.restore.list") + ) + restore_parser.add_argument( + "--select", + action="append", + default=None, + help=t("cli.help.restore.select"), + ) + restore_parser.add_argument("--tag", default="", help=t("cli.help.restore.tag")) + restore_parser.add_argument( + "--search", + default=None, + help=t("cli.help.restore.search"), + ) + restore_parser.add_argument( + "--stats", + action="store_true", + default=False, + help=t("cli.help.restore.stats"), + ) + + info_parser = subparsers.add_parser("info", help=t("cli.help.info")) + info_parser.add_argument("backup_file", help=t("cli.help.info.file")) + info_parser.add_argument("--password", default="", help=t("cli.help.info.password")) + + diff_parser = subparsers.add_parser("diff", help=t("cli.help.diff")) + diff_parser.add_argument("source", help=t("cli.help.diff.source")) + diff_parser.add_argument( + "backup_file", nargs="?", default=None, help=t("cli.help.diff.file") + ) + diff_parser.add_argument("--password", default="", help=t("cli.help.diff.password")) + diff_parser.add_argument( + "--detail", + action="store_true", + default=False, + help=t("cli.help.diff.detail"), + ) + + verify_parser = subparsers.add_parser("verify", help=t("cli.help.verify")) + verify_parser.add_argument( + "backup_file", nargs="?", default=None, help=t("cli.help.verify.file") + ) + verify_parser.add_argument( + "--fast", + action="store_true", + default=False, + help=t("cli.help.verify.fast"), + ) + verify_parser.add_argument( + "--all", + action="store_true", + default=False, + help=t("cli.help.verify.all"), + ) + verify_parser.add_argument( + "--detail", + action="store_true", + default=False, + help=t("cli.help.verify.detail"), + ) + verify_parser.add_argument( + "--split", + action="store_true", + default=False, + help=t("cli.help.verify.split"), + ) + verify_parser.add_argument( + "--password", default="", help=t("cli.help.verify.password") + ) + + sftp_parser = subparsers.add_parser("sftp", help=t("cli.help.sftp")) + sftp_sub = sftp_parser.add_subparsers( + dest="sftp_action", help=t("cli.help.sftp.action") + ) + sftp_config_parser = sftp_sub.add_parser("config", help=t("cli.help.sftp.config")) + sftp_config_parser.add_argument( + "--host", default=None, help=t("cli.help.sftp.host") + ) + sftp_config_parser.add_argument( + "--port", type=int, default=None, help=t("cli.help.sftp.port") + ) + sftp_config_parser.add_argument( + "--user", default=None, help=t("cli.help.sftp.user") + ) + sftp_config_parser.add_argument( + "--password", default=None, help=t("cli.help.sftp.password") + ) + sftp_config_parser.add_argument( + "--key-file", default=None, help=t("cli.help.sftp.key_file") + ) + sftp_config_parser.add_argument( + "--key-passphrase", default=None, help=t("cli.help.sftp.key_passphrase") + ) + sftp_config_parser.add_argument( + "--remote-path", default=None, help=t("cli.help.sftp.remote_path") + ) + sftp_sub.add_parser("test", help=t("cli.help.sftp.test")) + + webdav_parser = subparsers.add_parser("webdav", help=t("cli.help.webdav")) + webdav_sub = webdav_parser.add_subparsers( + dest="webdav_action", help=t("cli.help.webdav.action") + ) + webdav_config_parser = webdav_sub.add_parser( + "config", help=t("cli.help.webdav.config") + ) + webdav_config_parser.add_argument( + "--url", default=None, help=t("cli.help.webdav.url") + ) + webdav_config_parser.add_argument( + "--user", default=None, help=t("cli.help.webdav.user") + ) + webdav_config_parser.add_argument( + "--password", default=None, help=t("cli.help.webdav.password") + ) + webdav_config_parser.add_argument( + "--remote-path", default=None, help=t("cli.help.webdav.remote_path") + ) + webdav_sub.add_parser("test", help=t("cli.help.webdav.test")) + + remote_parser = subparsers.add_parser("remote", help=t("cli.help.remote")) + remote_sub = remote_parser.add_subparsers( + dest="remote_action", help=t("cli.help.remote.action") + ) + remote_list_parser = remote_sub.add_parser("list", help=t("cli.help.remote.list")) + remote_list_parser.add_argument( + "--path", default=None, help=t("cli.help.remote.list_path") + ) + remote_list_parser.add_argument( + "--sftp", action="store_true", default=False, help=t("cli.help.remote.sftp") + ) + remote_list_parser.add_argument( + "--webdav", action="store_true", default=False, help=t("cli.help.remote.webdav") + ) + remote_rm_parser = remote_sub.add_parser("rm", help=t("cli.help.remote.rm")) + remote_rm_parser.add_argument("filename", help=t("cli.help.remote.rm.filename")) + remote_rm_parser.add_argument( + "--sftp", action="store_true", default=False, help=t("cli.help.remote.sftp") + ) + remote_rm_parser.add_argument( + "--webdav", action="store_true", default=False, help=t("cli.help.remote.webdav") + ) + + export_parser = subparsers.add_parser("export", help=t("cli.help.metadata_export")) + export_sub = export_parser.add_subparsers( + dest="export_action", help=t("cli.help.metadata_export.action") + ) + export_history = export_sub.add_parser( + "history", help=t("cli.help.metadata_export.history") + ) + export_history.add_argument("output", help=t("cli.help.metadata_export.output")) + export_history.add_argument( + "--strategy", default="", help=t("cli.help.metadata_export.strategy") + ) + export_history.add_argument( + "--format", + default="csv", + choices=["csv", "json"], + help=t("cli.help.metadata_export.format"), + ) + export_audit = export_sub.add_parser( + "audit", help=t("cli.help.metadata_export.audit") + ) + export_audit.add_argument("output", help=t("cli.help.metadata_export.output")) + export_audit.add_argument( + "--event", default="", help=t("cli.help.metadata_export.event") + ) + export_audit.add_argument( + "--format", + default="csv", + choices=["csv", "json"], + help=t("cli.help.metadata_export.format"), + ) + export_all = export_sub.add_parser("all", help=t("cli.help.metadata_export.all")) + export_all.add_argument("output", help=t("cli.help.metadata_export.output")) + export_all.add_argument( + "--format", + default="json", + choices=["csv", "json"], + help=t("cli.help.metadata_export.format"), + ) + + import_parser = subparsers.add_parser("import", help=t("cli.help.import")) + import_parser.add_argument("file", help=t("cli.help.import.file")) + + ignore_parser = subparsers.add_parser("ignore", help=t("cli.help.ignore")) + ignore_parser.add_argument( + "--preset", + choices=["node", "python", "go", "rust", "java", "general"], + default="general", + help=t("cli.help.ignore.preset"), + ) + ignore_parser.add_argument( + "-o", + "--output", + default=".sbackupignore", + help=t("cli.help.ignore.output"), + ) + ignore_parser.add_argument( + "--list", + action="store_true", + dest="list_presets", + help=t("cli.help.ignore.list"), + ) + + subparsers.add_parser("status", help=t("cli.help.status")) + + report_parser = subparsers.add_parser("report", help=t("cli.help.report")) + report_parser.add_argument( + "-o", "--output", default="", help=t("cli.help.report.output") + ) + + search_parser = subparsers.add_parser("search", help=t("cli.help.search")) + search_parser.add_argument("pattern", help=t("cli.help.search.pattern")) + search_parser.add_argument( + "--in", dest="backup_file", default=None, help=t("cli.help.search.in") + ) + search_parser.add_argument( + "--password", default="", help=t("cli.help.search.password") + ) + + xsearch_parser = subparsers.add_parser("xsearch", help=t("cli.help.xsearch")) + xsearch_parser.add_argument("dirs", nargs="+", help=t("cli.help.xsearch.dirs")) + xsearch_parser.add_argument( + "--keyword", default=None, help=t("cli.help.xsearch.keyword") + ) + xsearch_parser.add_argument( + "--pattern", default=None, help=t("cli.help.xsearch.pattern") + ) + xsearch_parser.add_argument("--ext", default=None, help=t("cli.help.xsearch.ext")) + xsearch_parser.add_argument( + "--password", default="", help=t("cli.help.xsearch.password") + ) + xsearch_parser.add_argument( + "--lang", + default=None, + choices=["zh_CN", "en_US"], + help=t("cli.help.xsearch.output_lang"), + ) + + versions_parser = subparsers.add_parser("versions", help=t("cli.help.versions")) + versions_parser.add_argument( + "source", nargs="?", default="", help=t("cli.help.versions.source") + ) + versions_parser.add_argument("--tag", default="", help=t("cli.help.versions.tag")) + + schedule_parser = subparsers.add_parser("schedule", help=t("cli.help.schedule")) + schedule_sub = schedule_parser.add_subparsers( + dest="schedule_action", help=t("cli.help.schedule.action") + ) + schedule_export = schedule_sub.add_parser( + "export", help=t("cli.help.schedule.export") + ) + schedule_export.add_argument( + "--type", + choices=["systemd", "crontab", "schtasks"], + default="systemd", + help=t("cli.help.schedule.type"), + ) + schedule_export.add_argument( + "--interval", + type=int, + default=60, + help=t("cli.help.schedule.interval"), + ) + schedule_export.add_argument( + "-o", "--output", default="", help=t("cli.help.schedule.output") + ) + + schedule_install = schedule_sub.add_parser( + "install", help=t("cli.help.schedule.install") + ) + schedule_install.add_argument( + "--type", + choices=["systemd", "schtasks", "launchd"], + default="systemd", + help=t("cli.help.schedule.install.type"), + ) + schedule_install.add_argument( + "--interval", + type=int, + default=60, + help=t("cli.help.schedule.install.interval"), + ) + schedule_install.add_argument( + "--user", + default="", + help=t("cli.help.schedule.install.user"), + ) + + webhook_parser = subparsers.add_parser("webhook", help=t("cli.help.webhook_cmd")) + webhook_sub = webhook_parser.add_subparsers( + dest="webhook_action", help=t("cli.help.webhook_cmd.action") + ) + webhook_preset = webhook_sub.add_parser( + "preset", help=t("cli.help.webhook_cmd.preset") + ) + webhook_preset.add_argument( + "name", + choices=["dingtalk", "feishu", "wechat"], + help=t("cli.help.webhook_cmd.preset.name"), + ) + webhook_sub.add_parser("list", help=t("cli.help.webhook_cmd.list")) + + config_parser = subparsers.add_parser("config", help=t("cli.help.config_cmd")) + config_sub = config_parser.add_subparsers( + dest="config_action", help=t("cli.help.config_cmd.action") + ) + config_lock = config_sub.add_parser("lock", help=t("cli.help.config_cmd.lock")) + config_lock.add_argument( + "--password", default="", help=t("cli.help.config_cmd.lock.password") + ) + config_unlock = config_sub.add_parser( + "unlock", help=t("cli.help.config_cmd.unlock") + ) + config_unlock.add_argument( + "--password", default="", help=t("cli.help.config_cmd.unlock.password") + ) + config_sub.add_parser("validate", help=t("cli.help.config_cmd.validate")) + + clean_parser = subparsers.add_parser("clean", help=t("cli.help.clean")) + clean_parser.add_argument( + "--keep", type=int, default=0, help=t("cli.help.clean.keep") + ) + clean_parser.add_argument( + "--keep-days", type=int, default=0, help=t("cli.help.clean.keep_days") + ) + completion_parser = subparsers.add_parser( + "completion", help=t("cli.help.completion") + ) + completion_parser.add_argument( + "shell", + nargs="?", + default="bash", + choices=["bash", "zsh", "fish", "powershell"], + help=t("cli.help.completion.shell"), + ) + + clean_parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help=t("cli.help.clean.dry_run"), + ) + + rotate_parser = subparsers.add_parser("rotate", help=t("cli.help.rotate")) + rotate_sub = rotate_parser.add_subparsers( + dest="rotate_action", help="rotate action" + ) + rotate_list_parser = rotate_sub.add_parser("list", help=t("cli.help.rotate_list")) + rotate_list_parser.add_argument( + "backup_dir", help=t("cli.help.rotate_list.backup_dir") + ) + rotate_parser.add_argument( + "backup_dir", nargs="?", default=None, help=t("cli.help.rotate.backup_dir") + ) + rotate_parser.add_argument( + "--keep-count", + type=int, + default=0, + help=t("cli.help.rotate.keep_count"), + ) + rotate_parser.add_argument( + "--keep-days", + type=int, + default=0, + help=t("cli.help.rotate.keep_days"), + ) + rotate_parser.add_argument( + "--keep-daily", + type=int, + default=0, + help=t("cli.help.rotate.keep_daily"), + ) + rotate_parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help=t("cli.help.rotate.dry_run"), + ) + + diskcheck_parser = subparsers.add_parser("diskcheck", help=t("cli.help.diskcheck")) + diskcheck_parser.add_argument("source", help=t("cli.help.diskcheck.source")) + diskcheck_parser.add_argument("target", help=t("cli.help.diskcheck.target")) + diskcheck_parser.add_argument( + "--level", + type=int, + default=6, + help=t("cli.help.diskcheck.level"), + ) + + benchmark_parser = subparsers.add_parser("benchmark", help=t("cli.help.benchmark")) + benchmark_parser.add_argument("path", help=t("cli.help.benchmark.path")) + benchmark_parser.add_argument( + "--levels", + default=None, + help=t("cli.help.benchmark.levels"), + ) + benchmark_parser.add_argument( + "--quick", + action="store_true", + default=False, + help=t("cli.help.benchmark.quick"), + ) + benchmark_parser.add_argument( + "--password", + default="", + help=t("cli.help.benchmark.password"), + ) + + subparsers.add_parser("version", help=t("cli.help.version")) + + subparsers.add_parser("wizard", help=t("cli.help.wizard")) + + integrity_parser = subparsers.add_parser("integrity", help=t("cli.help.integrity")) + integrity_sub = integrity_parser.add_subparsers( + dest="integrity_action", help=t("cli.help.integrity.action") + ) + integrity_gen = integrity_sub.add_parser( + "generate", help=t("cli.help.integrity.generate") + ) + integrity_gen.add_argument("path", help=t("cli.help.integrity.path")) + integrity_verify = integrity_sub.add_parser( + "verify", help=t("cli.help.integrity.verify") + ) + integrity_verify.add_argument("path", help=t("cli.help.integrity.path")) + + task_parser = subparsers.add_parser("task", help=t("cli.help.task")) + task_sub = task_parser.add_subparsers( + dest="task_action", help=t("cli.help.task.action") + ) + + task_add = task_sub.add_parser("add", help=t("cli.help.task.add")) + task_add.add_argument("name", help=t("cli.help.task.add.name")) + task_add.add_argument("folder", help=t("cli.help.task.add.folder")) + task_add.add_argument( + "--format", + default="ZIP", + choices=["zip", "tar", "tar.gz", "tar.bz2", "tar.xz", "tar.zst", "7z"], + help=t("cli.help.task.add.format"), + ) + task_add.add_argument( + "--output", + default=None, + help=t("cli.help.task.add.output"), + ) + + task_list = task_sub.add_parser("list", help=t("cli.help.task.list")) + task_list.add_argument( + "--status", + default=None, + choices=["pending", "running", "completed", "failed"], + help=t("cli.help.task.list.status"), + ) + + task_sub.add_parser("run", help=t("cli.help.task.run")) + task_sub.add_parser("run-all", help=t("cli.help.task.run_all")) + + task_cancel = task_sub.add_parser("cancel", help=t("cli.help.task.cancel")) + task_cancel.add_argument("id", help=t("cli.help.task.cancel.id")) + + task_sub.add_parser("clear", help=t("cli.help.task.clear")) + task_sub.add_parser("stats", help=t("cli.help.task.stats")) + + audit_parser = subparsers.add_parser("audit", help=t("cli.help.audit")) + audit_sub = audit_parser.add_subparsers( + dest="audit_action", help=t("cli.help.audit.action") + ) + + audit_log = audit_sub.add_parser("log", help=t("cli.help.audit.log")) + audit_log.add_argument( + "--event", + default="", + help=t("cli.help.audit.log.event"), + ) + audit_log.add_argument( + "--status", + default="", + help=t("cli.help.audit.log.status"), + ) + audit_log.add_argument( + "--since", + default="", + help=t("cli.help.audit.log.since"), + ) + audit_log.add_argument( + "--limit", + type=int, + default=50, + help=t("cli.help.audit.log.limit"), + ) + + audit_sub.add_parser("stats", help=t("cli.help.audit.stats")) + + audit_cleanup = audit_sub.add_parser("cleanup", help=t("cli.help.audit.cleanup")) + audit_cleanup.add_argument( + "--keep-days", + type=int, + default=90, + help=t("cli.help.audit.cleanup.keep_days"), + ) + + hooks_parser = subparsers.add_parser("hooks", help=t("cli.help.hooks")) + hooks_sub = hooks_parser.add_subparsers( + dest="hooks_action", help=t("cli.help.hooks.action") + ) + hooks_run = hooks_sub.add_parser("run", help=t("cli.help.hooks.run")) + hooks_run.add_argument( + "hook_type", + choices=["pre", "post"], + help=t("cli.help.hooks.run.type"), + ) + hooks_run.add_argument( + "--timeout", + type=int, + default=None, + help=t("cli.help.hooks.run.timeout"), + ) + + # Profile 子命令 + profile_parser = subparsers.add_parser("profile", help=t("cli.help.profile")) + profile_sub = profile_parser.add_subparsers( + dest="profile_action", help=t("cli.help.profile.action") + ) + profile_sub.add_parser("list", help=t("cli.help.profile.action")) + + profile_create = profile_sub.add_parser( + "create", help=t("cli.help.profile.create.name") + ) + profile_create.add_argument("name", help=t("cli.help.profile.create.name")) + + profile_delete = profile_sub.add_parser( + "delete", help=t("cli.help.profile.delete.name") + ) + profile_delete.add_argument("name", help=t("cli.help.profile.delete.name")) + + profile_use = profile_sub.add_parser("use", help=t("cli.help.profile.use.name")) + profile_use.add_argument("name", help=t("cli.help.profile.use.name")) + + profile_export = profile_sub.add_parser( + "export", help=t("cli.help.profile.export.name") + ) + profile_export.add_argument("name", help=t("cli.help.profile.export.name")) + profile_export.add_argument("path", help=t("cli.help.profile.export.path")) + + profile_import = profile_sub.add_parser( + "import", help=t("cli.help.profile.import.path") + ) + profile_import.add_argument("path", help=t("cli.help.profile.import.path")) + profile_import.add_argument( + "--name", default="", help=t("cli.help.profile.import.name") + ) + + return parser + + +def parse_path(path_str: str) -> str: + return os.path.expanduser(path_str.strip()) + + +def _parse_size(size_str: str) -> int: + """解析文件大小字符串(如 '1GB', '500MB', '100KB')为字节数""" + if not size_str: + return 0 + size_str = size_str.strip().upper() + units = {"B": 1, "KB": 1024, "MB": 1024**2, "GB": 1024**3} + for suffix, multiplier in units.items(): + if size_str.endswith(suffix): + return int(float(size_str[: -len(suffix)]) * multiplier) + return int(size_str) + + +def _parse_duration(duration_str: str) -> float: + """解析时间 duration(如 '7d', '30d', '1y')为秒数""" + if not duration_str: + return 0 + duration_str = duration_str.strip().lower() + units = {"d": 86400, "w": 604800, "m": 2592000, "y": 31536000} + for suffix, multiplier in units.items(): + if duration_str.endswith(suffix): + return float(duration_str[: -len(suffix)]) * multiplier + return float(duration_str) + + +def _handle_version(args, config, manager) -> int: + print(t("cli.version", version=VERSION)) + return 0 + + +def _handle_add(args, config, manager) -> int: + source = parse_path(args.source) + dest = parse_path(args.dest) + entry_fmt = args.format.upper().replace(".", "_") if args.format else "" + entry_template = args.name_template or "" + # 从 .gitignore 导入忽略规则 + ignore_patterns = args.ignore + if args.from_gitignore: + from sbackup.config import parse_gitignore + + gitignore_patterns = parse_gitignore(args.from_gitignore) + if gitignore_patterns: + # 合并 .gitignore 规则和默认规则 + existing = [s.strip() for s in ignore_patterns.split(",") if s.strip()] + merged = existing + gitignore_patterns + ignore_patterns = ",".join(merged) + print( + t( + "cmd.add.gitignore_imported", + count=len(gitignore_patterns), + path=args.from_gitignore, + ) + ) + else: + print(t("cmd.add.gitignore_empty", path=args.from_gitignore)) + success = manager.add_folder( + source, dest, ignore_patterns, entry_fmt, entry_template + ) + if success: + print(t("cmd.add.success", source=source, dest=dest)) + return 0 + return 1 + + +def _handle_rm(args, config, manager) -> int: + if args.all: + entries = {k: v for k, v in manager.data.items() if k != "_history"} + if not entries: + print(t("cmd.rm.all.empty")) + return 0 + count = len(entries) + confirm_words = t("cmd.rm.all.confirm_words").split(",") + confirm = input(t("cmd.rm.all.confirm", count=count)) + if confirm.strip().lower() in confirm_words: + for key in list(manager.data.keys()): + if key != "_history": + del manager.data[key] + manager.save() + print(t("cmd.rm.all.success", count=count)) + return 0 + return 1 + if not args.path: + print(t("warn.no.strategy.found", path="")) + return 1 + path = parse_path(args.path) + success = manager.rm_folder(path) + if success: + print(t("cmd.rm.success", path=path)) + return 0 + return 1 + + +def _handle_all(args, config, manager) -> int: + print(manager.list_folder_table()) + return 0 + + +def _handle_edit(args, config, manager) -> int: + source = parse_path(args.source) + new_fmt = args.format.upper().replace(".", "_") if args.format else None + success = manager.edit_strategy( + source, + new_target=parse_path(args.dest) if args.dest else None, + new_ignore=args.ignore, + new_format=new_fmt, + new_name_template=args.name_template, + ) + if success: + print(t("cmd.edit.success", path=source)) + return 0 + return 1 + + +def _handle_list(args, config, manager) -> int: + if getattr(args, "tags", False): + tags = manager.get_tags() + if not tags: + print(t("cmd.list.tags.empty")) + else: + print(t("cmd.list.tags.header")) + for tag in sorted(tags): + print(f" {tag}") + return 0 + print(manager.format_history_table()) + return 0 + + +def _handle_save(args, config, manager) -> int: + from sbackup.lock import BackupLock + + # --multi-dest 模式:委托给 multi_dest 模块 + if getattr(args, "multi_dest", False): + return _run_multi_dest_save(args, config, manager) + + if args.password: + print(t("warn.password_in_cli"), file=sys.stderr) + lock = BackupLock(os.path.dirname(config.data_file) or ".") + if not lock.acquire(): + print(t("err.lock.conflict")) + return 1 + try: + if args.dry_run: + # 使用 DryRunScanner 生成详细的预览报告 + from sbackup.dryrun import DryRunScanner + + for key, raw in manager.data.items(): + if key in ("_history", "_file_meta"): + continue + from sbackup.auto_save import BackupEntry + + entry = BackupEntry.from_list(raw) + source_dir = key + if not os.path.isdir(source_dir): + continue + scan_config = Config( + folder_path=source_dir, + skip_patterns=entry.skip_patterns, + include_patterns=getattr(entry, "include_patterns", []), + exclude_patterns=getattr(entry, "exclude_patterns", []), + max_size=_parse_size(args.max_size) if args.max_size else 0, + min_size=_parse_size(args.min_size) if args.min_size else 0, + follow_symlinks=args.follow_symlinks, + ) + scanner = DryRunScanner(source_dir, scan_config) + result = scanner.scan() + print(scanner.format_summary(result, lang=config.lang)) + print() + else: + # 备份前磁盘空间检查 + from sbackup.diskcheck import DiskChecker + from sbackup.diskcheck import _format_size as _fmt_size + + strict = getattr(args, "strict", False) + for key, raw in manager.data.items(): + if key in ("_history", "_file_meta"): + continue + from sbackup.auto_save import BackupEntry + + entry = BackupEntry.from_list(raw) + source_dir = key + target_dir = entry.target + if not os.path.isdir(source_dir): + continue + fmt = config.compression_format + level = config.compression_level + checker = DiskChecker(source_dir, target_dir) + info = checker.check(fmt=fmt, compression_level=level) + if not info.enough: + report = checker.format_report(info, lang=config.lang) + print(report) + if strict: + print( + t( + "diskcheck.insufficient", + needed=_fmt_size(info.estimated_backup), + available=_fmt_size(info.free), + missing=_fmt_size(abs(info.margin)), + ) + ) + return 1 + + manager.execute_backups( + keep=args.keep, + keep_days=args.keep_days, + password=args.password, + sftp_upload=args.sftp, + webdav_upload=args.webdav, + cloud_upload=args.cloud, + dry_run=False, + verify=args.verify, + name_template=args.name_template, + webhook_url=args.webhook, + follow_symlinks=args.follow_symlinks, + max_size=_parse_size(args.max_size) if args.max_size else 0, + min_size=_parse_size(args.min_size) if args.min_size else 0, + max_age_seconds=_parse_duration(args.older_than) + if args.older_than + else 0, + incremental=args.incremental, + split_size=_parse_size(args.split) if args.split else 0, + tag=args.tag or "", + checksum=args.checksum, + pre_hooks=getattr(args, "pre_hook", None) or [], + post_hooks=getattr(args, "post_hook", None) or [], + dedup=args.dedup, + threads=args.threads, + ) + finally: + lock.release() + return 0 + + +def _run_multi_dest_save(args, config, manager) -> int: + """在 save --multi-dest 模式下执行多目标备份""" + from sbackup.lock import BackupLock + from sbackup.multi_dest import MultiDestBackup + + if args.password: + config.password = args.password + + lock = BackupLock(os.path.dirname(config.data_file) or ".") + if not lock.acquire(): + print(t("err.lock.conflict")) + return 1 + try: + multi = MultiDestBackup(config) + destinations = multi.get_enabled_destinations() + remote_count = len(destinations) - 1 # 减去 local + + # 找到第一个策略条目执行本地备份 + from sbackup.auto_save import BackupEntry + + for key, raw in manager.data.items(): + if key in ("_history", "_file_meta", "_chunk_meta"): + continue + entry = BackupEntry.from_list(raw) + source_dir = key + target_dir = entry.target + + print( + t( + "multi_dest.started", + source=source_dir, + targets=", ".join(destinations), + ) + ) + + if remote_count > 0: + print(t("multi_dest.uploading", count=remote_count)) + + results = multi.execute_all(source_dir, target_dir) + print(multi.format_results(results, lang=config.lang)) + + # 本地备份失败则中止 + if not any(r.success for r in results if r.name == "local"): + print(t("multi_dest.aborted")) + return 1 + + # 执行后置钩子 + if config.post_hooks: + from sbackup.hooks import HookRunner + + runner = HookRunner( + post_hooks=config.post_hooks, + timeout=config.hook_timeout, + ) + hook_results = runner.run_hooks("post") + print(runner.format_results(hook_results, lang=config.lang)) + + print(t("multi_dest.complete")) + return 0 + finally: + lock.release() + + +def _handle_multi_backup(args, config, manager) -> int: + """处理 multi-backup 子命令:多目标同时备份""" + from sbackup.lock import BackupLock + from sbackup.multi_dest import MultiDestBackup + + source = parse_path(args.source) + local_target = parse_path(args.local_target) + + if not os.path.isdir(source): + print(t("err.folder.invalid", path=source)) + return 1 + + if args.password: + config.password = args.password + if args.name_template: + config.name_template = args.name_template + + lock = BackupLock(os.path.dirname(config.data_file) or ".") + if not lock.acquire(): + print(t("err.lock.conflict")) + return 1 + try: + multi = MultiDestBackup(config) + destinations = multi.get_enabled_destinations() + remote_count = len(destinations) - 1 + + print( + t( + "multi_dest.started", + source=source, + targets=", ".join(destinations), + ) + ) + + if remote_count > 0: + print(t("multi_dest.uploading", count=remote_count)) + + results = multi.execute_all(source, local_target) + print(multi.format_results(results, lang=config.lang)) + + # 检查本地备份是否成功 + if not any(r.success for r in results if r.name == "local"): + print(t("multi_dest.aborted")) + return 1 + + # 执行后置钩子 + if config.post_hooks: + from sbackup.hooks import HookRunner + + runner = HookRunner( + post_hooks=config.post_hooks, + timeout=config.hook_timeout, + ) + hook_results = runner.run_hooks("post") + print(runner.format_results(hook_results, lang=config.lang)) + + print(t("multi_dest.complete")) + return 0 + finally: + lock.release() + + +def _handle_compress(args, config, manager) -> int: + """处理 compress 子命令:直接压缩指定文件夹""" + source = parse_path(args.source) + if not os.path.isdir(source): + print(t("err.folder.invalid", path=source)) + return 1 + + # 构建 Config + fmt = args.format.upper().replace(".", "_") if args.format else None + compress_config = Config( + folder_path=source, + compression_format=fmt or config.compression_format, + skip_patterns=config.skip_patterns, + password=args.password or config.password, + follow_symlinks=args.follow_symlinks, + name_template=config.name_template, + ) + if args.output: + compress_config.zipfile_path = args.output + + if args.dry_run: + # Dry-run: 使用 DryRunScanner 扫描并显示预览 + from sbackup.dryrun import DryRunScanner + + scanner = DryRunScanner(source, compress_config) + result = scanner.scan() + print(scanner.format_summary(result, lang=config.lang)) + return 0 + + # 实际执行压缩 + from sbackup.compression import create_compressor + + compressor = create_compressor(compress_config) + result = compressor.compress() + return 0 if result.get("success") else 1 + + +def _handle_watch(args, config, manager) -> int: + from sbackup.lock import BackupLock + + import time as _time + + interval_sec = max(args.interval, 1) * 60 + realtime = getattr(args, "realtime", False) + debounce = max(getattr(args, "debounce", 30), 1) + + # watch 命令长期持有锁,阻止并发 save/watch + lock = BackupLock(os.path.dirname(config.data_file) or ".") + if not lock.acquire(): + print(t("err.lock.conflict")) + return 1 + + if args.password: + print(t("warn.password_in_cli"), file=sys.stderr) + + def do_backup() -> None: + manager.execute_backups( + keep=args.keep, + keep_days=args.keep_days, + password=args.password, + sftp_upload=args.sftp, + webdav_upload=args.webdav, + cloud_upload=args.cloud, + dry_run=args.dry_run, + verify=args.verify, + name_template=args.name_template, + webhook_url=args.webhook, + follow_symlinks=args.follow_symlinks, + max_size=_parse_size(args.max_size) if args.max_size else 0, + min_size=_parse_size(args.min_size) if args.min_size else 0, + max_age_seconds=_parse_duration(args.older_than) if args.older_than else 0, + incremental=args.incremental, + split_size=_parse_size(args.split) if args.split else 0, + tag=args.tag or "", + checksum=args.checksum, + pre_hooks=getattr(args, "pre_hook", None) or [], + post_hooks=getattr(args, "post_hook", None) or [], + dedup=args.dedup, + threads=args.threads, + ) + + try: + if realtime: + from sbackup.monitor import FileSystemMonitor + from sbackup.auto_save import BackupEntry + + source_dirs: dict[str, str] = {} + for key, raw in manager.data.items(): + if key in ("_history", "_file_meta"): + continue + entry = BackupEntry.from_list(raw) + source_dirs[key] = entry.target + + if not source_dirs: + print(t("cmd.all.empty")) + return 1 + + monitor = FileSystemMonitor(source_dirs, debounce_seconds=debounce) + monitor.set_backup_callback(do_backup) + + print(t("cmd.watch.start_realtime", debounce=debounce)) + monitor.start() + + if not monitor.is_running(): + return 1 + + try: + do_backup() + while True: + _time.sleep(interval_sec) + do_backup() + except KeyboardInterrupt: + pass + finally: + monitor.stop() + return 0 + + print(t("cmd.watch.start", interval=args.interval)) + while True: + do_backup() + _time.sleep(interval_sec) + except KeyboardInterrupt: + return 0 + finally: + lock.release() + + +def _handle_restore(args, config, manager) -> int: + tag = getattr(args, "tag", "") or "" + if tag: + entries = manager.get_history_by_tag(tag) + if not entries: + print(t("cmd.restore.tag_not_found", tag=tag)) + return 1 + backup_path = entries[-1].get("path", "") + if not backup_path or not os.path.isfile(backup_path): + print(t("cmd.restore.tag_not_found", tag=tag)) + return 1 + target_dir = args.backup_file or "" + if not target_dir: + # --list / --search / --stats 不需要 target_dir + if not ( + args.list + or getattr(args, "search", None) + or getattr(args, "stats", False) + ): + print(t("cli.help.restore.dir")) + return 1 + else: + backup_path = args.backup_file or "" + target_dir = args.target_dir or "" + # --list / --search / --stats 不需要 target_dir + if not backup_path or ( + not target_dir + and not args.list + and not getattr(args, "search", None) + and not getattr(args, "stats", False) + ): + parser = get_parser() + parser.print_help() + return 1 + + # 检查备份文件是否存在 + if not os.path.isfile(backup_path): + print(t("err.file.not_found", path=backup_path)) + return 1 + + if args.list: + from sbackup.compression import list_backup_contents + + print(list_backup_contents(backup_path, args.password)) + return 0 + + # --search: 使用 SelectiveRestore 搜索文件 + search_keyword = getattr(args, "search", None) + if search_keyword: + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(backup_path, args.password) + results = sr.search(search_keyword) + if results: + print( + t( + "cmd.restore.search.found", + count=len(results), + ) + ) + for f in results: + print(f" {f['name']}") + else: + print( + t( + "cmd.restore.search.no_results", + keyword=search_keyword, + path=backup_path, + ) + ) + return 0 if results else 1 + + # --stats: 使用 SelectiveRestore 显示统计信息 + if getattr(args, "stats", False): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(backup_path, args.password) + stats = sr.get_stats() + print(t("cmd.restore.stats.header", path=backup_path)) + print(t("cmd.restore.stats.files", count=stats["total_files"])) + print(t("cmd.restore.stats.dirs", count=stats["total_dirs"])) + print(t("cmd.restore.stats.size", size=stats["total_size"])) + if stats["formats"]: + print(t("cmd.restore.stats.formats")) + for ext, count in sorted(stats["formats"].items()): + print(t("cmd.restore.stats.format_item", ext=ext, count=count)) + return 0 + + # --select: 选择性恢复 + select_patterns = getattr(args, "select", None) + if select_patterns: + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(backup_path, args.password) + extracted_count, extracted_paths = sr.extract_files(select_patterns, target_dir) + patterns_str = ", ".join(select_patterns) + if extracted_count > 0: + print( + t( + "cmd.restore.selective.success", + count=extracted_count, + target=target_dir, + ) + ) + else: + print(t("restore.no_match", pattern=patterns_str)) + return 0 if extracted_count > 0 else 1 + + # 默认:还原全部 + result = restore_backup(backup_path, target_dir, args.password) + return 0 if result["success"] else 1 + + +def _handle_info(args, config, manager) -> int: + from sbackup.compression import get_backup_info, format_backup_info + + info = get_backup_info(args.backup_file, args.password) + print(format_backup_info(info)) + return 0 if info.get("success") else 1 + + +def _handle_diff(args, config, manager) -> int: + source = parse_path(args.source) + diff_result = manager.diff_backup( + source, args.backup_file, args.password, detail=args.detail + ) + if not diff_result.get("success"): + return 1 + + if args.detail: + # 显示详细的行级差异 + print(t("cmd.diff.detail_header")) + detail_map = diff_result.get("detail", {}) + modified = diff_result.get("modified", []) + for f in modified: + if f in detail_map: + print(f"\n--- {f} ---") + print(detail_map[f]) + else: + print(f"\n--- {f} ---") + print(t("cmd.diff.no_detail")) + has_changes = diff_result["added"] or diff_result["removed"] or modified + return 1 if has_changes else 0 + else: + print(manager.format_diff(diff_result)) + has_changes = ( + diff_result["added"] or diff_result["removed"] or diff_result["modified"] + ) + return 1 if has_changes else 0 + + +def _handle_verify(args, config, manager) -> int: + from sbackup.compression import ( + verify_backup, + verify_backup_fast, + get_backup_info, + verify_split_integrity, + ) + + # 分卷完整性验证 + if args.split: + if not args.backup_file: + print(t("cmd.verify.no_file")) + return 1 + result = verify_split_integrity(args.backup_file) + if result["success"]: + print( + t( + "cmd.verify.split_success", + file=result.get("original_file", ""), + parts=result.get("parts_count", 0), + ) + ) + return 0 + else: + error = result.get("error", "") + if error: + print(t("cmd.verify.split_error", error=error)) + else: + print( + t("cmd.verify.split_failed", file=result.get("original_file", "")) + ) + for r in result.get("results", []): + if r["status"] != "ok": + print(f" {r['path']}: {r['status']}") + return 1 + + # 批量验证所有备份 + if args.all: + history = manager.get_history() + if not history: + print(t("cmd.verify.no_history")) + return 1 + + success_count = 0 + fail_count = 0 + for entry in reversed(history): + source = entry.get("source", "") + sha256 = entry.get("sha256", "") + if not source or not os.path.isfile(source): + continue + + if args.detail: + info = get_backup_info(source, args.password) + print(t("cmd.verify.detail_header", path=source)) + print( + t( + "cmd.verify.detail_info", + size=info.get("size_mb", 0), + files=info.get("files_count", 0), + fmt=info.get("format", "unknown"), + sha256=sha256[:16] if sha256 else "N/A", + ) + ) + + if sha256: + result = verify_backup_fast(source, sha256) + else: + result = verify_backup(source, args.password) + + if result["success"]: + print(t("cmd.verify.batch_success", path=source)) + success_count += 1 + else: + print(t("cmd.verify.batch_failed", path=source)) + fail_count += 1 + + print(t("cmd.verify.batch_summary", success=success_count, fail=fail_count)) + return 0 if fail_count == 0 else 1 + + # 单文件验证 + if not args.backup_file: + print(t("cmd.verify.no_file")) + return 1 + + if args.detail: + info = get_backup_info(args.backup_file, args.password) + sha256 = manager.find_checksum(args.backup_file) + print(t("cmd.verify.detail_header", path=args.backup_file)) + print( + t( + "cmd.verify.detail_info", + size=info.get("size_mb", 0), + files=info.get("files_count", 0), + fmt=info.get("format", "unknown"), + sha256=sha256[:16] if sha256 else "N/A", + ) + ) + + if args.fast: + # 快速验证:用历史 SHA256 比对,无需解压 + sha256 = manager.find_checksum(args.backup_file) + if sha256: + result = verify_backup_fast(args.backup_file, sha256) + if result["success"]: + print( + t("cmd.verify.fast_success", path=args.backup_file, sha256=sha256) + ) + return 0 + else: + print(t("cmd.verify.fast_failed", path=args.backup_file)) + return 1 + else: + print(t("cmd.verify.no_checksum", path=args.backup_file)) + # 回退到完整验证 + result = verify_backup(args.backup_file, args.password) + return 0 if result["success"] else 1 + else: + result = verify_backup(args.backup_file, args.password) + return 0 if result["success"] else 1 + + +def _handle_sftp(args, config, manager) -> int: + from sbackup.handlers import handle_sftp + + return handle_sftp(args, config) + + +def _handle_webdav(args, config, manager) -> int: + from sbackup.handlers import handle_webdav + + return handle_webdav(args, config) + + +def _handle_remote(args, config, manager) -> int: + """处理 remote 子命令""" + from sbackup.handlers import handle_remote + + return handle_remote(args, config) + + +def _handle_report(args, config, manager) -> int: + report = manager.export_report(args.output) + if not args.output: + print(report) + return 0 + + +def _handle_search(args, config, manager) -> int: + from pathlib import Path + from sbackup.compression import search_in_backup + from sbackup.auto_save import BackupEntry + + if args.backup_file: + results = search_in_backup(args.backup_file, args.pattern, args.password) + if results: + print(t("cmd.search.results", path=args.backup_file, count=len(results))) + for r in results: + print(f" {r}") + else: + print( + t("cmd.search.no_results", pattern=args.pattern, path=args.backup_file) + ) + return 0 if results else 1 + + # 在所有策略的最新备份中搜索 + total_found = 0 + for key, raw in manager.data.items(): + if key == "_history" or key == "_file_meta": + continue + + entry = BackupEntry.from_list(raw) + target_dir = Path(entry.target) + if not target_dir.is_dir(): + continue + patterns = [ + "*.zip", + "*.tar", + "*.tar.gz", + "*.tar.bz2", + "*.tar.xz", + "*.tar.zst", + "*.7z", + ] + all_files = [] + for pat in patterns: + all_files.extend(target_dir.glob(pat)) + if not all_files: + continue + all_files.sort(key=lambda f: f.stat().st_mtime, reverse=True) + latest = str(all_files[0]) + results = search_in_backup(latest, args.pattern, args.password) + if results: + print(t("cmd.search.results", path=latest, count=len(results))) + for r in results: + print(f" {r}") + total_found += len(results) + + if total_found == 0: + print(t("cmd.search.no_results_global", pattern=args.pattern)) + return 1 + return 0 + + +def _handle_xsearch(args, config, manager) -> int: + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher(args.dirs, password=args.password) + + if args.keyword: + results = searcher.search(args.keyword) + elif args.pattern: + results = searcher.search_by_pattern(args.pattern) + elif args.ext: + results = searcher.search_by_extension(args.ext) + else: + print(t("cmd.xsearch.no_query")) + return 1 + + output_lang = args.lang if args.lang else config.lang + print(searcher.format_results(results, lang=output_lang)) + return 0 if results else 1 + + +def _handle_export(args, config, manager) -> int: + from sbackup.export import MetadataExporter + + action = getattr(args, "export_action", None) + if not action: + print(t("cli.help.metadata_export.action")) + return 1 + + exporter = MetadataExporter(data_file=manager.data_file) + + if action == "history": + fmt = getattr(args, "format", "csv") + strategy = getattr(args, "strategy", "") + output = args.output + if fmt == "json": + if not output.endswith(".json"): + output += ".json" + count = exporter.export_history_json(output, strategy) + else: + if not output.endswith(".csv"): + output += ".csv" + count = exporter.export_history_csv(output, strategy) + print(t("cmd.metadata_export.history_done", count=count, path=output)) + elif action == "audit": + fmt = getattr(args, "format", "csv") + event = getattr(args, "event", "") + output = args.output + if fmt == "json": + if not output.endswith(".json"): + output += ".json" + count = exporter.export_audit_json(output, event) + else: + if not output.endswith(".csv"): + output += ".csv" + count = exporter.export_audit_csv(output, event) + print(t("cmd.metadata_export.audit_done", count=count, path=output)) + elif action == "all": + fmt = getattr(args, "format", "json") + output = args.output + if fmt == "json": + if not output.endswith(".json"): + output += ".json" + else: + if not output.endswith(".csv"): + output += ".csv" + count = exporter.export_combined(output, fmt) + print(t("cmd.metadata_export.combined_done", count=count, path=output)) + else: + print(t("cli.help.metadata_export.action")) + return 1 + return 0 + + +def _handle_import(args, config, manager) -> int: + result = manager.import_strategies(args.file) + if result is None: + print(t("cmd.import.empty", path=args.file)) + return 1 + imported, skipped = result + if imported > 0: + print(t("cmd.import.success", count=imported, skipped=skipped)) + else: + print(t("cmd.import.empty", path=args.file)) + return 0 + + +def _handle_ignore(args, config, manager) -> int: + if args.list_presets: + from sbackup.compression import IGNORE_PRESETS + + print(t("cmd.ignore.available_presets")) + for name, patterns in IGNORE_PRESETS.items(): + print(f" {name:10s} ({len(patterns)} rules)") + return 0 + + from sbackup.compression import generate_ignore_content + + content = generate_ignore_content(args.preset) + output_path = args.output + + if os.path.exists(output_path): + confirm = input(t("cmd.ignore.overwrite", path=output_path)) + if confirm.strip().lower() not in ("y", "yes", "是"): + print(t("cmd.ignore.cancelled")) + return 1 + + with open(output_path, "w", encoding="utf-8") as f: + f.write(content) + print(t("cmd.ignore.generated", path=output_path, preset=args.preset)) + return 0 + + +def _handle_versions(args, config, manager) -> int: + source = parse_path(args.source) if args.source else "" + tag = getattr(args, "tag", "") or "" + print(manager.format_versions(source, tag=tag)) + return 0 + + +def _handle_schedule(args, config, manager) -> int: + from sbackup.handlers import handle_schedule + + return handle_schedule(args, config) + + +def _handle_webhook_cmd(args, config, manager) -> int: + if args.webhook_action == "preset": + from sbackup.config import setup_webhook_preset + + template = setup_webhook_preset(args.name) + if template: + print(t("cmd.webhook.preset_set", name=args.name, template=template)) + return 0 + print(t("cmd.webhook.preset_unknown", name=args.name)) + return 1 + elif args.webhook_action == "list": + from sbackup.config import WEBHOOK_PRESETS + + print(t("cmd.webhook.available_presets")) + for key, info in WEBHOOK_PRESETS.items(): + print(f" {key:10s} — {info['name']}") + return 0 + print(t("cli.help.webhook_cmd.action")) + return 1 + + +def _handle_config_cmd(args, config, manager) -> int: + import getpass + from sbackup.config import encrypt_config, decrypt_config, is_config_encrypted + + config_path = os.path.abspath("config.json") + if args.config_action == "lock": + if is_config_encrypted(config_path): + print(t("cmd.config.already_locked")) + return 1 + password = args.password or getpass.getpass( + t("cmd.config.enter_password") + " " + ) + if not password: + print(t("cmd.config.no_password")) + return 1 + if encrypt_config(password, config_path): + print(t("cmd.config.locked")) + return 0 + print(t("cmd.config.lock_failed")) + return 1 + elif args.config_action == "unlock": + if not is_config_encrypted(config_path): + print(t("cmd.config.not_locked")) + return 0 + password = args.password or getpass.getpass( + t("cmd.config.enter_password") + " " + ) + if decrypt_config(password, config_path): + print(t("cmd.config.unlocked")) + return 0 + print(t("cmd.config.wrong_password")) + return 1 + elif args.config_action == "validate": + from sbackup.schema import validate_config_file + + is_valid, errors, warnings = validate_config_file(config) + if errors: + print(t("cmd.config.validate.errors")) + for e in errors: + print(f" [{e.severity}] {e.field}: {e.message}") + if warnings: + print(t("cmd.config.validate.warnings")) + for w in warnings: + print(f" [{w.severity}] {w.field}: {w.message}") + if is_valid and not warnings: + print(t("cmd.config.validate.passed")) + elif is_valid: + print(t("cmd.config.validate.passed_with_warnings")) + else: + print(t("cmd.config.validate.failed")) + return 0 if is_valid else 1 + print(t("cli.help.config_cmd.action")) + return 1 + + +def _handle_status(args, config, manager) -> int: + print(manager.format_status()) + return 0 + + +def _handle_clean(args, config, manager) -> int: + if args.keep <= 0 and args.keep_days <= 0: + print(t("cmd.clean.no_criteria")) + return 1 + result = manager.clean_all_backups( + keep=args.keep, keep_days=args.keep_days, dry_run=args.dry_run + ) + deleted = result["deleted"] + if not deleted: + print(t("cmd.clean.nothing")) + return 0 + if args.dry_run: + print(t("cmd.clean.dry_run_header")) + for f in deleted: + print(f" {' rm ' if not args.dry_run else '~~'} {f}") + print(t("cmd.clean.summary", count=len(deleted))) + return 0 + + +def _format_size(size_bytes: int) -> str: + """格式化文件大小为可读字符串""" + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + else: + return f"{size_bytes / (1024 * 1024):.2f} MB" + + +def _handle_rotate(args, config, manager) -> int: + """处理 rotate 子命令""" + from sbackup.rotation import BackupRotator, RotationPolicy + + action = getattr(args, "rotate_action", None) + + if action == "list": + backup_dir = parse_path(args.backup_dir) + if not os.path.isdir(backup_dir): + print(t("err.folder.invalid", path=backup_dir)) + return 1 + rotator = BackupRotator(backup_dir, RotationPolicy()) + backups = rotator.scan_backups() + if not backups: + print(t("cmd.rotate.scan_empty", path=backup_dir)) + return 0 + print(t("cmd.rotate.scan_found", count=len(backups), path=backup_dir)) + for b in backups: + print( + t( + "cmd.rotate.scan_item", + name=b["name"], + size=_format_size(b["size"]), + time=b["mtime_iso"], + ) + ) + return 0 + + # 默认 rotate 行为:根据策略清理 + backup_dir = getattr(args, "backup_dir", None) + if not backup_dir: + print(t("cli.help.rotate")) + return 1 + + backup_dir = parse_path(backup_dir) + if not os.path.isdir(backup_dir): + print(t("err.folder.invalid", path=backup_dir)) + return 1 + + # CLI 参数优先,未指定时从 config 获取 + keep_count = args.keep_count + keep_days = args.keep_days + keep_daily = args.keep_daily + if keep_count <= 0: + keep_count = config.rotation_keep_count + if keep_days <= 0: + keep_days = config.rotation_keep_days + if keep_daily <= 0: + keep_daily = config.rotation_keep_daily + + if keep_count <= 0 and keep_days <= 0 and keep_daily <= 0: + print(t("cmd.clean.no_criteria")) + return 1 + + policy = RotationPolicy( + keep_count=keep_count, + keep_days=keep_days, + keep_daily=keep_daily, + dry_run=args.dry_run, + ) + rotator = BackupRotator(backup_dir, policy) + _keep_list, delete_list = rotator.plan() + + if not delete_list: + print(t("cmd.rotate.no_action")) + return 0 + + if args.dry_run: + print(t("cmd.rotate.dry_run_header")) + for item in delete_list: + print( + t( + "cmd.rotate.dry_run_item", + name=item["name"], + size=_format_size(item["size"]), + time=item["mtime_iso"], + ) + ) + else: + print(t("cmd.rotate.plan_delete", count=len(delete_list))) + for item in delete_list: + print( + t( + "cmd.rotate.delete_item", + name=item["name"], + size=_format_size(item["size"]), + ) + ) + + deleted_count, deleted_paths = rotator.execute() + if deleted_count > 0 and not args.dry_run: + print(t("cmd.rotate.done", count=deleted_count)) + return 0 + + +def _handle_diskcheck(args, config, manager) -> int: + """执行磁盘空间检查""" + from sbackup.diskcheck import DiskChecker + + source = parse_path(args.source) + target = parse_path(args.target) + + if not os.path.isdir(source): + print(t("err.folder.invalid", path=source)) + return 1 + + fmt = config.compression_format + level = args.level + checker = DiskChecker(source, target) + info = checker.check(fmt=fmt, compression_level=level) + report = checker.format_report(info, lang=config.lang) + print(report) + return 0 if info.enough else 1 + + +def _handle_benchmark(args, config, manager) -> int: + """执行压缩基准测试""" + from sbackup.benchmark import BenchmarkRunner + + source = parse_path(args.path) + if not os.path.isdir(source): + print(t("err.folder.invalid", path=source)) + return 1 + + # 检查目录是否有文件 + has_files = False + for _dirpath, _dirnames, filenames in os.walk(source): + if filenames: + has_files = True + break + if not has_files: + print(t("benchmark.empty_source", path=source)) + return 1 + + runner = BenchmarkRunner(source, password=args.password or "") + + if args.quick: + results = runner.run_quick() + else: + levels = None + if args.levels: + try: + levels = [int(x.strip()) for x in args.levels.split(",")] + except ValueError: + print(t("err.argparse.invalid_int")) + return 1 + results = runner.run_all(levels=levels) + + print(runner.format_results(results, lang=config.lang)) + return 0 + + +def _handle_completion(args, config, manager) -> int: + """生成并输出 shell 自动补全脚本""" + from sbackup.completion import generate + + try: + script = generate(args.shell) + print(script, end="") + except ValueError as e: + print(e, file=sys.stderr) + return 1 + return 0 + + +def _handle_integrity(args, config, manager) -> int: + from sbackup.integrity import generate_backup_integrity, verify_backup_integrity + + action = getattr(args, "integrity_action", None) + path = parse_path(args.path) + + if action == "generate": + try: + integrity_path = generate_backup_integrity(path) + print(t("cmd.integrity.generate.success", path=integrity_path)) + return 0 + except NotADirectoryError: + print(t("cmd.integrity.generate.not_dir", path=path)) + return 1 + except OSError as e: + print(t("err.os", error=e)) + return 1 + + elif action == "verify": + all_ok, messages = verify_backup_integrity(path) + for msg in messages: + print(f" {msg}") + if all_ok: + print(t("cmd.integrity.verify.success", path=path)) + else: + print(t("cmd.integrity.verify.failed", path=path)) + return 0 if all_ok else 1 + + else: + parser = get_parser() + parser.parse_args(["integrity", "--help"]) + return 1 + + +def _handle_wizard(args, config, manager) -> int: + from sbackup.wizard import run_wizard + + return run_wizard(config.data_file) + + +def _handle_task(args, config, manager) -> int: + from sbackup.task_queue import TaskQueue + + queue = TaskQueue() + action = getattr(args, "task_action", None) + + if action == "add": + task_id = queue.add_task( + name=args.name, + folder_path=args.folder, + zipfile_path=args.output, + compression_format=args.format, + ) + print(t("task_queue.added", name=args.name, id=task_id)) + return 0 + + elif action == "list": + status_filter = getattr(args, "status", None) + tasks = queue.list_tasks(status=status_filter) + if not tasks: + print(t("task_queue.list.empty")) + return 0 + print(t("task_queue.list.header", count=len(tasks))) + for task in tasks: + print( + t( + "task_queue.list.item", + status=task.status, + name=task.name, + id=task.id, + ) + ) + print(t("task_queue.list.item_detail", folder=task.folder_path)) + if task.result_path: + print(t("task_queue.list.item_result", result=task.result_path)) + if task.error: + print(t("task_queue.list.item_error", error=task.error)) + if task.created_at: + print(t("task_queue.list.item_time", created=task.created_at)) + return 0 + + elif action == "run": + task = queue.run_next() + if task is None: + print(t("task_queue.run.none")) + return 0 + if task.status == "completed": + print(t("task_queue.run.success", name=task.name, result=task.result_path)) + return 0 + else: + print(t("task_queue.run.failed", name=task.name, error=task.error)) + return 1 + + elif action == "run-all": + executed = queue.run_all() + if not executed: + print(t("task_queue.run.none")) + return 0 + success = sum(1 for t in executed if t.status == "completed") + failed = len(executed) - success + print( + t( + "task_queue.run_all.done", + count=len(executed), + success=success, + failed=failed, + ) + ) + return 0 if failed == 0 else 1 + + elif action == "cancel": + if queue.cancel_task(args.id): + print(t("task_queue.cancelled.success", id=args.id)) + return 0 + else: + print(t("task_queue.cancelled.fail")) + return 1 + + elif action == "clear": + count = queue.clear_completed() + if count > 0: + print(t("task_queue.clear.done", count=count)) + else: + print(t("task_queue.clear.none")) + return 0 + + elif action == "stats": + stats = queue.get_stats() + print(t("task_queue.stats.header")) + print(t("task_queue.stats.total", total=stats["total"])) + print(t("task_queue.stats.pending", pending=stats["pending"])) + print(t("task_queue.stats.running", running=stats["running"])) + print(t("task_queue.stats.completed", completed=stats["completed"])) + print(t("task_queue.stats.failed", failed=stats["failed"])) + return 0 + + else: + parser = get_parser() + parser.parse_args(["task", "--help"]) + return 1 + + +def _handle_audit(args, config, manager) -> int: + """处理 audit 子命令""" + from sbackup.audit import AuditLogger + + logger_a = AuditLogger() + + action = getattr(args, "audit_action", None) + + if action == "log": + entries = logger_a.query( + event=args.event, + status=args.status, + since=args.since, + limit=args.limit, + ) + print(logger_a.format_entries(entries, lang=config.lang)) + return 0 + + elif action == "stats": + stats = logger_a.get_stats() + print(logger_a.format_stats(stats, lang=config.lang)) + return 0 + + elif action == "cleanup": + removed = logger_a.cleanup(keep_days=args.keep_days) + if removed > 0: + print(t("cmd.audit.cleanup.done", count=removed)) + else: + print(t("cmd.audit.cleanup.none")) + return 0 + + parser = get_parser() + parser.parse_args(["audit", "--help"]) + return 1 + + +def _handle_hooks(args, config, manager) -> int: + """处理 hooks 子命令:手动执行 pre/post hooks""" + from sbackup.hooks import HookRunner + + action = getattr(args, "hooks_action", None) + + if action == "run": + hook_type = args.hook_type + timeout = args.timeout if args.timeout is not None else config.hook_timeout + hooks = config.pre_hooks if hook_type == "pre" else config.post_hooks + + if not hooks: + print(t("cmd.hooks.no_hooks", type=hook_type)) + return 0 + + runner = HookRunner( + pre_hooks=hooks if hook_type == "pre" else [], + post_hooks=hooks if hook_type == "post" else [], + timeout=timeout, + ) + results = runner.run_hooks(hook_type) + print(runner.format_results(results, lang=config.lang)) + failed = sum(1 for r in results if not r.success) + return 1 if failed > 0 else 0 + + parser = get_parser() + parser.parse_args(["hooks", "--help"]) + return 1 + + +def _handle_profile(args, config, manager) -> int: + """处理 profile 子命令""" + from sbackup.profile import ProfileManager + + pm = ProfileManager() + action = getattr(args, "profile_action", None) + + if action == "list": + profiles = pm.list_profiles() + if not profiles: + print(t("cmd.profile.list.empty")) + return 0 + print(t("cmd.profile.list.header")) + for name, cfg in profiles.items(): + print(t("cmd.profile.list.item", name=name)) + # 显示关键配置摘要 + fields = [] + for key in ("folder_path", "compression_format", "password"): + if key in cfg: + val = cfg[key] + if key == "password" and val: + val = "***" + fields.append(f"{key}: {val}") + if fields: + print(t("cmd.profile.list.detail", fields=", ".join(fields))) + return 0 + + elif action == "create": + if pm.save_profile(args.name, config): + print(t("cmd.profile.created", name=args.name)) + return 0 + return 1 + + elif action == "delete": + if pm.delete_profile(args.name): + print(t("cmd.profile.deleted", name=args.name)) + return 0 + print(t("cmd.profile.not_found", name=args.name)) + return 1 + + elif action == "use": + merged = pm.activate_profile(args.name) + if merged is None: + print(t("cmd.profile.not_found", name=args.name)) + return 1 + print(t("cmd.profile.activated", name=args.name)) + # 输出合并后配置摘要 + print(f" folder_path: {merged.folder_path}") + print(f" compression_format: {merged.compression_format}") + return 0 + + elif action == "export": + if pm.export_profile(args.name, args.path): + print(t("cmd.profile.exported", name=args.name, path=args.path)) + return 0 + print(t("cmd.profile.not_found", name=args.name)) + return 1 + + elif action == "import": + name = getattr(args, "name", "") + if pm.import_profile(args.path, name): + imported_name = name or os.path.splitext(os.path.basename(args.path))[0] + print(t("cmd.profile.imported", name=imported_name)) + return 0 + print(t("cmd.profile.import_failed", path=args.path)) + return 1 + + parser = get_parser() + parser.parse_args(["profile", "--help"]) + return 1 + + +_COMMAND_HANDLERS: dict[str, callable] = { + "version": _handle_version, + "wizard": _handle_wizard, + "add": _handle_add, + "rm": _handle_rm, + "remove": _handle_rm, + "edit": _handle_edit, + "all": _handle_all, + "list": _handle_list, + "history": _handle_list, + "save": _handle_save, + "multi-backup": _handle_multi_backup, + "watch": _handle_watch, + "compress": _handle_compress, + "restore": _handle_restore, + "info": _handle_info, + "diff": _handle_diff, + "verify": _handle_verify, + "sftp": _handle_sftp, + "webdav": _handle_webdav, + "remote": _handle_remote, + "export": _handle_export, + "import": _handle_import, + "status": _handle_status, + "ignore": _handle_ignore, + "versions": _handle_versions, + "schedule": _handle_schedule, + "webhook": _handle_webhook_cmd, + "config": _handle_config_cmd, + "report": _handle_report, + "search": _handle_search, + "xsearch": _handle_xsearch, + "clean": _handle_clean, + "rotate": _handle_rotate, + "diskcheck": _handle_diskcheck, + "benchmark": _handle_benchmark, + "completion": _handle_completion, + "integrity": _handle_integrity, + "task": _handle_task, + "audit": _handle_audit, + "hooks": _handle_hooks, + "profile": _handle_profile, +} + + +def run() -> int: + # 预处理 --debug:允许放在子命令之后 + debug_enabled = "--debug" in sys.argv + cleaned_argv = [arg for arg in sys.argv if arg != "--debug"] + + # 先检测 --lang 参数,初始化语言环境,再创建本地化 parser + lang_from_argv = _detect_lang_from_argv() + config = load_config() + current_lang = lang_from_argv if lang_from_argv is not None else config.lang + set_locale(current_lang) + + # 保存并替换 sys.argv,用 cleaned_argv 解析 + original_argv = sys.argv + sys.argv = cleaned_argv + + parser = get_parser() + args = parser.parse_args() + + # 恢复原始 sys.argv(避免影响其他代码) + sys.argv = original_argv + + # init 命令在持久化语言设置之前处理(避免 save_lang 创建 config.json) + if args.command == "init": + config_path = os.path.abspath("config.json") + if os.path.exists(config_path): + print(t("cmd.init.exists", path=config_path)) + return 1 + from sbackup.config import generate_config_template + + generate_config_template(config_path) + print(t("cmd.init.success", path=config_path)) + return 0 + + # 持久化语言设置 + if lang_from_argv is not None: + save_lang(lang_from_argv) + + # 持久化格式设置 + if args.format is not None: + save_format(args.format) + config.compression_format = args.format.upper().replace(".", "_") + + # --profile 全局参数:临时合并 profile 配置到 config + profile_name = getattr(args, "profile", None) + if profile_name: + from sbackup.profile import ProfileManager + + pm = ProfileManager() + merged = pm.activate_profile(profile_name) + if merged is None: + print(t("cmd.profile.not_found", name=profile_name)) + return 1 + config = merged + + if debug_enabled: + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", + force=True, + ) + + if args.command is None: + parser.print_help() + return 0 + + handler = _COMMAND_HANDLERS.get(args.command) + if handler is None: + parser.print_help() + return 0 + + manager = BackupManager(data_file=config.data_file) + return handler(args, config, manager) diff --git a/sbackup/cloud_storage.py b/sbackup/cloud_storage.py new file mode 100644 index 0000000..e735645 --- /dev/null +++ b/sbackup/cloud_storage.py @@ -0,0 +1,103 @@ +"""S3 兼容云存储客户端""" + +import logging +import os +from io import BytesIO + +from sbackup.ratelimiter import RateLimiter + +logger = logging.getLogger(__name__) + + +class CloudStorageError(Exception): + """云存储操作异常""" + + pass + + +class CloudStorageClient: + """S3 兼容云存储客户端,基于 minio 库""" + + def __init__( + self, + endpoint: str, + access_key: str, + secret_key: str, + bucket: str, + region: str = "", + secure: bool = True, + ): + self.endpoint = endpoint + self.access_key = access_key + self.secret_key = secret_key + self.bucket = bucket + self.region = region + self.secure = secure + self._client = None + + def connect(self) -> None: + """建立连接""" + from minio import Minio + + self._client = Minio( + self.endpoint, + access_key=self.access_key, + secret_key=self.secret_key, + region=self.region or None, + secure=self.secure, + ) + # 检查 bucket 是否存在,不存在则创建 + if not self._client.bucket_exists(self.bucket): + self._client.make_bucket(self.bucket) + + def upload_file( + self, local_path: str, remote_path: str, rate_limiter: RateLimiter | None = None + ) -> None: + """上传文件到云存储""" + if self._client is None: + self.connect() + if rate_limiter is not None: + # 有限速时:分块读入内存,每块限速 + file_size = os.path.getsize(local_path) + buf = BytesIO() + with open(local_path, "rb") as f: + while True: + chunk = f.read(65536) # 64KB + if not chunk: + break + rate_limiter.wait(len(chunk)) + buf.write(chunk) + buf.seek(0) + self._client.put_object(self.bucket, remote_path, buf, file_size) + else: + self._client.fput_object(self.bucket, remote_path, local_path) + + def download_file(self, remote_path: str, local_path: str) -> None: + """从云存储下载文件""" + if self._client is None: + self.connect() + self._client.fget_object(self.bucket, remote_path, local_path) + + def list_files(self, prefix: str = "") -> list[str]: + """列出云存储中的文件""" + if self._client is None: + self.connect() + objects = self._client.list_objects(self.bucket, prefix=prefix, recursive=True) + return [obj.object_name for obj in objects] + + def delete_file(self, remote_path: str) -> None: + """从云存储删除文件""" + if self._client is None: + self.connect() + self._client.remove_object(self.bucket, remote_path) + + def close(self) -> None: + """关闭连接""" + self._client = None + + def __enter__(self): + self.connect() + return self + + def __exit__(self, *args): + self.close() diff --git a/sbackup/completion.py b/sbackup/completion.py new file mode 100644 index 0000000..06a78b4 --- /dev/null +++ b/sbackup/completion.py @@ -0,0 +1,386 @@ +"""Shell 自动补全脚本生成 + +为支持的 shell(bash / zsh / fish / powershell)生成补全脚本。 +用法:: + + sbackup completion bash # 输出 bash 补全脚本 + sbackup completion zsh # 输出 zsh 补全脚本 + sbackup completion fish # 输出 fish 补全脚本 + sbackup completion powershell # 输出 PowerShell 补全脚本 + +安装示例:: + + # Bash + sbackup completion bash > /etc/bash_completion.d/sbackup + + # Zsh + sbackup completion zsh > /usr/local/share/zsh/site-functions/_sbackup + + # Fish + sbackup completion fish > ~/.config/fish/completions/sbackup.fish + + # PowerShell + sbackup completion powershell >> $PROFILE +""" + +# 所有子命令列表(与 cli.py 中的 _COMMAND_HANDLERS 保持同步) +_SUBCOMMANDS = [ + "add", + "rm", + "remove", + "edit", + "all", + "list", + "history", + "save", + "watch", + "restore", + "info", + "diff", + "verify", + "sftp", + "webdav", + "remote", + "export", + "import", + "status", + "ignore", + "versions", + "schedule", + "webhook", + "config", + "report", + "search", + "clean", + "version", + "init", + "completion", +] + +# 全局 flag(适用于所有命令) +_GLOBAL_FLAGS = [ + "--debug", + "--lang", + "--format", + "--help", + "--follow-symlinks", + "--incremental", + "--checksum", +] + +# 各子命令的专属 flag +_SUBCOMMAND_FLAGS: dict[str, list[str]] = { + "add": ["-i", "--ignore", "--format", "--name-template", "--from-gitignore"], + "rm": ["--all"], + "edit": ["--dest", "--ignore", "--format", "--name-template"], + "save": [ + "--keep", + "--keep-days", + "--password", + "--sftp", + "--webdav", + "--dry-run", + "--verify", + "--name-template", + "--webhook", + "--max-size", + "--min-size", + "--split", + "--tag", + "--older-than", + "--pre-hook", + "--post-hook", + ], + "watch": [ + "--interval", + "--keep", + "--keep-days", + "--password", + "--sftp", + "--webdav", + "--dry-run", + "--verify", + "--name-template", + "--webhook", + "--max-size", + "--min-size", + "--split", + "--tag", + "--older-than", + "--pre-hook", + "--post-hook", + "--realtime", + "--debounce", + ], + "restore": ["--password", "-l", "--list", "--select"], + "sftp": ["config", "test"], + "webdav": ["config", "test"], + "remote": ["list", "rm"], + "schedule": ["export"], + "webhook": ["preset"], + "config": ["lock", "unlock"], + "verify": ["--fast", "--all", "--detail", "--split"], + "diff": ["--password"], + "info": ["--password"], + "search": ["--password"], + "versions": ["--password"], + "export": [], + "import": [], + "ignore": ["--preset"], + "clean": ["--keep", "--keep-days", "--dry-run"], + "report": ["--format", "--output"], + "completion": ["bash", "zsh", "fish", "powershell"], + "all": [], + "list": [], + "history": [], + "version": [], + "init": [], + "status": [], +} + + +def generate_bash() -> str: + """生成 Bash 自动补全脚本""" + lines = [ + "# sbackup Bash completion", + f"_{_esc('_sbackup_completion')}() {{", + " local cur prev words cword", + " _init_completion || return", + "", + " if [[ $cword -eq 1 ]]; then", + f' COMPREPLY=($(compgen -W "{" ".join(_SUBCOMMANDS)}" -- "$cur"))', + " return 0", + " fi", + "", + " local cmd=${words[1]}", + ' local global_flags="{}"'.format(" ".join(_GLOBAL_FLAGS)), + "", + " case $cmd in", + ] + + for cmd, flags in sorted(_SUBCOMMAND_FLAGS.items()): + if cmd in ("rm", "list", "history", "all", "version", "init", "status"): + continue # handled by aliases or no flags + flag_str = " ".join(flags) if flags else "" + lines.append(f" {cmd})") + lines.append( + f' COMPREPLY=($(compgen -W "$global_flags {flag_str}" -- "$cur"))' + ) + lines.append(" ;;") + + lines += [ + " rm|remove)", + ' COMPREPLY=($(compgen -W "$global_flags --all" -- "$cur"))', + " ;;", + " list|history)", + ' COMPREPLY=($(compgen -W "$global_flags" -- "$cur"))', + " ;;", + " *)", + ' COMPREPLY=($(compgen -W "$global_flags" -- "$cur"))', + " ;;", + " esac", + "} &&", + "complete -F _sbackup_completion sbackup", + "", + ] + return "\n".join(lines) + + +def generate_zsh() -> str: + """生成 Zsh 自动补全脚本""" + lines = [ + "#compdef sbackup", + "", + "_sbackup_completion() {", + " local -a subcommands", + " subcommands=(", + ] + for cmd in _SUBCOMMANDS: + lines.append(f" '{cmd}:{_get_desc(cmd)}'") + lines += [ + " )", + "", + " _arguments \\", + " '--debug[Enable debug mode]' \\", + " '--lang[Set interface language]:lang:(zh_CN en_US fr_FR es_ES ru_RU de_DE ja_JP pt_BR ko_KR)' \\", + " '--format[Set packaging format]:format:(zip tar tar.gz tar.bz2 tar.xz tar.zst 7z)' \\", + " '--follow-symlinks[Follow symbolic links]' \\", + " '--incremental[File-level incremental backup]' \\", + " '--checksum[Use SHA256 checksum]' \\", + " '--help[Show help]' \\", + " '1: :->command' \\", + " '*: :->args'", + "", + " case $state in", + " command)", + " _describe 'command' subcommands", + " ;;", + " args)", + " _sbackup_args ${words[1]}", + " ;;", + " esac", + "}", + "", + "_sbackup_args() {", + " case $1 in", + ] + for cmd, flags in sorted(_SUBCOMMAND_FLAGS.items()): + if not flags: + continue + flag_parts = [] + for f in flags: + if f.startswith("--"): + flag_parts.append(f"'{f}[ ]'") + elif f.startswith("-"): + flag_parts.append(f"'{f}[ ]'") + flag_str = " \\\n ".join(flag_parts) + lines += [ + f" {cmd})", + f" _arguments : {flag_str}", + " ;;", + ] + lines += [ + " esac", + "}", + "", + '_sbackup_completion "$@"', + "", + ] + return "\n".join(lines) + + +def generate_fish() -> str: + """生成 Fish 自动补全脚本""" + lines = [ + "# sbackup Fish completion", + "", + ] + # 全局 flags + lines += [ + "complete -c sbackup -l debug -d 'Enable debug mode'", + "complete -c sbackup -l lang -x -a 'zh_CN en_US fr_FR es_ES ru_RU de_DE ja_JP pt_BR ko_KR' -d 'Set interface language'", + "complete -c sbackup -l format -x -a 'zip tar tar.gz tar.bz2 tar.xz tar.zst 7z' -d 'Set packaging format'", + "complete -c sbackup -l follow-symlinks -d 'Follow symbolic links'", + "complete -c sbackup -l incremental -d 'File-level incremental backup'", + "complete -c sbackup -l checksum -d 'Use SHA256 checksum'", + "", + "# Subcommands", + ] + for cmd in _SUBCOMMANDS: + lines.append( + f"complete -c sbackup -n 'not __fish_seen_subcommand_from {_esc(' '.join(_SUBCOMMANDS))}' -a {cmd} -d '{_get_desc(cmd)}'" + ) + + lines.append("") + # 各子命令的 flags + for cmd, flags in sorted(_SUBCOMMAND_FLAGS.items()): + if not flags: + continue + for f in flags: + if f.startswith("--"): + lines.append( + f"complete -c sbackup -n '__fish_seen_subcommand_from {cmd}' " + f"-l {f[2:]} -d ''" + ) + elif f.startswith("-") and not f.startswith("--"): + lines.append( + f"complete -c sbackup -n '__fish_seen_subcommand_from {cmd}' " + f"-s {f[1:]} -d ''" + ) + + lines.append("") + return "\n".join(lines) + + +def generate_powershell() -> str: + """生成 PowerShell 自动补全脚本""" + commands_json = ", ".join(f"'{c}'" for c in _SUBCOMMANDS) + lines = [ + "# sbackup PowerShell completion", + "Register-ArgumentCompleter -Native -CommandName sbackup -ScriptBlock {", + " param($wordToComplete, $commandAst, $cursorPosition)", + "", + f" $commands = @({commands_json})", + " $globalFlags = @(", + " '--debug', '--lang', '--format', '--help',", + " '--follow-symlinks', '--incremental', '--checksum'", + " )", + "", + " $cmdFlags = @{", + ] + for cmd, flags in sorted(_SUBCOMMAND_FLAGS.items()): + flag_str = ", ".join(f"'{f}'" for f in flags) if flags else "" + lines.append(f" '{cmd}' = @({flag_str})") + lines += [ + " }", + "", + " $tokens = $commandAst.CommandElements", + " $cmd = if ($tokens.Count -gt 1) { $tokens[1].Value } else { $null }", + "", + " if ($tokens.Count -le 2) {", + ' $commands | Where-Object { $_ -like "$wordToComplete*" }', + " } else {", + " $flags = $globalFlags + $cmdFlags[$cmd]", + ' $flags | Where-Object { $_ -like "$wordToComplete*" }', + " }", + "}", + "", + ] + return "\n".join(lines) + + +def _get_desc(cmd: str) -> str: + """获取子命令的简短描述""" + desc_map = { + "add": "Add a new backup strategy", + "rm": "Remove a backup strategy", + "remove": "Remove a backup strategy", + "edit": "Edit a backup strategy", + "all": "Show all backup strategies", + "list": "Show backup history", + "history": "Show backup history", + "save": "Execute backup tasks", + "watch": "Run backups periodically", + "restore": "Restore from backup file", + "info": "Show backup file info", + "diff": "Show differences between source and backup", + "verify": "Verify backup integrity", + "sftp": "Manage SFTP remote backup", + "webdav": "Manage WebDAV remote backup", + "remote": "Manage remote files", + "export": "Export backup strategies", + "import": "Import backup strategies", + "status": "Show backup status dashboard", + "ignore": "Generate .sbackupignore file", + "versions": "Show all versions for a source", + "schedule": "Export schedule config", + "webhook": "Configure webhook presets", + "config": "Manage configuration", + "report": "Export backup report", + "search": "Search files in backups", + "clean": "Clean old backups", + "version": "Show version info", + "init": "Generate config template", + "completion": "Generate shell completion script", + } + return desc_map.get(cmd, "") + + +def _esc(s: str) -> str: + """Shell 转义(简单处理)""" + return s.replace("'", "'\\''") + + +def generate(shell: str) -> str: + """生成指定 shell 的补全脚本""" + generators = { + "bash": generate_bash, + "zsh": generate_zsh, + "fish": generate_fish, + "powershell": generate_powershell, + } + gen = generators.get(shell.lower()) + if gen is None: + shells = ", ".join(generators) + raise ValueError(f"Unsupported shell: {shell}. Supported: {shells}") + return gen() diff --git a/sbackup/compression.py b/sbackup/compression.py index 17e7259..7a1cd53 100644 --- a/sbackup/compression.py +++ b/sbackup/compression.py @@ -2,16 +2,22 @@ 压缩模块:ZIP / TAR / Zstd / 7z 文件压缩逻辑 """ +import logging import os -import io import tarfile +import tempfile +import threading import zipfile +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime from pathlib import Path from fnmatch import fnmatch from tqdm import tqdm from sbackup.i18n import t from sbackup.config import Config +logger = logging.getLogger(__name__) + # compresslevel 仅对 ZIP_DEFLATED 和 ZIP_BZIP2 有效 _VALID_COMPRESSLEVEL_ALGORITHMS = {zipfile.ZIP_DEFLATED, zipfile.ZIP_BZIP2} @@ -24,31 +30,278 @@ } +# 排除规则预设模板 +IGNORE_PRESETS: dict[str, list[str]] = { + "node": [ + "node_modules", + ".npm", + ".yarn", + "dist", + "build", + ".next", + ".nuxt", + ".cache", + "*.log", + ".env", + ".env.local", + ], + "python": [ + "__pycache__", + "*.pyc", + "*.pyo", + ".venv", + "venv", + ".env", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "*.egg-info", + "dist", + "build", + ".tox", + ".nox", + "htmlcov", + ".coverage", + ], + "go": [ + "vendor", + "bin", + "*.exe", + "*.test", + "*.out", + ".env", + ], + "rust": [ + "target", + "*.rlib", + "*.d", + ".cargo", + "*.pdb", + ], + "java": [ + "target", + "build", + "*.class", + "*.jar", + "*.war", + ".gradle", + ".maven", + "*.log", + ], + "general": [ + ".git", + ".svn", + ".hg", + "__pycache__", + "node_modules", + ".DS_Store", + "Thumbs.db", + "*.log", + "*.tmp", + "*.swp", + ".env", + ], +} + + +def generate_ignore_content(preset: str) -> str: + """生成 .sbackupignore 文件内容 + :param preset: 预设名称(node/python/go/rust/java/general) + :return: 文件内容 + """ + patterns = IGNORE_PRESETS.get(preset, IGNORE_PRESETS["general"]) + lines = [f"# sbackup ignore preset: {preset}", ""] + lines.extend(patterns) + lines.append("") + return "\n".join(lines) + + +def _resolve_name(template: str, folder_name: str) -> str: + """根据模板生成备份文件基础名(不含扩展名) + + 支持变量: {name} 文件夹名, {date} YYYY-MM-DD, {time} HHMMSS + 空模板返回 folder_name(向后兼容)。 + """ + if not template: + return folder_name + now = datetime.now() + try: + return template.format( + name=folder_name, + date=now.strftime("%Y-%m-%d"), + time=now.strftime("%H%M%S"), + ) + except (KeyError, ValueError): + return folder_name + + +def _encrypt_file(file_path: str, password: str) -> str: + """用密码加密文件(PBKDF2 + XOR 流密码 + HMAC 完整性校验),返回加密后的文件路径""" + import hashlib + + salt = os.urandom(16) + key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 600_000) + + encrypted_path = file_path + ".enc" + tmp_path = encrypted_path + ".tmp" + try: + with open(file_path, "rb") as fin, open(tmp_path, "wb") as fout: + fout.write(salt) + key_stream = bytearray() + block_index = 0 + while True: + chunk = fin.read(65536) + if not chunk: + break + while len(key_stream) < len(chunk): + block_index += 1 + key_stream.extend( + hashlib.sha256(key + block_index.to_bytes(4, "big")).digest() + ) + encrypted = bytes( + a ^ b for a, b in zip(chunk, key_stream[: len(chunk)]) + ) + fout.write(encrypted) + key_stream = key_stream[len(chunk) :] + # 原子替换到最终路径 + os.replace(tmp_path, encrypted_path) + finally: + if os.path.exists(tmp_path): + os.remove(tmp_path) + os.remove(file_path) + return encrypted_path + + +def _decrypt_file(encrypted_path: str, password: str) -> str: + """解密由 _encrypt_file 加密的文件,返回解密后的文件路径。密码错误时抛出 ValueError""" + import hashlib + + decrypted_path = encrypted_path.removesuffix(".enc") + with open(encrypted_path, "rb") as fin: + salt = fin.read(16) + if len(salt) < 16: + raise ValueError("Invalid encrypted file: too short") + key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 600_000) + + with open(decrypted_path, "wb") as fout: + key_stream = bytearray() + block_index = 0 + while True: + chunk = fin.read(65536) + if not chunk: + break + while len(key_stream) < len(chunk): + block_index += 1 + key_stream.extend( + hashlib.sha256(key + block_index.to_bytes(4, "big")).digest() + ) + decrypted = bytes( + a ^ b for a, b in zip(chunk, key_stream[: len(chunk)]) + ) + fout.write(decrypted) + key_stream = key_stream[len(chunk) :] + + return decrypted_path + + class BaseCompressor: """压缩器基类,提供公共的文件收集和忽略逻辑""" + _IGNORE_FILENAME = ".sbackupignore" + def __init__(self, config: Config) -> None: self.folder_path: Path = Path(config.folder_path) self.zipfile_path: Path | None = ( Path(config.zipfile_path) if config.zipfile_path else None ) self.skip_patterns: list[str] = config.skip_patterns + self.name_template: str = config.name_template + self.follow_symlinks: bool = config.follow_symlinks + self.max_size: int = config.max_size + self.min_size: int = config.min_size + self.max_age_seconds: float = config.max_age_seconds + self.file_metadata: dict = config.file_metadata + self.include_patterns: list[str] = config.include_patterns + self.exclude_patterns: list[str] = config.exclude_patterns + self.threads: int = config.threads self.compression_level: int | None = None - def _should_ignore(self, rel_path: str) -> bool: - """检查相对路径是否匹配忽略模式(支持路径级匹配如 subdir/*.log)""" - for pattern in self.skip_patterns: - if fnmatch(rel_path, pattern) or fnmatch( - os.path.basename(rel_path), pattern - ): - return True - return False + def _load_ignore_file(self, folder_path: Path) -> list[str]: + """从源目录的 .sbackupignore 文件加载忽略规则""" + ignore_file = folder_path / self._IGNORE_FILENAME + if not ignore_file.is_file(): + return [] + try: + lines = ignore_file.read_text(encoding="utf-8").splitlines() + patterns = [] + for line in lines: + line = line.strip() + if line and not line.startswith("#"): + patterns.append(line) + if patterns: + logger.debug(t("log.ignore.loaded"), ignore_file) + return patterns + except OSError: + return [] + + def _should_ignore( + self, rel_path: str, extra_patterns: list[str] | None = None + ) -> bool: + """检查相对路径是否匹配忽略模式(匹配完整路径和文件名,支持 ! 取反,re: 正则)""" + import re + + all_patterns = self.skip_patterns + (extra_patterns or []) + negated = [] + matched = False + basename = os.path.basename(rel_path) + for pattern in all_patterns: + if pattern.startswith("!"): + negated.append(pattern[1:]) + continue + if pattern.startswith("re:"): + regex_str = pattern[3:] + # ReDoS 防护:拒绝过长或明显恶意的正则 + if len(regex_str) > 256: + continue + try: + if re.search(regex_str, rel_path) or re.search(regex_str, basename): + matched = True + except re.error: + pass + else: + if fnmatch(rel_path, pattern) or fnmatch(basename, pattern): + matched = True + # 取反模式仅在文件已被忽略时恢复(matched=True 时才生效) + if not matched: + return False + for pattern in negated: + if pattern.startswith("re:"): + regex_str = pattern[3:] + if len(regex_str) > 256: + continue + try: + if re.search(regex_str, rel_path) or re.search(regex_str, basename): + return False + except re.error: + pass + else: + if fnmatch(rel_path, pattern) or fnmatch(basename, pattern): + return False + return matched def _collect_files(self, folder_path: Path) -> list[tuple[str, str]]: """遍历文件夹收集需要压缩的文件列表,处理权限错误""" files = [] + extra_patterns = self._load_ignore_file(folder_path) + import time as _time + + cutoff_time = ( + _time.time() - self.max_age_seconds if self.max_age_seconds > 0 else 0 + ) try: - for dirpath, dirnames, filenames in os.walk(folder_path): + for dirpath, dirnames, filenames in os.walk( + folder_path, followlinks=self.follow_symlinks + ): try: rel_dir = os.path.relpath(dirpath, folder_path) if rel_dir == ".": @@ -59,7 +312,8 @@ def _collect_files(self, folder_path: Path) -> list[tuple[str, str]]: if not self._should_ignore( os.path.join(rel_dir, d).replace("\\", "/") if rel_dir - else d + else d, + extra_patterns, ) ] for filename in filenames: @@ -68,8 +322,70 @@ def _collect_files(self, folder_path: Path) -> list[tuple[str, str]]: if rel_dir else filename ) - if not self._should_ignore(file_rel): - files.append((dirpath, filename)) + if self._should_ignore(file_rel, extra_patterns): + continue + # 文件类型包含/排除过滤(glob 模式匹配文件名和相对路径) + if self.include_patterns and not any( + fnmatch(file_rel, p) + or fnmatch(os.path.basename(file_rel), p) + for p in self.include_patterns + ): + continue + if self.exclude_patterns and any( + fnmatch(file_rel, p) + or fnmatch(os.path.basename(file_rel), p) + for p in self.exclude_patterns + ): + continue + # 文件大小过滤 + if self.max_size > 0 or self.min_size > 0: + try: + file_path = Path(dirpath) / filename + file_size = file_path.stat().st_size + if self.max_size > 0 and file_size > self.max_size: + continue + if self.min_size > 0 and file_size < self.min_size: + continue + except OSError: + pass + # 文件年龄过滤 + if cutoff_time > 0: + try: + file_path = Path(dirpath) / filename + if file_path.stat().st_mtime < cutoff_time: + continue + except OSError: + pass + # 增量备份:跳过元数据未变化的文件 + if self.file_metadata: + try: + file_path = Path(dirpath) / filename + stat = file_path.stat() + prev = self.file_metadata.get(file_rel) + if prev is not None: + if isinstance(prev, str) and len(prev) == 64: + # 校验和模式:比较 SHA256 + import hashlib + + sha = hashlib.sha256() + with open(file_path, "rb") as f: + while True: + chunk = f.read(65536) + if not chunk: + break + sha.update(chunk) + if prev == sha.hexdigest(): + continue + elif isinstance(prev, list) and len(prev) >= 2: + # 元数据模式:比较 mtime + size + if ( + prev[0] == stat.st_mtime + and prev[1] == stat.st_size + ): + continue + except OSError: + pass + files.append((dirpath, filename)) except PermissionError: print(t("err.permission", path=dirpath)) continue @@ -120,11 +436,12 @@ def _validate_compresslevel(level: int, algorithm: int) -> int | None: return level def _resolve_zipfile_path(self, folder_path: Path) -> Path: + base_name = _resolve_name(self.name_template, folder_path.name) if self.zipfile_path is None: - return folder_path.parent / f"{folder_path.name}.zip" + return folder_path.parent / f"{base_name}.zip" zipfile_path = self.zipfile_path.resolve() if zipfile_path.is_dir(): - return zipfile_path / f"{folder_path.name}.zip" + return zipfile_path / f"{base_name}.zip" if zipfile_path.suffix.lower() != ".zip": return zipfile_path.with_name(zipfile_path.name + ".zip") return zipfile_path @@ -142,40 +459,92 @@ def compress(self) -> dict: files_to_compress = self._collect_files(folder_path) total_files = len(files_to_compress) files_count = 0 + original_size = sum( + (Path(dp) / fn).stat().st_size + for dp, fn in files_to_compress + if (Path(dp) / fn).exists() + ) try: zip_kwargs = {"mode": "w", "compression": self.compression_algorithm} if self.compression_level is not None: zip_kwargs["compresslevel"] = self.compression_level - with zipfile.ZipFile(zipfile_path, **zip_kwargs) as zipf: - with tqdm( - total=total_files, - desc=t("compress.progress"), - unit=t("compress.unit"), - ) as pbar: - for dirpath, filename in files_to_compress: - file_path = Path(dirpath) / filename - arcname = str( - folder_path.name / file_path.relative_to(folder_path) - ).replace("\\", "/") - try: - zipf.write(file_path, arcname) + # 并行读取:用线程池预读文件到内存,再顺序写入归档 + if self.threads > 1 and files_to_compress: + file_data: list[tuple[str, bytes]] = [] + lock = threading.Lock() + + def _read_file(dirpath: str, filename: str) -> None: + fpath = Path(dirpath) / filename + arcname = str( + folder_path.name / fpath.relative_to(folder_path) + ).replace("\\", "/") + try: + data = fpath.read_bytes() + with lock: + file_data.append((arcname, data)) + except OSError: + pass + + with ThreadPoolExecutor(max_workers=self.threads) as ex: + list( + ex.map( + lambda args: _read_file(*args), + files_to_compress, + ) + ) + + with zipfile.ZipFile(zipfile_path, **zip_kwargs) as zipf: + with tqdm( + total=len(file_data), + unit="files", + desc="Compressing...", + ) as pbar: + for arcname, data in file_data: + zipf.writestr(arcname, data) pbar.update(1) files_count += 1 - except (FileNotFoundError, PermissionError): - continue + else: + with zipfile.ZipFile(zipfile_path, **zip_kwargs) as zipf: + with tqdm( + total=total_files, + unit="files", + desc="Compressing...", + ) as pbar: + for dirpath, filename in files_to_compress: + file_path = Path(dirpath) / filename + arcname = str( + folder_path.name / file_path.relative_to(folder_path) + ).replace("\\", "/") + pbar.set_description(filename) + try: + zipf.write(file_path, arcname) + pbar.update(1) + files_count += 1 + except (FileNotFoundError, PermissionError): + continue size_mb = zipfile_path.stat().st_size / (1024 * 1024) + original_mb = original_size / (1024 * 1024) + ratio = (1 - size_mb / original_mb) * 100 if original_mb > 0 else 0 print( t( "compress.success", path=zipfile_path, + original=original_mb, size=size_mb, + ratio=ratio, count=files_count, ) ) - return {"success": True, "files_count": files_count, "size_mb": size_mb} + return { + "success": True, + "files_count": files_count, + "size_mb": size_mb, + "original_size_mb": original_mb, + "path": str(zipfile_path), + } except KeyboardInterrupt: raise except PermissionError: @@ -194,7 +563,7 @@ class TarfileCompression(BaseCompressor): def __init__(self, config: Config) -> None: super().__init__(config) - fmt = config.compression_format.upper() + fmt = config.compression_format.upper().replace(".", "_") if fmt not in _TAR_FORMATS: fmt = "TAR_GZ" self._extension, self._mode = _TAR_FORMATS[fmt] @@ -208,11 +577,12 @@ def _validate_compresslevel(level: int) -> int: return level def _resolve_tarfile_path(self, folder_path: Path) -> Path: + base_name = _resolve_name(self.name_template, folder_path.name) if self.zipfile_path is None: - return folder_path.parent / f"{folder_path.name}{self._extension}" + return folder_path.parent / f"{base_name}{self._extension}" tarfile_path = self.zipfile_path.resolve() if tarfile_path.is_dir(): - return tarfile_path / f"{folder_path.name}{self._extension}" + return tarfile_path / f"{base_name}{self._extension}" # 如果已有后缀,直接使用;否则追加 name = tarfile_path.name if not any( @@ -234,6 +604,11 @@ def compress(self) -> dict: files_to_compress = self._collect_files(folder_path) total_files = len(files_to_compress) files_count = 0 + original_size = sum( + (Path(dp) / fn).stat().st_size + for dp, fn in files_to_compress + if (Path(dp) / fn).exists() + ) try: # compresslevel 仅对 gz 和 bz2 模式有效 @@ -244,14 +619,15 @@ def compress(self) -> dict: with tarfile.open(**tar_kwargs) as tarf: with tqdm( total=total_files, - desc=t("compress.progress"), - unit=t("compress.unit"), + unit="files", + desc="Compressing...", ) as pbar: for dirpath, filename in files_to_compress: file_path = Path(dirpath) / filename arcname = str( folder_path.name / file_path.relative_to(folder_path) ).replace("\\", "/") + pbar.set_description(filename) try: tarf.add(file_path, arcname=arcname, recursive=False) pbar.update(1) @@ -260,15 +636,25 @@ def compress(self) -> dict: continue size_mb = tarfile_path.stat().st_size / (1024 * 1024) + original_mb = original_size / (1024 * 1024) + ratio = (1 - size_mb / original_mb) * 100 if original_mb > 0 else 0 print( t( "compress.success", path=tarfile_path, + original=original_mb, size=size_mb, + ratio=ratio, count=files_count, ) ) - return {"success": True, "files_count": files_count, "size_mb": size_mb} + return { + "success": True, + "files_count": files_count, + "size_mb": size_mb, + "original_size_mb": original_mb, + "path": str(tarfile_path), + } except KeyboardInterrupt: raise except PermissionError: @@ -297,11 +683,12 @@ def _validate_compresslevel(level: int) -> int: return level def _resolve_path(self, folder_path: Path) -> Path: + base_name = _resolve_name(self.name_template, folder_path.name) if self.zipfile_path is None: - return folder_path.parent / f"{folder_path.name}.tar.zst" + return folder_path.parent / f"{base_name}.tar.zst" path = self.zipfile_path.resolve() if path.is_dir(): - return path / f"{folder_path.name}.tar.zst" + return path / f"{base_name}.tar.zst" if not path.name.endswith(".tar.zst"): return path.with_name(path.name + ".tar.zst") return path @@ -321,37 +708,56 @@ def compress(self) -> dict: files_to_compress = self._collect_files(folder_path) total_files = len(files_to_compress) files_count = 0 + original_size = sum( + (Path(dp) / fn).stat().st_size + for dp, fn in files_to_compress + if (Path(dp) / fn).exists() + ) try: cctx = zstd.ZstdCompressor(level=self.compression_level) - # 先创建 tar,再用 zstd 压缩 - tar_buffer = io.BytesIO() - with tarfile.open(fileobj=tar_buffer, mode="w") as tarf: - with tqdm( - total=total_files, - desc=t("compress.progress"), - unit=t("compress.unit"), - ) as pbar: - for dirpath, filename in files_to_compress: - file_path = Path(dirpath) / filename - arcname = str( - folder_path.name / file_path.relative_to(folder_path) - ).replace("\\", "/") - try: - tarf.add(file_path, arcname=arcname, recursive=False) - pbar.update(1) - files_count += 1 - except (FileNotFoundError, PermissionError): - continue - - compressed = cctx.compress(tar_buffer.getvalue()) - output_path.write_bytes(compressed) + with open(output_path, "wb") as f_out: + compressor = cctx.stream_writer(f_out) + with tarfile.open(fileobj=compressor, mode="w") as tarf: + with tqdm( + total=total_files, + unit="files", + desc="Compressing...", + ) as pbar: + for dirpath, filename in files_to_compress: + file_path = Path(dirpath) / filename + arcname = str( + folder_path.name / file_path.relative_to(folder_path) + ).replace("\\", "/") + pbar.set_description(filename) + try: + tarf.add(file_path, arcname=arcname, recursive=False) + pbar.update(1) + files_count += 1 + except (FileNotFoundError, PermissionError): + continue + compressor.close() size_mb = output_path.stat().st_size / (1024 * 1024) + original_mb = original_size / (1024 * 1024) + ratio = (1 - size_mb / original_mb) * 100 if original_mb > 0 else 0 print( - t("compress.success", path=output_path, size=size_mb, count=files_count) + t( + "compress.success", + path=output_path, + original=original_mb, + size=size_mb, + ratio=ratio, + count=files_count, + ) ) - return {"success": True, "files_count": files_count, "size_mb": size_mb} + return { + "success": True, + "files_count": files_count, + "size_mb": size_mb, + "original_size_mb": original_mb, + "path": str(output_path), + } except KeyboardInterrupt: raise except PermissionError: @@ -381,11 +787,12 @@ def _validate_compresslevel(level: int) -> int: return level def _resolve_path(self, folder_path: Path) -> Path: + base_name = _resolve_name(self.name_template, folder_path.name) if self.zipfile_path is None: - return folder_path.parent / f"{folder_path.name}.7z" + return folder_path.parent / f"{base_name}.7z" path = self.zipfile_path.resolve() if path.is_dir(): - return path / f"{folder_path.name}.7z" + return path / f"{base_name}.7z" if path.suffix.lower() != ".7z": return path.with_name(path.name + ".7z") return path @@ -405,6 +812,11 @@ def compress(self) -> dict: files_to_compress = self._collect_files(folder_path) total_files = len(files_to_compress) files_count = 0 + original_size = sum( + (Path(dp) / fn).stat().st_size + for dp, fn in files_to_compress + if (Path(dp) / fn).exists() + ) try: filters = [{"id": py7zr.FILTER_LZMA2, "preset": self.compression_level}] @@ -414,14 +826,15 @@ def compress(self) -> dict: with py7zr.SevenZipFile(output_path, **szf_kwargs) as szf: with tqdm( total=total_files, - desc=t("compress.progress"), - unit=t("compress.unit"), + unit="files", + desc="Compressing...", ) as pbar: for dirpath, filename in files_to_compress: file_path = Path(dirpath) / filename arcname = str( folder_path.name / file_path.relative_to(folder_path) ).replace("\\", "/") + pbar.set_description(filename) try: szf.write(file_path, arcname) pbar.update(1) @@ -430,10 +843,25 @@ def compress(self) -> dict: continue size_mb = output_path.stat().st_size / (1024 * 1024) + original_mb = original_size / (1024 * 1024) + ratio = (1 - size_mb / original_mb) * 100 if original_mb > 0 else 0 print( - t("compress.success", path=output_path, size=size_mb, count=files_count) + t( + "compress.success", + path=output_path, + original=original_mb, + size=size_mb, + ratio=ratio, + count=files_count, + ) ) - return {"success": True, "files_count": files_count, "size_mb": size_mb} + return { + "success": True, + "files_count": files_count, + "size_mb": size_mb, + "original_size_mb": original_mb, + "path": str(output_path), + } except KeyboardInterrupt: raise except PermissionError: @@ -449,7 +877,7 @@ def compress(self) -> dict: def create_compressor(config: Config) -> BaseCompressor: """工厂函数:根据配置创建对应的压缩器""" - fmt = config.compression_format.upper() + fmt = config.compression_format.upper().replace(".", "_") if fmt in _TAR_FORMATS: return TarfileCompression(config) if fmt == "TAR_ZST": @@ -459,17 +887,157 @@ def create_compressor(config: Config) -> BaseCompressor: return ZipfileCompression(config) -def restore_backup(backup_path: str, target_dir: str) -> dict: +def _match_select_pattern(name: str, pattern: str) -> bool: + """检查归档成员名是否匹配选择性还原的模式(支持 glob 通配符和精确路径)""" + if not pattern: + return True + basename = os.path.basename(name) + return fnmatch(name, pattern) or fnmatch(basename, pattern) + + +def _is_safe_member_name(member_name: str) -> bool: + """检查归档成员名是否安全(无路径遍历、无绝对路径、无空字节)""" + # 拒绝含空字节的路径(可能绕过之后的检查) + if "\x00" in member_name: + return False + # 拒绝绝对路径 + if os.path.isabs(member_name): + return False + # Windows: 拒绝驱动器相对路径(如 C:file.txt) + if os.name == "nt" and len(member_name) >= 2: + if member_name[1] == ":" and member_name[0].isalpha(): + return False + # 拒绝包含 .. 的路径组件 + parts = Path(member_name).parts + if ".." in parts: + return False + # 拒绝空路径 + if not member_name or member_name.strip() in ("", ".", "/"): + return False + return True + + +def _safe_extract_zip( + zf: zipfile.ZipFile, target: Path, members: list[str] | None = None +) -> list[str]: + """安全提取 ZIP 文件,过滤路径遍历成员,返回被跳过的成员列表""" + target_resolved = target.resolve() + skipped = [] + names = members if members is not None else zf.namelist() + for member in names: + if not _is_safe_member_name(member): + skipped.append(member) + continue + # 双重检查:解析后路径必须在目标目录内 + dest = (target / member).resolve() + try: + dest.relative_to(target_resolved) + except ValueError: + skipped.append(member) + continue + zf.extract(member, target) + return skipped + + +def _safe_extract_tar( + tarf: tarfile.TarFile, + target: Path, + members: list[tarfile.TarInfo], + quiet: bool = False, +) -> int: + """安全提取 tar 成员:过滤路径遍历和符号链接,返回跳过的数量""" + target_resolved = target.resolve() + skipped_count = 0 + import sys as _sys + + for member in members: + if not _is_safe_member_name(member.name): + skipped_count += 1 + continue + # 跳过符号链接和硬链接(防止链接攻击) + if member.issym() or member.islnk(): + skipped_count += 1 + continue + # Python < 3.12: 手动验证提取路径在目标目录内 + if _sys.version_info < (3, 12): + dest = (target / member.name).resolve() + try: + dest.relative_to(target_resolved) + except ValueError: + skipped_count += 1 + continue + # Python 3.12+: filter="data" 会处理路径清理和链接目标 + extra_args = {} + if _sys.version_info >= (3, 12): + extra_args["filter"] = "data" + tarf.extract(member, path=str(target), **extra_args) + return skipped_count + + +def restore_backup( + backup_path: str, + target_dir: str, + password: str = "", + *, + quiet: bool = False, + select_pattern: str = "", +) -> dict: """ 从备份文件还原到目标目录 - 自动检测格式(ZIP / tar.gz / tar.bz2 / tar.xz) + 自动检测格式(ZIP / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z) + :param password: 解密密码(仅 7z 加密备份需要) + :param quiet: 静默模式,不打印消息和进度条(供 verify_backup 内部调用) + :param select_pattern: 选择性还原模式(glob 通配符),空字符串还原全部 :return: 包含统计信息的字典 """ backup = Path(backup_path) if not backup.exists(): - print(t("err.folder.invalid", path=backup_path)) + if not quiet: + print(t("err.file.not_found", path=backup_path)) return {"success": False, "files_count": 0} + # 检测分卷文件(.001 后缀),自动合并 + tmp_merged = None + if backup.name.lower().endswith(".001"): + merged_path = str(backup)[:-4] # 移除 .001 后缀 + parts = [] + part_index = 1 + while True: + part_path = f"{merged_path}.{part_index:03d}" + if not os.path.exists(part_path): + break + parts.append(part_path) + part_index += 1 + if not quiet: + print(t("restore.split.detected", count=len(parts))) + # 检查分卷是否完整(从 .001 开始连续) + for i, p in enumerate(parts, 1): + expected = f"{merged_path}.{i:03d}" + if p != expected: + if not quiet: + print(t("restore.split.missing", path=expected)) + return {"success": False, "files_count": 0} + # 合并到临时文件 + import tempfile as _tmp + + fd, tmp_merged = _tmp.mkstemp(suffix=os.path.splitext(merged_path)[1]) + os.close(fd) + if merge_files(parts, tmp_merged): + if not quiet: + print(t("restore.split.merged", path=tmp_merged)) + backup = Path(tmp_merged) + else: + if os.path.exists(tmp_merged): + os.remove(tmp_merged) + tmp_merged = None + + # 处理 .enc 加密文件 + tmp_decrypted = None + if backup.name.lower().endswith(".enc") and password: + decrypted_path = _decrypt_file(str(backup), password) + backup = Path(decrypted_path) + tmp_decrypted = decrypted_path + target = Path(target_dir) target.mkdir(parents=True, exist_ok=True) @@ -477,49 +1045,122 @@ def restore_backup(backup_path: str, target_dir: str) -> dict: try: if name_lower.endswith(".zip"): with zipfile.ZipFile(backup, "r") as zf: - members = zf.namelist() - with tqdm( - total=len(members), - desc=t("restore.progress"), - unit=t("compress.unit"), - ) as pbar: - for member in members: - zf.extract(member, target) - pbar.update(1) - print(t("restore.success", path=target, count=len(members))) + all_members = zf.namelist() + members = ( + [ + m + for m in all_members + if _is_safe_member_name(m) + and _match_select_pattern(m, select_pattern) + ] + if select_pattern + else [m for m in all_members if _is_safe_member_name(m)] + ) + if not members: + if not quiet: + print(t("restore.no_match", pattern=select_pattern)) + return {"success": True, "files_count": 0} + skipped = _safe_extract_zip(zf, target, members) + if skipped and not quiet: + print(t("restore.skipped_unsafe", count=len(skipped))) + if not quiet: + print(t("restore.success", path=target, count=len(members))) return {"success": True, "files_count": len(members)} elif name_lower.endswith(".7z"): import py7zr - with py7zr.SevenZipFile(backup, "r") as szf: - members = szf.getnames() - with tqdm( - total=len(members), - desc=t("restore.progress"), - unit=t("compress.unit"), - ) as pbar: - szf.extractall(target) - pbar.update(len(members)) - print(t("restore.success", path=target, count=len(members))) + szf_kwargs = {"file": backup, "mode": "r"} + if password: + szf_kwargs["password"] = password + with py7zr.SevenZipFile(**szf_kwargs) as szf: + all_members = szf.getnames() + # 过滤路径遍历成员(Zip Slip 防护) + safe_members = [m for m in all_members if _is_safe_member_name(m)] + skipped_count = len(all_members) - len(safe_members) + if skipped_count > 0 and not quiet: + print( + t( + "restore.skipped_unsafe", + count=skipped_count, + ) + ) + members = ( + [ + m + for m in safe_members + if _match_select_pattern(m, select_pattern) + ] + if select_pattern + else safe_members + ) + if not members: + if not quiet: + print(t("restore.no_match", pattern=select_pattern)) + return {"success": True, "files_count": 0} + if select_pattern: + # 选择性还原:用 extract(targets=...) 提取匹配的文件 + with tqdm( + total=len(members), + desc=t("restore.progress"), + unit=t("compress.unit"), + disable=quiet, + ) as pbar: + szf.extract(path=target, targets=members) + pbar.update(len(members)) + else: + with tqdm( + total=len(members), + desc=t("restore.progress"), + unit=t("compress.unit"), + disable=quiet, + ) as pbar: + szf.extractall(target) + pbar.update(len(members)) + if not quiet: + print(t("restore.success", path=target, count=len(members))) return {"success": True, "files_count": len(members)} elif name_lower.endswith(".tar.zst"): import zstandard as zstd dctx = zstd.ZstdDecompressor() - compressed = backup.read_bytes() - tar_data = dctx.decompress(compressed) - with tarfile.open(fileobj=io.BytesIO(tar_data), mode="r") as tarf: - members = tarf.getmembers() - with tqdm( - total=len(members), - desc=t("restore.progress"), - unit=t("compress.unit"), - ) as pbar: - for member in members: - tarf.extract(member, target, filter="data") - pbar.update(1) - print(t("restore.success", path=target, count=len(members))) - return {"success": True, "files_count": len(members)} + # tarfile 需要可 seek 的 fileobj,先流式解压到临时文件 + with tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024) as tmp: + with open(backup, "rb") as f_in: + reader = dctx.stream_reader(f_in) + while True: + chunk = reader.read(65536) + if not chunk: + break + tmp.write(chunk) + tmp.seek(0) + with tarfile.open(fileobj=tmp, mode="r") as tarf: + all_members = tarf.getmembers() + if select_pattern: + members = [ + m + for m in all_members + if _match_select_pattern(m.name, select_pattern) + ] + else: + members = list(all_members) + if not members: + if not quiet: + print(t("restore.no_match", pattern=select_pattern)) + return {"success": True, "files_count": 0} + with tqdm( + total=len(members), + desc=t("restore.progress"), + unit=t("compress.unit"), + disable=quiet, + ) as pbar: + skipped = _safe_extract_tar(tarf, target, members) + pbar.update(len(members)) + if skipped and not quiet: + print(t("restore.skipped_unsafe", count=skipped)) + actual = len(members) - skipped + if not quiet: + print(t("restore.success", path=target, count=actual)) + return {"success": True, "files_count": actual} elif name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"): mode = "r:gz" elif name_lower.endswith(".tar.bz2") or name_lower.endswith(".tbz2"): @@ -529,27 +1170,545 @@ def restore_backup(backup_path: str, target_dir: str) -> dict: elif name_lower.endswith(".tar"): mode = "r" else: - print(t("err.unknown.format", path=backup_path)) + if not quiet: + print(t("err.unknown.format", path=backup_path)) return {"success": False, "files_count": 0} with tarfile.open(backup, mode) as tarf: - members = tarf.getmembers() + all_members = tarf.getmembers() + if select_pattern: + members = [ + m + for m in all_members + if _match_select_pattern(m.name, select_pattern) + ] + else: + members = list(all_members) + if not members: + if not quiet: + print(t("restore.no_match", pattern=select_pattern)) + return {"success": True, "files_count": 0} with tqdm( - total=len(members), desc=t("restore.progress"), unit=t("compress.unit") + total=len(members), + desc=t("restore.progress"), + unit=t("compress.unit"), + disable=quiet, ) as pbar: - for member in members: - tarf.extract(member, target, filter="data") - pbar.update(1) - print(t("restore.success", path=target, count=len(members))) - return {"success": True, "files_count": len(members)} + skipped = _safe_extract_tar(tarf, target, members) + pbar.update(len(members)) + if skipped and not quiet: + print(t("restore.skipped_unsafe", count=skipped)) + actual = len(members) - skipped + if not quiet: + print(t("restore.success", path=target, count=actual)) + return {"success": True, "files_count": actual} except KeyboardInterrupt: raise except PermissionError: - print(t("err.permission", path=target_dir)) + if not quiet: + print(t("err.permission", path=target_dir)) return {"success": False, "files_count": 0} except OSError as e: - print(t("err.os", error=e)) + if not quiet: + print(t("err.os", error=e)) + return {"success": False, "files_count": 0} + except Exception as e: + if not quiet: + print(t("err.unknown", error=e)) + return {"success": False, "files_count": 0} + finally: + if tmp_decrypted and os.path.exists(tmp_decrypted): + os.remove(tmp_decrypted) + if tmp_merged and os.path.exists(tmp_merged): + os.remove(tmp_merged) + + +def _get_archive_member_names(backup: Path, password: str = "") -> list[str]: + """获取压缩包内所有成员名称,自动检测格式""" + name_lower = backup.name.lower() + if name_lower.endswith(".zip"): + with zipfile.ZipFile(backup, "r") as zf: + return zf.namelist() + elif name_lower.endswith(".7z"): + import py7zr + + szf_kwargs = {"file": backup, "mode": "r"} + if password: + szf_kwargs["password"] = password + with py7zr.SevenZipFile(**szf_kwargs) as szf: + return szf.getnames() + elif name_lower.endswith(".tar.zst"): + import zstandard as zstd + + dctx = zstd.ZstdDecompressor() + with tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024) as tmp: + with open(backup, "rb") as f_in: + reader = dctx.stream_reader(f_in) + while True: + chunk = reader.read(65536) + if not chunk: + break + tmp.write(chunk) + tmp.seek(0) + with tarfile.open(fileobj=tmp, mode="r") as tarf: + return [m.name for m in tarf.getmembers()] + elif name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"): + mode = "r:gz" + elif name_lower.endswith(".tar.bz2") or name_lower.endswith(".tbz2"): + mode = "r:bz2" + elif name_lower.endswith(".tar.xz") or name_lower.endswith(".txz"): + mode = "r:xz" + elif name_lower.endswith(".tar"): + mode = "r" + else: + return [] + with tarfile.open(backup, mode) as tarf: + return tarf.getnames() + + +def list_backup_contents(backup_path: str, password: str = "") -> str: + """ + 列出备份文件内的所有文件,不解压 + :return: 格式化的文件列表字符串 + """ + backup = Path(backup_path) + if not backup.exists(): + return t("err.file.not_found", path=backup_path) + + try: + members = _get_archive_member_names(backup, password) + except Exception as e: + return t("err.unknown", error=e) + + if not members: + return t("restore.list.empty", path=backup_path) + + lines = [t("restore.list.title", path=backup_path)] + for name in members: + lines.append(f" {name}") + lines.append(f"\n({len(members)} files)") + return "\n".join(lines) + + +def verify_backup(backup_path: str, password: str = "") -> dict: + """ + 校验备份文件完整性:解压到临时目录后比对文件数 + :return: 包含校验结果的字典 + """ + backup = Path(backup_path) + if not backup.exists(): + print(t("err.file.not_found", path=backup_path)) return {"success": False, "files_count": 0} + + print(t("cmd.verify.checking", path=backup_path)) + + try: + expected_names = _get_archive_member_names(backup, password) except Exception as e: print(t("err.unknown", error=e)) return {"success": False, "files_count": 0} + + # 解压到临时目录 + import tempfile as _tempfile + + with _tempfile.TemporaryDirectory() as tmp_dir: + result = restore_backup(backup_path, tmp_dir, password, quiet=True) + if not result["success"]: + print( + t( + "cmd.verify.failed", + path=backup_path, + expected=len(expected_names), + actual=0, + ) + ) + return {"success": False, "files_count": 0} + + actual_count = result["files_count"] + if actual_count == len(expected_names): + print( + t( + "cmd.verify.success", + path=backup_path, + count=actual_count, + ) + ) + return {"success": True, "files_count": actual_count} + else: + print( + t( + "cmd.verify.failed", + path=backup_path, + expected=len(expected_names), + actual=actual_count, + ) + ) + return {"success": False, "files_count": actual_count} + + +def get_backup_info(backup_path: str, password: str = "") -> dict: + """ + 获取备份文件的详细信息:格式、文件数、大小、创建时间、SHA256 校验和 + :return: 包含备份信息的字典,失败时 success=False + """ + import hashlib + import time as _time + + backup = Path(backup_path) + if not backup.exists(): + return {"success": False, "error": t("err.file.not_found", path=backup_path)} + + try: + stat = backup.stat() + size_bytes = stat.st_size + size_mb = size_bytes / (1024 * 1024) + mtime = stat.st_mtime + mtime_str = _time.strftime("%Y-%m-%d %H:%M:%S", _time.localtime(mtime)) + + # SHA256 校验和 + sha256 = hashlib.sha256() + with open(backup, "rb") as f: + while True: + chunk = f.read(65536) + if not chunk: + break + sha256.update(chunk) + checksum = sha256.hexdigest() + + # 检测格式 + name_lower = backup.name.lower() + if name_lower.endswith(".zip"): + fmt = "ZIP" + elif name_lower.endswith(".7z"): + fmt = "7Z" + elif name_lower.endswith(".tar.zst"): + fmt = "TAR_ZST" + elif name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"): + fmt = "TAR_GZ" + elif name_lower.endswith(".tar.bz2") or name_lower.endswith(".tbz2"): + fmt = "TAR_BZ2" + elif name_lower.endswith(".tar.xz") or name_lower.endswith(".txz"): + fmt = "TAR_XZ" + elif name_lower.endswith(".tar"): + fmt = "TAR" + elif name_lower.endswith(".enc"): + fmt = "ENCRYPTED" + else: + fmt = "UNKNOWN" + + # 获取成员列表 + try: + members = _get_archive_member_names(backup, password) + files_count = len(members) + except Exception: + files_count = -1 + + return { + "success": True, + "path": str(backup.resolve()), + "format": fmt, + "size_bytes": size_bytes, + "size_mb": round(size_mb, 2), + "files_count": files_count, + "mtime": mtime_str, + "sha256": checksum, + } + except Exception as e: + return {"success": False, "error": str(e)} + + +def format_backup_info(info: dict) -> str: + """将 get_backup_info 返回的字典格式化为可读文本""" + if not info.get("success"): + return info.get("error", t("err.unknown", error="")) + + lines = [t("cmd.info.header")] + lines.append(t("cmd.info.path", path=info["path"])) + lines.append(t("cmd.info.format", format=info["format"])) + lines.append(t("cmd.info.size", size=info["size_mb"])) + lines.append(t("cmd.info.files", count=info["files_count"])) + lines.append(t("cmd.info.time", time=info["mtime"])) + lines.append(t("cmd.info.sha256", checksum=info["sha256"])) + return "\n".join(lines) + + +def get_archive_member_set(backup_path: str, password: str = "") -> set[str]: + """ + 获取压缩包内所有文件的相对路径集合(用于 diff 对比) + 过滤掉目录条目,只保留文件 + """ + backup = Path(backup_path) + if not backup.exists(): + return set() + try: + members = _get_archive_member_names(backup, password) + # 过滤目录(以 / 结尾的条目),规范化路径分隔符 + return { + m.rstrip("/").replace("\\", "/") for m in members if not m.endswith("/") + } + except Exception: + return set() + + +def search_in_backup(backup_path: str, pattern: str, password: str = "") -> list[str]: + """在备份文件中搜索匹配的文件名(支持 glob 通配符) + :return: 匹配的文件名列表 + """ + from fnmatch import fnmatch + + members = get_archive_member_set(backup_path, password) + if not members: + return [] + return sorted( + m + for m in members + if fnmatch(m, pattern) or fnmatch(os.path.basename(m), pattern) + ) + + +def verify_backup_fast(backup_path: str, expected_sha256: str) -> dict: + """ + 快速校验备份文件完整性:计算文件 SHA256 并与预期值比对,无需解压 + :param backup_path: 备份文件路径 + :param expected_sha256: 预期的 SHA256 校验和 + :return: 包含校验结果的字典 + """ + import hashlib + + backup = Path(backup_path) + if not backup.exists(): + return {"success": False, "error": t("err.file.not_found", path=backup_path)} + + sha256 = hashlib.sha256() + try: + with open(backup, "rb") as f: + while True: + chunk = f.read(65536) + if not chunk: + break + sha256.update(chunk) + except OSError as e: + return {"success": False, "error": str(e)} + + actual = sha256.hexdigest() + if actual == expected_sha256: + return {"success": True, "sha256": actual} + return { + "success": False, + "error": t("cmd.verify.checksum_mismatch"), + "expected": expected_sha256, + "actual": actual, + } + + +def split_file( + file_path: str, chunk_size: int, *, create_manifest: bool = True +) -> list[str]: + """将大文件分割为多个分卷文件 + :param file_path: 要分割的文件路径 + :param chunk_size: 每个分卷的大小(字节) + :param create_manifest: 是否创建清单文件 + :return: 分卷文件路径列表 + """ + import hashlib + import json + from datetime import datetime + + if chunk_size <= 0: + return [file_path] + + file_size = os.path.getsize(file_path) + if file_size <= chunk_size: + return [file_path] + + # 计算原文件 SHA256 + original_sha256 = hashlib.sha256() + with open(file_path, "rb") as f: + while True: + chunk = f.read(65536) + if not chunk: + break + original_sha256.update(chunk) + original_hash = original_sha256.hexdigest() + + parts = [] + part_checksums = [] + part_index = 1 + with open(file_path, "rb") as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + part_path = f"{file_path}.{part_index:03d}" + # 计算分卷校验和 + part_hash = hashlib.sha256(chunk).hexdigest() + part_checksums.append( + {"path": os.path.basename(part_path), "sha256": part_hash} + ) + with open(part_path, "wb") as pf: + pf.write(chunk) + parts.append(part_path) + part_index += 1 + + # 创建清单文件 + if create_manifest: + manifest_path = f"{file_path}.manifest.json" + manifest = { + "original_file": os.path.basename(file_path), + "original_size": file_size, + "original_sha256": original_hash, + "chunk_size": chunk_size, + "parts_count": len(parts), + "parts": part_checksums, + "created_at": datetime.now().isoformat(timespec="seconds"), + } + with open(manifest_path, "w", encoding="utf-8") as mf: + json.dump(manifest, mf, indent=2, ensure_ascii=False) + + # 删除原文件 + os.remove(file_path) + return parts + + +def merge_files( + part_paths: list[str], + output_path: str, + *, + resume: bool = False, + progress_callback=None, +) -> bool: + """合并分卷文件为单个文件 + :param part_paths: 分卷文件路径列表(按顺序) + :param output_path: 输出文件路径 + :param resume: 是否支持断点续传(从上次中断处继续) + :param progress_callback: 进度回调函数 callback(current_bytes, total_bytes) + :return: 是否成功 + """ + if not part_paths: + return False + + # 计算总大小 + total_size = 0 + for p in part_paths: + try: + total_size += os.path.getsize(p) + except OSError: + return False + + # 断点续传:检查已写入的字节数 + written_bytes = 0 + start_part_idx = 0 + if resume and os.path.exists(output_path): + written_bytes = os.path.getsize(output_path) + # 计算从哪个分卷开始 + accumulated = 0 + for i, p in enumerate(part_paths): + accumulated += os.path.getsize(p) + if accumulated > written_bytes: + start_part_idx = i + # 计算该分卷内已写入的偏移 + prev_accumulated = accumulated - os.path.getsize(p) + part_offset = written_bytes - prev_accumulated + break + else: + # 所有分卷都已写入 + return True + else: + part_offset = 0 + + try: + mode = "ab" if resume and os.path.exists(output_path) else "wb" + with open(output_path, mode) as out_f: + for i in range(start_part_idx, len(part_paths)): + part_path = part_paths[i] + with open(part_path, "rb") as in_f: + if i == start_part_idx and part_offset > 0: + in_f.seek(part_offset) + while True: + chunk = in_f.read(65536) + if not chunk: + break + out_f.write(chunk) + written_bytes += len(chunk) + if progress_callback: + progress_callback(written_bytes, total_size) + return True + except OSError: + return False + + +def verify_split_integrity(file_path: str) -> dict: + """验证分卷文件完整性 + :param file_path: 任意分卷文件路径或清单文件路径 + :return: 验证结果字典 + """ + import hashlib + import json + + # 查找清单文件 + if file_path.endswith(".manifest.json"): + manifest_path = file_path + else: + # 从分卷文件推断清单路径 + base = file_path.rsplit(".", 1)[0] if ".0" in file_path else file_path + manifest_path = f"{base}.manifest.json" + if not os.path.exists(manifest_path): + # 尝试去掉 .001 后缀 + if file_path.endswith(".001"): + manifest_path = f"{file_path[:-4]}.manifest.json" + + if not os.path.exists(manifest_path): + return {"success": False, "error": "Manifest file not found"} + + try: + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + except (json.JSONDecodeError, OSError) as e: + return {"success": False, "error": f"Failed to read manifest: {e}"} + + manifest_dir = os.path.dirname(manifest_path) + results = [] + all_ok = True + + for part_info in manifest.get("parts", []): + part_name = part_info["path"] + expected_hash = part_info["sha256"] + part_path = os.path.join(manifest_dir, part_name) + + if not os.path.exists(part_path): + results.append( + {"path": part_name, "status": "missing", "expected": expected_hash} + ) + all_ok = False + continue + + # 计算实际校验和 + sha256 = hashlib.sha256() + with open(part_path, "rb") as f: + while True: + chunk = f.read(65536) + if not chunk: + break + sha256.update(chunk) + actual_hash = sha256.hexdigest() + + if actual_hash == expected_hash: + results.append({"path": part_name, "status": "ok"}) + else: + results.append( + { + "path": part_name, + "status": "corrupted", + "expected": expected_hash, + "actual": actual_hash, + } + ) + all_ok = False + + return { + "success": all_ok, + "original_file": manifest.get("original_file", ""), + "original_size": manifest.get("original_size", 0), + "parts_count": manifest.get("parts_count", 0), + "results": results, + } diff --git a/sbackup/config.py b/sbackup/config.py index 6a0e22e..d566a73 100644 --- a/sbackup/config.py +++ b/sbackup/config.py @@ -14,6 +14,59 @@ DEFAULT_SKIP_PATTERNS = [".git", "__pycache__"] +def _load_json_file(config_file: str) -> dict: + """读取 JSON 配置文件,损坏时返回空字典""" + if not os.path.exists(config_file): + return {} + try: + with open(config_file, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError: + logger.warning(t("log.config.reset"), config_file) + return {} + + +def _save_json_file(data: dict, config_file: str) -> None: + """将字典写入 JSON 配置文件,自动创建目录,设置限制性权限""" + data_dir = os.path.dirname(config_file) + if data_dir: + try: + os.makedirs(data_dir, exist_ok=True) + except OSError as e: + logger.error(t("log.config.mkdir.error"), data_dir, e) + return + + # 原子写入:先写临时文件再替换 + tmp_path = config_file + ".tmp" + try: + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=4) + f.flush() + os.fsync(f.fileno()) + # 设置临时文件限制性权限(仅所有者可读写) + if sys.platform != "win32": + try: + os.chmod(tmp_path, 0o600) + except OSError: + pass + os.replace(tmp_path, config_file) + except OSError as e: + logger.error(t("log.config.write.error"), config_file, e) + if os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except OSError: + pass + return + + # 设置限制性文件权限(仅所有者可读写) + if sys.platform != "win32": + try: + os.chmod(config_file, 0o600) + except OSError: + pass # 权限设置失败不影响主流程 + + def get_default_data_file() -> str: """返回跨平台的默认数据文件路径""" if sys.platform == "win32": @@ -38,6 +91,73 @@ class Config: lang: str = "zh_CN" data_file: str = field(default_factory=get_default_data_file) password: str = "" + # 备份文件名模板 + name_template: str = "" + # Webhook 通知 URL(单个,向后兼容) + webhook_url: str = "" + # Webhook 通知 URL 列表(支持多个) + webhook_urls: list[str] = field(default_factory=list) + # Webhook 自定义 payload 模板(空字符串使用默认 JSON) + webhook_template: str = "" + # Webhook 失败重试次数 + webhook_retries: int = 2 + # 符号链接处理 + follow_symlinks: bool = False + # 文件过滤(字节数,0 = 不限制) + max_size: int = 0 + min_size: int = 0 + # 文件类型包含/排除(glob 模式列表,fnmatch 匹配) + include_patterns: list[str] = field(default_factory=list) + exclude_patterns: list[str] = field(default_factory=list) + # 并行压缩线程数(仅 ZIP 格式支持并行写入,默认为 1=串行) + threads: int = 1 + # 文件年龄过滤(秒,0 = 不限制) + max_age_seconds: float = 0 + # 增量备份:文件级元数据 {rel_path: [mtime, size]} + file_metadata: dict = field(default_factory=dict) + # SFTP 配置 + sftp_host: str = "" + sftp_port: int = 22 + sftp_user: str = "" + sftp_password: str = "" + sftp_key_file: str = "" + sftp_key_passphrase: str = "" + sftp_remote_path: str = "/" + sftp_enabled: bool = False + # WebDAV 配置 + webdav_url: str = "" + webdav_user: str = "" + webdav_password: str = "" + webdav_remote_path: str = "/" + webdav_enabled: bool = False + # S3 云存储配置 + cloud_endpoint: str = "" + cloud_access_key: str = "" + cloud_secret_key: str = "" + cloud_bucket: str = "" + cloud_region: str = "" + cloud_secure: bool = True + cloud_remote_path: str = "/" + cloud_enabled: bool = False + # SMTP 邮件通知配置 + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "" + smtp_to: str = "" + smtp_tls: bool = True + smtp_enabled: bool = False + # 备份轮转策略 + rotation_keep_count: int = 0 + rotation_keep_days: int = 0 + rotation_keep_daily: int = 0 + # Pre/Post Backup Hooks + pre_hooks: list[str] = field(default_factory=list) + post_hooks: list[str] = field(default_factory=list) + hook_timeout: int = 300 + # 内部字段:配置校验和(用于检测意外修改) + _config_checksum: str = "" def load_config(config_file: str = "config.json") -> Config: @@ -57,8 +177,54 @@ def load_config(config_file: str = "config.json") -> Config: compression_config = config_data.get("compression", {}) skip_patterns = config_data.get("skip_patterns", DEFAULT_SKIP_PATTERNS) data_file = config_data.get("data_file", get_default_data_file()) - lang = config_data.get("lang", "en_US") + lang = config_data.get("lang", "zh_CN") compression_format = config_data.get("compression_format", "ZIP") + password = config_data.get("password", "") + name_template = config_data.get("name_template", "") + webhook_config = config_data.get("webhook", {}) + sftp_config = config_data.get("sftp", {}) + + # 向后兼容:单个 url 字段自动迁移到 urls 列表 + webhook_urls: list[str] = [] + webhook_template = "" + webhook_retries = 2 + webhook_url_legacy = "" + if isinstance(webhook_config, dict): + webhook_url_legacy = webhook_config.get("url", "") + webhook_urls = webhook_config.get("urls", []) + webhook_template = webhook_config.get("template", "") + webhook_retries = webhook_config.get("retries", 2) + # 向后兼容:如果有 url 但没有 urls,自动合并 + if webhook_url_legacy and not webhook_urls: + webhook_urls = [webhook_url_legacy] + webdav_config = config_data.get("webdav", {}) + cloud_config = config_data.get("cloud", {}) + smtp_config = config_data.get("smtp", {}) + + # 验证端口范围(1-65535) + sftp_port = sftp_config.get("port", 22) + if not isinstance(sftp_port, int) or not (1 <= sftp_port <= 65535): + logger.warning( + "SFTP port %s is out of range (1-65535), using default 22", sftp_port + ) + sftp_port = 22 + + smtp_port = smtp_config.get("port", 587) + if not isinstance(smtp_port, int) or not (1 <= smtp_port <= 65535): + logger.warning( + "SMTP port %s is out of range (1-65535), using default 587", smtp_port + ) + smtp_port = 587 + + rotation_config = config_data.get("rotation", {}) + rotation_keep_count = rotation_config.get("keep_count", 0) + rotation_keep_days = rotation_config.get("keep_days", 0) + rotation_keep_daily = rotation_config.get("keep_daily", 0) + + hooks_config = config_data.get("hooks", {}) + pre_hooks = hooks_config.get("pre", []) + post_hooks = hooks_config.get("post", []) + hook_timeout = hooks_config.get("timeout", 300) return Config( folder_path="", @@ -69,66 +235,400 @@ def load_config(config_file: str = "config.json") -> Config: compression_level=compression_config.get("level", 6), lang=lang, data_file=data_file, + password=password, + name_template=name_template, + webhook_url=webhook_url_legacy, + webhook_urls=webhook_urls, + webhook_template=webhook_template, + webhook_retries=webhook_retries, + sftp_host=sftp_config.get("host", ""), + sftp_port=sftp_port, + sftp_user=sftp_config.get("user", ""), + sftp_password=sftp_config.get("password", ""), + sftp_key_file=sftp_config.get("key_file", ""), + sftp_key_passphrase=sftp_config.get("key_passphrase", ""), + sftp_remote_path=sftp_config.get("remote_path", "/"), + sftp_enabled=sftp_config.get("enabled", False), + webdav_url=webdav_config.get("url", ""), + webdav_user=webdav_config.get("user", ""), + webdav_password=webdav_config.get("password", ""), + webdav_remote_path=webdav_config.get("remote_path", "/"), + webdav_enabled=webdav_config.get("enabled", False), + cloud_endpoint=cloud_config.get("endpoint", ""), + cloud_access_key=cloud_config.get("access_key", ""), + cloud_secret_key=cloud_config.get("secret_key", ""), + cloud_bucket=cloud_config.get("bucket", ""), + cloud_region=cloud_config.get("region", ""), + cloud_secure=cloud_config.get("secure", True), + cloud_remote_path=cloud_config.get("remote_path", "/"), + cloud_enabled=cloud_config.get("enabled", False), + smtp_host=smtp_config.get("host", ""), + smtp_port=smtp_port, + smtp_user=smtp_config.get("user", ""), + smtp_password=smtp_config.get("password", ""), + smtp_from=smtp_config.get("from", ""), + smtp_to=smtp_config.get("to", ""), + smtp_tls=smtp_config.get("tls", True), + smtp_enabled=smtp_config.get("enabled", False), + rotation_keep_count=rotation_keep_count, + rotation_keep_days=rotation_keep_days, + rotation_keep_daily=rotation_keep_daily, + pre_hooks=pre_hooks, + post_hooks=post_hooks, + hook_timeout=hook_timeout, ) def save_lang(lang: str, config_file: str = "config.json") -> None: + """将语言偏好保存到配置文件""" + data = _load_json_file(config_file) + data["lang"] = lang + _save_json_file(data, config_file) + + +def save_format(fmt: str, config_file: str = "config.json") -> None: + """将打包格式偏好保存到配置文件""" + data = _load_json_file(config_file) + data["compression_format"] = fmt + _save_json_file(data, config_file) + + +def save_sftp_config( + host: str, + port: int, + user: str, + password: str, + remote_path: str, + enabled: bool = True, + key_file: str = "", + key_passphrase: str = "", + config_file: str = "config.json", +) -> None: + """将 SFTP 配置保存到配置文件""" + data = _load_json_file(config_file) + data["sftp"] = { + "host": host, + "port": port, + "user": user, + "password": password, + "key_file": key_file, + "key_passphrase": key_passphrase, + "remote_path": remote_path, + "enabled": enabled, + } + _save_json_file(data, config_file) + + +def save_webdav_config( + url: str, + user: str, + password: str, + remote_path: str = "/", + enabled: bool = True, + config_file: str = "config.json", +) -> None: + """将 WebDAV 配置保存到配置文件""" + data = _load_json_file(config_file) + data["webdav"] = { + "url": url, + "user": user, + "password": password, + "remote_path": remote_path, + "enabled": enabled, + } + _save_json_file(data, config_file) + + +def save_cloud_config( + endpoint: str, + access_key: str, + secret_key: str, + bucket: str, + region: str = "", + secure: bool = True, + remote_path: str = "/", + enabled: bool = True, + config_file: str = "config.json", +) -> None: + """将云存储配置保存到配置文件""" + data = _load_json_file(config_file) + data["cloud"] = { + "endpoint": endpoint, + "access_key": access_key, + "secret_key": secret_key, + "bucket": bucket, + "region": region, + "secure": secure, + "remote_path": remote_path, + "enabled": enabled, + } + _save_json_file(data, config_file) + + +def generate_config_template(config_file: str = "config.json") -> None: + """生成完整的 config.json 模板,包含所有配置项的默认值""" + template = { + "lang": "zh_CN", + "compression_format": "ZIP", + "compression": { + "algorithm": "ZIP_DEFLATED", + "level": 6, + }, + "skip_patterns": [".git", "__pycache__"], + "name_template": "", + "password": "", + "webhook": { + "urls": [], + "template": "", + "retries": 2, + }, + "sftp": { + "host": "", + "port": 22, + "user": "", + "password": "", + "key_file": "", + "key_passphrase": "", + "remote_path": "/", + "enabled": False, + }, + "webdav": { + "url": "", + "user": "", + "password": "", + "remote_path": "/", + "enabled": False, + }, + "cloud": { + "endpoint": "", + "access_key": "", + "secret_key": "", + "bucket": "", + "region": "", + "secure": True, + "remote_path": "/", + "enabled": False, + }, + } + _save_json_file(template, config_file) + + +# 配置文件加密/解密 +_SENSITIVE_FIELDS = [ + ("password",), + ("sftp", "password"), + ("sftp", "key_passphrase"), + ("webdav", "password"), + ("smtp", "password"), + ("cloud", "access_key"), + ("cloud", "secret_key"), +] + + +def _derive_key(master_password: str, salt: bytes) -> bytes: + """从主密码派生加密密钥""" + import hashlib + + return hashlib.pbkdf2_hmac("sha256", master_password.encode("utf-8"), salt, 600_000) + + +def _encrypt_value(value: str, key: bytes) -> str: + """加密单个字符串值,返回 base64 编码的 salt+密文+HMAC(完整性校验)""" + import base64 + import hashlib + import hmac as _hmac + + salt = os.urandom(16) + derived = _derive_key(key.hex(), salt) + encrypted = bytes( + a ^ b for a, b in zip(value.encode("utf-8"), derived[: len(value)]) + ) + # 添加 HMAC 完整性校验(使用派生密钥的后 32 字节作为 HMAC 密钥) + hmac_key = hashlib.sha256(key + b"hmac_key").digest() + mac = _hmac.new(hmac_key, salt + encrypted, "sha256").digest() + return base64.b64encode(salt + encrypted + mac).decode("ascii") + + +def _decrypt_value(encrypted_value: str, key: bytes) -> str: + """解密单个字符串值(验证 HMAC 完整性)""" + import base64 + import hashlib + import hmac as _hmac + + data = base64.b64decode(encrypted_value) + # 提取 HMAC(最后 32 字节) + if len(data) < 48: # 16 (salt) + 0 (min ciphertext) + 32 (hmac) + raise ValueError("Invalid encrypted data: too short") + salt = data[:16] + mac_received = data[-32:] + ciphertext = data[16:-32] + + # 验证 HMAC 完整性 + hmac_key = hashlib.sha256(key + b"hmac_key").digest() + mac_expected = _hmac.new(hmac_key, salt + ciphertext, "sha256").digest() + if not _hmac.compare_digest(mac_received, mac_expected): + raise ValueError("HMAC verification failed: data may have been tampered with") + + derived = _derive_key(key.hex(), salt) + decrypted = bytes(a ^ b for a, b in zip(ciphertext, derived[: len(ciphertext)])) + return decrypted.decode("utf-8") + + +def encrypt_config(master_password: str, config_file: str = "config.json") -> bool: + """用主密码加密配置文件中的敏感字段 + :return: 是否成功 """ - 将语言偏好保存到配置文件 - """ - if os.path.exists(config_file): - try: - with open(config_file, "r", encoding="utf-8") as f: - data = json.load(f) - except json.JSONDecodeError: - logger.warning(t("log.config.reset"), config_file) - data = {} + data = _load_json_file(config_file) + if not data: + return False + + import base64 + + # 读取或生成盐值(存储在配置中以备解密) + salt_b64 = data.get("_key_salt") + if salt_b64: + salt = base64.b64decode(salt_b64) else: - data = {} + salt = os.urandom(16) + data["_key_salt"] = base64.b64encode(salt).decode("ascii") - data["lang"] = lang + # 使用 PBKDF2 派生加密密钥(600K 迭代) + key = _derive_key(master_password, salt) - data_dir = os.path.dirname(config_file) - if data_dir: - try: - os.makedirs(data_dir, exist_ok=True) - except OSError as e: - logger.error(t("log.config.mkdir.error"), data_dir, e) - return + for field_path in _SENSITIVE_FIELDS: + obj = data + for part in field_path[:-1]: + obj = obj.get(part, {}) + field_name = field_path[-1] + value = obj.get(field_name, "") + if value and not value.startswith("enc:"): + obj[field_name] = "enc:" + _encrypt_value(value, key) - try: - with open(config_file, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=4) - except OSError as e: - logger.error(t("log.config.write.error"), config_file, e) + data["_encrypted"] = True + _save_json_file(data, config_file) + return True -def save_format(fmt: str, config_file: str = "config.json") -> None: +def decrypt_config(master_password: str, config_file: str = "config.json") -> bool: + """用主密码解密配置文件中的敏感字段 + :return: 是否成功(密码错误时返回 False) """ - 将打包格式偏好保存到配置文件 + data = _load_json_file(config_file) + if not data or not data.get("_encrypted"): + return True # 未加密,视为成功 + + import base64 + + salt_b64 = data.get("_key_salt") + if not salt_b64: + return False # 缺少盐值,无法解密 + salt = base64.b64decode(salt_b64) + + key = _derive_key(master_password, salt) + + try: + for field_path in _SENSITIVE_FIELDS: + obj = data + for part in field_path[:-1]: + obj = obj.get(part, {}) + field_name = field_path[-1] + value = obj.get(field_name, "") + if value and value.startswith("enc:"): + obj[field_name] = _decrypt_value(value[4:], key) + except (UnicodeDecodeError, ValueError): + return False + + data["_encrypted"] = False + _save_json_file(data, config_file) + return True + + +def is_config_encrypted(config_file: str = "config.json") -> bool: + """检查配置文件是否已加密""" + data = _load_json_file(config_file) + return bool(data.get("_encrypted", False)) + + +# IM 通知 Webhook 预设模板 +WEBHOOK_PRESETS: dict[str, dict[str, str]] = { + "dingtalk": { + "name": "钉钉机器人", + "template": '{"msgtype":"text","text":{"content":"[sbackup] {status} | 备份: {backed} | 跳过: {skipped} | 耗时: {elapsed}s"}}', + "content_type": "application/json", + }, + "feishu": { + "name": "飞书机器人", + "template": '{"msg_type":"text","content":{"text":"[sbackup] {status} | 备份: {backed} | 跳过: {skipped} | 耗时: {elapsed}s"}}', + "content_type": "application/json", + }, + "wechat": { + "name": "企业微信机器人", + "template": '{"msgtype":"text","text":{"content":"[sbackup] {status}\n备份: {backed}\n跳过: {skipped}\n耗时: {elapsed}s"}}', + "content_type": "application/json", + }, +} + + +def setup_webhook_preset(preset: str, config_file: str = "config.json") -> str: + """配置 IM 通知 Webhook 预设 + :param preset: 预设名称(dingtalk/feishu/wechat) + :return: 预设的 template 字符串 """ - if os.path.exists(config_file): - try: - with open(config_file, "r", encoding="utf-8") as f: - data = json.load(f) - except json.JSONDecodeError: - logger.warning(t("log.config.reset"), config_file) - data = {} - else: - data = {} + info = WEBHOOK_PRESETS.get(preset) + if not info: + return "" + data = _load_json_file(config_file) + webhook = data.get("webhook", {}) + webhook["template"] = info["template"] + data["webhook"] = webhook + _save_json_file(data, config_file) + return info["template"] - data["compression_format"] = fmt - data_dir = os.path.dirname(config_file) - if data_dir: - try: - os.makedirs(data_dir, exist_ok=True) - except OSError as e: - logger.error(t("log.config.mkdir.error"), data_dir, e) - return +def parse_gitignore(gitignore_path: str) -> list[str]: + """解析 .gitignore 文件并转换为 sbackup 的 skip_patterns + :param gitignore_path: .gitignore 文件路径 + :return: skip_patterns 列表 + """ + if not os.path.isfile(gitignore_path): + return [] + # 安全检查:确保路径在当前工作目录内 + cwd = os.path.realpath(os.getcwd()) + real_path = os.path.realpath(gitignore_path) + if os.path.commonpath([cwd, real_path]) != cwd: + raise ValueError("gitignore path must be within the current working directory") + + patterns = [] try: - with open(config_file, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=4) - except OSError as e: - logger.error(t("log.config.write.error"), config_file, e) + with open(gitignore_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + # 跳过空行和注释 + if not line or line.startswith("#"): + continue + # 取反模式保留 + if line.startswith("!"): + pattern = line[1:] + patterns.append("!" + _gitignore_to_fnmatch(pattern)) + else: + patterns.append(_gitignore_to_fnmatch(line)) + except OSError: + pass + return patterns + + +def _gitignore_to_fnmatch(pattern: str) -> str: + """将单个 .gitignore 模式转换为 fnmatch 兼容模式""" + # 移除尾部斜杠(目录标记) + pattern = pattern.rstrip("/") + # 移除开头斜杠(根目录标记,fnmatch 不需要) + pattern = pattern.lstrip("/") + # ** 匹配任意路径深度 → 转为 * + pattern = pattern.replace("**/", "*") + pattern = pattern.replace("/**", "*") + # 如果模式不包含路径分隔符且不以 * 开头,添加前缀匹配 + # 例如 "build" 应匹配 "build" 和 "sub/build" + if "/" not in pattern and not pattern.startswith("*"): + pattern = f"*{pattern}" + return pattern diff --git a/sbackup/cross_search.py b/sbackup/cross_search.py new file mode 100644 index 0000000..c1b6b31 --- /dev/null +++ b/sbackup/cross_search.py @@ -0,0 +1,296 @@ +"""跨档案搜索模块:在多个备份文件中搜索匹配的文件名""" + +import logging +import os +import tarfile +import tempfile +import time as _time +import zipfile +from dataclasses import dataclass, field +from fnmatch import fnmatch +from pathlib import Path + +logger = logging.getLogger(__name__) + +# 支持的备份文件后缀 +_ARCHIVE_EXTENSIONS = { + ".zip": "ZIP", + ".tar": "TAR", + ".tar.gz": "TAR_GZ", + ".tar.bz2": "TAR_BZ2", + ".tar.xz": "TAR_XZ", + ".tar.zst": "TAR_ZST", + ".7z": "7Z", +} + + +def _detect_format(filename: str) -> str: + """根据文件名检测压缩格式,返回格式标识或空字符串""" + name_lower = filename.lower() + # 长后缀优先匹配 + for ext in sorted(_ARCHIVE_EXTENSIONS, key=len, reverse=True): + if name_lower.endswith(ext): + return _ARCHIVE_EXTENSIONS[ext] + return "" + + +def _is_archive(filename: str) -> bool: + """判断文件名是否为支持的备份格式""" + return _detect_format(filename) != "" + + +def _get_member_names(backup_path: Path, password: str = "") -> list[dict]: + """获取压缩包内所有成员信息,返回 [{"name": str, "size": int}]""" + name_lower = backup_path.name.lower() + try: + if name_lower.endswith(".zip"): + with zipfile.ZipFile(backup_path, "r") as zf: + return [ + {"name": info.filename, "size": info.file_size} + for info in zf.infolist() + if not info.is_dir() + ] + elif name_lower.endswith(".7z"): + import py7zr + + szf_kwargs = {"file": backup_path, "mode": "r"} + if password: + szf_kwargs["password"] = password + with py7zr.SevenZipFile(**szf_kwargs) as szf: + return [ + {"name": name, "size": 0} + for name in szf.getnames() + if not name.endswith("/") + ] + elif name_lower.endswith(".tar.zst"): + import zstandard as zstd + + dctx = zstd.ZstdDecompressor() + with tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024) as tmp: + with open(backup_path, "rb") as f_in: + reader = dctx.stream_reader(f_in) + while True: + chunk = reader.read(65536) + if not chunk: + break + tmp.write(chunk) + tmp.seek(0) + with tarfile.open(fileobj=tmp, mode="r") as tarf: + return [ + {"name": m.name, "size": m.size} + for m in tarf.getmembers() + if m.isfile() + ] + else: + # tar.gz / tar.bz2 / tar.xz / tar + mode_map = { + ".tar.gz": "r:gz", + ".tgz": "r:gz", + ".tar.bz2": "r:bz2", + ".tbz2": "r:bz2", + ".tar.xz": "r:xz", + ".txz": "r:xz", + ".tar": "r", + } + mode = None + for suffix, m in mode_map.items(): + if name_lower.endswith(suffix): + mode = m + break + if mode is None: + return [] + with tarfile.open(backup_path, mode) as tarf: + return [ + {"name": member.name, "size": member.size} + for member in tarf.getmembers() + if member.isfile() + ] + except Exception as e: + logger.debug("Failed to read archive %s: %s", backup_path, e) + return [] + + +@dataclass +class SearchResult: + """搜索结果数据类""" + + archive: str # 备份文件路径 + members: list[dict] = field(default_factory=list) # [{"name": str, "size": int}] + archive_size: int = 0 # 备份文件大小 + archive_mtime: str = "" # 备份文件修改时间 + + +class CrossSearcher: + """跨档案搜索器,在多个备份文件中搜索匹配的文件名""" + + def __init__(self, search_dirs: list[str], password: str = ""): + self.search_dirs = search_dirs + self.password = password + + def scan_archives(self) -> list[dict]: + """扫描搜索目录下所有备份文件 + + 递归搜索,识别 .zip/.tar*/.7z 后缀 + 返回 [{"path": str, "size": int, "mtime": str, "format": str}] + """ + archives = [] + for search_dir in self.search_dirs: + dir_path = Path(search_dir) + if not dir_path.is_dir(): + logger.debug("Directory does not exist: %s", search_dir) + continue + try: + for root, _dirs, files in os.walk(dir_path): + for filename in files: + fmt = _detect_format(filename) + if not fmt: + continue + filepath = Path(root) / filename + try: + stat = filepath.stat() + mtime_str = _time.strftime( + "%Y-%m-%d %H:%M:%S", + _time.localtime(stat.st_mtime), + ) + archives.append( + { + "path": str(filepath), + "size": stat.st_size, + "mtime": mtime_str, + "format": fmt, + } + ) + except OSError: + continue + except OSError: + continue + # 按修改时间倒序排列 + archives.sort(key=lambda a: a["mtime"], reverse=True) + return archives + + def _list_members(self, archive_path: str) -> list[dict]: + """列出单个档案的成员信息""" + return _get_member_names(Path(archive_path), self.password) + + def search(self, keyword: str) -> list[SearchResult]: + """在所有备份文件中搜索匹配 keyword 的文件名 + + 不区分大小写,返回每个匹配的档案及其匹配成员 + """ + results = [] + for archive_info in self.scan_archives(): + archive_path = archive_info["path"] + members = self._list_members(archive_path) + matched = [m for m in members if keyword.lower() in m["name"].lower()] + if matched: + results.append( + SearchResult( + archive=archive_path, + members=matched, + archive_size=archive_info["size"], + archive_mtime=archive_info["mtime"], + ) + ) + return results + + def search_by_pattern(self, pattern: str) -> list[SearchResult]: + """按 glob 模式搜索(使用 fnmatch)""" + results = [] + for archive_info in self.scan_archives(): + archive_path = archive_info["path"] + members = self._list_members(archive_path) + matched = [ + m + for m in members + if fnmatch(m["name"], pattern) + or fnmatch(os.path.basename(m["name"]), pattern) + ] + if matched: + results.append( + SearchResult( + archive=archive_path, + members=matched, + archive_size=archive_info["size"], + archive_mtime=archive_info["mtime"], + ) + ) + return results + + def search_by_extension(self, ext: str) -> list[SearchResult]: + """按扩展名搜索(如 .log, .py)""" + if not ext.startswith("."): + ext = "." + ext + ext_lower = ext.lower() + results = [] + for archive_info in self.scan_archives(): + archive_path = archive_info["path"] + members = self._list_members(archive_path) + matched = [ + m + for m in members + if os.path.splitext(m["name"])[1].lower() == ext_lower + ] + if matched: + results.append( + SearchResult( + archive=archive_path, + members=matched, + archive_size=archive_info["size"], + archive_mtime=archive_info["mtime"], + ) + ) + return results + + def get_summary(self, results: list[SearchResult]) -> dict: + """搜索结果统计 + + 返回 {"total_archives": int, "matched_archives": int, "total_matches": int} + """ + total_archives = len(self.scan_archives()) + matched_archives = len(results) + total_matches = sum(len(r.members) for r in results) + return { + "total_archives": total_archives, + "matched_archives": matched_archives, + "total_matches": total_matches, + } + + def format_results(self, results: list[SearchResult], lang: str = "zh_CN") -> str: + """格式化搜索结果""" + if not results: + if lang == "zh_CN": + return "未找到匹配的文件。" + return "No matching files found." + + lines: list[str] = [] + summary = self.get_summary(results) + if lang == "zh_CN": + lines.append( + f"搜索结果: {summary['matched_archives']}/{summary['total_archives']} " + f"个档案包含匹配项, 共 {summary['total_matches']} 个匹配文件" + ) + else: + lines.append( + f"Search results: {summary['matched_archives']}/{summary['total_archives']} " + f"archives matched, {summary['total_matches']} matching files total" + ) + lines.append("") + + for result in results: + if lang == "zh_CN": + lines.append( + f" 档案: {result.archive} " + f"({result.archive_size / (1024 * 1024):.2f} MB, {result.archive_mtime})" + ) + lines.append(f" 匹配文件 ({len(result.members)}):") + else: + lines.append( + f" Archive: {result.archive} " + f"({result.archive_size / (1024 * 1024):.2f} MB, {result.archive_mtime})" + ) + lines.append(f" Matching files ({len(result.members)}):") + for m in result.members: + lines.append(f" {m['name']}") + lines.append("") + + return "\n".join(lines) diff --git a/sbackup/dedup.py b/sbackup/dedup.py new file mode 100644 index 0000000..75532f0 --- /dev/null +++ b/sbackup/dedup.py @@ -0,0 +1,157 @@ +"""文件级去重:基于 SHA256 内容哈希的跨策略去重存储""" + +import os +import hashlib +import json +import logging + +logger = logging.getLogger(__name__) + +# 去重存储的子目录名 +DEDUP_DIR = ".dedup" +INDEX_FILE = "index.json" + + +class DedupStore: + """内容寻址去重存储 + + 文件存储路径: {store_dir}/{hash[:2]}/{hash[2:]} + index 格式: {hash: refcount} + + 记录每个文件内容的 SHA256 哈希和引用次数,当多个策略包含相同内容的文件时, + 哈希会被复用,从而实现跨策略去重统计。 + """ + + def __init__(self, store_dir: str): + self.store_dir = os.path.join(store_dir, DEDUP_DIR) + self.index_path = os.path.join(self.store_dir, INDEX_FILE) + self._index: dict[str, int] = {} + self._load_index() + + def _load_index(self) -> None: + if os.path.exists(self.index_path): + try: + with open(self.index_path, encoding="utf-8") as f: + self._index = json.load(f) + except (json.JSONDecodeError, OSError): + self._index = {} + + def _save_index(self) -> None: + os.makedirs(self.store_dir, exist_ok=True) + with open(self.index_path, "w", encoding="utf-8") as f: + json.dump(self._index, f, indent=2) + + def _hash_path(self, content_hash: str) -> str: + return os.path.join(self.store_dir, content_hash[:2], content_hash[2:]) + + def store(self, source_path: str) -> tuple[str, bool]: + """存储文件到去重池 + + :returns: (content_hash, is_new) — is_new 表示这是首次存储 + """ + content_hash = self._compute_hash(source_path) + target = self._hash_path(content_hash) + + if os.path.exists(target): + # 文件已存在,增加引用计数 + self._index[content_hash] = self._index.get(content_hash, 0) + 1 + self._save_index() + return content_hash, False + + # 首次存储 + os.makedirs(os.path.dirname(target), exist_ok=True) + # 优先使用硬链接(同一文件系统),否则复制 + try: + os.link(source_path, target) + except OSError: + try: + import shutil + + shutil.copy2(source_path, target) + except OSError as e: + logger.warning("Failed to copy file to dedup store: %s", e) + return content_hash, False + + self._index[content_hash] = self._index.get(content_hash, 0) + 1 + self._save_index() + return content_hash, True + + def release(self, content_hash: str) -> bool: + """释放文件引用。引用计数归零时删除文件。 + + :returns: True 如果文件被实际删除 + """ + if content_hash not in self._index: + return False + + self._index[content_hash] -= 1 + if self._index[content_hash] <= 0: + del self._index[content_hash] + target = self._hash_path(content_hash) + try: + os.remove(target) + except OSError: + pass + self._save_index() + return True + + def get_path(self, content_hash: str) -> str | None: + """获取已去重文件的路径""" + target = self._hash_path(content_hash) + if os.path.exists(target): + return target + return None + + def has(self, content_hash: str) -> bool: + """检查哈希是否已存储""" + return content_hash in self._index + + @staticmethod + def _compute_hash(filepath: str) -> str: + """计算文件的 SHA256 哈希""" + sha = hashlib.sha256() + with open(filepath, "rb") as f: + while True: + chunk = f.read(65536) + if not chunk: + break + sha.update(chunk) + return sha.hexdigest() + + def scan_directory(self, directory: str) -> dict[str, int]: + """扫描目录,计算所有文件的哈希并存入去重池 + + :returns: {content_hash: file_count} 的字典 + """ + seen = {} # hash -> count of files with this hash + for dirpath, _dirnames, filenames in os.walk(directory): + for filename in filenames: + file_path = os.path.join(dirpath, filename) + try: + if not os.path.isfile(file_path): + continue + content_hash, is_new = self.store(file_path) + if content_hash in seen: + seen[content_hash] += 1 + else: + seen[content_hash] = 1 + except (OSError, PermissionError): + continue + return seen + + def count_duplicates(self, hash_counts: dict[str, int]) -> tuple[int, int]: + """统计跨策略的重复文件数量和节省的估算大小 + + :param hash_counts: scan_directory 返回的哈希计数 + :returns: (duplicate_count, approximate_saved_bytes) + """ + dup_count = 0 + saved_bytes = 0 + for content_hash, count in hash_counts.items(): + if self._index.get(content_hash, 0) > count: + # 该哈希已经被其他策略存储过 + dup_count += count + target = self._hash_path(content_hash) + if os.path.exists(target): + saved_bytes += os.path.getsize(target) * count + return dup_count, saved_bytes diff --git a/sbackup/diskcheck.py b/sbackup/diskcheck.py new file mode 100644 index 0000000..3121967 --- /dev/null +++ b/sbackup/diskcheck.py @@ -0,0 +1,327 @@ +""" +磁盘空间预估模块:扫描源目录、估算备份大小、检查目标磁盘空间 +""" + +import os +import sys +import logging +from dataclasses import dataclass, field +from pathlib import Path + +from sbackup.i18n import t + +logger = logging.getLogger(__name__) + +# 按文件扩展名分类的压缩比映射 +# 文本类:高压缩率 +_TEXT_EXTENSIONS = frozenset( + { + ".py", + ".js", + ".ts", + ".jsx", + ".tsx", + ".txt", + ".json", + ".xml", + ".html", + ".css", + ".md", + ".csv", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", + ".conf", + ".sh", + ".bat", + ".cmd", + ".ps1", + ".java", + ".c", + ".cpp", + ".h", + ".hpp", + ".go", + ".rs", + ".rb", + ".php", + ".sql", + ".log", + ".rtf", + ".tex", + } +) + +# 数据库文件 +_DB_EXTENSIONS = frozenset({".db", ".sqlite", ".sqlite3"}) + +# 图片文件(几乎不压缩) +_IMAGE_EXTENSIONS = frozenset( + {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".heic"} +) + +# 视频/音频文件(几乎不压缩) +_MEDIA_EXTENSIONS = frozenset( + {".mp4", ".mp3", ".wav", ".avi", ".mkv", ".flac", ".ogg", ".aac", ".wma", ".mov"} +) + +# 已压缩的归档文件 +_ARCHIVE_EXTENSIONS = frozenset( + {".zip", ".gz", ".7z", ".rar", ".xz", ".bz2", ".zst", ".lz4"} +) + +# 各文件类型的默认压缩比 +_RATIO_TEXT = 0.3 +_RATIO_DB = 0.5 +_RATIO_DEFAULT = 0.6 +_RATIO_IMAGE = 0.95 +_RATIO_MEDIA = 0.98 +_RATIO_ARCHIVE = 0.99 +_RATIO_UNCOMPRESSED = 1.0 + + +def _get_extension_ratio(ext: str) -> float: + """根据文件扩展名返回估算压缩比(压缩后大小 / 原始大小)""" + ext = ext.lower() + if ext in _TEXT_EXTENSIONS: + return _RATIO_TEXT + if ext in _DB_EXTENSIONS: + return _RATIO_DB + if ext in _IMAGE_EXTENSIONS: + return _RATIO_IMAGE + if ext in _MEDIA_EXTENSIONS: + return _RATIO_MEDIA + if ext in _ARCHIVE_EXTENSIONS: + return _RATIO_ARCHIVE + return _RATIO_DEFAULT + + +def _get_disk_space_info(path: str) -> tuple[int, int, int]: + """获取指定路径所在磁盘的空间信息(跨平台) + :return: (total, used, free) 单位 bytes + """ + target = os.path.realpath(path) + if sys.platform == "win32": + import ctypes + + free_bytes = ctypes.c_ulonglong(0) + total_bytes = ctypes.c_ulonglong(0) + ctypes.windll.kernel32.GetDiskFreeSpaceExW( + ctypes.c_wchar_p(target), + None, + ctypes.pointer(total_bytes), + ctypes.pointer(free_bytes), + ) + total = total_bytes.value + free = free_bytes.value + used = total - free + return total, used, free + else: + usage = os.statvfs(target) + total = usage.f_blocks * usage.f_frsize + free = usage.f_bavail * usage.f_frsize + used = total - free + return total, used, free + + +@dataclass +class DiskSpaceInfo: + """磁盘空间检查结果""" + + total: int = 0 # 目标磁盘总空间 (bytes) + used: int = 0 # 已用空间 + free: int = 0 # 可用空间 + # 备份相关 + source_total: int = 0 # 源文件总大小 + estimated_backup: int = 0 # 估算备份文件大小 + margin: int = 0 # 剩余空间 - 估算大小 + enough: bool = True # 空间是否足够 + warnings: list[str] = field(default_factory=list) + + +class DiskChecker: + """磁盘空间检查器""" + + def __init__(self, source_path: str, target_path: str) -> None: + self.source_path = os.path.realpath(source_path) + self.target_path = os.path.realpath(target_path) + + def get_disk_space(self, path: str) -> DiskSpaceInfo: + """获取指定路径所在磁盘的空间信息(跨平台)""" + real_path = os.path.realpath(path) + if not os.path.isdir(real_path): + # 尝试取父目录 + real_path = str(Path(real_path).parent) + total, used, free = _get_disk_space_info(real_path) + return DiskSpaceInfo(total=total, used=used, free=free) + + def scan_source(self) -> int: + """扫描源目录,返回总文件大小(bytes)""" + total = 0 + if not os.path.isdir(self.source_path): + return 0 + for dirpath, _dirnames, filenames in os.walk(self.source_path): + for filename in filenames: + filepath = os.path.join(dirpath, filename) + try: + total += os.path.getsize(filepath) + except OSError: + pass + return total + + def estimate_backup_size( + self, + fmt: str = "ZIP", + compression_level: int = 6, + source_total: int | None = None, + ) -> int: + """估算备份文件大小 + + 简单估算: + - TAR 格式(无压缩):所有文件压缩比 1.0 + - 其他格式:按文件类型选择压缩比 + - 压缩级别越高,压缩比越低(在基础上乘以一个调整系数) + """ + if source_total is None: + source_total = self.scan_source() + + # TAR 格式不压缩 + fmt_upper = fmt.upper().replace(".", "_") + if fmt_upper == "TAR": + return source_total + + # 压缩级别调整系数:级别 0 时不压缩,级别 9 时最佳压缩 + level_factor = 1.0 + if fmt_upper == "7Z": + # 7z 默认更优 + level_factor = max(0.7, 1.0 - compression_level * 0.035) + else: + level_factor = max(0.85, 1.0 - compression_level * 0.02) + + total = 0 + if not os.path.isdir(self.source_path): + return 0 + + for dirpath, _dirnames, filenames in os.walk(self.source_path): + for filename in filenames: + filepath = os.path.join(dirpath, filename) + try: + size = os.path.getsize(filepath) + except OSError: + continue + ext = Path(filename).suffix + ratio = _get_extension_ratio(ext) + total += int(size * ratio * level_factor) + + return max(total, 1 if source_total > 0 else 0) + + def check( + self, + fmt: str = "ZIP", + compression_level: int = 6, + ) -> DiskSpaceInfo: + """完整检查:扫描源 + 估算大小 + 检查目标磁盘空间""" + # 扫描源目录 + source_total = self.scan_source() + + # 获取目标磁盘空间 + disk_info = self.get_disk_space(self.target_path) + + # 估算备份大小 + estimated = self.estimate_backup_size(fmt, compression_level, source_total) + + # 计算剩余空间 + margin = disk_info.free - estimated + enough = margin >= 0 + + disk_info.source_total = source_total + disk_info.estimated_backup = estimated + disk_info.margin = margin + disk_info.enough = enough + + # 生成警告 + if not enough: + missing = abs(margin) + disk_info.warnings.append( + t( + "diskcheck.insufficient", + needed=_format_size(estimated), + available=_format_size(disk_info.free), + missing=_format_size(missing), + ) + ) + + return disk_info + + def format_report(self, info: DiskSpaceInfo, lang: str = "zh_CN") -> str: + """格式化检查报告""" + lines: list[str] = [] + lines.append(t("diskcheck.header")) + lines.append(t("diskcheck.source", path=self.source_path)) + lines.append(t("diskcheck.target", path=self.target_path)) + lines.append("") + + # 源文件信息 + lines.append(t("diskcheck.source_size", size=_format_size(info.source_total))) + lines.append( + t( + "diskcheck.estimated_size", + size=_format_size(info.estimated_backup), + ratio=_calc_ratio_text(info.source_total, info.estimated_backup), + ) + ) + lines.append("") + + # 目标磁盘信息 + lines.append(t("diskcheck.disk_header")) + lines.append(t("diskcheck.disk_total", size=_format_size(info.total))) + lines.append(t("diskcheck.disk_used", size=_format_size(info.used))) + lines.append(t("diskcheck.disk_free", size=_format_size(info.free))) + lines.append("") + + # 空间检查结果 + if info.enough: + lines.append( + t( + "diskcheck.status.ok", + margin=_format_size(info.margin), + ) + ) + else: + missing = abs(info.margin) + lines.append( + t( + "diskcheck.status.insufficient", + needed=_format_size(info.estimated_backup), + available=_format_size(info.free), + missing=_format_size(missing), + ) + ) + + for w in info.warnings: + lines.append(f" {w}") + + return "\n".join(lines) + + +def _format_size(size_bytes: int) -> str: + """将字节数格式化为人类可读的字符串""" + if size_bytes < 0: + return f"-{_format_size(-size_bytes)}" + if size_bytes < 1024: + return f"{size_bytes} B" + if size_bytes < 1024**2: + return f"{size_bytes / 1024:.1f} KB" + if size_bytes < 1024**3: + return f"{size_bytes / (1024**2):.1f} MB" + return f"{size_bytes / (1024**3):.2f} GB" + + +def _calc_ratio_text(original: int, compressed: int) -> str: + """计算并返回压缩比百分比文本""" + if original <= 0: + return "0%" + ratio = compressed / original * 100 + return f"{ratio:.1f}%" diff --git a/sbackup/dryrun.py b/sbackup/dryrun.py new file mode 100644 index 0000000..d28fc92 --- /dev/null +++ b/sbackup/dryrun.py @@ -0,0 +1,441 @@ +"""Dry-run 预览模式:扫描目录并模拟备份的文件选择逻辑,不创建任何文件""" + +import os +from pathlib import Path +from fnmatch import fnmatch +from dataclasses import dataclass, field +from sbackup.i18n import t +from sbackup.config import Config + + +@dataclass +class DryRunResult: + """Dry-run 扫描结果""" + + total_files: int = 0 + included_files: int = 0 + excluded_files: int = 0 + total_size: int = 0 + included_size: int = 0 + excluded_size: int = 0 + skip_patterns_matched: dict = field(default_factory=dict) # pattern -> [files] + large_files: list = field(default_factory=list) # 最大的 10 个文件 + file_types: dict = field(default_factory=dict) # extension -> count + warnings: list = field(default_factory=list) # 磁盘空间不足等 + included_list: list = field(default_factory=list) # (rel_path, size) + excluded_list: list = field(default_factory=list) # (rel_path, size, reason) + + +class DryRunScanner: + """扫描目录,模拟备份的文件选择逻辑,返回分析结果""" + + def __init__(self, folder_path: str, config: Config): + self.folder_path = folder_path + self.config = config + + def scan(self) -> DryRunResult: + """扫描目录,模拟备份的文件选择逻辑(skip_patterns, include/exclude, max_size, min_size) + 不创建任何文件,只返回分析结果 + """ + result = DryRunResult() + folder = Path(self.folder_path) + + if not folder.is_dir(): + result.warnings.append(t("dryrun.warning.not_dir", path=self.folder_path)) + return result + + skip_patterns = self.config.skip_patterns or [] + include_patterns = self.config.include_patterns or [] + exclude_patterns = self.config.exclude_patterns or [] + max_size = self.config.max_size + min_size = self.config.min_size + + # 加载 .sbackupignore 文件 + extra_patterns = self._load_ignore_file(folder) + + all_files: list[tuple[str, int]] = [] # (rel_path, size) + + try: + for dirpath, dirnames, filenames in os.walk( + folder, followlinks=self.config.follow_symlinks + ): + rel_dir = os.path.relpath(dirpath, folder) + if rel_dir == ".": + rel_dir = "" + + # 过滤子目录(与 skip_patterns 匹配的目录不再遍历) + filtered_dirs = [] + for d in dirnames: + dir_rel = ( + os.path.join(rel_dir, d).replace("\\", "/") if rel_dir else d + ) + if self._matches_skip_pattern( + dir_rel, skip_patterns, extra_patterns + ): + continue + filtered_dirs.append(d) + dirnames[:] = filtered_dirs + + for filename in filenames: + file_rel = ( + os.path.join(rel_dir, filename).replace("\\", "/") + if rel_dir + else filename + ) + + try: + file_path = Path(dirpath) / filename + file_size = file_path.stat().st_size + except OSError: + continue + + result.total_files += 1 + result.total_size += file_size + + all_files.append((file_rel, file_size)) + + # 1. skip_patterns 过滤 + skip_reason = self._check_skip_pattern( + file_rel, skip_patterns, extra_patterns + ) + if skip_reason: + result.excluded_files += 1 + result.excluded_size += file_size + result.excluded_list.append((file_rel, file_size, skip_reason)) + result.skip_patterns_matched.setdefault(skip_reason, []).append( + file_rel + ) + continue + + # 2. include_patterns 过滤 + if include_patterns: + basename = os.path.basename(file_rel) + if not any( + fnmatch(file_rel, p) or fnmatch(basename, p) + for p in include_patterns + ): + reason = t("dryrun.reason.not_included") + result.excluded_files += 1 + result.excluded_size += file_size + result.excluded_list.append((file_rel, file_size, reason)) + continue + + # 3. exclude_patterns 过滤 + if exclude_patterns: + basename = os.path.basename(file_rel) + if any( + fnmatch(file_rel, p) or fnmatch(basename, p) + for p in exclude_patterns + ): + matched_pattern = self._find_matching_exclude( + file_rel, exclude_patterns + ) + reason = ( + f"{t('dryrun.reason.excluded')} {matched_pattern}" + if matched_pattern + else t("dryrun.reason.excluded") + ) + result.excluded_files += 1 + result.excluded_size += file_size + result.excluded_list.append((file_rel, file_size, reason)) + continue + + # 4. max_size 过滤 + if max_size > 0 and file_size > max_size: + reason = f"{t('dryrun.reason.too_large')} > {self._format_size(max_size)}" + result.excluded_files += 1 + result.excluded_size += file_size + result.excluded_list.append((file_rel, file_size, reason)) + continue + + # 5. min_size 过滤 + if min_size > 0 and file_size < min_size: + reason = f"{t('dryrun.reason.too_small')} < {self._format_size(min_size)}" + result.excluded_files += 1 + result.excluded_size += file_size + result.excluded_list.append((file_rel, file_size, reason)) + continue + + # 文件被包含 + result.included_files += 1 + result.included_size += file_size + result.included_list.append((file_rel, file_size)) + + # 统计文件类型 + ext = Path(filename).suffix.lower() or t("dryrun.no_extension") + result.file_types[ext] = result.file_types.get(ext, 0) + 1 + + except PermissionError: + result.warnings.append( + t("dryrun.warning.permission", path=self.folder_path) + ) + except OSError as e: + result.warnings.append(t("dryrun.warning.os_error", error=e)) + + # 找出最大的 10 个文件 + all_files_sorted = sorted(all_files, key=lambda x: x[1], reverse=True) + result.large_files = [(path, size) for path, size in all_files_sorted[:10]] + + # 检查磁盘空间 + self._check_disk_space(result) + + return result + + def format_summary(self, result: DryRunResult, lang: str = "zh_CN") -> str: + """格式化扫描结果为可读文本""" + lines = [] + lines.append(f"{'=' * 20} {t('dryrun.title')} {'=' * 20}") + lines.append(f"{t('dryrun.scanned_dir')}: {self.folder_path}") + lines.append( + f"{t('dryrun.total_files')}: {result.total_files} " + f"({t('dryrun.total_size')}: {self._format_size(result.total_size)})" + ) + lines.append( + f"{t('dryrun.included_files')}: {result.included_files} " + f"({self._format_size(result.included_size)})" + ) + lines.append( + f"{t('dryrun.excluded_files')}: {result.excluded_files} " + f"({self._format_size(result.excluded_size)})" + ) + + # 排除原因 + if result.skip_patterns_matched: + lines.append("") + lines.append(f"{t('dryrun.exclusion_reasons')}:") + for pattern, files in sorted( + result.skip_patterns_matched.items(), + key=lambda x: len(x[1]), + reverse=True, + ): + size = sum( + s + for _, s, r in result.excluded_list + if r == pattern or r.startswith(pattern) + ) + lines.append( + f" {t('dryrun.skip')}: {pattern}: " + f"{len(files)} {t('dryrun.unit_files')} " + f"({self._format_size(size)})" + ) + + # 统计非 skip_patterns 的排除原因 + other_reasons: dict[str, int] = {} + for _, _, reason in result.excluded_list: + if reason not in result.skip_patterns_matched: + other_reasons[reason] = other_reasons.get(reason, 0) + 1 + if other_reasons: + if not result.skip_patterns_matched: + lines.append("") + lines.append(f"{t('dryrun.exclusion_reasons')}:") + for reason, count in sorted( + other_reasons.items(), key=lambda x: x[1], reverse=True + ): + lines.append(f" {reason}: {count} {t('dryrun.unit_files')}") + + # 文件类型分布 + if result.file_types: + lines.append("") + lines.append(f"{t('dryrun.file_types')}:") + sorted_types = sorted( + result.file_types.items(), key=lambda x: x[1], reverse=True + ) + for ext, count in sorted_types[:10]: + lines.append(f" {ext}: {count} {t('dryrun.unit_files')}") + if len(sorted_types) > 10: + remaining = sum(c for _, c in sorted_types[10:]) + lines.append( + f" ... {t('dryrun.and_others')}: {remaining} {t('dryrun.unit_files')}" + ) + + # 最大文件 + if result.large_files: + lines.append("") + lines.append(f"{t('dryrun.largest_files')}:") + for i, (fpath, fsize) in enumerate(result.large_files, 1): + lines.append(f" {i}. {fpath} ({self._format_size(fsize)})") + + # 警告 + if result.warnings: + lines.append("") + lines.append(f"{t('dryrun.warnings')}:") + for w in result.warnings: + lines.append(f" {w}") + + return "\n".join(lines) + + def format_file_list( + self, + result: DryRunResult, + show_excluded: bool = False, + limit: int = 50, + ) -> str: + """格式化文件列表""" + lines = [] + if show_excluded: + files = result.excluded_list + lines.append( + f"--- {t('dryrun.excluded_files_list')} ({len(files)} {t('dryrun.unit_files')}) ---" + ) + for fpath, fsize, reason in files[:limit]: + lines.append(f" {fpath} ({self._format_size(fsize)}) [{reason}]") + if len(files) > limit: + lines.append( + f" ... {t('dryrun.more_files', count=len(files) - limit)}" + ) + else: + files = result.included_list + lines.append( + f"--- {t('dryrun.included_files_list')} ({len(files)} {t('dryrun.unit_files')}) ---" + ) + for fpath, fsize in files[:limit]: + lines.append(f" {fpath} ({self._format_size(fsize)})") + if len(files) > limit: + lines.append( + f" ... {t('dryrun.more_files', count=len(files) - limit)}" + ) + + return "\n".join(lines) + + # ----- 内部辅助方法 ----- + + def _load_ignore_file(self, folder_path: Path) -> list[str]: + """从源目录的 .sbackupignore 文件加载忽略规则""" + ignore_file = folder_path / ".sbackupignore" + if not ignore_file.is_file(): + return [] + try: + lines = ignore_file.read_text(encoding="utf-8").splitlines() + patterns = [] + for line in lines: + line = line.strip() + if line and not line.startswith("#"): + patterns.append(line) + return patterns + except OSError: + return [] + + def _matches_skip_pattern( + self, + rel_path: str, + skip_patterns: list[str], + extra_patterns: list[str], + ) -> bool: + """检查路径是否匹配 skip_patterns(用于目录级过滤)""" + basename = os.path.basename(rel_path) + all_patterns = skip_patterns + extra_patterns + negated = [] + matched = False + for pattern in all_patterns: + if pattern.startswith("!"): + negated.append(pattern[1:]) + continue + if fnmatch(rel_path, pattern) or fnmatch(basename, pattern): + matched = True + if not matched: + return False + for pattern in negated: + if fnmatch(rel_path, pattern) or fnmatch(basename, pattern): + return False + return True + + def _check_skip_pattern( + self, + rel_path: str, + skip_patterns: list[str], + extra_patterns: list[str], + ) -> str | None: + """检查文件是否匹配 skip_patterns,返回匹配的 pattern 或 None""" + import re + + basename = os.path.basename(rel_path) + all_patterns = skip_patterns + extra_patterns + negated = [] + matched_pattern = None + + for pattern in all_patterns: + if pattern.startswith("!"): + negated.append(pattern[1:]) + continue + if pattern.startswith("re:"): + regex_str = pattern[3:] + if len(regex_str) > 256: + continue + try: + if re.search(regex_str, rel_path) or re.search(regex_str, basename): + matched_pattern = pattern + except re.error: + pass + else: + if fnmatch(rel_path, pattern) or fnmatch(basename, pattern): + matched_pattern = pattern + + if not matched_pattern: + return None + + for pattern in negated: + if pattern.startswith("re:"): + regex_str = pattern[3:] + if len(regex_str) > 256: + continue + try: + if re.search(regex_str, rel_path) or re.search(regex_str, basename): + return None + except re.error: + pass + else: + if fnmatch(rel_path, pattern) or fnmatch(basename, pattern): + return None + + return matched_pattern + + def _find_matching_exclude(self, rel_path: str, exclude_patterns: list[str]) -> str: + """找到匹配的 exclude pattern""" + basename = os.path.basename(rel_path) + for p in exclude_patterns: + if fnmatch(rel_path, p) or fnmatch(basename, p): + return p + return "" + + def _check_disk_space(self, result: DryRunResult) -> None: + """检查目标目录是否有足够磁盘空间""" + folder = Path(self.folder_path) + try: + stat = os.statvfs(str(folder)) + free_bytes = stat.f_bavail * stat.f_frsize + if free_bytes < result.included_size: + result.warnings.append( + t( + "dryrun.warning.disk_space", + free=self._format_size(free_bytes), + need=self._format_size(result.included_size), + ) + ) + except (OSError, AttributeError): + # Windows 没有 statvfs,使用 shutil + try: + import shutil + + free_bytes, _, _ = shutil.disk_usage(str(folder)) + if free_bytes < result.included_size: + result.warnings.append( + t( + "dryrun.warning.disk_space", + free=self._format_size(free_bytes), + need=self._format_size(result.included_size), + ) + ) + except OSError: + pass + + @staticmethod + def _format_size(size_bytes: int) -> str: + """将字节数格式化为可读的大小字符串""" + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + elif size_bytes < 1024 * 1024 * 1024: + return f"{size_bytes / (1024 * 1024):.1f} MB" + else: + return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" diff --git a/sbackup/export.py b/sbackup/export.py new file mode 100644 index 0000000..3bb659f --- /dev/null +++ b/sbackup/export.py @@ -0,0 +1,226 @@ +"""元数据导出模块:支持导出备份历史和审计日志为 CSV/JSON 格式""" + +import csv +import json +import os +from datetime import datetime, timezone + +from sbackup.config import get_default_data_file + + +class MetadataExporter: + """备份元数据导出器""" + + def __init__(self, data_file: str = ""): + self.data_file = data_file or get_default_data_file() + + # ── 内部数据加载 ────────────────────────────────────────── + + def _load_data(self) -> dict: + """加载数据文件""" + if not os.path.exists(self.data_file): + return {} + try: + with open(self.data_file, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {} + + def _load_audit_entries(self) -> list[dict]: + """加载审计日志文件""" + from sbackup.audit import _default_audit_path + + audit_file = _default_audit_path() + if not os.path.exists(audit_file): + return [] + try: + with open(audit_file, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, list) else [] + except (json.JSONDecodeError, OSError): + return [] + + # ── 公共查询方法 ────────────────────────────────────────── + + def get_history(self, strategy_name: str = "") -> list[dict]: + """获取备份历史记录 + + :param strategy_name: 策略名称过滤(匹配 source 路径包含),空字符串导出全部 + """ + data = self._load_data() + history = data.get("_history", []) + if strategy_name: + history = [e for e in history if strategy_name in e.get("source", "")] + return history + + def get_audit_entries(self, event: str = "") -> list[dict]: + """获取审计日志条目 + + :param event: 事件类型过滤,空字符串导出全部 + """ + entries = self._load_audit_entries() + if event: + entries = [e for e in entries if e.get("event") == event] + return entries + + # ── 历史导出 ────────────────────────────────────────────── + + def export_history_csv(self, output_path: str, strategy_name: str = "") -> int: + """导出备份历史到 CSV 文件 + + 列: time, strategy, source, files_count, size, checksum + 返回导出的行数 + """ + history = self.get_history(strategy_name) + output_dir = os.path.dirname(output_path) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + + fieldnames = ["time", "strategy", "source", "files_count", "size", "checksum"] + with open(output_path, "w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + for entry in history: + source = entry.get("source", "") + writer.writerow( + { + "time": entry.get("time", ""), + "strategy": os.path.basename(source) if source else "", + "source": source, + "files_count": entry.get("files_count", 0), + "size": entry.get("size_mb", 0), + "checksum": entry.get("sha256", ""), + } + ) + return len(history) + + def export_history_json(self, output_path: str, strategy_name: str = "") -> int: + """导出备份历史到 JSON 文件(格式化输出) + + 返回导出的行数 + """ + history = self.get_history(strategy_name) + output_dir = os.path.dirname(output_path) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(history, f, ensure_ascii=False, indent=2) + return len(history) + + # ── 审计日志导出 ────────────────────────────────────────── + + def export_audit_csv(self, output_path: str, event: str = "") -> int: + """导出审计日志到 CSV 文件 + + 列: id, timestamp, event, status, source_path, target_path, + format, files_count, total_size, backup_size, duration, error_message + 返回导出的行数 + """ + entries = self.get_audit_entries(event) + output_dir = os.path.dirname(output_path) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + + fieldnames = [ + "id", + "timestamp", + "event", + "status", + "source_path", + "target_path", + "format", + "files_count", + "total_size", + "backup_size", + "duration", + "error_message", + ] + with open(output_path, "w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + for entry in entries: + writer.writerow({k: entry.get(k, "") for k in fieldnames}) + return len(entries) + + def export_audit_json(self, output_path: str, event: str = "") -> int: + """导出审计日志到 JSON 文件 + + 返回导出的行数 + """ + entries = self.get_audit_entries(event) + output_dir = os.path.dirname(output_path) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(entries, f, ensure_ascii=False, indent=2) + return len(entries) + + # ── 综合导出 ────────────────────────────────────────────── + + def _compute_stats(self) -> dict: + """计算历史和审计的统计信息""" + data = self._load_data() + history = data.get("_history", []) + audit_entries = self._load_audit_entries() + + total_size_mb = sum(e.get("size_mb", 0) for e in history) + total_files = sum(e.get("files_count", 0) for e in history) + + # 审计统计 + audit_event_counts: dict[str, int] = {} + for entry in audit_entries: + evt = entry.get("event", "") + audit_event_counts[evt] = audit_event_counts.get(evt, 0) + 1 + + return { + "history_count": len(history), + "audit_count": len(audit_entries), + "total_size_mb": round(total_size_mb, 2), + "total_files": total_files, + "audit_event_counts": audit_event_counts, + } + + def export_combined(self, output_path: str, fmt: str = "json") -> int: + """导出综合报告:历史 + 审计 + 统计信息 + + :param fmt: 输出格式,"json" 或 "csv" + :return: 导出的历史记录数 + 审计条目数 + """ + history = self.get_history() + audit_entries = self.get_audit_entries() + stats = self._compute_stats() + export_time = datetime.now(timezone.utc).isoformat() + + output_dir = os.path.dirname(output_path) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + + if fmt == "csv": + # CSV 综合导出:两个独立的 CSV 文件通过后缀区分 + history_path = output_path.replace(".csv", "_history.csv") + audit_path = output_path.replace(".csv", "_audit.csv") + self.export_history_csv(history_path) + self.export_audit_csv(audit_path) + # 额外写入统计信息到 JSON + stats_path = output_path.replace(".csv", "_stats.json") + with open(stats_path, "w", encoding="utf-8") as f: + json.dump( + {"export_time": export_time, "stats": stats}, + f, + ensure_ascii=False, + indent=2, + ) + return len(history) + len(audit_entries) + + # JSON 格式(默认) + report = { + "export_time": export_time, + "history": history, + "audit": audit_entries, + "stats": stats, + } + with open(output_path, "w", encoding="utf-8") as f: + json.dump(report, f, ensure_ascii=False, indent=2) + return len(history) + len(audit_entries) diff --git a/sbackup/handlers.py b/sbackup/handlers.py new file mode 100644 index 0000000..d12bcdf --- /dev/null +++ b/sbackup/handlers.py @@ -0,0 +1,624 @@ +"""命令处理器模块:SFTP 和 WebDAV 子命令的交互逻辑""" + +import getpass +import logging +import os +import sys +from sbackup.i18n import t + +logger = logging.getLogger(__name__) + + +def _validate_remote_filename(filename: str) -> str | None: + """验证远程文件名安全性,返回净化后的文件名或 None(不安全时) + 只允许纯文件名,拒绝路径分隔符和 .. 序列 + """ + if not filename: + return None + # 拒绝 null 字节注入 + if "\x00" in filename: + return None + # 拒绝路径分隔符 + if "/" in filename or "\\" in filename: + return None + # 拒绝 .. 序列 + if ".." in filename: + return None + # 拒绝绝对路径 + if os.path.isabs(filename): + return None + # 只取 basename(防御性编程) + safe_name = os.path.basename(filename) + if not safe_name or safe_name in (".", ".."): + return None + return safe_name + + +def _resolve_sftp_auth( + key_file: str, + key_passphrase: str, + password: str, + *, + interactive: bool = False, + allow_password_fallback: bool = True, +) -> tuple[str, str, str]: + """ + 解析 SFTP 认证凭据,返回 (key_file, key_passphrase, password)。 + + 优先使用私钥认证,自动检测默认密钥,必要时交互式提示输入密码短语。 + 用户放弃输入密码短语时,若 allow_password_fallback=True 则回退到密码认证, + 否则直接返回私钥路径(连接时再提示密码短语)。 + + :param key_file: 私钥文件路径(空字符串表示未指定) + :param key_passphrase: 私钥密码短语(空字符串表示未指定) + :param password: 密码(空字符串表示未指定) + :param interactive: 是否在缺少密码时交互式提示输入 + :param allow_password_fallback: 是否允许在密码短语缺失时回退到密码认证 + """ + from sbackup.sftp import SFTPClient + + # 已有完整私钥凭据,直接返回 + if key_file and key_passphrase: + return key_file, key_passphrase, "" + # 已有密码,直接返回 + if password: + return "", "", password + + # 尝试解析私钥 + effective_key = key_file + + if not effective_key: + # 未指定私钥,尝试默认位置 + default_key = SFTPClient.try_default_key() + if default_key: + print(t("cmd.sftp.using_default_key", path=default_key)) + effective_key = default_key + else: + # 无默认私钥,回退到密码 + if interactive and not password: + password = getpass.getpass(t("cli.prompt.sftp.password") + " ") + return "", "", password + + # 检测私钥是否需要密码短语 + resolved = SFTPClient.resolve_key_passphrase(effective_key) + if resolved is None: + if allow_password_fallback: + # 用户放弃输入密码短语,回退到密码认证 + if interactive and not password: + password = getpass.getpass(t("cli.prompt.sftp.password") + " ") + return "", "", password + # 不回退密码,返回私钥路径(使用时再提示密码短语) + return effective_key, "", "" + + return effective_key, resolved, "" + + +def handle_sftp(args, config) -> int: + """处理 sftp 子命令""" + from sbackup.config import save_sftp_config + from sbackup.sftp import SFTPClient, SFTPError + + if args.sftp_action == "config": + # 交互式配置 + host = args.host or input(t("cli.prompt.sftp.host") + " ") + port_str = ( + str(args.port) + if args.port is not None + else input(t("cli.prompt.sftp.port") + " ") + ) + try: + port = int(port_str) + if not (1 <= port <= 65535): + print(t("err.sftp.ssh", error=f"port {port} out of range")) + port = 22 + except ValueError: + print(t("err.sftp.ssh", error=f"invalid port: {port_str}")) + port = 22 + user = args.user or input(t("cli.prompt.sftp.user") + " ") + key_file_input = args.key_file or input(t("cli.prompt.sftp.key_file") + " ") + + # 警告:通过 CLI 传递密码会暴露在进程列表中 + if args.password: + print( + t("warn.password_in_cli", arg="--password"), + file=sys.stderr, + ) + if args.key_passphrase: + print( + t("warn.password_in_cli", arg="--key-passphrase"), + file=sys.stderr, + ) + + key_file, key_passphrase, password = _resolve_sftp_auth( + key_file_input, + args.key_passphrase or "", + args.password or "", + interactive=True, + allow_password_fallback=False, + ) + + remote_path = ( + args.remote_path or input(t("cli.prompt.sftp.remote_path") + " ") or "/" + ) + + save_sftp_config( + host, + port, + user, + password, + remote_path, + key_file=key_file, + key_passphrase=key_passphrase, + ) + print(t("cmd.sftp.config_saved")) + return 0 + + elif args.sftp_action == "test": + if not config.sftp_enabled or not config.sftp_host: + print(t("err.sftp.not_configured")) + return 1 + + key_file, key_passphrase, password = _resolve_sftp_auth( + config.sftp_key_file, + config.sftp_key_passphrase, + config.sftp_password, + ) + + if not key_file and not password: + print(t("cmd.sftp.no_default_key")) + return 1 + + print(t("cmd.sftp.testing", host=config.sftp_host)) + try: + client = SFTPClient( + config.sftp_host, + config.sftp_port, + config.sftp_user, + password, + key_file, + key_passphrase, + ) + client.connect() + client.disconnect() + print(t("cmd.sftp.test_ok")) + return 0 + except SFTPError as e: + print(str(e)) + return 1 + + print(t("cli.help.sftp.action")) + return 1 + + +def handle_webdav(args, config) -> int: + """处理 webdav 子命令""" + from sbackup.config import save_webdav_config + from sbackup.webdav import WebDAVClient, WebDAVError + + if args.webdav_action == "config": + # 交互式配置 + url = args.url or input(t("cli.prompt.webdav.url") + " ") + user = args.user or input(t("cli.prompt.webdav.user") + " ") + # 警告:通过 CLI 传递密码会暴露在进程列表中 + if args.password: + print( + t("warn.password_in_cli", arg="--password"), + file=sys.stderr, + ) + password = args.password or getpass.getpass( + t("cli.prompt.webdav.password") + " " + ) + remote_path = ( + args.remote_path or input(t("cli.prompt.webdav.remote_path") + " ") or "/" + ) + + save_webdav_config(url, user, password, remote_path) + print(t("cmd.webdav.config_saved")) + return 0 + + elif args.webdav_action == "test": + if not config.webdav_enabled or not config.webdav_url: + print(t("err.webdav.not_configured")) + return 1 + + print(t("cmd.webdav.testing", url=config.webdav_url)) + try: + client = WebDAVClient( + config.webdav_url, + config.webdav_user, + config.webdav_password, + ) + client.connect() + print(t("cmd.webdav.test_ok")) + return 0 + except WebDAVError as e: + print(str(e)) + return 1 + + print(t("cli.help.webdav.action")) + return 1 + + +def _format_file_size(size_bytes: int) -> str: + """格式化文件大小为人类可读格式""" + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + elif size_bytes < 1024 * 1024 * 1024: + return f"{size_bytes / (1024 * 1024):.2f} MB" + else: + return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" + + +def handle_remote(args, config) -> int: + """处理 remote 子命令""" + use_sftp = getattr(args, "sftp", False) + use_webdav = getattr(args, "webdav", False) + + # 如果两个都没指定,默认使用 sftp + if not use_sftp and not use_webdav: + use_sftp = True + + if use_sftp: + return _handle_remote_sftp(args, config) + elif use_webdav: + return _handle_remote_webdav(args, config) + else: + print(t("cmd.remote.select_protocol")) + return 1 + + +def _handle_remote_sftp(args, config) -> int: + """处理 SFTP 远程文件管理""" + import time as _time_mod + from sbackup.sftp import SFTPClient, SFTPError + + if not config.sftp_enabled or not config.sftp_host: + print(t("err.sftp.not_configured")) + return 1 + + key_file, key_passphrase, password = _resolve_sftp_auth( + config.sftp_key_file, + config.sftp_key_passphrase, + config.sftp_password, + ) + + if not key_file and not password: + print(t("cmd.sftp.no_default_key")) + return 1 + + try: + with SFTPClient( + config.sftp_host, + config.sftp_port, + config.sftp_user, + password, + key_file, + key_passphrase, + ) as client: + if args.remote_action == "list": + remote_path = getattr(args, "path", None) or config.sftp_remote_path + files = client.list_remote_files(remote_path) + if not files: + print(t("cmd.remote.empty", path=remote_path)) + return 0 + print(t("cmd.remote.list_header", path=remote_path, count=len(files))) + for f in files: + mtime_str = ( + _time_mod.strftime( + "%Y-%m-%d %H:%M:%S", _time_mod.localtime(f["mtime"]) + ) + if f["mtime"] + else "N/A" + ) + size_str = _format_file_size(f["size"]) + print(f" {f['name']:40s} {size_str:>10s} {mtime_str}") + return 0 + + elif args.remote_action == "rm": + safe_name = _validate_remote_filename(args.filename) + if not safe_name: + print(t("err.invalid_filename", filename=args.filename)) + return 1 + remote_path = config.sftp_remote_path.rstrip("/") + "/" + safe_name + client.delete_remote_file(remote_path) + print(t("cmd.remote.deleted", path=remote_path)) + return 0 + + except SFTPError as e: + print(str(e)) + return 1 + + print(t("cli.help.remote.action")) + return 1 + + +def _handle_remote_webdav(args, config) -> int: + """处理 WebDAV 远程文件管理""" + import time as _time_mod + from sbackup.webdav import WebDAVClient, WebDAVError + + if not config.webdav_enabled or not config.webdav_url: + print(t("err.webdav.not_configured")) + return 1 + + try: + client = WebDAVClient( + config.webdav_url, + config.webdav_user, + config.webdav_password, + ) + client.connect() + + if args.remote_action == "list": + remote_path = getattr(args, "path", None) or config.webdav_remote_path + files = client.list_remote_files(remote_path) + if not files: + print(t("cmd.remote.empty", path=remote_path)) + return 0 + print(t("cmd.remote.list_header", path=remote_path, count=len(files))) + for f in files: + mtime_str = ( + _time_mod.strftime( + "%Y-%m-%d %H:%M:%S", _time_mod.localtime(f["mtime"]) + ) + if f["mtime"] + else "N/A" + ) + size_str = _format_file_size(f["size"]) + print(f" {f['name']:40s} {size_str:>10s} {mtime_str}") + return 0 + + elif args.remote_action == "rm": + safe_name = _validate_remote_filename(args.filename) + if not safe_name: + print(t("err.invalid_filename", filename=args.filename)) + return 1 + remote_path = config.webdav_remote_path.rstrip("/") + "/" + safe_name + client.delete_remote_file(remote_path) + print(t("cmd.remote.deleted", path=remote_path)) + return 0 + + except WebDAVError as e: + print(str(e)) + return 1 + + print(t("cli.help.remote.action")) + return 1 + + +def handle_schedule(args, config) -> int: + """处理 schedule 子命令""" + if args.schedule_action == "export": + return _export_schedule(args, config) + elif args.schedule_action == "install": + return _handle_schedule_install(args, config) + print(t("cli.help.schedule.action")) + return 1 + + +def _export_schedule(args, config) -> int: + """导出定时调度配置""" + fmt = args.type + interval = args.interval + output = args.output or "" + + if fmt == "systemd": + content = _generate_systemd(interval) + elif fmt == "crontab": + content = _generate_crontab(interval) + elif fmt == "schtasks": + content = _generate_schtasks(interval) + else: + print(t("cmd.schedule.unknown_type", type=fmt)) + return 1 + + if output: + with open(output, "w", encoding="utf-8") as f: + f.write(content) + print(t("cmd.schedule.exported", path=output, type=fmt)) + else: + print(content) + return 0 + + +def _generate_systemd(interval_minutes: int) -> str: + """生成 systemd service + timer 配置""" + import shutil + + sbackup_path = shutil.which("sbackup") or "/usr/local/bin/sbackup" + service = f"""[Unit] +Description=Sbackup incremental backup +After=network.target + +[Service] +Type=oneshot +ExecStart={sbackup_path} save +""" + timer = f"""[Unit] +Description=Run sbackup every {interval_minutes} minutes + +[Timer] +OnBootSec=5min +OnUnitActiveSec={interval_minutes}min +Persistent=true + +[Install] +WantedBy=timers.target +""" + return ( + "# === sbackup.service ===\n" + + service + + "\n# === sbackup.timer ===\n" + + timer + + "\n# 安装方法:\n" + + "# sudo cp sbackup.service sbackup.timer /etc/systemd/system/\n" + + "# sudo systemctl daemon-reload\n" + + "# sudo systemctl enable --now sbackup.timer\n" + ) + + +def _generate_crontab(interval_minutes: int) -> str: + """生成 crontab 条目""" + import shutil + + sbackup_path = shutil.which("sbackup") or "/usr/local/bin/sbackup" + + if interval_minutes < 60: + cron_expr = f"*/{interval_minutes} * * * *" + elif interval_minutes < 1440: + hours = interval_minutes // 60 + cron_expr = f"0 */{hours} * * *" + else: + days = interval_minutes // 1440 + cron_expr = f"0 0 */{days} * *" + + return ( + f"# sbackup 定时备份 (每 {interval_minutes} 分钟)\n" + f"{cron_expr} {sbackup_path} save\n" + f"\n# 安装方法:\n" + f"# crontab -e 然后粘贴上面的行\n" + ) + + +def _generate_schtasks(interval_minutes: int) -> str: + """生成 Windows 计划任务 XML 和 schtasks 命令""" + import shutil + + sbackup_path = shutil.which("sbackup") or "sbackup" + + # 计算重复间隔(PT{H}H{M}M 格式) + hours = interval_minutes // 60 + minutes = interval_minutes % 60 + if hours > 0 and minutes > 0: + repetition = f"PT{hours}H{minutes}M" + elif hours > 0: + repetition = f"PT{hours}H" + else: + repetition = f"PT{minutes}M" + + xml = f""" + + + Sbackup incremental backup (every {interval_minutes} min) + + + + + {repetition} + false + + 2026-01-01T00:00:00 + true + + + + + {sbackup_path} + save + + + + IgnoreNew + false + false + true + true + +""" + + cmd = f'schtasks /create /tn "SbackupBackup" /tr "{sbackup_path} save" /sc MINUTE /mo {interval_minutes} /f' + + return ( + f"# === schtasks 命令(推荐) ===\n" + f"# 以管理员权限运行以下命令:\n" + f"{cmd}\n" + f"\n# === 或使用 XML 导入 ===\n" + f"# 保存为 sbackup_task.xml,然后运行:\n" + f'# schtasks /create /tn "SbackupBackup" /xml sbackup_task.xml /f\n' + f"\n{xml}" + ) + + +def _handle_schedule_install(args, config) -> int: + """生成并打印安装系统调度服务的命令""" + sbackup_cmd = f"{sys.executable} -m sbackup" + + if args.type == "systemd": + service_content = f"""[Unit] +Description=sbackup periodic backup +After=network.target + +[Service] +Type=oneshot +ExecStart={sbackup_cmd} save +User={args.user or os.environ.get("USER", "root")} + +[Install] +WantedBy=multi-user.target +""" + timer_content = f"""[Unit] +Description=sbackup backup timer + +[Timer] +OnCalendar=*:0/{args.interval} +Persistent=true + +[Install] +WantedBy=timers.target +""" + print(t("cmd.schedule.install.systemd")) + print() + print("# sbackup.service:") + print(service_content) + print("# sbackup.timer:") + print(timer_content) + print("# To install:") + print(" cat > /etc/systemd/system/sbackup.service << 'EOF'") + print(service_content.replace("'", "'\\''")) + print(" EOF") + print(" cat > /etc/systemd/system/sbackup.timer << 'EOF'") + print(timer_content.replace("'", "'\\''")) + print(" EOF") + print(" systemctl daemon-reload && systemctl enable --now sbackup.timer") + + elif args.type == "schtasks": + cmdline = f"{sbackup_cmd} save" + print( + t( + "cmd.schedule.install.schtasks_cmd", + interval=args.interval, + cmdline=cmdline, + ) + ) + + elif args.type == "launchd": + plist = f""" + + + + Label + com.sbackup.backup + ProgramArguments + + {sys.executable} + -m + sbackup + save + + StartInterval + {args.interval * 60} + RunAtLoad + + +""" + print(t("cmd.schedule.install.launchd")) + print() + print(plist) + print("# Save as ~/Library/LaunchAgents/com.sbackup.backup.plist") + print( + "# Then run: launchctl load ~/Library/LaunchAgents/com.sbackup.backup.plist" + ) + + return 0 diff --git a/sbackup/hooks.py b/sbackup/hooks.py new file mode 100644 index 0000000..b826851 --- /dev/null +++ b/sbackup/hooks.py @@ -0,0 +1,165 @@ +""" +Pre/Post Backup Hook 执行模块 + +提供 HookResult 数据类和 HookRunner 类,用于在备份前后执行用户自定义命令。 +""" + +import sys +import time +import logging +import subprocess +import shlex +from dataclasses import dataclass + +from sbackup.i18n import t + +logger = logging.getLogger(__name__) + + +@dataclass +class HookResult: + """单个 hook 命令的执行结果""" + + hook_type: str # "pre" or "post" + command: str + return_code: int | None # None if timeout + stdout: str + stderr: str + success: bool + duration: float # seconds + timed_out: bool = False + + +class HookRunner: + """执行 pre/post backup hooks 的运行器""" + + def __init__( + self, + pre_hooks: list[str] | None = None, + post_hooks: list[str] | None = None, + timeout: int = 300, + ): + self.pre_hooks = pre_hooks or [] + self.post_hooks = post_hooks or [] + self.timeout = timeout + + def run_hooks(self, hook_type: str) -> list[HookResult]: + """执行所有 pre 或 post hooks,返回结果列表""" + hooks = self.pre_hooks if hook_type == "pre" else self.post_hooks + results: list[HookResult] = [] + for cmd in hooks: + if not cmd: + continue + results.append(self.run_single(cmd, hook_type)) + return results + + def run_pre_hooks(self) -> list[HookResult]: + """执行所有 pre-hook""" + return self.run_hooks("pre") + + def run_post_hooks(self) -> list[HookResult]: + """执行所有 post-hook""" + return self.run_hooks("post") + + def run_single(self, command: str, hook_type: str) -> HookResult: + """执行单个 hook 命令""" + label = f"cmd.hook.{hook_type}_running" + print(t(label, command=command)) + + start = time.monotonic() + try: + # 跨平台命令解析 + # posix=True(默认):剥离引号,各平台行为一致 + # Windows 上 shlex.split 可能对某些命令格式报 ValueError,回退到 [command] + try: + args_list = shlex.split(command) + except ValueError: + if sys.platform == "win32": + args_list = [command] + else: + raise + + result = subprocess.run( + args_list, + shell=False, + timeout=self.timeout, + capture_output=True, + text=True, + ) + duration = time.monotonic() - start + success = result.returncode == 0 + + if not success: + logger.warning( + t("cmd.hook.failed", command=command, error=result.stderr.strip()) + ) + + return HookResult( + hook_type=hook_type, + command=command, + return_code=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + success=success, + duration=duration, + ) + except subprocess.TimeoutExpired as e: + duration = time.monotonic() - start + logger.warning(t("cmd.hook.failed", command=command, error="timeout")) + return HookResult( + hook_type=hook_type, + command=command, + return_code=None, + stdout=e.stdout or "", + stderr=e.stderr or "", + success=False, + duration=duration, + timed_out=True, + ) + except Exception as e: + duration = time.monotonic() - start + logger.warning(t("cmd.hook.failed", command=command, error=str(e))) + return HookResult( + hook_type=hook_type, + command=command, + return_code=None, + stdout="", + stderr=str(e), + success=False, + duration=duration, + ) + + def format_results(self, results: list[HookResult], lang: str = "zh_CN") -> str: + """格式化 hook 执行结果""" + if not results: + return t("cmd.hooks.no_results") + + lines: list[str] = [] + success_count = sum(1 for r in results if r.success) + fail_count = len(results) - success_count + + for r in results: + status_icon = "ok" if r.success else "FAIL" + if r.timed_out: + status_icon = "TIMEOUT" + line = t( + "cmd.hooks.result_line", + status=status_icon, + command=r.command, + duration=f"{r.duration:.1f}", + return_code=str(r.return_code) if r.return_code is not None else "N/A", + ) + lines.append(line) + if r.stderr.strip(): + for err_line in r.stderr.strip().splitlines(): + lines.append(f" stderr: {err_line}") + + lines.append( + t( + "cmd.hooks.summary", + total=len(results), + success=success_count, + fail=fail_count, + ) + ) + return "\n".join(lines) diff --git a/sbackup/i18n.py b/sbackup/i18n.py index 0c90164..f2afdde 100644 --- a/sbackup/i18n.py +++ b/sbackup/i18n.py @@ -34,6 +34,41 @@ def set_locale(lang: str) -> None: 设置当前语言环境并加载对应的翻译文件 """ global _current_locale, _translations + + # 安全检查:拒绝包含路径遍历字符的语言代码 + if not lang or "/" in lang or "\\" in lang or ".." in lang or ":" in lang: + logger.warning("i18n.set_locale.invalid_lang: %s", lang) + return + + # 白名单校验:只允许已知的语言代码格式 + _KNOWN_LOCALES = { + "zh_CN", + "en_US", + "fr_FR", + "es_ES", + "ru_RU", + "de_DE", + "ja_JP", + "pt_BR", + "ko_KR", + } + if lang not in _KNOWN_LOCALES: + logger.warning("i18n.set_locale.unknown_locale: %s", lang) + # 仍然设置 _current_locale 以保持向后兼容 + _current_locale = lang + # 回退到默认中文翻译 + default_file = os.path.join(_get_locales_dir(), "zh_CN.json") + if os.path.exists(default_file): + try: + with open(default_file, "r", encoding="utf-8") as f: + _translations = json.load(f) + except (json.JSONDecodeError, OSError) as e: + logger.warning( + t("log.i18n.load.fallback_failed", path=default_file, error=e) + ) + _translations = {} + return + _current_locale = lang locale_file = os.path.join(_get_locales_dir(), f"{lang}.json") if os.path.exists(locale_file): diff --git a/sbackup/integrity.py b/sbackup/integrity.py new file mode 100644 index 0000000..4e840e2 --- /dev/null +++ b/sbackup/integrity.py @@ -0,0 +1,220 @@ +""" +数据完整性校验模块:SHA256 校验和计算、校验文件生成与验证 +""" + +import hashlib +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path + +logger = logging.getLogger(__name__) + +BACKUP_EXTENSIONS = { + ".zip", + ".tar", + ".tar.gz", + ".tar.bz2", + ".tar.xz", + ".tar.zst", + ".7z", +} + + +def compute_checksum(filepath: str, chunk_size: int = 8192) -> str: + """计算文件的 SHA256 校验和。 + + Args: + filepath: 文件路径 + chunk_size: 每次读取的块大小 + + Returns: + SHA256 十六进制摘要字符串 + + Raises: + FileNotFoundError: 文件不存在 + OSError: 读取文件出错 + """ + sha256 = hashlib.sha256() + with open(filepath, "rb") as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + sha256.update(chunk) + return sha256.hexdigest() + + +def generate_checksum_file(filepath: str) -> str: + """为文件生成 `.sha256` 校验文件。 + + 校验文件格式: ``HASH FILENAME``(与 sha256sum 工具兼容)。 + + Args: + filepath: 源文件路径 + + Returns: + 生成的 .sha256 文件路径 + + Raises: + FileNotFoundError: 源文件不存在 + """ + filepath = os.path.abspath(filepath) + if not os.path.isfile(filepath): + raise FileNotFoundError(f"File not found: {filepath}") + + file_hash = compute_checksum(filepath) + filename = os.path.basename(filepath) + checksum_path = filepath + ".sha256" + + with open(checksum_path, "w", encoding="utf-8") as f: + f.write(f"{file_hash} {filename}\n") + + logger.debug("Checksum file generated: %s", checksum_path) + return checksum_path + + +def verify_checksum_file(filepath: str) -> tuple[bool, str]: + """验证文件是否与对应的 `.sha256` 校验文件匹配。 + + Args: + filepath: 源文件路径(会查找 ``filepath.sha256``) + + Returns: + (是否通过, 消息) + """ + filepath = os.path.abspath(filepath) + checksum_path = filepath + ".sha256" + + if not os.path.isfile(filepath): + return False, f"File not found: {filepath}" + if not os.path.isfile(checksum_path): + return False, f"Checksum file not found: {checksum_path}" + + with open(checksum_path, "r", encoding="utf-8") as f: + line = f.readline().strip() + + if not line: + return False, f"Checksum file is empty: {checksum_path}" + + # 解析 "HASH FILENAME" 格式 + parts = line.split(" ", 1) + if len(parts) != 2: + return False, f"Invalid checksum file format: {checksum_path}" + + expected_hash, expected_filename = parts + actual_filename = os.path.basename(filepath) + + if expected_filename != actual_filename: + return ( + False, + f"Filename mismatch: expected '{expected_filename}', got '{actual_filename}'", + ) + + actual_hash = compute_checksum(filepath) + if actual_hash == expected_hash: + return True, f"Checksum verified: {filepath}" + else: + return ( + False, + f"Checksum mismatch for {filepath}: " + f"expected {expected_hash[:16]}..., got {actual_hash[:16]}...", + ) + + +def generate_backup_integrity(backup_path: str) -> str: + """扫描备份目录下所有备份文件,生成 ``integrity.json``。 + + Args: + backup_path: 备份目录路径 + + Returns: + 生成的 integrity.json 文件路径 + + Raises: + NotADirectoryError: 路径不是目录 + OSError: 目录不存在 + """ + backup_path = os.path.abspath(backup_path) + if not os.path.isdir(backup_path): + raise NotADirectoryError(f"Not a directory: {backup_path}") + + files_map: dict[str, str] = {} + backup_dir = Path(backup_path) + + for ext in BACKUP_EXTENSIONS: + for filepath in backup_dir.glob(f"*{ext}"): + name = filepath.name + if name not in files_map: + logger.debug("Computing checksum for: %s", filepath) + files_map[name] = compute_checksum(str(filepath)) + + # 同时扫描带 .001 后缀的分卷文件(如 backup.zip.001) + for filepath in backup_dir.glob("*.001"): + name = filepath.name + if name not in files_map: + logger.debug("Computing checksum for split part: %s", filepath) + files_map[name] = compute_checksum(str(filepath)) + + integrity_data = { + "created": datetime.now(timezone.utc).isoformat(), + "files": dict(sorted(files_map.items())), + } + + integrity_path = os.path.join(backup_path, "integrity.json") + with open(integrity_path, "w", encoding="utf-8") as f: + json.dump(integrity_data, f, indent=2, ensure_ascii=False) + + logger.debug( + "Integrity manifest generated: %s (%d files)", integrity_path, len(files_map) + ) + return integrity_path + + +def verify_backup_integrity(backup_path: str) -> tuple[bool, list[str]]: + """使用 ``integrity.json`` 验证备份目录中所有文件的完整性。 + + Args: + backup_path: 备份目录路径 + + Returns: + (是否全部通过, 消息列表) + """ + backup_path = os.path.abspath(backup_path) + integrity_path = os.path.join(backup_path, "integrity.json") + + if not os.path.isfile(integrity_path): + return False, [f"Integrity manifest not found: {integrity_path}"] + + with open(integrity_path, "r", encoding="utf-8") as f: + try: + integrity_data = json.load(f) + except json.JSONDecodeError as e: + return False, [f"Invalid integrity manifest: {e}"] + + files_map = integrity_data.get("files", {}) + if not files_map: + return True, ["No files recorded in integrity manifest"] + + messages: list[str] = [] + all_ok = True + + for filename, expected_hash in files_map.items(): + filepath = os.path.join(backup_path, filename) + if not os.path.isfile(filepath): + messages.append(f"MISSING: {filename}") + all_ok = False + continue + + actual_hash = compute_checksum(filepath) + if actual_hash == expected_hash: + messages.append(f"OK: {filename}") + else: + messages.append( + f"FAILED: {filename} — expected {expected_hash[:16]}..., " + f"got {actual_hash[:16]}..." + ) + all_ok = False + + return all_ok, messages diff --git a/sbackup/keychain.py b/sbackup/keychain.py new file mode 100644 index 0000000..e17d4c1 --- /dev/null +++ b/sbackup/keychain.py @@ -0,0 +1,293 @@ +"""系统密钥链集成:跨平台安全存储密码 + +使用各平台原生 API 将密码存储在系统密钥链中,而不是明文存在 config.json。 + +支持平台: + - Windows: Credential Manager (Win32 API 通过 ctypes) + - macOS: Keychain (security 命令行工具) + - Linux: libsecret (secret-tool 命令行工具) + +用法:: + + from sbackup.keychain import get_password, set_password, delete_password + + # 存储密码 + set_password("sbackup", "sftp", "my_secret") + + # 读取密码 + pwd = get_password("sbackup", "sftp") + + # 删除密码 + delete_password("sbackup", "sftp") +""" + +import sys +import subprocess +import logging + +logger = logging.getLogger(__name__) + +SERVICE_NAME = "sbackup" + + +def get_password(service: str, username: str) -> str | None: + """从系统密钥链获取密码,返回 None 表示未找到或不可用""" + try: + if sys.platform == "win32": + return _win32_get_password(service, username) + elif sys.platform == "darwin": + return _darwin_get_password(service, username) + else: + return _linux_get_password(service, username) + except Exception: + logger.debug("Keychain get_password failed", exc_info=True) + return None + + +def set_password(service: str, username: str, password: str) -> bool: + """存储密码到系统密钥链,返回是否成功""" + try: + if sys.platform == "win32": + return _win32_set_password(service, username, password) + elif sys.platform == "darwin": + return _darwin_set_password(service, username, password) + else: + return _linux_set_password(service, username, password) + except Exception: + logger.debug("Keychain set_password failed", exc_info=True) + return False + + +def delete_password(service: str, username: str) -> bool: + """从系统密钥链删除密码,返回是否成功""" + try: + if sys.platform == "win32": + return _win32_delete_password(service, username) + elif sys.platform == "darwin": + return _darwin_delete_password(service, username) + else: + return _linux_delete_password(service, username) + except Exception: + logger.debug("Keychain delete_password failed", exc_info=True) + return False + + +def is_available() -> bool: + """检查系统密钥链是否可用""" + if sys.platform == "win32": + # Windows Credential Manager 通常总是可用 + try: + _win32_get_password("sbackup", "__probe__") + return True + except Exception: + return False + elif sys.platform == "darwin": + try: + subprocess.run( + ["security", "help"], + capture_output=True, + timeout=5, + ) + return True + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + else: + try: + subprocess.run( + ["secret-tool", "--help"], + capture_output=True, + timeout=5, + ) + return True + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +# ─── Windows ───────────────────────────────────────────────────────── + + +def _win32_get_password(service: str, username: str) -> str | None: + """使用 Win32 CredReadW API 读取凭据""" + import ctypes + from ctypes import wintypes + + advapi32 = ctypes.windll.advapi32 + + class CREDENTIALW(ctypes.Structure): + _fields_ = [ + ("Flags", wintypes.DWORD), + ("Type", wintypes.DWORD), + ("TargetName", ctypes.c_wchar_p), + ("Comment", ctypes.c_wchar_p), + ("LastWritten", wintypes.FILETIME), + ("CredentialBlobSize", wintypes.DWORD), + ("CredentialBlob", ctypes.c_void_p), + ("Persist", wintypes.DWORD), + ("AttributeCount", wintypes.DWORD), + ("Attributes", ctypes.c_void_p), + ("TargetAlias", ctypes.c_wchar_p), + ("UserName", ctypes.c_wchar_p), + ] + + target = f"{service}/{username}" + pcred = ctypes.POINTER(CREDENTIALW)() + + ret = advapi32.CredReadW(target, 1, 0, ctypes.byref(pcred)) + if not ret: + return None + + try: + cred = pcred.contents + if cred.CredentialBlob and cred.CredentialBlobSize > 0: + buf = (ctypes.c_char * cred.CredentialBlobSize).from_address( + cred.CredentialBlob + ) + return buf.value.decode("utf-16-le") + return None + finally: + advapi32.CredFree(pcred) + + +def _win32_set_password(service: str, username: str, password: str) -> bool: + """使用 Win32 CredWriteW API 存储凭据""" + import ctypes + from ctypes import wintypes + + advapi32 = ctypes.windll.advapi32 + + target = f"{service}/{username}" + blob = password.encode("utf-16-le") + + class CREDENTIALW(ctypes.Structure): + _fields_ = [ + ("Flags", wintypes.DWORD), + ("Type", wintypes.DWORD), + ("TargetName", ctypes.c_wchar_p), + ("Comment", ctypes.c_wchar_p), + ("LastWritten", wintypes.FILETIME), + ("CredentialBlobSize", wintypes.DWORD), + ("CredentialBlob", ctypes.c_void_p), + ("Persist", wintypes.DWORD), + ("AttributeCount", wintypes.DWORD), + ("Attributes", ctypes.c_void_p), + ("TargetAlias", ctypes.c_wchar_p), + ("UserName", ctypes.c_wchar_p), + ] + + cred = CREDENTIALW() + cred.Type = 1 # CRED_TYPE_GENERIC + cred.TargetName = target + cred.CredentialBlobSize = len(blob) + cred.CredentialBlob = ctypes.cast( + ctypes.create_string_buffer(blob), ctypes.c_void_p + ) + cred.Persist = 2 # CRED_PERSIST_LOCAL_MACHINE + cred.UserName = username + + ret = advapi32.CredWriteW(ctypes.byref(cred), 0) + return bool(ret) + + +def _win32_delete_password(service: str, username: str) -> bool: + """使用 Win32 CredDeleteW API 删除凭据""" + import ctypes + + advapi32 = ctypes.windll.advapi32 + target = f"{service}/{username}" + ret = advapi32.CredDeleteW(target, 1, 0) + return bool(ret) + + +# ─── macOS ─────────────────────────────────────────────────────────── + + +def _darwin_get_password(service: str, username: str) -> str | None: + """使用 macOS security CLI 读取钥匙串""" + result = subprocess.run( + ["security", "find-generic-password", "-s", service, "-a", username, "-w"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + return result.stdout.strip() + return None + + +def _darwin_set_password(service: str, username: str, password: str) -> bool: + """使用 macOS security CLI 存储到钥匙串""" + result = subprocess.run( + [ + "security", + "add-generic-password", + "-s", + service, + "-a", + username, + "-w", + password, + "-U", + ], + capture_output=True, + text=True, + timeout=10, + ) + return result.returncode == 0 + + +def _darwin_delete_password(service: str, username: str) -> bool: + """使用 macOS security CLI 从钥匙串删除""" + result = subprocess.run( + ["security", "delete-generic-password", "-s", service, "-a", username], + capture_output=True, + text=True, + timeout=10, + ) + return result.returncode == 0 + + +# ─── Linux ─────────────────────────────────────────────────────────── + + +def _linux_get_password(service: str, username: str) -> str | None: + """使用 secret-tool 读取密码""" + result = subprocess.run( + ["secret-tool", "lookup", "service", service, "username", username], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + return result.stdout.strip() + return None + + +def _linux_set_password(service: str, username: str, password: str) -> bool: + """使用 secret-tool 存储密码""" + result = subprocess.run( + [ + "secret-tool", + "store", + "--label", + f"sbackup/{username}", + "service", + service, + "username", + username, + ], + input=password, + text=True, + timeout=10, + ) + return result.returncode == 0 + + +def _linux_delete_password(service: str, username: str) -> bool: + """使用 secret-tool 删除密码""" + result = subprocess.run( + ["secret-tool", "clear", "service", service, "username", username], + capture_output=True, + text=True, + timeout=10, + ) + return result.returncode == 0 diff --git a/sbackup/locales/de_DE.json b/sbackup/locales/de_DE.json index 36c8efb..1a0b3cd 100644 --- a/sbackup/locales/de_DE.json +++ b/sbackup/locales/de_DE.json @@ -1,80 +1,677 @@ -{ - "cmd.add.success": "Backup-Strategie erfolgreich hinzugefügt: {source} -> {dest}", - "cmd.rm.success": "Backup-Strategie erfolgreich entfernt: {path}", - "err.folder.invalid": "Fehler: {path} ist kein gültiger Ordner oder existiert nicht.", - "err.dest.invalid": "Fehler: Zielordner {path} ist kein gültiger Ordner oder existiert nicht.", - "info.already.added": "Info: {path} wurde bereits hinzugefügt. Bitte nicht duplizieren.", - "warn.no.strategy.found": "Warnung: Backup-Strategie für {path} nicht gefunden.", - "warn.source.missing": "Warnung: Quellordner existiert nicht: {path}", - "warn.json.decode.error": "Warnung: Datendatei {path} ist fehlerhaft, Initialisierung als leeres Dictionary.", - "warn.json.backup": "Warnung: Fehlerhafte Datendatei wurde nach {path} kopiert.", - "warn.json.renamed": "Warnung: Fehlerhafte Datendatei wurde nach {path} umbenannt.", - "compress.success": "Backup erfolgreich: {path} ({size:.2f} MB, {count} Dateien)", - "compress.progress": "Komprimierung", - "compress.unit": "Dateien", - "err.permission": "Zugriff verweigert: Kann nicht in '{path}' schreiben", - "err.os": "Systemfehler: {error}", - "err.unknown": "Unbekannter Fehler: {error}", - "err.unknown.format": "Fehler: Unbekanntes Backup-Dateiformat {path}", - "err.argparse.invalid_choice": "ungültige Auswahl: ", - "err.argparse.choose_from": "auswählen aus", - "err.argparse.invalid_float": "ungültiger Float-Wert: ", - "err.argparse.invalid_int": "ungültiger Int-Wert: ", - "err.argparse.unrecognized_args": "unbekannte Argumente: ", - "err.argparse.required": "erforderlich", - "warn.zip.overwrite": "Warnung: Überschreiben der vorhandenen Backup-Datei {path}", - "warn.invalid.compresslevel": "Warnung: Komprimierungsstufe {level} außerhalb des Bereichs (0-9), Standardwert 6 wird verwendet", - "cmd.save.uptodate": "Alle Backup-Strategien sind aktuell, kein Backup erforderlich.", - "cmd.save.completed": "Backup abgeschlossen, {count} Strategie(n) bearbeitet.", - "cmd.all.empty": "Keine Backup-Strategien konfiguriert.", - "exit.message": "\nBeendet.", - "table.header.source": "Quellordner", - "table.header.dest": "Zielordner", - "table.header.ignore": " Ausschlussmuster", - "table.cell.none": "Keine", - "table.header.format": "Format", - "table.cell.default": "Standard", - "restore.progress": "Wiederherstellung", - "restore.success": "Wiederherstellung erfolgreich: {path} ({count} Dateien)", - "cmd.watch.start": "Überwachung gestartet, Intervall {interval} Min. (Ctrl+C zum Stoppen)", - - "cli.description": "Sbackup v{version} — Leichtes inkrementelles Backup-Tool\n\nSbackup hilft Ihnen, Multi-Ordner-Backup-Aufgaben einfach zu verwalten.\nEs nutzt eine inkrementelle Backup-Strategie, komprimiert nur geänderte Dateien,\nunterstützt benutzerdefinierte Ausschlussregeln und bietet intuitives Fortschrittsfeedback.\n\nVerfügbare Befehle:\n add Neue Backup-Strategie hinzufügen (Quelle -> Ziel)\n rm Existing Backup-Strategie entfernen\n all Alle konfigurierten Backup-Strategien anzeigen\n save Backup-Aufgaben ausführen\n version Versionsinformationen anzeigen", - "cli.epilog": "Beispiele:\n Backup-Strategie hinzufügen:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Strategie mit eintragsbezogenem Format hinzufügen:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Backup-Strategie entfernen:\n sbackup rm F:/my_folder\n\n Alle Strategien anzeigen:\n sbackup all\n\n Backup ausführen (ZIP):\n sbackup save\n\n Backup ausführen (tar.gz):\n sbackup --format tar.gz save\n\n Backup ausführen (7z verschlüsselt):\n sbackup --format 7z save --password 123456\n\n Letzte 5 Backups behalten:\n sbackup save --keep 5\n\n Geplantes Backup (alle 60 Min.):\n sbackup watch --interval 60\n\n Überwachung + Bereinigung:\n sbackup watch --interval 120 --keep 10\n\n Backup wiederherstellen:\n sbackup restore backup.7z F:/restored\n\n Chinesische Oberfläche:\n sbackup --lang zh_CN save\n\n Debug-Modus:\n sbackup --debug save", - "cli.help.debug": "Debug-Modus mit detaillierter Protokollierung aktivieren", - "cli.help.help": "Diese Hilfe anzeigen und beenden", - "cli.help.lang": "Sprache der Oberfläche festlegen: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", - "cli.help.format": "Archivformat festlegen: zip (Standard) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.subcommands": "Befehl zum Ausführen wählen", - "cli.help.add": "Neue Backup-Strategie hinzufügen", - "cli.help.add.source": "Quellordnerpfad: das zu sichernde Verzeichnis", - "cli.help.add.dest": "Zielordnerpfad: Speicherort für Backup-Dateien", - "cli.help.add.ignore": "Dateien oder Ordner ausschließen (komma-separiert, Standard: .git,__pycache__)", - "cli.help.add.format": "Eintragsbezogenes Archivformat (nur für diese Strategie, Standard global): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.rm": "Backup-Strategie entfernen", - "cli.help.rm.path": "Quellordnerpfad der zu entferndenden Strategie", - "cli.help.all": "Alle Backup-Strategien anzeigen", - "cli.help.save": "Alle Backup-Strategien ausführen", - "cli.help.save.keep": "Die letzten N Backup-Dateien behalten, 0 deaktiviert Bereinigung", - "cli.help.save.password": "Verschlüsselungspasswort (nur für 7z-Format)", - "cli.help.watch": "Backups regelmäßig ausführen", - "cli.help.watch.interval": "Backup-Intervall in Minuten, Standard 60", - "cli.help.watch.keep": "Die letzten N Backup-Dateien behalten", - "cli.help.watch.password": "Verschlüsselungspasswort (nur für 7z-Format)", - "cli.help.restore": "Aus einer Backup-Datei wiederherstellen", - "cli.help.restore.file": "Backup-Dateipfad (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", - "cli.help.restore.dir": "Zielverzeichnis für Wiederherstellung", - "cli.help.version": "Versionsinformationen anzeigen", - "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLizenz GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", - - "log.config.malformed": "Konfigurationsdatei %s ist fehlerhaft, Standardwerte werden verwendet", - "log.config.reset": "Konfigurationsdatei %s ist fehlerhaft, Zurücksetzen", - "log.config.mkdir.error": "Konfigurationsverzeichnis %s kann nicht erstellt werden: %s", - "log.config.write.error": "Konfigurationsdatei %s kann nicht geschrieben werden: %s", - "log.data.read": "Datendatei lesen: %s", - "log.data.create": "Datendatei existiert nicht, Erstellen: %s", - "log.data.load": "Existing Datendatei laden: %s", - "log.data.write": "Datendatei schreiben: %s", - "log.cleanup.delete": "Altes Backup gelöscht: %s", - "log.i18n.load.failed": "Locale {path} konnte nicht geladen werden: {error}", - "log.i18n.load.fallback_failed": "Fallback-Locale {path} konnte nicht geladen werden: {error}" +{ + "cmd.add.success": "Backup-Strategie erfolgreich hinzugefügt: {source} -> {dest}", + "cmd.rm.success": "Backup-Strategie erfolgreich entfernt: {path}", + "cmd.edit.success": "Backup-Strategie erfolgreich bearbeitet: {path}", + "cmd.edit.nothing": "Keine Änderungsparameter angegeben.", + "err.folder.invalid": "Fehler: {path} ist kein gültiger Ordner oder existiert nicht.", + "err.file.not_found": "Error: Backup-Datei {path} existiert nicht.", + "err.dest.invalid": "Fehler: Zielordner {path} ist kein gültiger Ordner oder existiert nicht.", + "info.already.added": "Info: {path} wurde bereits hinzugefügt. Bitte nicht duplizieren.", + "warn.no.strategy.found": "Warnung: Backup-Strategie für {path} nicht gefunden.", + "warn.source.missing": "Warnung: Quellordner existiert nicht: {path}", + "warn.json.decode.error": "Warnung: Datendatei {path} ist fehlerhaft, Initialisierung als leeres Dictionary.", + "warn.json.backup": "Warnung: Fehlerhafte Datendatei wurde nach {path} kopiert.", + "warn.json.renamed": "Warnung: Fehlerhafte Datendatei wurde nach {path} umbenannt.", + "compress.success": "Backup successful: {path} ({original:.2f} MB → {size:.2f} MB, {ratio:.0f}%, {count} files)", + "compress.progress": "Komprimierung", + "compress.unit": "Dateien", + "err.permission": "Zugriff verweigert: Kann nicht in '{path}' schreiben", + "err.os": "Systemfehler: {error}", + "err.unknown": "Unbekannter Fehler: {error}", + "err.unknown.format": "Fehler: Unbekanntes Backup-Dateiformat {path}", + "err.argparse.invalid_choice": "ungültige Auswahl: ", + "err.argparse.choose_from": "auswählen aus", + "err.argparse.invalid_float": "ungültiger Float-Wert: ", + "err.argparse.invalid_int": "ungültiger Int-Wert: ", + "err.argparse.unrecognized_args": "unbekannte Argumente: ", + "err.argparse.required": "erforderlich", + "warn.zip.overwrite": "Warnung: Überschreiben der vorhandenen Backup-Datei {path}", + "warn.invalid.compresslevel": "Warnung: Komprimierungsstufe {level} außerhalb des Bereichs (0-9), Standardwert 6 wird verwendet", + "cmd.save.uptodate": "Alle Backup-Strategien sind aktuell, kein Backup erforderlich.", + "cmd.save.completed": "Backup abgeschlossen, {count} Strategie(n) bearbeitet.", + "cmd.all.empty": "Keine Backup-Strategien konfiguriert.", + "cmd.list.empty": "Noch keine Backup-Historie.", + "cmd.verify.checking": "Überprüfe Backup: {path}", + "cmd.verify.success": "Überprüfung bestanden: {path} ({count} Dateien)", + "cmd.verify.failed": "Überprüfung fehlgeschlagen: {path} — erwartet {expected} Dateien, erhalten {actual}", + "cmd.info.header": "=== Backup-Datei-Informationen ===", + "cmd.info.path": "Pfad: {path}", + "cmd.info.format": "Format: {format}", + "cmd.info.size": "Größe: {size:.2f} MB", + "cmd.info.files": "Dateien: {count}", + "cmd.info.time": "Geändert: {time}", + "cmd.info.sha256": "SHA256: {checksum}", + "cmd.rm.all.confirm": "Alle {count} Backup-Strategien löschen? (j/N): ", + "cmd.rm.all.confirm_words": "y,yes", + "cmd.rm.all.success": "Alle {count} Backup-Strategien gelöscht.", + "cmd.rm.all.empty": "Keine Backup-Strategien zum Löschen vorhanden.", + "cmd.init.success": "Konfigurationsdatei erstellt: {path}", + "cmd.init.exists": "Konfigurationsdatei existiert bereits: {path}, übersprungen.", + "warn.disk.space": "Warnung: Zielverzeichnis {path} hat nicht genügend Speicherplatz (benötigt {need:.1f} MB, nur {free:.1f} MB frei)", + "cmd.save.summary": "Backup-Zusammenfassung: {total} Strategien, {backed} gesichert, {skipped} übersprungen, dauerte {elapsed:.1f}s", + "cmd.dry_run.header": "Dry-run preview (no backup will be performed):", + "cmd.dry_run.item": " {source} -> {target} format: {format} files: {count} size: {size} MB", + "cmd.dry_run.total": "Total: {count} strategies, {files} files, {size} MB", + "cmd.dry_run.nothing": "No strategies need backup (all are up to date).", + "cmd.verify.partial_fail": "Warning: {count} backup file(s) failed verification!", + "cmd.status.header": "=== Backup Status Dashboard ===", + "cmd.status.strategies": "Total strategies: {count}", + "cmd.status.history": "History records: {count}", + "cmd.status.total_size": "Total backup size: {size:.2f} MB", + "cmd.status.avg_ratio": "Average compression ratio: {ratio:.1f}%", + "cmd.status.strategy.header": "--- Strategy Details ---", + "cmd.status.strategy.item": " {source} -> {target}", + "cmd.status.strategy.last_backup": " Last backup: {time}", + "cmd.status.strategy.never": " Last backup: Never", + "cmd.status.strategy.format": " Format: {format}", + "cmd.status.strategy.size": " Target size: {size:.2f} MB", + "log.parallel.backup": "Parallele Sicherung von %d Strategien", + "restore.list.title": "Archivinhalt: {path}", + "restore.list.empty": "Archiv ist leer: {path}", + "log.ignore.loaded": "Ignorierregeln-Datei geladen: %s", + "exit.message": "\nBeendet.", + "table.header.source": "Quellordner", + "table.header.dest": "Zielordner", + "table.header.ignore": " Ausschlussmuster", + "table.header.time": "Backup-Zeit", + "table.header.size": "Größe(MB)", + "table.header.ratio": "Ratio", + "table.header.files": "Dateien", + "table.cell.none": "Keine", + "table.header.format": "Format", + "table.cell.default": "Standard", + "restore.progress": "Wiederherstellung", + "restore.success": "Wiederherstellung erfolgreich: {path} ({count} Dateien)", + "restore.skipped_unsafe": "{count} unsichere Archivmitglieder übersprungen (Pfadtraversal-Schutz)", + "cmd.watch.start": "Überwachung gestartet, Intervall {interval} Min. (Ctrl+C zum Stoppen)", + "cli.description": "Sbackup v{version} — Leichtes inkrementelles Backup-Tool\n\nSbackup hilft Ihnen, Multi-Ordner-Backup-Aufgaben einfach zu verwalten.\nEs nutzt eine inkrementelle Backup-Strategie, komprimiert nur geänderte Dateien,\nunterstützt benutzerdefinierte Ausschlussregeln und bietet intuitives Fortschrittsfeedback.\n\nVerfügbare Befehle:\n add Neue Backup-Strategie hinzufügen (Quelle -> Ziel)\n rm Existing Backup-Strategie entfernen\n all Alle konfigurierten Backup-Strategien anzeigen\n save Backup-Aufgaben ausführen\n version Versionsinformationen anzeigen", + "cli.epilog": "Beispiele:\n Backup-Strategie hinzufügen:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Backup mit Verschlüsselung (7z):\n sbackup --format 7z save --password 123456\n\n Wiederherstellen:\n sbackup restore backup.7z F:/restored\n\n SFTP Remote (Auto-Erkennung, empfohlen):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP Remote (Passwort):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP Remote (Schlüssel):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP Remote (Schlüssel + Passphrase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp", + "cli.help.debug": "Debug-Modus mit detailliertem Logging (nach Unterbefehl)", + "cli.help.help": "Diese Hilfe anzeigen und beenden", + "cli.help.lang": "Sprache der Oberfläche festlegen: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", + "cli.help.format": "Archivformat festlegen: zip (Standard) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.follow_symlinks": "Follow symbolic links (default: do not follow to avoid loops)", + "cli.help.incremental": "File-level incremental backup (only compress changed files, significantly reduces backup time)", + "cli.help.subcommands": "Befehl zum Ausführen wählen", + "cli.help.add": "Neue Backup-Strategie hinzufügen", + "cli.help.add.source": "Quellordnerpfad: das zu sichernde Verzeichnis", + "cli.help.add.dest": "Zielordnerpfad: Speicherort für Backup-Dateien", + "cli.help.add.ignore": "Dateien oder Ordner ausschließen (komma-separiert, Standard: .git,__pycache__)", + "cli.help.add.format": "Eintragsbezogenes Archivformat (nur für diese Strategie, Standard global): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.add.name_template": "Per-entry filename template (supports {name} {date} {time} variables, uses global default if not specified)", + "cli.help.rm": "Backup-Strategie entfernen", + "cli.help.rm.path": "Quellordnerpfad der zu entfernenden Strategie", + "cli.help.all": "Alle Backup-Strategien anzeigen", + "cli.help.save": "Alle Backup-Strategien ausführen", + "cli.help.save.keep": "Die letzten N Backup-Dateien behalten, 0 deaktiviert Bereinigung", + "cli.help.save.password": "Verschlüsselungspasswort (nur für 7z-Format)", + "cli.help.watch": "Backups regelmäßig ausführen", + "cli.help.watch.interval": "Backup-Intervall in Minuten, Standard 60", + "cli.help.watch.keep": "Die letzten N Backup-Dateien behalten", + "cli.help.watch.password": "Verschlüsselungspasswort (nur für 7z-Format)", + "cli.help.restore": "Aus einer Backup-Datei wiederherstellen", + "cli.help.restore.file": "Backup-Dateipfad (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", + "cli.help.restore.dir": "Zielverzeichnis für Wiederherstellung", + "cli.help.restore.password": "Entschlüsselungspasswort (zum Wiederherstellen verschüsselter 7z-Backups)", + "cli.help.restore.list": "Archivinhalt auflisten ohne zu extrahieren", + "cli.help.list": "Backup-Verlauf anzeigen", + "cli.help.verify": "Backup-Datei-Integrität überprüfen", + "cli.help.verify.file": "Zu überprüfende Backup-Datei", + "cli.help.verify.fast": "Schnellüberprüfung (historische SHA256 vergleichen, ohne Entpacken)", + "cli.help.verify.all": "Alle historischen Backups batchweise überprüfen", + "cli.help.verify.detail": "Detaillierte Überprüfungsinformationen anzeigen (Größe, Format, Prüfsumme etc.)", + "cli.help.verify.split": "Integrität der geteilten Dateien überprüfen (erfordert Manifest-Datei)", + "cli.help.verify.password": "Entschlüsselungspasswort (für verschlüsselte Backups)", + "cmd.verify.fast_success": "Schnellüberprüfung bestanden: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "Schnellüberprüfung fehlgeschlagen: {path}, Datei könnte beschädigt oder manipuliert sein", + "cmd.verify.no_checksum": "Keine historische Prüfsumme gefunden, vollständige Überprüfung: {path}", + "cmd.verify.checksum_mismatch": "Prüfsumme stimmt nicht überein", + "cmd.verify.no_history": "Kein Backup-Verlauf gefunden.", + "cmd.verify.no_file": "Bitte Backup-Dateipfad angeben oder --all verwenden, um alle Backups zu überprüfen.", + "cmd.verify.detail_header": "=== Backup-Details: {path} ===", + "cmd.verify.detail_info": "Größe: {size:.2f} MB | Dateien: {files} | Format: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ Überprüft: {path}", + "cmd.verify.batch_failed": "✗ Fehlgeschlagen: {path}", + "cmd.verify.batch_summary": "Batch-Überprüfung abgeschlossen: {success} bestanden, {fail} fehlgeschlagen", + "cmd.verify.split_success": "Integrität der geteilten Dateien bestätigt: {file} ({parts} Teile)", + "cmd.verify.split_failed": "Integritätsprüfung der geteilten Dateien fehlgeschlagen: {file}", + "cmd.verify.split_error": "Fehler bei der Überprüfung geteilter Dateien: {error}", + "cli.help.info": "Backup-Datei-Details anzeigen (Format, Größe, Dateianzahl, Prüfsumme)", + "cli.help.info.file": "Pfad zur Backup-Datei", + "cli.help.info.password": "Entschlüsselungspasswort (für verschlüsselte 7z-Backups)", + "cli.help.export": "Export all backup strategies to a JSON file", + "cli.help.export.file": "Export file path (default: sbackup_export.json)", + "cli.help.import": "Import backup strategies from a JSON file", + "cli.help.import.file": "JSON file to import", + "cli.help.status": "View backup status dashboard", + "cmd.export.success": "Exported {count} backup strategies to {path}", + "cmd.import.success": "Imported {count} backup strategies, skipped {skipped} existing", + "cmd.import.empty": "No valid backup strategies found in: {path}", + "cli.help.save.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.save.verify": "Verify backup integrity after completion", + "cli.help.save.name_template": "Backup filename template (supports {name} {date} {time} variables)", + "cli.help.save.webhook": "POST backup results to this URL after completion", + "cli.help.save.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", + "cli.help.save.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", + "cli.help.save.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", + "cli.help.watch.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.watch.verify": "Verify backup integrity after each backup", + "cli.help.watch.name_template": "Backup filename template (supports {name} {date} {time} variables)", + "cli.help.watch.webhook": "POST backup results to this URL after each backup", + "cli.help.watch.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", + "cli.help.watch.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", + "cli.help.watch.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", + "cli.help.init": "Standard-Konfigurationsdatei erstellen", + "cli.help.rm.all": "Alle Backup-Strategien löschen", + "cli.help.edit": "Vorhandene Backup-Strategie bearbeiten (ändert nur angegebene Felder)", + "cli.help.edit.source": "Quellordnerpfad (zum Lokalisieren der Strategie)", + "cli.help.edit.dest": "Zielordnerpfad ändern", + "cli.help.edit.ignore": "Ignoriermuster ändern (kommagetrennt)", + "cli.help.edit.format": "Packformat ändern: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.edit.name_template": "Dateinamensvorlage ändern (unterstützt Variablen {name} {date} {time})", + "cli.help.info": "Backup-Datei-Details anzeigen (Format, Größe, Dateianzahl, Prüfsumme usw.)", + "cli.help.info.file": "Backup-Dateipfad", + "cli.help.info.password": "Entschlüsselungspasswort (für verschlüsselte 7z-Backups)", + "cmd.info.header": "=== Backup-Datei-Informationen ===", + "cmd.info.path": "Dateipfad: {path}", + "cmd.info.format": "Komprimierungsformat: {format}", + "cmd.info.size": "Dateigröße: {size:.2f} MB", + "cmd.info.files": "Dateianzahl: {count}", + "cmd.info.time": "Geändert: {time}", + "cmd.info.sha256": "SHA256: {checksum}", + "cli.help.diff": "Unterschiede zwischen Quellverzeichnis und letztem Backup vergleichen", + "cli.help.diff.source": "Quellordnerpfad", + "cli.help.diff.file": "Backup-Dateipfad (sucht automatisch das neueste, wenn nicht angegeben)", + "cli.help.diff.password": "Entschlüsselungspasswort (für verschlüsselte Backups)", + "cli.help.remote": "Remote-Backup-Dateiverwaltung (auflöschen/löschen auf SFTP oder WebDAV)", + "cli.help.remote.action": "Remote-Aktion: list / rm", + "cli.help.remote.list": "Remote-Backup-Dateien auflisten", + "cli.help.remote.rm": "Remote-Backup-Datei löschen", + "cli.help.remote.rm.filename": "Zu löschender Remote-Dateiname", + "cli.help.remote.sftp": "Auf SFTP-Server operieren", + "cli.help.remote.webdav": "Auf WebDAV-Server operieren", + "cmd.remote.deleted": "Remote-Datei gelöscht: {path}", + "cmd.remote.empty": "Remote-Verzeichnis ist leer: {path}", + "cmd.remote.list_header": "Remote-Verzeichnis: {path} ({count} Dateien)", + "cmd.remote.rm_requires_path": "Bitte den zu löschenden Remote-Dateinamen angeben", + "cmd.remote.select_protocol": "Bitte Protokoll angeben: --sftp oder --webdav", + "cmd.diff.header": "=== Unterschiede ===\nBackup-Datei: {backup}", + "cmd.diff.added_header": "\nHinzugefügte Dateien ({count}):", + "cmd.diff.removed_header": "\nGelöschte Dateien ({count}):", + "cmd.diff.modified_header": "\nMöglicherweise geänderte Dateien ({count}):", + "cmd.diff.no_changes": "\nKeine Unterschiede, Quelle stimmt mit Backup überein.", + "cmd.diff.summary": "\nZusammenfassung: +{added} hinzugefügt, -{removed} gelöscht, ~{modified} geändert", + "cmd.diff.no_backup": "Keine Backup-Dateien im Zielverzeichnis gefunden: {path}", + "cmd.diff.empty_backup": "Backup-Datei ist leer: {path}", + "cli.help.version": "Versionsinformationen anzeigen", + "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLizenz GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", + "log.config.malformed": "Konfigurationsdatei %s ist fehlerhaft, Standardwerte werden verwendet", + "log.config.reset": "Konfigurationsdatei %s ist fehlerhaft, Zurücksetzen", + "log.config.mkdir.error": "Konfigurationsverzeichnis %s kann nicht erstellt werden: %s", + "log.config.write.error": "Konfigurationsdatei %s kann nicht geschrieben werden: %s", + "log.data.read": "Datendatei lesen: %s", + "log.data.create": "Datendatei existiert nicht, Erstellen: %s", + "log.data.load": "Existing Datendatei laden: %s", + "log.data.write": "Datendatei schreiben: %s", + "log.cleanup.delete": "Altes Backup gelöscht: %s", + "log.i18n.load.failed": "Locale {path} konnte nicht geladen werden: {error}", + "log.i18n.load.fallback_failed": "Fallback-Locale {path} konnte nicht geladen werden: {error}", + "err.sftp.auth": "SFTP-Authentifizierung fehlgeschlagen: {host}, Benutzername und Passwort prüfen", + "err.sftp.ssh": "SFTP-Verbindungsfehler: {error}", + "err.sftp.connect": "Verbindung zu {host}:{port} nicht möglich: {error}", + "err.sftp.not_connected": "SFTP nicht verbunden, rufen Sie zuerst connect() auf", + "err.sftp.local_not_found": "Lokale Datei nicht gefunden: {path}", + "err.sftp.upload": "Upload fehlgeschlagen {path}: {error}", + "err.sftp.mkdir": "Erstellen des Remote-Verzeichnisses fehlgeschlagen {path}: {error}", + "err.sftp.not_configured": "SFTP nicht konfiguriert, führen Sie aus: sbackup sftp config --host --user ", + "cmd.sftp.uploading": "Upload: {file}", + "cmd.sftp.progress": "Upload", + "cmd.sftp.success": "Upload erfolgreich: {file}", + "cmd.sftp.config_saved": "SFTP-Konfiguration in config.json gespeichert", + "cmd.sftp.testing": "Teste Verbindung zu {host}...", + "cmd.sftp.test_ok": "SFTP-Verbindungstest erfolgreich!", + "cmd.sftp.test_fail": "SFTP-Verbindungstest fehlgeschlagen, Konfiguration prüfen", + "cli.help.save.sftp": "Nach Backup zu SFTP-Server hochladen", + "cli.help.watch.sftp": "Nach jedem Backup zu SFTP-Server hochladen", + "cli.help.sftp": "SFTP Remote-Backup Verwaltung (Passwort und SSH-Schlüssel)", + "cli.help.sftp.action": "SFTP-Aktion: config / test", + "cli.help.sftp.config": "SFTP-Verbindungsparameter konfigurieren", + "cli.help.sftp.test": "SFTP-Verbindung testen", + "cli.help.sftp.host": "SFTP-Server-Adresse (z.B. 192.168.1.100)", + "cli.help.sftp.port": "SFTP-Port (Standard 22)", + "cli.help.sftp.user": "SFTP-Benutzername", + "cli.help.sftp.password": "SFTP-Passwort (nur Passwort-Authentifizierung)", + "cli.help.sftp.remote_path": "Remote-Zielpfad (Standard: /)", + "cli.prompt.sftp.host": "SFTP-Server-Adresse:", + "cli.prompt.sftp.port": "SFTP-Port (Standard 22):", + "cli.prompt.sftp.user": "SFTP-Benutzername:", + "cli.prompt.sftp.password": "SFTP-Passwort:", + "cli.prompt.sftp.remote_path": "Remote-Pfad (Standard /):", + "err.sftp.key_load": "Privater Schlüssel {path} kann nicht geladen werden, Format prüfen (RSA/Ed25519/ECDSA)", + "cli.help.sftp.key_file": "Pfad zum privaten SSH-Schlüssel (RSA/Ed25519/ECDSA, Vorrang vor Passwort)", + "cli.help.sftp.key_passphrase": "Passphrase des privaten Schlüssels (interaktiv eingebbar)", + "cli.prompt.sftp.key_file": "Pfad zum privaten SSH-Schlüssel (leer für Passwort):", + "cli.prompt.sftp.key_passphrase": "Schlüssel-Passphrase (Enter zum Überspringen):", + "cmd.sftp.using_default_key": "Standard SSH-Schlüssel erkannt, verwendet: {path}", + "cmd.sftp.no_default_key": "Kein Standard SSH-Schlüssel gefunden, Passwort konfigurieren oder Pfad angeben", + "err.sftp.key_needs_passphrase": "Schlüssel {path} benötigt Passphrase, eingeben oder --key-passphrase verwenden", + "err.sftp.wrong_passphrase": "Falsche Passphrase, erneut versuchen (Enter zum Überspringen)", + "cli.help.webdav": "WebDAV Remote-Backup-Verwaltung (unterstützt Jianguoyun, NextCloud, Synology usw.)", + "cli.help.webdav.action": "WebDAV-Aktion: config / test", + "cli.help.webdav.config": "WebDAV-Verbindungsparameter konfigurieren", + "cli.help.webdav.test": "WebDAV-Verbindung testen", + "cli.help.webdav.url": "WebDAV-Server-URL (z.B. https://dav.jianguoyun.com/dav/)", + "cli.help.webdav.user": "WebDAV-Benutzername", + "cli.help.webdav.password": "WebDAV-Passwort", + "cli.help.webdav.remote_path": "Remote-Zielpfad (Standard: /)", + "cli.help.save.webdav": "Nach Backup an WebDAV-Server hochladen", + "cli.help.watch.webdav": "Nach jedem Backup an WebDAV-Server hochladen", + "cli.prompt.webdav.url": "WebDAV-Server-URL:", + "cli.prompt.webdav.user": "WebDAV-Benutzername:", + "cli.prompt.webdav.password": "WebDAV-Passwort:", + "cli.prompt.webdav.remote_path": "Remote-Pfad (Standard /):", + "cmd.webdav.config_saved": "WebDAV-Konfiguration in config.json gespeichert", + "cmd.webdav.testing": "Teste WebDAV-Verbindung zu {url}...", + "cmd.webdav.test_ok": "WebDAV-Verbindungstest erfolgreich!", + "cmd.webdav.uploading": "Hochladen zu WebDAV: {file}", + "cmd.webdav.success": "WebDAV-Upload erfolgreich: {file}", + "err.webdav.auth": "WebDAV-Authentifizierung fehlgeschlagen: {host}", + "err.webdav.connect": "Kann keine Verbindung zu WebDAV herstellen: {url}, Fehler: {error}", + "err.webdav.ssh": "WebDAV-Verbindungsfehler: {error}", + "err.webdav.not_configured": "WebDAV nicht konfiguriert, führen Sie aus: sbackup webdav config --url --user ", + "err.webdav.local_not_found": "Lokale Datei nicht gefunden: {path}", + "err.webdav.upload": "Upload zu WebDAV fehlgeschlagen {path}: {error}", + "err.webdav.mkdir": "WebDAV-Remote-Verzeichnis erstellen fehlgeschlagen {path}: {error}", + "cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)", + "cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)", + "cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)", + "log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)", + "log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)", + "log.monitor.source_missing": "Monitor source directory not found, skipping: {path}", + "log.monitor.watching": "Now monitoring: {path}", + "log.monitor.no_sources": "No valid source directories to monitor, monitor not started", + "benchmark.empty_source": "Quellverzeichnis ist leer: {path}", + "benchmark.error": "Benchmark fehlgeschlagen {format}: {error}", + "benchmark.header": "═══ Komprimierungs-Benchmark ═══", + "benchmark.recommended": "Empfohlen: {format} Stufe={level} ({size}, beste Balance)", + "benchmark.running": "Teste {format} Stufe={level}...", + "benchmark.source_info": "Quellverzeichnis: {path} ({size}, {count} Dateien)", + "cli.help.add.from_gitignore": "Ignorierregeln aus .gitignore-Datei importieren", + "cli.help.audit": "Backup-Audit-Protokoll anzeigen", + "cli.help.audit.action": "Audit-Aktion: log(Abfrage) / stats(Statistik) / cleanup(Bereinigung)", + "cli.help.audit.cleanup": "Alte Audit-Protokolleinträge bereinigen", + "cli.help.audit.cleanup.keep_days": "Einträge der letzten N Tage behalten (Standard: 90)", + "cli.help.audit.log": "Audit-Protokolleinträge abfragen", + "cli.help.audit.log.event": "Nach Ereignistyp filtern", + "cli.help.audit.log.limit": "Maximal anzuzeigende Einträge (Standard: 50)", + "cli.help.audit.log.since": "Einträge nach diesem Datum anzeigen (ISO 8601)", + "cli.help.audit.log.status": "Nach Status filtern: success / failed / partial", + "cli.help.audit.stats": "Audit-Protokoll-Statistik anzeigen", + "cli.help.benchmark": "Komprimierungs-Benchmark für ein Verzeichnis ausführen und Formate vergleichen", + "cli.help.benchmark.levels": "Zu testende Komprimierungsstufen (kommagetrennt, Standard: 1,6,9)", + "cli.help.benchmark.password": "Verschlüsselungspasswort (für 7z-Format)", + "cli.help.benchmark.path": "Pfad zum Quellverzeichnis für den Test", + "cli.help.benchmark.quick": "Schnellmodus: Nur Standardstufe jedes Formats testen", + "cli.help.checksum": "Änderungserkennung über SHA256-Prüfsummen (genauer als mtime, aber langsamer)", + "cli.help.clean": "Backup-Dateien aller Strategien intelligent bereinigen", + "cli.help.clean.dry_run": "Nur Vorschau der zu löschenden Dateien, keine tatsächliche Löschung", + "cli.help.clean.keep": "Die letzten N Backup-Dateien pro Strategie behalten", + "cli.help.clean.keep_days": "Backup-Dateien älter als N Tage löschen", + "cli.help.completion": "Shell-Autovervollständigungsskript generieren", + "cli.help.completion.shell": "Shell-Typ: bash(Standard) / zsh / fish / powershell", + "cli.help.compress": "Ordner direkt komprimieren (ohne Strategie)", + "cli.help.compress.dry_run": "Nur Vorschau der zu sichernden Dateien, keine tatsächliche Komprimierung", + "cli.help.compress.format": "Komprimierungsformat: zip(Standard) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.compress.output": "Ausgabedateipfad (optional)", + "cli.help.compress.password": "Verschlüsselungspasswort (nur 7z-Format)", + "cli.help.compress.source": "Pfad zum Quellordner", + "cli.help.config_cmd": "Konfigurationsdatei verwalten (sensible Felder verschlüsseln/entschlüsseln)", + "cli.help.config_cmd.action": "Aktion: lock(Verschlüsseln) / unlock(Entschlüsseln)", + "cli.help.config_cmd.lock": "Sensible Felder in der Konfigurationsdatei mit dem Masterpasswort verschlüsseln", + "cli.help.config_cmd.lock.password": "Masterpasswort (wenn nicht angegeben, interaktive Eingabe)", + "cli.help.config_cmd.unlock": "Sensible Felder in der Konfigurationsdatei mit dem Masterpasswort entschlüsseln", + "cli.help.config_cmd.unlock.password": "Masterpasswort (wenn nicht angegeben, interaktive Eingabe)", + "cli.help.config_cmd.validate": "Aktuelle Konfiguration validieren und alle Fehler/Warnungen anzeigen", + "cli.help.dedup": "Inhaltsbasierte (SHA256) Datei-Deduplizierung über Strategien hinweg aktivieren", + "cli.help.diff.detail": "Zeilenweise Unterschiede geänderter Dateien anzeigen", + "cli.help.diskcheck": "Vor dem Backup Speicherplatz prüfen (Schätzung von Quell- und Backupgröße)", + "cli.help.diskcheck.level": "Komprimierungsstufe (0-9, Standard: 6)", + "cli.help.diskcheck.source": "Pfad zum Quellverzeichnis zum Scannen", + "cli.help.diskcheck.target": "Pfad zum Zielverzeichnis (Speicherort für Backups)", + "cli.help.hooks": "Backup-Vor-/Nach-Hooks verwalten und manuell ausführen", + "cli.help.hooks.action": "Hook-Aktion: run(Ausführen)", + "cli.help.hooks.run": "Backup-Vor- oder Nach-Hook manuell ausführen", + "cli.help.hooks.run.timeout": "Hook-Timeout überschreiben (Sekunden) (Standard: Konfigurationswert)", + "cli.help.hooks.run.type": "Hook-Typ: pre(Vor) oder post(Nach)", + "cli.help.ignore": ".sbackupignore-Ausschlussregel-Datei generieren", + "cli.help.ignore.list": "Alle verfügbaren Vorlagen auflisten", + "cli.help.ignore.output": "Ausgabedateipfad (Standard: .sbackupignore)", + "cli.help.ignore.preset": "Vorlage: node / python / go / rust / java / general (Standard: general)", + "cli.help.integrity": "Datenintegritätsprüfung (Prüfliste generieren/verifizieren)", + "cli.help.integrity.action": "Integritätsaktion: generate(Generieren) / verify(Verifizieren)", + "cli.help.integrity.generate": "Integritätsprüfdatei integrity.json für das Backup-Verzeichnis generieren", + "cli.help.integrity.path": "Pfad zum Backup-Verzeichnis", + "cli.help.integrity.verify": "Backup-Dateien anhand der integrity.json-Prüfliste verifizieren", + "cli.help.list.tags": "Alle Backup-Tags auflisten", + "cli.help.metadata_export": "Backup-Metadaten (Verlauf/Audit-Protokoll) exportieren", + "cli.help.metadata_export.action": "Export-Aktion: history(Verlauf) / audit(Audit) / all(Zusammenfassung)", + "cli.help.metadata_export.all": "Zusammenfassungsbericht exportieren (Verlauf + Audit + Statistik)", + "cli.help.metadata_export.audit": "Audit-Protokoll als CSV oder JSON exportieren", + "cli.help.metadata_export.event": "Nach Ereignistyp filtern", + "cli.help.metadata_export.format": "Ausgabeformat: csv (Standard für history/audit) oder json", + "cli.help.metadata_export.history": "Backup-Verlauf als CSV oder JSON exportieren", + "cli.help.metadata_export.output": "Ausgabedateipfad", + "cli.help.metadata_export.strategy": "Nach Strategiename filtern (stimmt mit Quellpfad überein)", + "cli.help.multi_backup": "Multi-Ziel-Backup: Lokale Sicherung + paralleler Upload zu allen aktivierten Remote-Zielen", + "cli.help.multi_backup.dry_run": "Nur Vorschau der zu sichernden Dateien, keine tatsächliche Ausführung", + "cli.help.multi_backup.keep": "Die letzten N Backup-Dateien behalten (0 = keine Bereinigung)", + "cli.help.multi_backup.keep_days": "Backup-Dateien der letzten N Tage behalten", + "cli.help.multi_backup.local_target": "Pfad zum lokalen Backup-Zielordner", + "cli.help.multi_backup.name_template": "Vorlage für Backup-Dateinamen", + "cli.help.multi_backup.password": "Verschlüsselungspasswort (nur 7z-Format)", + "cli.help.multi_backup.source": "Pfad zum Quellordner", + "cli.help.multi_backup.verify": "Integrität nach dem Backup automatisch prüfen", + "cli.help.profile": "Konfigurationsprofile verwalten", + "cli.help.profile.action": "Aktion: list(Liste) / create(Erstellen) / delete(Löschen) / use(Verwenden) / export(Exportieren) / import(Importieren)", + "cli.help.profile.create.name": "Name des Profils, das aus der aktuellen Konfiguration erstellt werden soll", + "cli.help.profile.delete.name": "Name des zu löschenden Profils", + "cli.help.profile.export.name": "Name des zu exportierenden Profils", + "cli.help.profile.export.path": "Pfad der Exportdatei", + "cli.help.profile.import.name": "Profilname (Standard: Dateiname)", + "cli.help.profile.import.path": "Pfad der Importdatei", + "cli.help.profile.use.name": "Name des temporär zu verwendenden Profils", + "cli.help.profile_name": "Angegebenes Profil in allen Befehlen verwenden", + "cli.help.remote.list_path": "Remote-Verzeichnispfad angeben (Standard: konfigurierter Standardpfad)", + "cli.help.report": "Backup-Bericht im Markdown-Format generieren", + "cli.help.report.format": "Berichtformat: text(Text) oder html(HTML)", + "cli.help.report.output": "Ausgabedateipfad (wenn nicht angegeben, wird im Terminal ausgegeben)", + "cli.help.restore.search": "Dateinamen in der Backup-Datei suchen (ohne Berücksichtigung der Groß-/Kleinschreibung)", + "cli.help.restore.select": "Selektive Wiederherstellung: Nur Dateien nach Muster extrahieren (glob-fähig, z.B. \"*.py\" oder \"src/main.py\")", + "cli.help.restore.stats": "Statistiken der Backup-Datei anzeigen (Dateianzahl, Größenverteilung)", + "cli.help.restore.tag": "Backup nach Tag wiederherstellen", + "cli.help.rotate": "Backups nach Aufbewahrungsrichtlinie rotieren", + "cli.help.rotate.backup_dir": "Verzeichnis mit Backup-Dateien", + "cli.help.rotate.dry_run": "Nur Vorschau der zu löschenden Dateien, keine tatsächliche Löschung", + "cli.help.rotate.keep_count": "Die letzten N Backup-Dateien behalten (0 = unbegrenzt)", + "cli.help.rotate.keep_daily": "Ein Backup pro Tag für die letzten N Tage behalten (0 = unbegrenzt)", + "cli.help.rotate.keep_days": "Backup-Dateien der letzten N Tage behalten (0 = unbegrenzt)", + "cli.help.rotate_list": "Alle Backup-Dateien in einem Verzeichnis auflisten", + "cli.help.rotate_list.backup_dir": "Zu scannendes Backup-Verzeichnis", + "cli.help.save.keep_days": "Backup-Dateien der letzten N Tage behalten, 0 deaktiviert Zeitbereinigung (Standard: 0)", + "cli.help.save.multi_dest": "Multi-Ziel-Backup-Modus aktivieren", + "cli.help.save.post_hook": "Befehl nach dem Backup ausführbar (mehrfach verwendbar)", + "cli.help.save.pre_hook": "Befehl vor dem Backup ausführbar (mehrfach verwendbar)", + "cli.help.save.split": "Teilungsgröße für Backup-Dateien (z.B. 100MB, 1GB), automatische Aufteilung bei Überschreitung", + "cli.help.save.tag": "Tag für dieses Backup hinzufügen (z.B. daily, release)", + "cli.help.schedule": "Zeitgesteuerte Konfiguration verwalten", + "cli.help.schedule.action": "Aktion: export(Konfiguration exportieren) / install(Dienst installieren)", + "cli.help.schedule.export": "Zeitgesteuerte Konfiguration exportieren", + "cli.help.schedule.install": "Zeitgesteuertes Backup als Systemdienst installieren", + "cli.help.schedule.install.interval": "Backup-Intervall (Minuten, Standard: 60)", + "cli.help.schedule.install.type": "Diensttyp: systemd / schtasks / launchd", + "cli.help.schedule.install.user": "Benutzer für den Dienst (Standard: aktueller Benutzer)", + "cli.help.schedule.interval": "Backup-Intervall (Minuten, Standard: 60)", + "cli.help.schedule.output": "Ausgabedateipfad (wenn nicht angegeben, wird im Terminal ausgegeben)", + "cli.help.schedule.type": "Typ: systemd / crontab (Standard: systemd)", + "cli.help.schedule.type.schtasks": "Windows-Aufgabenplanung", + "cli.help.search": "Dateinamen in Backup-Dateien suchen", + "cli.help.search.in": "Backup-Dateipfad angeben (wenn nicht angegeben, wird in allen neuesten Strategie-Backups gesucht)", + "cli.help.search.password": "Entschlüsselungspasswort (für verschlüsselte Backups)", + "cli.help.search.pattern": "Suchmuster (glob-fähig, z.B. *.txt)", + "cli.help.strict": "Backup abbrechen bei unzureichendem Speicherplatz (ohne Angabe nur Warnung)", + "cli.help.task": "Aufgabenwarteschlange verwalten (Backup-Aufgaben hinzufügen/ausführen/abbrechen)", + "cli.help.task.action": "Aktion: add(Hinzufügen) / list(Liste) / run(Ausführen) / run-all(Alle ausführen) / cancel(Abbrechen) / clear(Löschen) / stats(Statistik)", + "cli.help.task.add": "Neue Backup-Aufgabe zur Warteschlange hinzufügen", + "cli.help.task.add.folder": "Zu sichernder Ordnerpfad", + "cli.help.task.add.format": "Komprimierungsformat: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.task.add.name": "Aufgabenname (zur Identifikation)", + "cli.help.task.add.output": "Ausgabedateipfad (optional)", + "cli.help.task.cancel": "Eine ausstehende Aufgabe abbrechen", + "cli.help.task.cancel.id": "ID der abzubrechenden Aufgabe", + "cli.help.task.clear": "Abgeschlossene und fehlgeschlagene Aufgaben aus der Warteschlange entfernen", + "cli.help.task.list": "Aufgaben in der Warteschlange auflisten", + "cli.help.task.list.status": "Nach Status filtern: pending / running / completed / failed", + "cli.help.task.run": "Nächste ausstehende Aufgabe in der Warteschlange ausführen", + "cli.help.task.run_all": "Alle ausstehenden Aufgaben nacheinander ausführen", + "cli.help.task.stats": "Statistik der Aufgabenwarteschlange anzeigen", + "cli.help.versions": "Backup-Versionsverlauf anzeigen (gruppiert nach Quellverzeichnis)", + "cli.help.versions.source": "Quellverzeichnispfad angeben (wenn nicht angegeben, werden alle angezeigt)", + "cli.help.versions.tag": "Nach Tag filtern", + "cli.help.watch.keep_days": "Backup-Dateien der letzten N Tage behalten (Standard: 0)", + "cli.help.watch.post_hook": "Befehl nach dem Backup ausführbar (mehrfach verwendbar)", + "cli.help.watch.pre_hook": "Befehl vor dem Backup ausführbar (mehrfach verwendbar)", + "cli.help.watch.split": "Teilungsgröße für Backup-Dateien (z.B. 100MB, 1GB)", + "cli.help.watch.tag": "Tag für dieses Backup hinzufügen", + "cli.help.webhook_cmd": "Webhook-Benachrichtigungen verwalten", + "cli.help.webhook_cmd.action": "Webhook-Aktion: preset(Vorlage einstellen) / list(Vorlagen auflisten)", + "cli.help.webhook_cmd.list": "Alle verfügbaren IM-Benachrichtigungsvorlagen auflisten", + "cli.help.webhook_cmd.preset": "IM-Benachrichtigungsvorlage einstellen", + "cli.help.webhook_cmd.preset.name": "Vorlagenname: dingtalk / feishu / wechat", + "cli.help.wizard": "Interaktiver Einrichtungsassistent für die Erstkonfiguration", + "cli.help.xsearch": "Suche über Archive hinweg: Dateinamen in mehreren Backup-Dateien suchen", + "cli.help.xsearch.dirs": "Verzeichnisliste für die rekursive Backup-Suche", + "cli.help.xsearch.ext": "Nach Erweiterung suchen (z.B. .log, .py)", + "cli.help.xsearch.keyword": "Suchbegriff (ohne Berücksichtigung der Groß-/Kleinschreibung, Dateiname)", + "cli.help.xsearch.output_lang": "Ausgabesprache: zh_CN(Chinesisch) / en_US(Englisch)", + "cli.help.xsearch.password": "Entschlüsselungspasswort (für verschlüsselte Backups)", + "cli.help.xsearch.pattern": "Glob-Muster für Dateinamen (z.B. \"*.log\")", + "cmd.add.gitignore_empty": "Keine Regeln aus {path} importiert", + "cmd.add.gitignore_imported": "{count} Ignorierregeln aus {path} importiert", + "cmd.audit.cleanup.done": "{count} alte Audit-Protokolleinträge bereinigt.", + "cmd.audit.cleanup.none": "Keine alten Einträge zum Bereinigen vorhanden.", + "cmd.clean.dry_run_header": "Vorschau-Modus (keine tatsächliche Löschung):", + "cmd.clean.no_criteria": "Bitte Bereinigungskriterium angeben: --keep N oder --keep-days N", + "cmd.clean.nothing": "Keine Backup-Dateien zum Bereinigen.", + "cmd.clean.summary": "Insgesamt {count} Backup-Dateien bereinigt.", + "cmd.config.already_locked": "Konfigurationsdatei ist bereits verschlüsselt", + "cmd.config.enter_password": "Bitte Masterpasswort eingeben", + "cmd.config.lock_failed": "Verschlüsselung fehlgeschlagen", + "cmd.config.locked": "Konfigurationsdatei verschlüsselt (sensible Felder geschützt)", + "cmd.config.no_password": "Passwort darf nicht leer sein", + "cmd.config.not_locked": "Konfigurationsdatei ist nicht verschlüsselt", + "cmd.config.unlocked": "Konfigurationsdatei entschlüsselt", + "cmd.config.validate.errors": "Fehler gefunden:", + "cmd.config.validate.failed": "Konfigurationsvalidierung fehlgeschlagen, bitte die oben genannten Fehler beheben.", + "cmd.config.validate.passed": "Konfigurationsvalidierung bestanden, keine Probleme gefunden.", + "cmd.config.validate.passed_with_warnings": "Konfigurationsvalidierung bestanden (mit Warnungen).", + "cmd.config.validate.warnings": "Warnungen gefunden:", + "cmd.config.wrong_password": "Falsches Passwort, Entschlüsselung fehlgeschlagen", + "cmd.dedup.saved": "Deduplizierung: {count} doppelte Dateien übersprungen, {saved:.2f} MB eingespart", + "cmd.diff.detail_header": "--- Zeilenweise Unterschiede ---", + "cmd.diff.no_detail": "(Binärdatei oder nicht lesbar)", + "cmd.hook.failed": "Hook fehlgeschlagen: {command} — {error}", + "cmd.hook.post_running": "Führe Nach-Hook aus: {command}", + "cmd.hook.pre_running": "Führe Vor-Hook aus: {command}", + "cmd.hooks.no_hooks": "Keine {type}-Hooks konfiguriert.", + "cmd.hooks.no_results": "Keine Hook-Ergebnisse.", + "cmd.hooks.result_line": "[{status}] {command} (Rückgabecode={return_code}, Dauer={duration}s)", + "cmd.hooks.summary": "Gesamt {total}, Erfolgreich {success}, Fehlgeschlagen {fail}", + "cmd.ignore.available_presets": "Verfügbare Vorlagen:", + "cmd.ignore.cancelled": "Abgebrochen.", + "cmd.ignore.generated": "Ausschlussregeln für Vorlage {preset} generiert: {path}", + "cmd.ignore.overwrite": "Datei {path} existiert bereits. Überschreiben? (j/N): ", + "cmd.integrity.generate.not_dir": "Fehler: {path} ist kein Verzeichnis", + "cmd.integrity.generate.success": "Integritätsprüfdatei generiert: {path}", + "cmd.integrity.verify.failed": "Integritätsprüfung fehlgeschlagen: {path}", + "cmd.integrity.verify.success": "Integritätsprüfung bestanden: {path}", + "cmd.list.tags.empty": "Noch keine Tags", + "cmd.list.tags.header": "Backup-Tags:", + "cmd.metadata_export.audit_done": "{count} Audit-Protokolleinträge nach {path} exportiert", + "cmd.metadata_export.combined_done": "Zusammenfassungsbericht ({count} Einträge) nach {path} exportiert", + "cmd.metadata_export.history_done": "{count} Verlaufseinträge nach {path} exportiert", + "cmd.profile.activated": "Profil '{name}' aktiviert.", + "cmd.profile.created": "Profil '{name}' erfolgreich erstellt.", + "cmd.profile.deleted": "Profil '{name}' gelöscht.", + "cmd.profile.exported": "Profil '{name}' exportiert nach: {path}", + "cmd.profile.import_failed": "Import des Profils aus {path} fehlgeschlagen.", + "cmd.profile.imported": "Profil '{name}' erfolgreich importiert.", + "cmd.profile.list.detail": " {fields}", + "cmd.profile.list.empty": "Keine Profile konfiguriert.", + "cmd.profile.list.header": "Konfigurationsprofile:", + "cmd.profile.list.item": " {name}", + "cmd.profile.not_found": "Profil '{name}' existiert nicht.", + "cmd.report.saved": "Backup-Bericht gespeichert unter: {path}", + "cmd.restore.search.found": "{count} passende Dateien gefunden:", + "cmd.restore.search.header": "Suchergebnisse für '{keyword}' in {path}:", + "cmd.restore.search.no_results": "Keine Dateien für '{keyword}' in {path} gefunden", + "cmd.restore.selective.header": "Selektive Wiederherstellung: Extrahiere {count} Dateien passend zu {patterns}", + "cmd.restore.selective.success": "{count} Dateien nach {target} extrahiert", + "cmd.restore.stats.dirs": "Verzeichnisse: {count}", + "cmd.restore.stats.files": "Dateien: {count}", + "cmd.restore.stats.format_item": " {ext}: {count} Dateien", + "cmd.restore.stats.formats": "Dateitypverteilung:", + "cmd.restore.stats.header": "═══ Backup-Statistiken: {path} ═══", + "cmd.restore.stats.size": "Gesamtgröße: {size} Bytes", + "cmd.restore.tag_not_found": "Kein Backup mit Tag '{tag}' gefunden", + "cmd.rotate.auto_done": "Automatische Rotation abgeschlossen: {deleted} Dateien gelöscht.", + "cmd.rotate.auto_title": "Rotiere Backups in {path}...", + "cmd.rotate.delete_item": " - {name} {size}", + "cmd.rotate.done": "{count} Backup-Dateien rotiert.", + "cmd.rotate.dry_run_header": "Vorschau-Modus (keine tatsächliche Löschung):", + "cmd.rotate.dry_run_item": " ~ {name} {size} {time}", + "cmd.rotate.header": "═══ Backup-Rotation ═══", + "cmd.rotate.no_action": "Keine Dateien zum Rotieren.", + "cmd.rotate.plan_delete": "{count} Dateien werden gelöscht:", + "cmd.rotate.plan_keep": "{count} Dateien werden behalten:", + "cmd.rotate.scan_empty": "Keine Backup-Dateien in {path} gefunden.", + "cmd.rotate.scan_found": "{count} Backup-Dateien in {path} gefunden:", + "cmd.rotate.scan_item": " {name} {size} {time}", + "cmd.schedule.exported": "Konfiguration exportiert nach: {path} ({type})", + "cmd.schedule.install.launchd": "Nach dem Speichern der plist-Datei ausführen:", + "cmd.schedule.install.schtasks_cmd": "Folgenden Befehl als Administrator ausführen, um die geplante Aufgabe zu erstellen:\n {cmdline}", + "cmd.schedule.install.systemd": "Folgenden Befehl als root ausführen, um den systemd-Timer zu installieren:", + "cmd.schedule.schtasks.generated": "Windows-Aufgabenplanung-Befehl wurde generiert", + "cmd.schedule.unknown_type": "Unbekannter Typ: {type}", + "cmd.search.no_results": "Keine Dateien passend zu '{pattern}' in {path} gefunden", + "cmd.search.no_results_global": "Keine Dateien passend zu '{pattern}' in allen Backups gefunden", + "cmd.search.results": "{count} passende Dateien in {path} gefunden:", + "cmd.sftp.parallel_upload": "Paralleler Upload von {count} Dateien...", + "cmd.upload.parallel": "Paralleler Upload zu SFTP und WebDAV...", + "cmd.versions.empty": "Noch keine Backup-Versionen.", + "cmd.versions.header": "═══ Backup-Versionsverlauf ═══", + "cmd.versions.source": "📁 {source}", + "cmd.versions.total": "{count} Versionen, {sources} Quellverzeichnisse", + "cmd.webdav.parallel_upload": "Paralleler Upload von {count} Dateien zu WebDAV...", + "cmd.webhook.available_presets": "Verfügbare IM-Benachrichtigungsvorlagen:", + "cmd.webhook.preset_set": "{name}-Benachrichtigungsvorlage eingestellt:\n{template}", + "cmd.webhook.preset_unknown": "Unbekannte Vorlage: {name}", + "cmd.wizard.choice": "Bitte Option eingeben:", + "cmd.wizard.cloud_done": "Cloud-Speicher erfolgreich konfiguriert.", + "cmd.wizard.cloud_info": "S3-kompatibler Cloud-Speicher (AWS S3, MinIO, Backblaze):", + "cmd.wizard.complete": "sbackup Einrichtung abgeschlossen!", + "cmd.wizard.dest": "Zielordner für Backup-Dateien:", + "cmd.wizard.email_done": "E-Mail-Benachrichtigung konfiguriert.", + "cmd.wizard.email_yn": "E-Mail-Benachrichtigung konfigurieren? (j/N):", + "cmd.wizard.host": "Serveradresse:", + "cmd.wizard.ignore": "Zu ignorierende Muster (kommagetrennt, Standard: .git,__pycache__):", + "cmd.wizard.lang_set": "Sprache auf {lang} gesetzt", + "cmd.wizard.more": "Weitere hinzufügen? (j/N):", + "cmd.wizard.password": "Passwort:", + "cmd.wizard.port": "Port:", + "cmd.wizard.remote_path": "Remote-Pfad:", + "cmd.wizard.schedule_info": "Folgenden Befehl ausführen, um das zeitgesteuerte Backup zu starten (alle {interval} Minuten):", + "cmd.wizard.schedule_interval": "Backup-Intervall (Minuten, Standard: 60):", + "cmd.wizard.schedule_yn": "Zeitgesteuertes Backup konfigurieren? (j/N):", + "cmd.wizard.sftp_done": "SFTP erfolgreich konfiguriert.", + "cmd.wizard.skip": "Überspringen", + "cmd.wizard.source": "Zu sichernder Quellordnerpfad:", + "cmd.wizard.step_email": "Schritt 4/5: E-Mail-Benachrichtigung (optional)", + "cmd.wizard.step_lang": "Schritt 1/5: Oberflächensprache wählen", + "cmd.wizard.step_remote": "Schritt 3/5: Remote-Speicher konfigurieren (optional)", + "cmd.wizard.step_schedule": "Schritt 5/5: Zeitgesteuertes Backup (optional)", + "cmd.wizard.step_strategy": "Schritt 2/5: Backup-Strategie hinzufügen", + "cmd.wizard.summary": "{strategies} Backup-Strategie(n) hinzugefügt.\n'sbackup save' ausführen, um sofort ein Backup zu erstellen.", + "cmd.wizard.user": "Benutzername:", + "cmd.wizard.webdav_done": "WebDAV erfolgreich konfiguriert.", + "cmd.wizard.welcome": "Willkommen beim sbackup Einrichtungsassistenten!\nDieser Assistent führt Sie durch die Erstkonfiguration.", + "cmd.xsearch.no_query": "Bitte Suchkriterium angeben: --keyword, --pattern oder --ext", + "diskcheck.disk_free": " Verfügbar: {size}", + "diskcheck.disk_header": "Zielfestplatte:", + "diskcheck.disk_total": " Gesamt: {size}", + "diskcheck.disk_used": " Belegt: {size}", + "diskcheck.estimated_size": "Geschätzte Backup-Größe: {size} (Komprimierungsrate: {ratio})", + "diskcheck.header": "═══ Speicherplatzprüfung ═══", + "diskcheck.insufficient": "Unzureichender Speicherplatz: Benötigt {needed}, Verfügbar {available}, Fehlend {missing}", + "diskcheck.source": "Quellverzeichnis: {path}", + "diskcheck.source_size": "Gesamtgröße der Quelldateien: {size}", + "diskcheck.status.insufficient": "Speicherprüfung: Unzureichend!\n Geschätzter Bedarf: {needed}\n Verfügbar: {available}\n Fehlend: {missing}", + "diskcheck.status.ok": "Speicherprüfung: Ausreichend (Verfügbar: {margin})", + "diskcheck.target": "Zielpfad: {path}", + "dryrun.and_others": "Weitere", + "dryrun.excluded_files": "Ausgeschlossene Dateien", + "dryrun.excluded_files_list": "Liste ausgeschlossener Dateien", + "dryrun.exclusion_reasons": "Ausschlussgründe", + "dryrun.file_types": "Dateitypen", + "dryrun.included_files": "Eingeschlossene Dateien", + "dryrun.included_files_list": "Liste eingeschlossener Dateien", + "dryrun.largest_files": "Größte Dateien", + "dryrun.more_files": "... und {count} weitere Dateien", + "dryrun.no_extension": "(keine Erweiterung)", + "dryrun.reason.excluded": "Entspricht Ausschlussregel", + "dryrun.reason.not_included": "Entspricht nicht der Einschlussregel", + "dryrun.reason.too_large": "Überschreitet maximale Dateigröße", + "dryrun.reason.too_small": "Unterhalb der minimalen Dateigröße", + "dryrun.scanned_dir": "Gescanntes Verzeichnis", + "dryrun.skip": "Überspringen", + "dryrun.title": "Dry-run Vorschau", + "dryrun.total_files": "Dateien gesamt", + "dryrun.total_size": "Gesamtgröße", + "dryrun.unit_files": "Dateien", + "dryrun.warning.disk_space": "Unzureichender Speicherplatz: Verfügbar {free}, Benötigt {need}", + "dryrun.warning.not_dir": "Pfad ist kein gültiges Verzeichnis: {path}", + "dryrun.warning.os_error": "Systemfehler: {error}", + "dryrun.warning.permission": "Unzureichende Berechtigung für Zugriff auf: {path}", + "dryrun.warnings": "Warnungen", + "err.invalid_filename": "Ungültiger Dateiname: {filename} (Pfad-Trennzeichen und ..-Folgen nicht erlaubt)", + "err.lock.conflict": "Fehler: Eine andere Backup-Operation läuft bereits (Sperrdatei vorhanden). Wenn sicher kein anderer Prozess läuft, Sperrdatei manuell löschen.", + "err.sftp.delete": "Löschen der Remote-Datei fehlgeschlagen {path}: {error}", + "err.sftp.host_key_mismatch": "Schlüssel des Hosts {host} stimmt nicht mit known_hosts überein (möglicher Man-in-the-Middle-Angriff)", + "err.sftp.mkdir_max_depth": "Remote-Verzeichnistiefe zu groß oder Schleife erkannt: {path}", + "err.sftp.no_host_key": "Schlüssel des Hosts {host} konnte nicht abgerufen werden", + "err.sftp.remote_not_found": "Remote-Datei existiert nicht: {path}", + "err.webdav.delete": "Löschen der Remote-Datei fehlgeschlagen {path}: {error}", + "err.webdav.invalid_scheme": "Nicht unterstütztes URL-Schema '{scheme}', nur http und https erlaubt.", + "err.webdav.list": "Auflisten der Remote-Dateien fehlgeschlagen {path}: {error}", + "err.webdav.not_found": "Remote-Datei existiert nicht: {path}", + "err.webdav.private_ip": "Verbindung zu privater Netzwerkadresse blockiert: {host}", + "err.webdav.xml_too_large": "WebDAV-Antwort zu groß ({size} Bytes), möglicher XML-Bombenangriff", + "log.rotation.delete_error": "Löschen fehlgeschlagen %s: %s", + "log.rotation.deleted": "Gelöscht: %s (%.2f MB)", + "log.rotation.dry_run_skip": "[dry-run] Wird gelöscht: %s (%.2f MB)", + "multi_dest.aborted": "Multi-Ziel-Backup abgebrochen: Lokales Backup fehlgeschlagen", + "multi_dest.complete": "Multi-Ziel-Backup abgeschlossen", + "multi_dest.local.failed": "Lokales Backup fehlgeschlagen", + "multi_dest.local.success": "Lokales Backup abgeschlossen: {path}", + "multi_dest.started": "Multi-Ziel-Backup gestartet: {source} -> {targets}", + "multi_dest.uploading": "Paralleler Upload zu {count} Remote-Zielen...", + "restore.no_match": "Keine Dateien passend zu \"{pattern}\"", + "restore.split.detected": "Geteiltes Backup erkannt, {count} Teile", + "restore.split.merged": "Zusammenführung abgeschlossen: {path}", + "restore.split.missing": "Fehler: Teilungsdatei fehlt, erwartet {path}", + "task_queue.added": "Aufgabe hinzugefügt: {name} (ID: {id})", + "task_queue.cancelled": "Vom Benutzer abgebrochen", + "task_queue.cancelled.fail": "Aufgabe konnte nicht abgebrochen werden: Nicht gefunden oder nicht im ausstehenden Zustand", + "task_queue.cancelled.success": "Aufgabe abgebrochen: {id}", + "task_queue.clear.done": "{count} Aufgaben aus der Warteschlange entfernt.", + "task_queue.clear.none": "Keine abgeschlossenen oder fehlgeschlagenen Aufgaben zum Entfernen.", + "task_queue.list.empty": "Aufgabenwarteschlange ist leer.", + "task_queue.list.header": "═══ Aufgabenwarteschlange ({count} Aufgaben) ═══", + "task_queue.list.item": " [{status}] {name} (ID: {id})", + "task_queue.list.item_detail": " Verzeichnis: {folder}", + "task_queue.list.item_error": " Fehler: {error}", + "task_queue.list.item_result": " Ergebnis: {result}", + "task_queue.list.item_time": " Erstellt: {created}", + "task_queue.run.failed": "Aufgabe fehlgeschlagen: {name} — {error}", + "task_queue.run.none": "Keine ausstehenden Aufgaben in der Warteschlange.", + "task_queue.run.success": "Aufgabe erfolgreich ausgeführt: {name} (Ergebnis: {result})", + "task_queue.run_all.done": "{count} Aufgaben ausgeführt, {success} erfolgreich, {failed} fehlgeschlagen.", + "task_queue.stats.completed": "Abgeschlossen: {completed}", + "task_queue.stats.failed": "Fehlgeschlagen: {failed}", + "task_queue.stats.header": "═══ Warteschlangenstatistik ═══", + "task_queue.stats.pending": "Ausstehend: {pending}", + "task_queue.stats.running": "Laufend: {running}", + "task_queue.stats.total": "Gesamt: {total}", + "warn.password_in_cli": "Warnung: Über {arg} übermittelte Passwörter sind in der Prozessliste sichtbar. Interaktive Eingabe wird empfohlen.", + "warn.sftp.no_known_hosts": "Warnung: Keine known_hosts-Datei gefunden, Hostschlüssel von {host} kann nicht überprüft werden. Verbindung möglicherweise unsicher.", + "warn.webdav.http_plaintext": "Warnung: Unverschlüsselte HTTP-Verbindung zu WebDAV ({url}), Zugangsdaten werden im Klartext übertragen. HTTPS wird empfohlen." } \ No newline at end of file diff --git a/sbackup/locales/en_US.json b/sbackup/locales/en_US.json index 761bfc7..22bec35 100644 --- a/sbackup/locales/en_US.json +++ b/sbackup/locales/en_US.json @@ -1,80 +1,667 @@ -{ - "cmd.add.success": "Backup strategy added successfully: {source} -> {dest}", - "cmd.rm.success": "Backup strategy removed successfully: {path}", - "err.folder.invalid": "Error: {path} is not a valid folder or does not exist.", - "err.dest.invalid": "Error: Target folder {path} is not a valid folder or does not exist.", - "info.already.added": "Info: {path} has already been added. Please do not duplicate.", - "warn.no.strategy.found": "Warning: Backup strategy for {path} not found.", - "warn.source.missing": "Warning: Source folder does not exist: {path}", - "warn.json.decode.error": "Warning: Data file {path} is malformed, initializing as empty dict.", - "warn.json.backup": "Warning: Corrupted data file backed up to {path}.", - "warn.json.renamed": "Warning: Corrupted data file renamed to {path}.", - "compress.success": "Backup successful: {path} ({size:.2f} MB, {count} files)", - "compress.progress": "Compressing", - "compress.unit": "files", - "err.permission": "Permission denied: Cannot write to '{path}'", - "err.os": "OS Error: {error}", - "err.unknown": "Unknown error: {error}", - "err.unknown.format": "Error: Unrecognized backup file format {path}", - "err.argparse.invalid_choice": "invalid choice: ", - "err.argparse.choose_from": "choose from", - "err.argparse.invalid_float": "invalid float value: ", - "err.argparse.invalid_int": "invalid int value: ", - "err.argparse.unrecognized_args": "unrecognized arguments: ", - "err.argparse.required": "required", - "warn.zip.overwrite": "Warning: Overwriting existing backup file {path}", - "warn.invalid.compresslevel": "Warning: Compression level {level} out of range (0-9), using default 6", - "cmd.save.uptodate": "All backup strategies are up to date, no backup needed.", - "cmd.save.completed": "Backup completed, processed {count} strategy(s).", - "cmd.all.empty": "No backup strategies configured.", - "exit.message": "\nExiting.", - "table.header.source": "Source Folder", - "table.header.dest": "Target Folder", - "table.header.ignore": "Ignore Patterns", - "table.cell.none": "None", - "table.header.format": "Format", - "table.cell.default": "Default", - "restore.progress": "Restoring", - "restore.success": "Restore successful: {path} ({count} files)", - "cmd.watch.start": "Watch started, interval {interval} min (press Ctrl+C to stop)", - - "cli.description": "Sbackup v{version} — Lightweight incremental backup tool\n\nSbackup helps you easily manage multi-folder backup tasks. It uses incremental backup strategy,\nonly compressing modified files, supporting custom ignore rules, and providing intuitive progress feedback.\n\nAvailable commands:\n add Add a new backup strategy (source -> destination)\n rm Remove an existing backup strategy\n all Show all configured backup strategies\n save Execute backup tasks\n version Show version information", - "cli.epilog": "Examples:\n Add backup strategy:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Add strategy with entry-level format:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Remove backup strategy:\n sbackup rm F:/my_folder\n\n View all strategies:\n sbackup all\n\n Execute backup (ZIP):\n sbackup save\n\n Execute backup (tar.gz):\n sbackup --format tar.gz save\n\n Execute backup (7z encrypted):\n sbackup --format 7z save --password 123456\n\n Keep last 5 backups:\n sbackup save --keep 5\n\n Watch backup (every 60 min):\n sbackup watch --interval 60\n\n Watch + cleanup:\n sbackup watch --interval 120 --keep 10\n\n Restore backup:\n sbackup restore backup.7z F:/restored\n\n Chinese interface:\n sbackup --lang zh_CN save\n\n Debug mode:\n sbackup --debug save", - "cli.help.debug": "Enable debug mode with detailed logging", - "cli.help.help": "Show this help message and exit", - "cli.help.lang": "Set interface language: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", - "cli.help.format": "Set packaging format: zip (default) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.subcommands": "Select a command to execute", - "cli.help.add": "Add a new backup strategy", - "cli.help.add.source": "Source folder path: the directory to be backed up", - "cli.help.add.dest": "Target folder path: where backup files will be stored", - "cli.help.add.ignore": "Files or folders to ignore (comma separated, default: .git,__pycache__)", - "cli.help.add.format": "Entry-level packaging format (only for this strategy, defaults to global setting): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.rm": "Remove a backup strategy", - "cli.help.rm.path": "Source folder path of the strategy to remove", - "cli.help.all": "View all backup strategies", - "cli.help.save": "Execute all backup strategies", - "cli.help.save.keep": "Keep the most recent N backup files, 0 to disable cleanup", - "cli.help.save.password": "Encryption password (only supported for 7z format)", - "cli.help.watch": "Run backups periodically", - "cli.help.watch.interval": "Backup interval in minutes, default 60", - "cli.help.watch.keep": "Keep the most recent N backup files", - "cli.help.watch.password": "Encryption password (only supported for 7z format)", - "cli.help.restore": "Restore from a backup file", - "cli.help.restore.file": "Backup file path (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", - "cli.help.restore.dir": "Target directory for restoration", - "cli.help.version": "Show version information", - "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", - - "log.config.malformed": "Config file %s is malformed, using defaults", - "log.config.reset": "Config file %s is malformed, resetting to empty", - "log.config.mkdir.error": "Cannot create config directory %s: %s", - "log.config.write.error": "Cannot write config file %s: %s", - "log.data.read": "Reading data file: %s", - "log.data.create": "Data file does not exist, creating: %s", - "log.data.load": "Loading existing data file: %s", - "log.data.write": "Writing data file: %s", - "log.cleanup.delete": "Deleted old backup: %s", - "log.i18n.load.failed": "Failed to load locale {path}: {error}", - "log.i18n.load.fallback_failed": "Failed to load fallback locale {path}: {error}" -} +{ + "cmd.add.success": "Backup strategy added successfully: {source} -> {dest}", + "cmd.rm.success": "Backup strategy removed successfully: {path}", + "cmd.edit.success": "Backup strategy edited successfully: {path}", + "cmd.edit.nothing": "No modification parameters specified.", + "err.folder.invalid": "Error: {path} is not a valid folder or does not exist.", + "err.file.not_found": "Error: Backup file {path} does not exist.", + "err.dest.invalid": "Error: Target folder {path} is not a valid folder or does not exist.", + "info.already.added": "Info: {path} has already been added. Please do not duplicate.", + "warn.no.strategy.found": "Warning: Backup strategy for {path} not found.", + "warn.source.missing": "Warning: Source folder does not exist: {path}", + "warn.json.decode.error": "Warning: Data file {path} is malformed, initializing as empty dict.", + "warn.json.backup": "Warning: Corrupted data file backed up to {path}.", + "warn.json.renamed": "Warning: Corrupted data file renamed to {path}.", + "warn.password_in_cli": "Warning: Password provided via {arg} is visible in the process list. Consider using interactive input instead.", + "warn.webdav.http_plaintext": "WARNING: Using plain HTTP for WebDAV ({url}) -- credentials will be sent in cleartext. Use HTTPS when possible.", + "compress.success": "Backup successful: {path} ({original:.2f} MB -> {size:.2f} MB, {ratio:.0f}%, {count} files)", + "compress.progress": "Compressing", + "compress.unit": "files", + "err.permission": "Permission denied: Cannot write to '{path}'", + "err.os": "OS Error: {error}", + "err.unknown": "Unknown error: {error}", + "err.unknown.format": "Error: Unrecognized backup file format {path}", + "err.argparse.invalid_choice": "invalid choice: ", + "err.argparse.choose_from": "choose from", + "err.argparse.invalid_float": "invalid float value: ", + "err.argparse.invalid_int": "invalid int value: ", + "err.argparse.unrecognized_args": "unrecognized arguments: ", + "err.argparse.required": "required", + "err.lock.conflict": "Error: Another backup operation is already running (lock file exists). Use 'sbackup clean --force-lock' to remove a stale lock.", + "warn.zip.overwrite": "Warning: Overwriting existing backup file {path}", + "warn.invalid.compresslevel": "Warning: Compression level {level} out of range (0-9), using default 6", + "cmd.save.uptodate": "All backup strategies are up to date, no backup needed.", + "cmd.save.completed": "Backup completed, processed {count} strategy(s).", + "cmd.all.empty": "No backup strategies configured.", + "cmd.list.empty": "No backup history yet.", + "cmd.verify.checking": "Verifying backup: {path}", + "cmd.verify.success": "Verification passed: {path} ({count} files)", + "cmd.verify.failed": "Verification failed: {path} — expected {expected} files, got {actual}", + "cmd.rm.all.confirm": "Delete all {count} backup strategies? (y/N): ", + "cmd.rm.all.confirm_words": "y,yes", + "cmd.rm.all.success": "Deleted all {count} backup strategies.", + "cmd.rm.all.empty": "No backup strategies to delete.", + "cmd.init.success": "Config file generated: {path}", + "cmd.init.exists": "Config file already exists: {path}, skipping.", + "warn.disk.space": "Warning: Target directory {path} has insufficient space (need {need:.1f} MB, only {free:.1f} MB free)", + "cmd.save.summary": "Backup summary: {total} strategies, {backed} backed up, {skipped} skipped, took {elapsed:.1f}s", + "cmd.upload.parallel": "Uploading to SFTP and WebDAV in parallel...", + "cmd.dry_run.header": "Dry-run preview (no backup will be performed):", + "cmd.dry_run.item": " {source} -> {target} format: {format} files: {count} size: {size} MB", + "cmd.dry_run.total": "Total: {count} strategies, {files} files, {size} MB", + "cmd.dry_run.nothing": "No strategies need backup (all are up to date).", + "cmd.verify.partial_fail": "Warning: {count} backup file(s) failed verification!", + "cmd.status.header": "=== Backup Status Dashboard ===", + "cmd.status.strategies": "Total strategies: {count}", + "cmd.status.history": "History records: {count}", + "cmd.status.total_size": "Total backup size: {size:.2f} MB", + "cmd.status.avg_ratio": "Average compression ratio: {ratio:.1f}%", + "cmd.status.strategy.header": "--- Strategy Details ---", + "cmd.status.strategy.item": " {source} -> {target}", + "cmd.status.strategy.last_backup": " Last backup: {time}", + "cmd.status.strategy.never": " Last backup: Never", + "cmd.status.strategy.format": " Format: {format}", + "cmd.status.strategy.size": " Target size: {size:.2f} MB", + "log.parallel.backup": "Parallel backup of %d strategies", + "restore.list.title": "Archive contents: {path}", + "restore.list.empty": "Archive is empty: {path}", + "log.ignore.loaded": "Loaded ignore rules file: %s", + "exit.message": "\nExiting.", + "table.header.source": "Source Folder", + "table.header.dest": "Target Folder", + "table.header.ignore": "Ignore Patterns", + "table.cell.none": "None", + "table.header.format": "Format", + "table.header.ratio": "Ratio", + "table.header.files": "Files", + "table.header.size": "Size(MB)", + "table.header.time": "Backup Time", + "table.cell.default": "Default", + "restore.progress": "Restoring", + "restore.success": "Restore successful: {path} ({count} files)", + "restore.skipped_unsafe": "Skipped {count} unsafe archive members (path traversal protection)", + "err.invalid_filename": "Invalid filename: {filename} (path separators and .. sequences not allowed)", + "err.sftp.no_host_key": "Unable to get host key for {host}", + "err.sftp.host_key_mismatch": "Host key for {host} does not match known_hosts (possible MITM attack)", + "warn.sftp.no_known_hosts": "Warning: No known_hosts file found, cannot verify host key for {host}, connection may be insecure", + "err.sftp.mkdir_max_depth": "Remote directory too deep or loop detected: {path}", + "err.webdav.xml_too_large": "WebDAV response too large ({size} bytes), possible XML bomb attack", + "cmd.watch.start": "Watch started, interval {interval} min (press Ctrl+C to stop)", + "cli.description": "Sbackup v{version} — Lightweight incremental backup tool\n\nSbackup helps you easily manage multi-folder backup tasks. It uses incremental backup strategy,\nonly compressing modified files, supporting custom ignore rules, and providing intuitive progress feedback.\n\nAvailable commands:\n add Add a new backup strategy (source -> destination)\n rm Remove an existing backup strategy\n all Show all configured backup strategies\n save Execute backup tasks\n version Show version information", + "cli.epilog": "Examples:\n Add backup strategy:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Add strategy with entry-level format:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Remove backup strategy:\n sbackup rm F:/my_folder\n\n View all strategies:\n sbackup all\n\n Execute backup (ZIP):\n sbackup save\n\n Execute backup (tar.gz):\n sbackup --format tar.gz save\n\n Execute backup (7z encrypted):\n sbackup --format 7z save --password 123456\n\n Keep last 5 backups:\n sbackup save --keep 5\n\n Watch backup (every 60 min):\n sbackup watch --interval 60\n\n Watch + cleanup:\n sbackup watch --interval 120 --keep 10\n\n Restore backup:\n sbackup restore backup.7z F:/restored\n\n Chinese interface:\n sbackup --lang zh_CN save\n\n Debug mode:\n sbackup --debug save\n\n SFTP remote backup (auto-detect key, recommended):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP remote backup (password auth):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP remote backup (specify key):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP remote backup (key + passphrase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp\n\n SFTP watch and upload:\n sbackup watch --interval 60 --sftp", + "cli.help.debug": "Enable debug mode with detailed logging (can be placed after subcommand)", + "cli.help.help": "Show this help message and exit", + "cli.help.lang": "Set interface language: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", + "cli.help.format": "Set packaging format: zip(default) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z(encrypted)", + "cli.help.follow_symlinks": "Follow symbolic links (default: do not follow to avoid loops)", + "cli.help.incremental": "File-level incremental backup (only compress changed files, significantly reduces backup time)", + "cli.help.checksum": "Use file content SHA256 checksum for change detection (more precise than mtime, but slower)", + "cli.help.subcommands": "Select a command to execute", + "cli.help.add": "Add a new backup strategy", + "cli.help.add.source": "Source folder path: the directory to be backed up", + "cli.help.add.dest": "Target folder path: where backup files will be stored", + "cli.help.add.ignore": "Files or folders to ignore (comma separated, default: .git,__pycache__)", + "cli.help.add.format": "Entry-level packaging format (only for this strategy, defaults to global): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.add.name_template": "Per-entry filename template (supports {name} {date} {time} variables, uses global default if not specified)", + "cli.help.add.from_gitignore": "Import ignore rules from .gitignore file", + "cmd.add.gitignore_imported": "Imported {count} ignore rules from {path}", + "cmd.add.gitignore_empty": "No rules imported from {path}", + "cli.help.rm": "Remove a backup strategy", + "cli.help.rm.path": "Source folder path of the strategy to remove", + "cli.help.all": "View all backup strategies", + "cli.help.save": "Execute all backup strategies", + "cli.help.save.keep": "Keep the most recent N backup files, 0 to disable cleanup (default: 0)", + "cli.help.save.keep_days": "Keep backups from the last N days, 0 to disable time-based cleanup (default: 0)", + "cli.help.save.password": "Encryption password (7z format only, can also be set in config.json)", + "cli.help.watch": "Run backups periodically", + "cli.help.watch.interval": "Backup interval in minutes, default 60", + "cli.help.watch.keep": "Keep the most recent N backup files (default: 0)", + "cli.help.watch.keep_days": "Keep backups from the last N days (default: 0)", + "cli.help.watch.password": "Encryption password (7z format only)", + "cli.help.restore": "Restore from a backup file (supports ZIP / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)", + "cli.help.restore.file": "Backup file path", + "cli.help.restore.dir": "Target directory for restoration", + "cli.help.restore.password": "Decryption password (for restoring encrypted 7z backups)", + "cli.help.restore.list": "List archive contents without extracting", + "cli.help.list": "View backup history", + "cli.help.verify": "Verify backup file integrity", + "cli.help.verify.file": "Backup file to verify", + "cli.help.verify.fast": "Fast verify (compare historical SHA256, no extraction needed)", + "cli.help.verify.all": "Batch verify all historical backups", + "cli.help.verify.detail": "Show detailed verification info (size, format, checksum, etc.)", + "cli.help.verify.split": "Verify split file integrity (requires manifest file)", + "cli.help.verify.password": "Decryption password (for encrypted backups)", + "cmd.verify.fast_success": "Fast verification passed: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "Fast verification failed: {path}, file may be corrupted or tampered", + "cmd.verify.no_checksum": "No historical checksum found, falling back to full verify: {path}", + "cmd.verify.checksum_mismatch": "Checksum mismatch", + "cmd.verify.no_history": "No backup history found.", + "cmd.verify.no_file": "Please specify a backup file path or use --all to verify all backups.", + "cmd.verify.detail_header": "=== Backup Details: {path} ===", + "cmd.verify.detail_info": "Size: {size:.2f} MB | Files: {files} | Format: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ Verified: {path}", + "cmd.verify.batch_failed": "✗ Failed: {path}", + "cmd.verify.batch_summary": "Batch verification complete: {success} passed, {fail} failed", + "cmd.verify.split_success": "Split integrity verified: {file} ({parts} parts)", + "cmd.verify.split_failed": "Split integrity check failed: {file}", + "cmd.verify.split_error": "Split verification error: {error}", + "cli.help.export": "Export all backup strategies to a JSON file", + "cli.help.export.file": "Export file path (default: sbackup_export.json)", + "cli.help.import": "Import backup strategies from a JSON file", + "cli.help.import.file": "JSON file to import", + "cli.help.status": "View backup status dashboard", + "cli.help.ignore": "Generate .sbackupignore exclude rules file", + "cli.help.ignore.preset": "Preset template: node / python / go / rust / java / general (default: general)", + "cli.help.ignore.output": "Output file path (default: .sbackupignore)", + "cli.help.ignore.list": "List all available presets", + "cmd.ignore.available_presets": "Available presets:", + "cmd.ignore.overwrite": "File {path} already exists, overwrite? (y/N): ", + "cmd.ignore.cancelled": "Cancelled.", + "cmd.ignore.generated": "Generated {preset} preset exclude rules: {path}", + "cli.help.report": "Generate backup report in Markdown format", + "cli.help.report.output": "Output file path (prints to terminal if not specified)", + "cli.help.search": "Search filenames in backup files", + "cli.help.search.pattern": "Search pattern (supports glob wildcards, e.g. *.txt)", + "cli.help.search.in": "Specify backup file path (searches all strategies if not specified)", + "cli.help.search.password": "Decryption password (for encrypted backups)", + "cli.help.versions": "View backup version history (grouped by source)", + "cli.help.versions.source": "Specify source directory (shows all if not specified)", + "cmd.versions.header": "=== Backup Version History ===", + "cmd.versions.source": "📁 {source}", + "cmd.versions.empty": "No backup version records yet.", + "cmd.versions.total": "Total {count} versions, {sources} source directories", + "cli.help.schedule": "Scheduled backup configuration management", + "cli.help.schedule.action": "Schedule action: export / install", + "cli.help.schedule.export": "Export scheduled backup configuration file", + "cli.help.schedule.type": "Schedule type: systemd / crontab (default: systemd)", + "cli.help.schedule.interval": "Backup interval in minutes (default: 60)", + "cli.help.schedule.output": "Output file path (prints to terminal if not specified)", + "cmd.schedule.exported": "Schedule config exported to: {path} ({type})", + "cmd.schedule.unknown_type": "Unknown schedule type: {type}", + "cli.help.webhook_cmd": "Webhook notification management", + "cli.help.webhook_cmd.action": "Webhook action: preset / list", + "cli.help.webhook_cmd.preset": "Set IM notification preset template", + "cli.help.webhook_cmd.preset.name": "Preset name: dingtalk / feishu / wechat", + "cli.help.webhook_cmd.list": "List all available IM notification presets", + "cmd.webhook.preset_set": "Set {name} notification template:\n{template}", + "cmd.webhook.preset_unknown": "Unknown preset: {name}", + "cmd.webhook.available_presets": "Available IM notification presets:", + "cli.help.config_cmd": "Config file management (encrypt/decrypt sensitive fields)", + "cli.help.config_cmd.action": "Action: lock / unlock", + "cli.help.config_cmd.lock": "Encrypt sensitive fields in config file with master password", + "cli.help.config_cmd.lock.password": "Master password (interactive input if not specified)", + "cli.help.config_cmd.unlock": "Decrypt sensitive fields in config file with master password", + "cli.help.config_cmd.unlock.password": "Master password (interactive input if not specified)", + "cmd.config.locked": "Config file encrypted (sensitive fields protected)", + "cmd.config.unlocked": "Config file decrypted", + "cmd.config.already_locked": "Config file is already encrypted", + "cmd.config.not_locked": "Config file is not encrypted", + "cmd.config.enter_password": "Enter master password", + "cmd.config.no_password": "Password cannot be empty", + "cmd.config.wrong_password": "Wrong password, decryption failed", + "cmd.config.lock_failed": "Encryption failed", + "cli.help.config_cmd.validate": "Validate configuration and show all errors and warnings", + "cmd.config.validate.errors": "Errors found:", + "cmd.config.validate.warnings": "Warnings found:", + "cmd.config.validate.passed": "Configuration validation passed. No issues found.", + "cmd.config.validate.passed_with_warnings": "Configuration validation passed (with warnings).", + "cmd.config.validate.failed": "Configuration validation failed. Please fix the errors above.", + "cli.help.clean": "Smart cleanup of backup files across all strategies", + "cli.help.clean.keep": "Keep the most recent N backup files per strategy", + "cli.help.clean.keep_days": "Delete backup files older than N days", + "cli.help.clean.dry_run": "Preview files to be deleted without actually deleting", + "cmd.clean.no_criteria": "Please specify cleanup criteria: --keep N or --keep-days N", + "cmd.clean.nothing": "No backup files to clean up.", + "cmd.clean.dry_run_header": "Dry-run preview (no files will be deleted):", + "cmd.clean.summary": "Cleaned up {count} backup file(s).", + "cli.help.completion": "Generate shell completion script", + "cli.help.completion.shell": "Shell type: bash(default) / zsh / fish / powershell", + "cmd.report.saved": "Backup report saved to: {path}", + "cmd.search.results": "Found {count} matching file(s) in {path}:", + "cmd.search.no_results": "No files matching '{pattern}' found in {path}", + "cmd.search.no_results_global": "No files matching '{pattern}' found in any backup", + "cmd.export.success": "Exported {count} backup strategies to {path}", + "cmd.import.success": "Imported {count} backup strategies, skipped {skipped} existing", + "cmd.import.empty": "No valid backup strategies found in: {path}", + "cli.help.save.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.save.verify": "Verify backup integrity after completion", + "cli.help.save.name_template": "Backup filename template (supports {name} {date} {time} variables)", + "cli.help.save.webhook": "POST backup results to this URL after completion", + "cli.help.save.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", + "cli.help.save.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", + "cli.help.save.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", + "cli.help.save.split": "Split backup file into chunks (e.g. 100MB, 1GB), auto-splits when exceeded", + "cli.help.save.tag": "Add a tag to this backup (e.g. daily, release)", + "cli.help.watch.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.watch.verify": "Verify backup integrity after each backup", + "cli.help.watch.name_template": "Backup filename template (supports {name} {date} {time} variables)", + "cli.help.watch.webhook": "POST backup results to this URL after each backup", + "cli.help.watch.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", + "cli.help.watch.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", + "cli.help.watch.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", + "cli.help.watch.split": "Split backup file into chunks (e.g. 100MB, 1GB)", + "cli.help.watch.tag": "Add a tag to this backup", + "cli.help.init": "Generate default config file template", + "cli.help.rm.all": "Delete all backup strategies", + "cli.help.edit": "Edit an existing backup strategy (only modifies specified fields)", + "cli.help.edit.source": "Source folder path (to locate the strategy to edit)", + "cli.help.edit.dest": "Change target folder path", + "cli.help.edit.ignore": "Change ignore patterns (comma separated)", + "cli.help.edit.format": "Change packaging format: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.edit.name_template": "Change filename template (supports {name} {date} {time} variables)", + "cli.help.info": "View backup file details (format, size, file count, checksum, etc.)", + "cli.help.info.file": "Backup file path", + "cli.help.info.password": "Decryption password (for encrypted 7z backups)", + "cmd.info.header": "=== Backup File Info ===", + "cmd.info.path": "File path: {path}", + "cmd.info.format": "Compression format: {format}", + "cmd.info.size": "File size: {size:.2f} MB", + "cmd.info.files": "File count: {count}", + "cmd.info.time": "Modified: {time}", + "cmd.info.sha256": "SHA256: {checksum}", + "cmd.diff.header": "=== Diff Comparison ===\nBackup file: {backup}", + "cmd.diff.added_header": "\nAdded files ({count}):", + "cmd.diff.removed_header": "\nRemoved files ({count}):", + "cmd.diff.modified_header": "\nPossibly modified files ({count}):", + "cmd.diff.no_changes": "\nNo differences, source matches backup.", + "cmd.diff.summary": "\nDiff summary: +{added} added, -{removed} removed, ~{modified} modified", + "cmd.diff.no_backup": "No backup files found in target directory: {path}", + "cmd.diff.empty_backup": "Backup file is empty: {path}", + "cli.help.diff": "Compare source directory with the latest backup", + "cli.help.diff.source": "Source folder path", + "cli.help.diff.file": "Specify backup file path (auto-finds latest if not specified)", + "cli.help.diff.password": "Decryption password (for encrypted backups)", + "cli.help.diff.detail": "Show line-level differences for modified files", + "cmd.diff.detail_header": "--- Line-level differences ---", + "cmd.diff.no_detail": "(binary file or unable to read)", + "cli.help.remote": "Remote backup file management (list/delete backups on SFTP or WebDAV)", + "cli.help.remote.action": "Remote action: list / rm", + "cli.help.remote.list": "List remote backup files", + "cli.help.remote.rm": "Delete a remote backup file", + "cli.help.remote.rm.filename": "Remote filename to delete", + "cli.help.remote.sftp": "Operate on SFTP server", + "cli.help.remote.webdav": "Operate on WebDAV server", + "cli.help.version": "Show version information", + "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", + "log.config.malformed": "Config file %s is malformed, using defaults", + "log.config.reset": "Config file %s is malformed, resetting to empty", + "log.config.mkdir.error": "Cannot create config directory %s: %s", + "log.config.write.error": "Cannot write config file %s: %s", + "log.data.read": "Reading data file: %s", + "log.data.create": "Data file does not exist, creating: %s", + "log.data.load": "Loading existing data file: %s", + "log.data.write": "Writing data file: %s", + "log.cleanup.delete": "Deleted old backup: %s", + "log.i18n.load.failed": "Failed to load locale {path}: {error}", + "log.i18n.load.fallback_failed": "Failed to load fallback locale {path}: {error}", + "err.sftp.auth": "SFTP authentication failed: {host}, check username and password", + "err.sftp.ssh": "SFTP connection error: {error}", + "err.sftp.connect": "Cannot connect to {host}:{port}: {error}", + "err.sftp.not_connected": "SFTP not connected, call connect() first", + "err.sftp.local_not_found": "Local file not found: {path}", + "err.sftp.upload": "Upload failed {path}: {error}", + "err.sftp.mkdir": "Failed to create remote directory {path}: {error}", + "err.sftp.not_configured": "SFTP not configured, run: sbackup sftp config --host
--user ", + "cmd.sftp.uploading": "Uploading: {file}", + "cmd.sftp.progress": "Upload", + "cmd.sftp.success": "Upload successful: {file}", + "cmd.sftp.parallel_upload": "Uploading {count} files in parallel...", + "cmd.sftp.config_saved": "SFTP configuration saved to config.json", + "cmd.sftp.testing": "Testing connection to {host}...", + "cmd.sftp.test_ok": "SFTP connection test successful!", + "cmd.sftp.test_fail": "SFTP connection test failed, check configuration", + "cli.help.save.sftp": "Upload to SFTP server after backup (auto-detects SSH key or uses password)", + "cli.help.watch.sftp": "Upload to SFTP server after each backup", + "cli.help.sftp": "SFTP remote backup management (supports password and SSH key authentication)", + "cli.help.sftp.action": "SFTP action: config / test", + "cli.help.sftp.config": "Configure SFTP connection (supports password and SSH key authentication)", + "cli.help.sftp.test": "Test if SFTP connection is available", + "cli.help.sftp.host": "SFTP server address (e.g., 192.168.1.100 or example.com)", + "cli.help.sftp.port": "SFTP port number (default: 22)", + "cli.help.sftp.user": "SFTP username", + "cli.help.sftp.password": "SFTP password (password auth only)", + "cli.help.sftp.remote_path": "Remote target path (default: /, creates directory if not exists)", + "cli.prompt.sftp.host": "SFTP server address:", + "cli.prompt.sftp.port": "SFTP port (default 22):", + "cli.prompt.sftp.user": "SFTP username:", + "cli.prompt.sftp.password": "SFTP password:", + "cli.prompt.sftp.remote_path": "Remote path (default /):", + "err.sftp.key_load": "Cannot load private key {path}, check file format (RSA/Ed25519/ECDSA supported)", + "cli.help.sftp.key_file": "SSH private key file path (RSA/Ed25519/ECDSA supported, takes priority over password)", + "cli.help.sftp.key_passphrase": "Private key passphrase (for encrypted keys, can be entered interactively)", + "cli.prompt.sftp.key_file": "SSH private key file path (leave empty for password auth):", + "cli.prompt.sftp.key_passphrase": "Private key passphrase (press Enter to skip):", + "cmd.sftp.using_default_key": "Detected default SSH key, using: {path}", + "cmd.sftp.no_default_key": "No default SSH key found, please configure password or specify key path", + "err.sftp.key_needs_passphrase": "Private key {path} requires a passphrase, enter it or use --key-passphrase", + "err.sftp.wrong_passphrase": "Wrong passphrase, please retry (press Enter to skip private key)", + "cli.help.webdav": "WebDAV remote backup management (supports Jianguoyun, NextCloud, Synology, etc.)", + "cli.help.webdav.action": "WebDAV action: config / test", + "cli.help.webdav.config": "Configure WebDAV connection parameters", + "cli.help.webdav.test": "Test if WebDAV connection is available", + "cli.help.webdav.url": "WebDAV server URL (e.g., https://dav.jianguoyun.com/dav/)", + "cli.help.webdav.user": "WebDAV username", + "cli.help.webdav.password": "WebDAV password", + "cli.help.webdav.remote_path": "Remote target path (default: /, creates directory if not exists)", + "cli.help.save.webdav": "Upload to WebDAV server after backup", + "cli.help.watch.webdav": "Upload to WebDAV server after each backup", + "cli.prompt.webdav.url": "WebDAV server URL:", + "cli.prompt.webdav.user": "WebDAV username:", + "cli.prompt.webdav.password": "WebDAV password:", + "cli.prompt.webdav.remote_path": "Remote path (default /):", + "cmd.webdav.config_saved": "WebDAV configuration saved to config.json", + "cmd.webdav.testing": "Testing WebDAV connection to {url}...", + "cmd.webdav.test_ok": "WebDAV connection test successful!", + "cmd.webdav.uploading": "Uploading to WebDAV: {file}", + "cmd.webdav.success": "WebDAV upload successful: {file}", + "cmd.webdav.parallel_upload": "Uploading {count} files to WebDAV in parallel...", + "err.webdav.auth": "WebDAV authentication failed: {host}, check username and password", + "err.webdav.connect": "Cannot connect to WebDAV: {url}, error: {error}", + "err.webdav.ssh": "WebDAV connection error: {error}", + "err.webdav.not_configured": "WebDAV not configured, run: sbackup webdav config --url
--user ", + "err.webdav.local_not_found": "Local file not found: {path}", + "err.webdav.upload": "Upload to WebDAV failed {path}: {error}", + "err.webdav.mkdir": "Failed to create WebDAV remote directory {path}: {error}", + "err.webdav.invalid_scheme": "Unsupported URL scheme '{scheme}' -- only http and https are allowed.", + "err.webdav.private_ip": "WebDAV connection to internal/private address blocked for security: {host}", + "cmd.remote.empty": "Remote directory is empty: {path}", + "cmd.remote.list_header": "Remote directory: {path} ({count} files)", + "cmd.remote.deleted": "Deleted remote file: {path}", + "cmd.remote.select_protocol": "Please specify protocol: --sftp or --webdav", + "cmd.remote.rm_requires_path": "Please specify the remote filename to delete", + "cli.help.remote.list_path": "Specify remote directory path (uses configured default if not specified)", + "err.sftp.remote_not_found": "Remote file not found: {path}", + "err.sftp.delete": "Failed to delete remote file {path}: {error}", + "err.webdav.not_found": "Remote file not found: {path}", + "err.webdav.delete": "Failed to delete remote file {path}: {error}", + "err.webdav.list": "Failed to list remote files {path}: {error}", + "restore.no_match": "No files matching \"{pattern}\"", + "cli.help.restore.select": "Selective restore: extract only files matching pattern (supports glob wildcards, e.g. \"*.py\" or \"src/main.py\")", + "cli.help.schedule.type.schtasks": "Windows Task Scheduler", + "cmd.schedule.schtasks.generated": "Windows Task Scheduler command generated", + "cmd.hook.pre_running": "Running pre-backup hook: {command}", + "cmd.hook.post_running": "Running post-backup hook: {command}", + "cmd.hook.failed": "Hook execution failed: {command} — {error}", + "cli.help.save.pre_hook": "Command to run before backup (can be used multiple times)", + "cli.help.save.post_hook": "Command to run after backup (can be used multiple times)", + "cli.help.watch.pre_hook": "Command to run before backup (can be used multiple times)", + "cli.help.watch.post_hook": "Command to run after backup (can be used multiple times)", + "restore.split.detected": "Split backup detected, {count} parts total", + "restore.split.missing": "Error: split part missing, expected {path}", + "restore.split.merged": "Split parts merged: {path}", + "cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)", + "cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)", + "cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)", + "log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)", + "log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)", + "log.monitor.source_missing": "Monitor source directory not found, skipping: {path}", + "log.monitor.watching": "Now monitoring: {path}", + "log.monitor.no_sources": "No valid source directories to monitor, monitor not started", + "cli.help.schedule.install": "Install backup schedule as a system service", + "cli.help.schedule.install.type": "Service type: systemd / schtasks / launchd", + "cli.help.schedule.install.interval": "Backup interval in minutes (default: 60)", + "cli.help.schedule.install.user": "User to run the service as (default: current user)", + "cmd.schedule.install.systemd": "To install as a systemd service, create these files as root:", + "cmd.schedule.install.launchd": "To install as a launchd service, save the plist file and run:", + "cmd.schedule.install.schtasks_cmd": "Run this command as Administrator to create the scheduled task:\n {cmdline}", + "cli.help.restore.tag": "Restore the backup with the specified tag", + "cli.help.list.tags": "List all backup tags", + "cli.help.versions.tag": "Filter versions by tag", + "cmd.restore.tag_not_found": "No backup found with tag '{tag}'", + "cmd.list.tags.header": "Backup Tags:", + "cmd.list.tags.empty": "No tags found", + "cli.help.dedup": "Enable content-based deduplication across backup strategies (SHA256)", + "cmd.dedup.saved": "Deduplication: skipped {count} duplicate file(s), saved {saved:.2f} MB", + "cli.help.report.format": "Report format: text (default) or html", + "cli.help.wizard": "Interactive setup wizard for first-time configuration", + "cmd.wizard.welcome": "Welcome to sbackup setup wizard!\nThis will guide you through the initial configuration.", + "cmd.wizard.step_lang": "Step 1/5: Select interface language", + "cmd.wizard.choice": "Enter your choice:", + "cmd.wizard.lang_set": "Language set to {lang}", + "cmd.wizard.step_strategy": "Step 2/5: Add backup strategies", + "cmd.wizard.source": "Source folder to backup:", + "cmd.wizard.dest": "Destination folder for backups:", + "cmd.wizard.ignore": "Patterns to ignore (comma separated, default: .git,__pycache__):", + "cmd.wizard.more": "Add another strategy? (y/N):", + "cmd.wizard.step_remote": "Step 3/5: Configure remote storage (optional)", + "cmd.wizard.skip": "Skip", + "cmd.wizard.step_email": "Step 4/5: Email notifications (optional)", + "cmd.wizard.email_yn": "Configure email notifications? (y/N):", + "cmd.wizard.step_schedule": "Step 5/5: Schedule periodic backups (optional)", + "cmd.wizard.schedule_yn": "Configure periodic backups? (y/N):", + "cmd.wizard.schedule_interval": "Backup interval in minutes (default: 60):", + "cmd.wizard.schedule_info": "Run this command to start periodic backups (every {interval} min):", + "cmd.wizard.complete": "sbackup setup complete!", + "cmd.wizard.summary": "Added {strategies} backup strategy(s).\nRun 'sbackup save' to execute backup now.", + "cmd.wizard.host": "Host:", + "cmd.wizard.port": "Port:", + "cmd.wizard.user": "User:", + "cmd.wizard.password": "Password:", + "cmd.wizard.remote_path": "Remote path:", + "cmd.wizard.sftp_done": "SFTP configured successfully.", + "cmd.wizard.webdav_done": "WebDAV configured successfully.", + "cmd.wizard.cloud_info": "S3-compatible cloud storage (AWS S3, MinIO, Backblaze, Tencent COS):", + "cmd.wizard.cloud_done": "Cloud storage configured successfully.", + "cmd.wizard.email_done": "Email notifications configured.", + "cli.help.task": "Task queue management (add/run/cancel backup tasks)", + "cli.help.task.action": "Task action: add / list / run / run-all / cancel / clear / stats", + "cli.help.task.add": "Add a new backup task to the queue", + "cli.help.task.add.name": "Task name (for identification)", + "cli.help.task.add.folder": "Folder path to back up", + "cli.help.task.add.format": "Compression format: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.task.add.output": "Output file path (optional)", + "cli.help.task.list": "List tasks in the queue", + "cli.help.task.list.status": "Filter by status: pending / running / completed / failed", + "cli.help.task.run": "Execute the next pending task", + "cli.help.task.run_all": "Execute all pending tasks sequentially", + "cli.help.task.cancel": "Cancel a pending task", + "cli.help.task.cancel.id": "Task ID to cancel", + "cli.help.task.clear": "Clear completed and failed tasks from the queue", + "cli.help.task.stats": "Show task queue statistics", + "task_queue.added": "Task added: {name} (ID: {id})", + "task_queue.cancelled": "Cancelled by user", + "task_queue.cancelled.success": "Task cancelled: {id}", + "task_queue.cancelled.fail": "Cannot cancel task: not found or not pending", + "task_queue.run.success": "Task completed: {name} (result: {result})", + "task_queue.run.failed": "Task failed: {name} — {error}", + "task_queue.run.none": "No pending tasks in queue.", + "task_queue.run_all.done": "Executed {count} task(s), {success} succeeded, {failed} failed.", + "task_queue.clear.done": "Cleared {count} task(s) from queue.", + "task_queue.clear.none": "No completed or failed tasks to clear.", + "task_queue.stats.header": "=== Task Queue Statistics ===", + "task_queue.stats.total": "Total: {total}", + "task_queue.stats.pending": "Pending: {pending}", + "task_queue.stats.running": "Running: {running}", + "task_queue.stats.completed": "Completed: {completed}", + "task_queue.stats.failed": "Failed: {failed}", + "task_queue.list.header": "=== Task Queue ({count} tasks) ===", + "task_queue.list.empty": "Task queue is empty.", + "task_queue.list.item": " [{status}] {name} (ID: {id})", + "task_queue.list.item_detail": " Folder: {folder}", + "task_queue.list.item_result": " Result: {result}", + "task_queue.list.item_error": " Error: {error}", + "task_queue.list.item_time": " Created: {created}", + "cli.help.integrity": "Data integrity verification (generate/verify checksum manifests)", + "cli.help.integrity.action": "Integrity action: generate / verify", + "cli.help.integrity.generate": "Generate integrity.json manifest for backup directory", + "cli.help.integrity.verify": "Verify backup files against integrity.json manifest", + "cli.help.integrity.path": "Backup directory path", + "cmd.integrity.generate.success": "Integrity manifest generated: {path}", + "cmd.integrity.generate.not_dir": "Error: {path} is not a directory", + "cmd.integrity.verify.success": "Integrity verification passed: {path}", + "cmd.integrity.verify.failed": "Integrity verification failed: {path}", + "cli.help.diskcheck": "Check disk space before backup (estimate source size and backup size)", + "cli.help.diskcheck.source": "Source directory path to scan", + "cli.help.diskcheck.target": "Target directory path (where backup will be stored)", + "cli.help.diskcheck.level": "Compression level (0-9, default: 6)", + "cli.help.strict": "Abort backup if estimated disk space is insufficient", + "diskcheck.header": "=== Disk Space Check ===", + "diskcheck.source": "Source dir: {path}", + "diskcheck.target": "Target: {path}", + "diskcheck.source_size": "Source total size: {size}", + "diskcheck.estimated_size": "Estimated backup: {size} (ratio: {ratio})", + "diskcheck.disk_header": "Target disk:", + "diskcheck.disk_total": " Total: {size}", + "diskcheck.disk_used": " Used: {size}", + "diskcheck.disk_free": " Free: {size}", + "diskcheck.status.ok": "Space check: OK (margin: {margin})", + "diskcheck.status.insufficient": "Space check: INSUFFICIENT!\n Estimated backup: {needed}\n Available space: {available}\n Missing: {missing}", + "diskcheck.insufficient": "Insufficient space: need {needed}, only {available} free, missing {missing}", + "cli.help.rotate": "Rotate backup files based on retention policy", + "cli.help.rotate.backup_dir": "Directory containing backup files to rotate", + "cli.help.rotate.keep_count": "Keep the most recent N backup files (0 = disabled)", + "cli.help.rotate.keep_days": "Keep backups from the last N days (0 = disabled)", + "cli.help.rotate.keep_daily": "Keep one backup per day for the last N days (0 = disabled)", + "cli.help.rotate.dry_run": "Preview files to be deleted without actually deleting", + "cli.help.rotate_list": "List all backup files in the directory", + "cli.help.rotate_list.backup_dir": "Directory to scan for backup files", + "cmd.rotate.header": "=== Backup Rotation ===", + "cmd.rotate.scan_found": "Found {count} backup file(s) in {path}:", + "cmd.rotate.scan_empty": "No backup files found in {path}.", + "cmd.rotate.scan_item": " {name} {size} {time}", + "cmd.rotate.plan_keep": "Will keep {count} file(s):", + "cmd.rotate.plan_delete": "Will delete {count} file(s):", + "cmd.rotate.no_action": "No files need to be rotated.", + "cmd.rotate.dry_run_header": "Dry-run preview (no files will be deleted):", + "cmd.rotate.dry_run_item": " ~ {name} {size} {time}", + "cmd.rotate.delete_item": " - {name} {size}", + "cmd.rotate.done": "Rotated {count} backup file(s).", + "cmd.rotate.auto_title": "Auto-rotating backups in {path}...", + "cmd.rotate.auto_done": "Auto-rotation complete: {deleted} file(s) deleted.", + "log.rotation.dry_run_skip": "[dry-run] Would delete: %s (%.2f MB)", + "log.rotation.deleted": "Deleted: %s (%.2f MB)", + "log.rotation.delete_error": "Failed to delete %s: %s", + "dryrun.title": "Dry-run Preview", + "dryrun.scanned_dir": "Scanned directory", + "dryrun.total_files": "Total files", + "dryrun.total_size": "Total size", + "dryrun.included_files": "Included files", + "dryrun.excluded_files": "Excluded files", + "dryrun.exclusion_reasons": "Exclusion reasons", + "dryrun.skip": "Skipped", + "dryrun.unit_files": "file(s)", + "dryrun.file_types": "File types", + "dryrun.and_others": "others", + "dryrun.largest_files": "Largest files", + "dryrun.warnings": "Warnings", + "dryrun.included_files_list": "Included file list", + "dryrun.excluded_files_list": "Excluded file list", + "dryrun.more_files": "... and {count} more file(s)", + "dryrun.no_extension": "(no extension)", + "dryrun.reason.not_included": "Not matching include rules", + "dryrun.reason.excluded": "Matching exclude rules", + "dryrun.reason.too_large": "Exceeds max file size", + "dryrun.reason.too_small": "Below min file size", + "dryrun.warning.not_dir": "Path is not a valid directory: {path}", + "dryrun.warning.permission": "Permission denied: {path}", + "dryrun.warning.os_error": "OS error: {error}", + "dryrun.warning.disk_space": "Insufficient disk space: {free} free, {need} needed", + "cli.help.compress": "Directly compress a folder (without using strategies)", + "cli.help.compress.source": "Source folder path to compress", + "cli.help.compress.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.compress.format": "Compression format: zip(default) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.compress.output": "Output file path (optional)", + "cli.help.compress.password": "Encryption password (7z format only)", + "cli.help.audit": "View backup audit logs", + "cli.help.audit.action": "Audit action: log / stats / cleanup", + "cli.help.audit.log": "Query audit log entries", + "cli.help.audit.log.event": "Filter by event type", + "cli.help.audit.log.status": "Filter by status: success / failed / partial", + "cli.help.audit.log.since": "Show entries since date (ISO 8601)", + "cli.help.audit.log.limit": "Maximum number of entries to show (default: 50)", + "cli.help.audit.stats": "Show audit log statistics", + "cli.help.audit.cleanup": "Remove old audit log entries", + "cli.help.audit.cleanup.keep_days": "Keep entries from the last N days (default: 90)", + "cmd.audit.cleanup.done": "Cleaned up {count} old audit log entry(ies).", + "cmd.audit.cleanup.none": "No old entries to clean up.", + "cli.help.profile": "Manage configuration profiles", + "cli.help.profile.action": "Action: list / create / delete / use / export / import", + "cli.help.profile.create.name": "Profile name to create from current config", + "cli.help.profile.delete.name": "Profile name to delete", + "cli.help.profile.use.name": "Profile name to activate temporarily", + "cli.help.profile.export.name": "Profile name to export", + "cli.help.profile.export.path": "Export file path", + "cli.help.profile.import.path": "Import file path", + "cli.help.profile.import.name": "Profile name (default: filename without extension)", + "cli.help.profile_name": "Use specified profile for all commands", + "cmd.profile.list.header": "Configuration profiles:", + "cmd.profile.list.empty": "No profiles configured.", + "cmd.profile.list.item": " {name}", + "cmd.profile.list.detail": " {fields}", + "cmd.profile.created": "Profile '{name}' created successfully.", + "cmd.profile.deleted": "Profile '{name}' deleted.", + "cmd.profile.not_found": "Profile '{name}' not found.", + "cmd.profile.activated": "Profile '{name}' activated.", + "cmd.profile.exported": "Profile '{name}' exported to: {path}", + "cmd.profile.imported": "Profile '{name}' imported successfully.", + "cmd.profile.import_failed": "Failed to import profile from: {path}", + "cli.help.restore.search": "Search filenames inside the backup archive (case-insensitive)", + "cli.help.restore.stats": "Show backup archive statistics (file count, size, format breakdown)", + "cmd.restore.search.header": "Search results for '{keyword}' in {path}:", + "cmd.restore.search.found": "Found {count} matching file(s):", + "cmd.restore.search.no_results": "No files matching '{keyword}' found in {path}", + "cmd.restore.stats.header": "=== Backup Archive Statistics: {path} ===", + "cmd.restore.stats.files": "Files: {count}", + "cmd.restore.stats.dirs": "Directories: {count}", + "cmd.restore.stats.size": "Total size: {size} bytes", + "cmd.restore.stats.formats": "File type breakdown:", + "cmd.restore.stats.format_item": " {ext}: {count} file(s)", + "cmd.restore.selective.header": "Selective restore: extracting {count} file(s) matching {patterns}", + "cmd.restore.selective.success": "Extracted {count} file(s) to {target}", + "cli.help.hooks": "Manage and manually execute pre/post backup hooks", + "cli.help.hooks.action": "Hooks action: run", + "cli.help.hooks.run": "Manually execute pre or post backup hooks", + "cli.help.hooks.run.type": "Hook type to execute: pre or post", + "cli.help.hooks.run.timeout": "Override hook timeout in seconds (default: from config)", + "cmd.hooks.no_hooks": "No {type} hooks configured.", + "cmd.hooks.no_results": "No hook results.", + "cmd.hooks.result_line": "[{status}] {command} (rc={return_code}, {duration}s)", + "cmd.hooks.summary": "Total: {total}, Success: {success}, Failed: {fail}", + "cli.help.save.multi_dest": "Enable multi-destination backup mode", + "cli.help.multi_backup": "Multi-destination backup: local backup + parallel upload to all enabled remote targets", + "cli.help.multi_backup.source": "Source folder path to back up", + "cli.help.multi_backup.local_target": "Local backup destination folder path", + "cli.help.multi_backup.keep": "Keep the last N backup files (0 = no cleanup)", + "cli.help.multi_backup.keep_days": "Keep backups from the last N days", + "cli.help.multi_backup.password": "Encryption password (7z format only)", + "cli.help.multi_backup.dry_run": "Preview files to be backed up without actually executing", + "cli.help.multi_backup.verify": "Verify backup integrity after creation", + "cli.help.multi_backup.name_template": "Backup filename template", + "multi_dest.local.failed": "Local backup failed", + "multi_dest.started": "Multi-dest backup started: {source} -> {targets}", + "multi_dest.local.success": "Local backup completed: {path}", + "multi_dest.uploading": "Parallel upload to {count} remote target(s)...", + "multi_dest.aborted": "Multi-dest backup aborted: local backup failed", + "multi_dest.complete": "Multi-dest backup completed", + "cli.help.xsearch": "Cross-archive search: search file names across multiple backup archives", + "cli.help.xsearch.dirs": "Directories to search recursively for backup archives", + "cli.help.xsearch.keyword": "Keyword to search for (case-insensitive, matches file names)", + "cli.help.xsearch.pattern": "Glob pattern to match file names (e.g. \"*.log\")", + "cli.help.xsearch.ext": "File extension to search for (e.g. .log, .py)", + "cli.help.xsearch.password": "Decryption password (for encrypted archives)", + "cli.help.xsearch.output_lang": "Output language: zh_CN(Chinese) / en_US(English)", + "cmd.xsearch.no_query": "Please specify a search query: --keyword, --pattern, or --ext", + "cli.help.benchmark": "Run compression benchmark on a directory to compare format performance", + "cli.help.benchmark.path": "Source directory to benchmark", + "cli.help.benchmark.levels": "Compression levels to test (comma separated, default: 1,6,9)", + "cli.help.benchmark.quick": "Quick mode: test each format at its default level only", + "cli.help.benchmark.password": "Encryption password (for 7z format)", + "benchmark.header": "=== Compression Benchmark ===", + "benchmark.source_info": "Source: {path} ({size}, {count} files)", + "benchmark.recommended": "Recommended: {format} level={level} ({size}, best balance)", + "benchmark.running": "Benchmarking {format} level={level}...", + "benchmark.error": "Benchmark failed for {format}: {error}", + "benchmark.empty_source": "Source directory is empty: {path}", + "cli.help.metadata_export": "Export backup metadata (history/audit logs) to files", + "cli.help.metadata_export.action": "Export action: history / audit / all", + "cli.help.metadata_export.history": "Export backup history to CSV or JSON", + "cli.help.metadata_export.audit": "Export audit log to CSV or JSON", + "cli.help.metadata_export.all": "Export combined report (history + audit + stats)", + "cli.help.metadata_export.output": "Output file path", + "cli.help.metadata_export.strategy": "Filter by strategy name (matches source path)", + "cli.help.metadata_export.event": "Filter by event type", + "cli.help.metadata_export.format": "Output format: csv (default for history/audit) or json", + "cmd.metadata_export.history_done": "Exported {count} history record(s) to {path}", + "cmd.metadata_export.audit_done": "Exported {count} audit log entry(ies) to {path}", + "cmd.metadata_export.combined_done": "Exported combined report ({count} entries) to {path}" +} diff --git a/sbackup/locales/es_ES.json b/sbackup/locales/es_ES.json index e1e074f..ecdc84e 100644 --- a/sbackup/locales/es_ES.json +++ b/sbackup/locales/es_ES.json @@ -1,80 +1,667 @@ -{ - "cmd.add.success": "Estrategia de copia de seguridad añadida con éxito: {source} -> {dest}", - "cmd.rm.success": "Estrategia de copia de seguridad eliminada con éxito: {path}", - "err.folder.invalid": "Error: {path} no es una carpeta válida o no existe.", - "err.dest.invalid": "Error: La carpeta destino {path} no es una carpeta válida o no existe.", - "info.already.added": "Info: {path} ya ha sido añadido. No lo duplique.", - "warn.no.strategy.found": "Advertencia: Estrategia de copia de seguridad para {path} no encontrada.", - "warn.source.missing": "Advertencia: La carpeta fuente no existe: {path}", - "warn.json.decode.error": "Advertencia: El archivo de datos {path} está malformado, inicializando como diccionario vacío.", - "warn.json.backup": "Advertencia: El archivo de datos corrupto se ha respaldado en {path}.", - "warn.json.renamed": "Advertencia: El archivo de datos corrupto se ha renombrado a {path}.", - "compress.success": "Copia de seguridad exitosa: {path} ({size:.2f} MB, {count} archivos)", - "compress.progress": "Comprimiendo", - "compress.unit": "archivos", - "err.permission": "Permiso denegado: No se puede escribir en '{path}'", - "err.os": "Error del sistema: {error}", - "err.unknown": "Error desconocido: {error}", - "err.unknown.format": "Error: Format de archivo de copia de seguridad no reconocido {path}", - "err.argparse.invalid_choice": "opción no válida: ", - "err.argparse.choose_from": "elegir entre", - "err.argparse.invalid_float": "valor de tipo float no válido: ", - "err.argparse.invalid_int": "valor de tipo int no válido: ", - "err.argparse.unrecognized_args": "argumentos no reconocidos: ", - "err.argparse.required": "requerido", - "warn.zip.overwrite": "Advertencia: Sobrescribiendo el archivo de copia de seguridad existente {path}", - "warn.invalid.compresslevel": "Advertencia: Nivel de compresión {level} fuera de rango (0-9), usando el valor predeterminado 6", - "cmd.save.uptodate": "Todas las estrategias de copia de seguridad están actualizadas, no se necesita copia.", - "cmd.save.completed": "Copia de seguridad completada, {count} estrategia(s) procesada(s).", - "cmd.all.empty": "No hay estrategias de copia de seguridad configuradas.", - "exit.message": "\nSaliendo.", - "table.header.source": "Carpeta fuente", - "table.header.dest": "Carpeta destino", - "table.header.ignore": "Patrones de exclusión", - "table.cell.none": "Ninguno", - "table.header.format": "Formato", - "table.cell.default": "Predet.", - "restore.progress": "Restaurando", - "restore.success": "Restauración exitosa: {path} ({count} archivos)", - "cmd.watch.start": "Monitorización iniciada, intervalo {interval} min (pulse Ctrl+C para detener)", - - "cli.description": "Sbackup v{version} — Herramienta de copia de seguridad incremental ligera\n\nSbackup le ayuda a gestionar fácilmente las copias de seguridad de múltiples carpetas.\nUtiliza una estrategia de copia de seguridad incremental, comprimiendo únicamente los archivos modificados,\nsoportando reglas de exclusión personalizadas y ofreciendo una retroalimentación de progresión intuitiva.\n\nComandos disponibles :\n add Añadir una nueva estrategia de copia de seguridad (fuente -> destino)\n rm Eliminar una estrategia de copia de seguridad existente\n all Mostrar todas las estrategias de copia de seguridad configuradas\n save Ejecutar las tareas de copia de seguridad\n version Mostrar la información de versión", - "cli.epilog": "Ejemplos :\n Añadir una estrategia de copia de seguridad :\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Añadir estrategia con formato por entrada:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Eliminar una estrategia de copia de seguridad :\n sbackup rm F:/my_folder\n\n Mostrar todas las estrategias :\n sbackup all\n\n Ejecutar una copia de seguridad (ZIP) :\n sbackup save\n\n Ejecutar una copia de seguridad (tar.gz) :\n sbackup --format tar.gz save\n\n Ejecutar una copia de seguridad (7z cifrado) :\n sbackup --format 7z save --password 123456\n\n Conservar las 5 últimas copias de seguridad :\n sbackup save --keep 5\n\n Copia de seguridad programada (cada 60 min) :\n sbackup watch --interval 60\n\n Monitorización + limpieza :\n sbackup watch --interval 120 --keep 10\n\n Restaurar una copia de seguridad :\n sbackup restore backup.7z F:/restored\n\n Interfaz en chino :\n sbackup --lang zh_CN save\n\n Modo depuración :\n sbackup --debug save", - "cli.help.debug": "Activar el modo depuración con registro detallado", - "cli.help.help": "Mostrar este mensaje de ayuda y salir", - "cli.help.lang": "Definir el idioma de la interfaz: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", - "cli.help.format": "Definir el formato de archivado: zip (predet.) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.subcommands": "Seleccionar un comando para ejecutar", - "cli.help.add": "Añadir una nueva estrategia de copia de seguridad", - "cli.help.add.source": "Ruta de la carpeta fuente: el directorio a copiar", - "cli.help.add.dest": "Ruta de la carpeta destino: donde se almacenarán los archivos de copia de seguridad", - "cli.help.add.ignore": "Archivos o carpetas a ignorar (separados por comas, predet.: .git,__pycache__)", - "cli.help.add.format": "Formato de archivado por entrada (solo para esta estrategia, por defecto global): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.rm": "Eliminar una estrategia de copia de seguridad", - "cli.help.rm.path": "Ruta de la carpeta fuente de la estrategia a eliminar", - "cli.help.all": "Mostrar todas las estrategias de copia de seguridad", - "cli.help.save": "Ejecutar todas las estrategias de copia de seguridad", - "cli.help.save.keep": "Conservar los N últimos archivos de copia de seguridad, 0 para desactivar la limpieza", - "cli.help.save.password": "Contraseña de cifrado (solo para el formato 7z)", - "cli.help.watch": "Ejecutar copias de seguridad periódicamente", - "cli.help.watch.interval": "Intervalo de copia de seguridad en minutos, predet. 60", - "cli.help.watch.keep": "Conservar los N últimos archivos de copia de seguridad", - "cli.help.watch.password": "Contraseña de cifrado (solo para el formato 7z)", - "cli.help.restore": "Restaurar desde un archivo de copia de seguridad", - "cli.help.restore.file": "Ruta del archivo de copia de seguridad (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", - "cli.help.restore.dir": "Directorio destino para la restauración", - "cli.help.version": "Mostrar la información de versión", - "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicencia GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", - - "log.config.malformed": "El archivo de configuración %s está malformado, usando valores predeterminados", - "log.config.reset": "El archivo de configuración %s está malformado, reiniciando", - "log.config.mkdir.error": "No se puede crear el directorio de configuración %s : %s", - "log.config.write.error": "No se puede escribir el archivo de configuración %s : %s", - "log.data.read": "Leyendo el archivo de datos : %s", - "log.data.create": "El archivo de datos no existe, creando : %s", - "log.data.load": "Cargando el archivo de datos existente : %s", - "log.data.write": "Escribiendo el archivo de datos : %s", - "log.cleanup.delete": "Copia de seguridad antigua eliminada : %s", - "log.i18n.load.failed": "No se pudo cargar el locale {path}: {error}", - "log.i18n.load.fallback_failed": "No se pudo cargar el locale de respaldo {path}: {error}" -} \ No newline at end of file +{ + "benchmark.empty_source": "Directorio fuente vacío: {path}", + "benchmark.error": "Prueba de rendimiento fallida {format}: {error}", + "benchmark.header": "═══ Prueba de rendimiento de compresión ═══", + "benchmark.recommended": "Recomendado: {format} nivel={level} ({size}, mejor equilibrio)", + "benchmark.running": "Probando {format} nivel={level}...", + "benchmark.source_info": "Directorio fuente: {path} ({size}, {count} archivos)", + "cli.description": "Sbackup v{version} — Herramienta de copia de seguridad incremental ligera\n\nSbackup le ayuda a gestionar fácilmente las copias de seguridad de múltiples carpetas.\nUtiliza una estrategia de copia de seguridad incremental, comprimiendo únicamente los archivos modificados,\nsoportando reglas de exclusión personalizadas y ofreciendo una retroalimentación de progresión intuitiva.\n\nComandos disponibles :\n add Añadir una nueva estrategia de copia de seguridad (fuente -> destino)\n rm Eliminar una estrategia de copia de seguridad existente\n all Mostrar todas las estrategias de copia de seguridad configuradas\n save Ejecutar las tareas de copia de seguridad\n version Mostrar la información de versión", + "cli.epilog": "Ejemplos:\n Agregar estrategia de respaldo:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Ejecutar respaldo (7z cifrado):\n sbackup --format 7z save --password 123456\n\n Restaurar:\n sbackup restore backup.7z F:/restored\n\n SFTP remoto (detección automática, recomendado):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP remoto (contraseña):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP remoto (clave privada):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP remoto (clave + frase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp", + "cli.help.add": "Añadir una nueva estrategia de copia de seguridad", + "cli.help.add.dest": "Ruta de la carpeta destino: donde se almacenarán los archivos de copia de seguridad", + "cli.help.add.format": "Formato de archivado por entrada (solo para esta estrategia, por defecto global): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.add.from_gitignore": "Importar reglas de exclusión desde el archivo .gitignore", + "cli.help.add.ignore": "Archivos o carpetas a ignorar (separados por comas, predet.: .git,__pycache__)", + "cli.help.add.name_template": "Per-entry filename template (supports {name} {date} {time} variables, uses global default if not specified)", + "cli.help.add.source": "Ruta de la carpeta fuente: el directorio a copiar", + "cli.help.all": "Mostrar todas las estrategias de copia de seguridad", + "cli.help.audit": "Consultar el registro de auditoría de copias de seguridad", + "cli.help.audit.action": "Acción de auditoría: log (consultar) / stats (estadísticas) / cleanup (limpiar)", + "cli.help.audit.cleanup": "Limpiar entradas antiguas del registro de auditoría", + "cli.help.audit.cleanup.keep_days": "Conservar entradas de los últimos N días (predet.: 90)", + "cli.help.audit.log": "Consultar entradas del registro de auditoría", + "cli.help.audit.log.event": "Filtrar por tipo de evento", + "cli.help.audit.log.limit": "Número máximo de entradas a mostrar (predet.: 50)", + "cli.help.audit.log.since": "Mostrar entradas posteriores a esta fecha (formato ISO 8601)", + "cli.help.audit.log.status": "Filtrar por estado: success / failed / partial", + "cli.help.audit.stats": "Mostrar estadísticas del registro de auditoría", + "cli.help.benchmark": "Ejecutar prueba de rendimiento de compresión en un directorio, comparando distintos formatos", + "cli.help.benchmark.levels": "Niveles de compresión a probar (separados por coma, predet.: 1,6,9)", + "cli.help.benchmark.password": "Contraseña de cifrado (para formato 7z)", + "cli.help.benchmark.path": "Ruta del directorio fuente a probar", + "cli.help.benchmark.quick": "Modo rápido: probar solo el nivel predeterminado de cada formato", + "cli.help.checksum": "Detectar cambios mediante checksum SHA256 del contenido (más preciso que mtime, pero más lento)", + "cli.help.clean": "Limpiar inteligentemente los archivos de copia de seguridad según todas las estrategias", + "cli.help.clean.dry_run": "Solo previsualizar los archivos a eliminar, sin eliminar realmente", + "cli.help.clean.keep": "Conservar los N archivos de copia de seguridad más recientes de cada estrategia", + "cli.help.clean.keep_days": "Eliminar archivos de copia de seguridad anteriores a N días", + "cli.help.completion": "Generar script de autocompletado para la Shell", + "cli.help.completion.shell": "Tipo de Shell: bash (predet.) / zsh / fish / powershell", + "cli.help.compress": "Comprimir directamente la carpeta especificada (sin usar estrategia)", + "cli.help.compress.dry_run": "Solo previsualizar los archivos a respaldar, sin ejecutar la compresión", + "cli.help.compress.format": "Formato de compresión: zip (predet.) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.compress.output": "Ruta del archivo de salida (opcional)", + "cli.help.compress.password": "Contraseña de cifrado (solo formato 7z)", + "cli.help.compress.source": "Ruta de la carpeta fuente a comprimir", + "cli.help.config_cmd": "Gestión del archivo de configuración (cifrar/descifrar campos sensibles)", + "cli.help.config_cmd.action": "Acción: lock (cifrar) / unlock (descifrar)", + "cli.help.config_cmd.lock": "Cifrar campos sensibles del archivo de configuración con la contraseña maestra", + "cli.help.config_cmd.lock.password": "Contraseña maestra (si no se especifica, se solicita interactivamente)", + "cli.help.config_cmd.unlock": "Descifrar campos sensibles del archivo de configuración con la contraseña maestra", + "cli.help.config_cmd.unlock.password": "Contraseña maestra (si no se especifica, se solicita interactivamente)", + "cli.help.config_cmd.validate": "Validar la configuración actual y mostrar todos los errores y advertencias", + "cli.help.debug": "Modo depuración con registro detallado (después del subcomando)", + "cli.help.dedup": "Habilitar deduplicación de archivos basada en contenido (SHA256) entre estrategias", + "cli.help.diff": "Comparar directorio fuente con la ultima copia de seguridad", + "cli.help.diff.detail": "Mostrar diferencias a nivel de línea de los archivos modificados", + "cli.help.diff.file": "Ruta del archivo de copia de seguridad (busca automaticamente si no se especifica)", + "cli.help.diff.password": "Contrasena de descifrado (para copias de seguridad 7z cifradas)", + "cli.help.diff.source": "Ruta de la carpeta origen", + "cli.help.diskcheck": "Verificar espacio en disco antes de la copia (estimar tamaño del directorio fuente y del archivo de respaldo)", + "cli.help.diskcheck.level": "Nivel de compresión (0-9, predet.: 6)", + "cli.help.diskcheck.source": "Ruta del directorio fuente a analizar", + "cli.help.diskcheck.target": "Ruta del directorio destino (donde se almacenan las copias de seguridad)", + "cli.help.edit": "Editar una estrategia de copia de seguridad existente (solo modificar los campos indicados)", + "cli.help.edit.dest": "Modificar la ruta de la carpeta destino", + "cli.help.edit.format": "Modificar el formato de empaquetado: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.edit.ignore": "Modificar los patrones de exclusión (separados por coma)", + "cli.help.edit.name_template": "Modificar la plantilla de nombre de archivo (admite variables {name} {date} {time})", + "cli.help.edit.source": "Ruta de la carpeta fuente (para localizar la estrategia a editar)", + "cli.help.export": "Export all backup strategies to a JSON file", + "cli.help.export.file": "Export file path (default: sbackup_export.json)", + "cli.help.follow_symlinks": "Follow symbolic links (default: do not follow to avoid loops)", + "cli.help.format": "Definir el formato de archivado: zip (predet.) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.help": "Mostrar este mensaje de ayuda y salir", + "cli.help.hooks": "Gestionar y ejecutar manualmente los ganchos pre/post de copia de seguridad", + "cli.help.hooks.action": "Acción de ganchos: run (ejecutar)", + "cli.help.hooks.run": "Ejecutar manualmente un gancho pre o post de copia de seguridad", + "cli.help.hooks.run.timeout": "Sobrescribir el tiempo de espera del gancho (en segundos) (predet.: usar valor de configuración)", + "cli.help.hooks.run.type": "Tipo de gancho a ejecutar: pre (previo) o post (posterior)", + "cli.help.ignore": "Generar archivo de reglas de exclusión .sbackupignore", + "cli.help.ignore.list": "Listar todas las plantillas disponibles", + "cli.help.ignore.output": "Ruta del archivo de salida (predet.: .sbackupignore)", + "cli.help.ignore.preset": "Plantilla predefinida: node / python / go / rust / java / general (predet.: general)", + "cli.help.import": "Import backup strategies from a JSON file", + "cli.help.import.file": "JSON file to import", + "cli.help.incremental": "File-level incremental backup (only compress changed files, significantly reduces backup time)", + "cli.help.info": "Ver información detallada del archivo de copia de seguridad (formato, tamaño, cantidad de archivos, checksum, etc.)", + "cli.help.info.file": "Ruta del archivo de copia de seguridad", + "cli.help.info.password": "Contraseña de descifrado (para copias 7z cifradas)", + "cli.help.init": "Generar archivo de configuración predeterminado", + "cli.help.integrity": "Verificación de integridad de datos (generar/verificar manifiesto de integridad)", + "cli.help.integrity.action": "Acción de integridad: generate (generar) / verify (verificar)", + "cli.help.integrity.generate": "Generar manifiesto integrity.json para el directorio de copias de seguridad", + "cli.help.integrity.path": "Ruta del directorio de copias de seguridad", + "cli.help.integrity.verify": "Verificar la integridad de las copias de seguridad según el manifiesto integrity.json", + "cli.help.lang": "Definir el idioma de la interfaz: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", + "cli.help.list": "Ver historial de copias de seguridad", + "cli.help.list.tags": "Listar todas las etiquetas de copias de seguridad", + "cli.help.metadata_export": "Exportar metadatos de copias de seguridad (historial/registro de auditoría) a archivo", + "cli.help.metadata_export.action": "Acción de exportación: history (historial) / audit (auditoría) / all (completo)", + "cli.help.metadata_export.all": "Exportar informe completo (historial + auditoría + estadísticas)", + "cli.help.metadata_export.audit": "Exportar registro de auditoría a CSV o JSON", + "cli.help.metadata_export.event": "Filtrar por tipo de evento", + "cli.help.metadata_export.format": "Formato de salida: csv (predet. para history/audit) o json", + "cli.help.metadata_export.history": "Exportar historial de copias de seguridad a CSV o JSON", + "cli.help.metadata_export.output": "Ruta del archivo de salida", + "cli.help.metadata_export.strategy": "Filtrar por nombre de estrategia (coincidir con ruta fuente)", + "cli.help.multi_backup": "Copia de seguridad multi-destino: respaldo local + subida paralela a todos los destinos remotos habilitados", + "cli.help.multi_backup.dry_run": "Solo previsualizar los archivos a respaldar, sin ejecutar", + "cli.help.multi_backup.keep": "Conservar los N archivos de copia de seguridad más recientes (0 = sin limpieza)", + "cli.help.multi_backup.keep_days": "Conservar archivos de copia de seguridad de los últimos N días", + "cli.help.multi_backup.local_target": "Ruta de la carpeta destino local para la copia de seguridad", + "cli.help.multi_backup.name_template": "Plantilla de nombre de archivo de copia de seguridad", + "cli.help.multi_backup.password": "Contraseña de cifrado (solo formato 7z)", + "cli.help.multi_backup.source": "Ruta de la carpeta fuente a respaldar", + "cli.help.multi_backup.verify": "Verificar integridad automáticamente después de la copia de seguridad", + "cli.help.profile": "Gestionar perfiles de configuración", + "cli.help.profile.action": "Acción: list (listar) / create (crear) / delete (eliminar) / use (usar) / export (exportar) / import (importar)", + "cli.help.profile.create.name": "Nombre del perfil a crear a partir de la configuración actual", + "cli.help.profile.delete.name": "Nombre del perfil a eliminar", + "cli.help.profile.export.name": "Nombre del perfil a exportar", + "cli.help.profile.export.path": "Ruta del archivo de exportación", + "cli.help.profile.import.name": "Nombre del perfil (predet.: usar nombre de archivo)", + "cli.help.profile.import.path": "Ruta del archivo a importar", + "cli.help.profile.use.name": "Nombre del perfil a usar temporalmente", + "cli.help.profile_name": "Usar el perfil especificado en todos los comandos", + "cli.help.remote": "Gestión de archivos de copia de seguridad remotos (listar/eliminar en SFTP o WebDAV)", + "cli.help.remote.action": "Acción remota: list / rm", + "cli.help.remote.list": "Listar archivos de copia de seguridad remotos", + "cli.help.remote.list_path": "Especificar ruta de directorio remoto (si no se indica, se usa la ruta predeterminada de la configuración)", + "cli.help.remote.rm": "Eliminar un archivo de copia de seguridad remoto", + "cli.help.remote.rm.filename": "Nombre del archivo remoto a eliminar", + "cli.help.remote.sftp": "Operar en servidor SFTP", + "cli.help.remote.webdav": "Operar en servidor WebDAV", + "cli.help.report": "Generar informe de copia de seguridad en formato Markdown", + "cli.help.report.format": "Formato del informe: text (texto) o html (página web)", + "cli.help.report.output": "Ruta del archivo de salida (si no se indica, se imprime en la terminal)", + "cli.help.restore": "Restaurar desde un archivo de copia de seguridad", + "cli.help.restore.dir": "Directorio destino para la restauración", + "cli.help.restore.file": "Ruta del archivo de copia de seguridad (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", + "cli.help.restore.list": "Listar contenido del archivo sin extraer", + "cli.help.restore.password": "Contraseña de descifrado (para restaurar copias de seguridad 7z cifradas)", + "cli.help.restore.search": "Buscar nombres de archivo dentro del paquete de respaldo (sin distinguir mayúsculas)", + "cli.help.restore.select": "Restauración selectiva: extraer solo archivos que coincidan con el patrón (admite comodines glob, como \"*.py\" o \"src/main.py\")", + "cli.help.restore.stats": "Mostrar estadísticas del paquete de respaldo (cantidad de archivos, tamaño, distribución por formato)", + "cli.help.restore.tag": "Restaurar la copia de seguridad con la etiqueta especificada", + "cli.help.rm": "Eliminar una estrategia de copia de seguridad", + "cli.help.rm.all": "Eliminar todas las estrategias de copia de seguridad", + "cli.help.rm.path": "Ruta de la carpeta fuente de la estrategia a eliminar", + "cli.help.rotate": "Rotar y limpiar archivos de copia de seguridad según la política de retención", + "cli.help.rotate.backup_dir": "Ruta del directorio que contiene los archivos de copia de seguridad", + "cli.help.rotate.dry_run": "Solo previsualizar los archivos a eliminar, sin eliminar realmente", + "cli.help.rotate.keep_count": "Conservar los N archivos de copia de seguridad más recientes (0 = sin límite)", + "cli.help.rotate.keep_daily": "Conservar una copia diaria de los últimos N días (0 = sin límite)", + "cli.help.rotate.keep_days": "Conservar archivos de copia de seguridad de los últimos N días (0 = sin límite)", + "cli.help.rotate_list": "Listar todos los archivos de copia de seguridad en un directorio", + "cli.help.rotate_list.backup_dir": "Directorio de archivos de copia de seguridad a analizar", + "cli.help.save": "Ejecutar todas las estrategias de copia de seguridad", + "cli.help.save.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.save.keep": "Conservar los N últimos archivos de copia de seguridad, 0 para desactivar la limpieza", + "cli.help.save.keep_days": "Conservar archivos de copia de seguridad de los últimos N días, 0 para no limpiar por antigüedad (predet.: 0)", + "cli.help.save.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", + "cli.help.save.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", + "cli.help.save.multi_dest": "Habilitar modo de copia de seguridad multi-destino", + "cli.help.save.name_template": "Backup filename template (supports {name} {date} {time} variables)", + "cli.help.save.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", + "cli.help.save.password": "Contraseña de cifrado (solo para el formato 7z)", + "cli.help.save.post_hook": "Comando a ejecutar después de la copia de seguridad (se puede usar varias veces)", + "cli.help.save.pre_hook": "Comando a ejecutar antes de la copia de seguridad (se puede usar varias veces)", + "cli.help.save.sftp": "Subir al servidor SFTP después del respaldo", + "cli.help.save.split": "Tamaño de volumen para dividir el archivo de copia de seguridad (ej.: 100MB, 1GB); se divide automáticamente al superar este tamaño", + "cli.help.save.tag": "Añadir etiqueta a esta copia de seguridad (ej.: daily, release)", + "cli.help.save.verify": "Verify backup integrity after completion", + "cli.help.save.webdav": "Subir al servidor WebDAV después de la copia de seguridad", + "cli.help.save.webhook": "POST backup results to this URL after completion", + "cli.help.schedule": "Gestión de la configuración de programación de tareas", + "cli.help.schedule.action": "Acción de programación: export (exportar configuración) / install (instalar servicio)", + "cli.help.schedule.export": "Exportar archivo de configuración de programación", + "cli.help.schedule.install": "Instalar la copia de seguridad programada como servicio del sistema", + "cli.help.schedule.install.interval": "Intervalo de copia de seguridad (en minutos, predet.: 60)", + "cli.help.schedule.install.type": "Tipo de servicio: systemd / schtasks / launchd", + "cli.help.schedule.install.user": "Usuario que ejecuta el servicio (predet.: usuario actual)", + "cli.help.schedule.interval": "Intervalo de copia de seguridad (en minutos, predet.: 60)", + "cli.help.schedule.output": "Ruta del archivo de salida (si no se indica, se imprime en la terminal)", + "cli.help.schedule.type": "Tipo de programación: systemd / crontab (predet.: systemd)", + "cli.help.schedule.type.schtasks": "Tareas programadas de Windows", + "cli.help.search": "Buscar nombres de archivo en las copias de seguridad", + "cli.help.search.in": "Especificar ruta del archivo de copia de seguridad (si no se indica, buscar en la última copia de todas las estrategias)", + "cli.help.search.password": "Contraseña de descifrado (para buscar en copias cifradas)", + "cli.help.search.pattern": "Patrón de búsqueda (admite comodines glob, como *.txt)", + "cli.help.sftp": "Gestión de respaldo SFTP remoto (soporta contraseña y clave SSH)", + "cli.help.sftp.action": "Acción SFTP: config / test", + "cli.help.sftp.config": "Configurar parámetros de conexión SFTP", + "cli.help.sftp.host": "Dirección del servidor SFTP (ej: 192.168.1.100)", + "cli.help.sftp.key_file": "Ruta de clave privada SSH (RSA/Ed25519/ECDSA, prioridad sobre contraseña)", + "cli.help.sftp.key_passphrase": "Frase de la clave privada (ingresable interactivamente)", + "cli.help.sftp.password": "Contraseña SFTP (solo autenticación por contraseña)", + "cli.help.sftp.port": "Puerto SFTP (predeterminado 22)", + "cli.help.sftp.remote_path": "Ruta remota destino (defecto: /)", + "cli.help.sftp.test": "Probar si la conexión SFTP está disponible", + "cli.help.sftp.user": "Usuario SFTP", + "cli.help.status": "View backup status dashboard", + "cli.help.strict": "Abortar la copia de seguridad si no hay suficiente espacio en disco (si no se indica, solo muestra advertencia)", + "cli.help.subcommands": "Seleccionar un comando para ejecutar", + "cli.help.task": "Gestión de la cola de tareas (añadir/ejecutar/cancelar tareas de copia de seguridad)", + "cli.help.task.action": "Acción de tarea: add (añadir) / list (listar) / run (ejecutar) / run-all (ejecutar todas) / cancel (cancelar) / clear (limpiar) / stats (estadísticas)", + "cli.help.task.add": "Añadir una nueva tarea de copia de seguridad a la cola", + "cli.help.task.add.folder": "Ruta de la carpeta a respaldar", + "cli.help.task.add.format": "Formato de compresión: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.task.add.name": "Nombre de la tarea (para identificación)", + "cli.help.task.add.output": "Ruta del archivo de salida (opcional)", + "cli.help.task.cancel": "Cancelar una tarea pendiente", + "cli.help.task.cancel.id": "ID de la tarea a cancelar", + "cli.help.task.clear": "Limpiar tareas completadas y fallidas de la cola", + "cli.help.task.list": "Listar las tareas en la cola", + "cli.help.task.list.status": "Filtrar por estado: pending / running / completed / failed", + "cli.help.task.run": "Ejecutar la siguiente tarea pendiente en la cola", + "cli.help.task.run_all": "Ejecutar secuencialmente todas las tareas pendientes", + "cli.help.task.stats": "Mostrar estadísticas de la cola de tareas", + "cli.help.verify": "Verificar integridad del archivo de copia de seguridad", + "cli.help.verify.all": "Verificar todas las copias de seguridad históricas en lote", + "cli.help.verify.detail": "Mostrar información detallada de verificación (tamaño, formato, suma de verificación, etc.)", + "cli.help.verify.fast": "Verificación rápida (comparar SHA256 histórico, sin extracción)", + "cli.help.verify.file": "Archivo de copia de seguridad a verificar", + "cli.help.verify.password": "Contraseña de descifrado (para copias cifradas)", + "cli.help.verify.split": "Verificar integridad de archivos divididos (requiere archivo de manifiesto)", + "cli.help.version": "Mostrar la información de versión", + "cli.help.versions": "Ver historial de versiones de copias de seguridad (agrupado por directorio fuente)", + "cli.help.versions.source": "Especificar ruta del directorio fuente (si no se indica, mostrar todas)", + "cli.help.versions.tag": "Filtrar versiones por etiqueta", + "cli.help.watch": "Ejecutar copias de seguridad periódicamente", + "cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)", + "cli.help.watch.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.watch.interval": "Intervalo de copia de seguridad en minutos, predet. 60", + "cli.help.watch.keep": "Conservar los N últimos archivos de copia de seguridad", + "cli.help.watch.keep_days": "Conservar archivos de copia de seguridad de los últimos N días (predet.: 0)", + "cli.help.watch.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", + "cli.help.watch.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", + "cli.help.watch.name_template": "Backup filename template (supports {name} {date} {time} variables)", + "cli.help.watch.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", + "cli.help.watch.password": "Contraseña de cifrado (solo para el formato 7z)", + "cli.help.watch.post_hook": "Comando a ejecutar después de la copia de seguridad (se puede usar varias veces)", + "cli.help.watch.pre_hook": "Comando a ejecutar antes de la copia de seguridad (se puede usar varias veces)", + "cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)", + "cli.help.watch.sftp": "Subir al servidor SFTP después de cada respaldo", + "cli.help.watch.split": "Tamaño de volumen para dividir el archivo de copia de seguridad (ej.: 100MB, 1GB)", + "cli.help.watch.tag": "Añadir etiqueta a esta copia de seguridad", + "cli.help.watch.verify": "Verify backup integrity after each backup", + "cli.help.watch.webdav": "Subir al servidor WebDAV después de cada copia de seguridad", + "cli.help.watch.webhook": "POST backup results to this URL after each backup", + "cli.help.webdav": "Gestión de copia de seguridad remota WebDAV (soporta Jianguoyun, NextCloud, Synology, etc.)", + "cli.help.webdav.action": "Acción WebDAV: config / test", + "cli.help.webdav.config": "Configurar parámetros de conexión WebDAV", + "cli.help.webdav.password": "Contraseña WebDAV", + "cli.help.webdav.remote_path": "Ruta remota destino (defecto: /)", + "cli.help.webdav.test": "Probar si la conexión WebDAV está disponible", + "cli.help.webdav.url": "URL del servidor WebDAV (ej: https://dav.jianguoyun.com/dav/)", + "cli.help.webdav.user": "Usuario WebDAV", + "cli.help.webhook_cmd": "Gestión de notificaciones por Webhook", + "cli.help.webhook_cmd.action": "Acción de Webhook: preset (configurar plantilla) / list (listar plantillas)", + "cli.help.webhook_cmd.list": "Listar todas las plantillas de notificación IM disponibles", + "cli.help.webhook_cmd.preset": "Configurar plantilla de notificación IM", + "cli.help.webhook_cmd.preset.name": "Nombre de la plantilla: dingtalk / feishu / wechat", + "cli.help.wizard": "Asistente de configuración interactivo, guía la configuración inicial paso a paso", + "cli.help.xsearch": "Búsqueda entre archivos: buscar nombres de archivo en múltiples copias de seguridad", + "cli.help.xsearch.dirs": "Lista de directorios de copias de seguridad donde buscar recursivamente", + "cli.help.xsearch.ext": "Buscar por extensión (ej.: .log, .py)", + "cli.help.xsearch.keyword": "Palabra clave de búsqueda (sin distinguir mayúsculas, coincide con nombres de archivo)", + "cli.help.xsearch.output_lang": "Idioma de salida: zh_CN (chino) / en_US (inglés)", + "cli.help.xsearch.password": "Contraseña de descifrado (para buscar en copias cifradas)", + "cli.help.xsearch.pattern": "Patrón glob para coincidir con nombres de archivo (ej.: \"*.log\")", + "cli.prompt.sftp.host": "Dirección del servidor SFTP:", + "cli.prompt.sftp.key_file": "Ruta de clave privada SSH (vacío para contraseña):", + "cli.prompt.sftp.key_passphrase": "Frase de la clave (Enter para omitir):", + "cli.prompt.sftp.password": "Contraseña SFTP:", + "cli.prompt.sftp.port": "Puerto SFTP (predeterminado 22):", + "cli.prompt.sftp.remote_path": "Ruta remota (predeterminado /):", + "cli.prompt.sftp.user": "Usuario SFTP:", + "cli.prompt.webdav.password": "Contraseña WebDAV:", + "cli.prompt.webdav.remote_path": "Ruta remota (defecto /):", + "cli.prompt.webdav.url": "URL del servidor WebDAV:", + "cli.prompt.webdav.user": "Usuario WebDAV:", + "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicencia GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", + "cmd.add.gitignore_empty": "No se importó ninguna regla desde {path}", + "cmd.add.gitignore_imported": "Se importaron {count} reglas de exclusión desde {path}", + "cmd.add.success": "Estrategia de copia de seguridad añadida con éxito: {source} -> {dest}", + "cmd.all.empty": "No hay estrategias de copia de seguridad configuradas.", + "cmd.audit.cleanup.done": "Se limpiaron {count} entradas antiguas del registro de auditoría.", + "cmd.audit.cleanup.none": "No hay registros antiguos que limpiar.", + "cmd.clean.dry_run_header": "Modo de previsualización (no se eliminará nada):", + "cmd.clean.no_criteria": "Especifique criterios de limpieza: --keep N o --keep-days N", + "cmd.clean.nothing": "No hay archivos de copia de seguridad que limpiar.", + "cmd.clean.summary": "Se limpiaron {count} archivos de copia de seguridad en total.", + "cmd.config.already_locked": "El archivo de configuración ya está cifrado", + "cmd.config.enter_password": "Introduzca la contraseña maestra", + "cmd.config.lock_failed": "Error al cifrar", + "cmd.config.locked": "Archivo de configuración cifrado (campos sensibles protegidos)", + "cmd.config.no_password": "La contraseña no puede estar vacía", + "cmd.config.not_locked": "El archivo de configuración no está cifrado", + "cmd.config.unlocked": "Archivo de configuración descifrado", + "cmd.config.validate.errors": "Se encontraron errores:", + "cmd.config.validate.failed": "La validación de la configuración falló. Corrija los errores anteriores.", + "cmd.config.validate.passed": "La validación de la configuración pasó, no se encontraron problemas.", + "cmd.config.validate.passed_with_warnings": "La validación de la configuración pasó (pero hay advertencias).", + "cmd.config.validate.warnings": "Se encontraron advertencias:", + "cmd.config.wrong_password": "Contraseña incorrecta, error al descifrar", + "cmd.dedup.saved": "Deduplicación: se omitieron {count} archivos duplicados, ahorrando {saved:.2f} MB", + "cmd.diff.added_header": "Anadidos ({count}):", + "cmd.diff.detail_header": "--- Diferencias a nivel de línea ---", + "cmd.diff.empty_backup": "El archivo de copia de seguridad esta vacio: {path}", + "cmd.diff.header": "=== Diferencias: {backup} ===", + "cmd.diff.modified_header": "Posiblemente modificados ({count}):", + "cmd.diff.no_backup": "No se encontraron archivos de copia de seguridad en {path}", + "cmd.diff.no_changes": "El directorio fuente coincide con la copia de seguridad, sin diferencias.", + "cmd.diff.no_detail": "(archivo binario o no legible)", + "cmd.diff.removed_header": "Eliminados ({count}):", + "cmd.diff.summary": "\nResumen de diferencias: +{added} añadidos, -{removed} eliminados, ~{modified} modificados", + "cmd.dry_run.header": "Dry-run preview (no backup will be performed):", + "cmd.dry_run.item": " {source} -> {target} format: {format} files: {count} size: {size} MB", + "cmd.dry_run.nothing": "No strategies need backup (all are up to date).", + "cmd.dry_run.total": "Total: {count} strategies, {files} files, {size} MB", + "cmd.edit.nothing": "No se especificó ningún parámetro de modificación.", + "cmd.edit.success": "Estrategia de copia de seguridad editada con éxito: {path}", + "cmd.export.success": "Exported {count} backup strategies to {path}", + "cmd.hook.failed": "Error al ejecutar el gancho: {command} — {error}", + "cmd.hook.post_running": "Ejecutando gancho posterior: {command}", + "cmd.hook.pre_running": "Ejecutando gancho previo: {command}", + "cmd.hooks.no_hooks": "No hay ganchos {type} configurados.", + "cmd.hooks.no_results": "No hay resultados de ejecución de ganchos.", + "cmd.hooks.result_line": "[{status}] {command} (código de retorno={return_code}, duración={duration}s)", + "cmd.hooks.summary": "Total {total}, éxitos {success}, fallos {fail}", + "cmd.ignore.available_presets": "Plantillas disponibles:", + "cmd.ignore.cancelled": "Cancelado.", + "cmd.ignore.generated": "Se generaron reglas de exclusión para la plantilla {preset}: {path}", + "cmd.ignore.overwrite": "El archivo {path} ya existe, ¿desea sobrescribirlo? (s/N): ", + "cmd.import.empty": "No valid backup strategies found in: {path}", + "cmd.import.success": "Imported {count} backup strategies, skipped {skipped} existing", + "cmd.info.files": "Cantidad de archivos: {count}", + "cmd.info.format": "Formato de compresión: {format}", + "cmd.info.header": "═══ Información del archivo de copia de seguridad ═══", + "cmd.info.path": "Ruta del archivo: {path}", + "cmd.info.sha256": "SHA256: {checksum}", + "cmd.info.size": "Tamaño del archivo: {size:.2f} MB", + "cmd.info.time": "Fecha de modificación: {time}", + "cmd.init.exists": "El archivo de configuración ya existe: {path}, omitiendo.", + "cmd.init.success": "Archivo de configuración generado: {path}", + "cmd.integrity.generate.not_dir": "Error: {path} no es un directorio", + "cmd.integrity.generate.success": "Manifiesto de integridad generado: {path}", + "cmd.integrity.verify.failed": "Verificación de integridad fallida: {path}", + "cmd.integrity.verify.success": "Verificación de integridad aprobada: {path}", + "cmd.list.empty": "Aún no hay historial de copias de seguridad.", + "cmd.list.tags.empty": "Aún no hay etiquetas", + "cmd.list.tags.header": "Etiquetas de copias de seguridad:", + "cmd.metadata_export.audit_done": "Se exportaron {count} entradas de auditoría a {path}", + "cmd.metadata_export.combined_done": "Se exportó el informe completo ({count} registros) a {path}", + "cmd.metadata_export.history_done": "Se exportaron {count} registros históricos a {path}", + "cmd.profile.activated": "Perfil '{name}' activado.", + "cmd.profile.created": "Perfil '{name}' creado con éxito.", + "cmd.profile.deleted": "Perfil '{name}' eliminado.", + "cmd.profile.exported": "Perfil '{name}' exportado a: {path}", + "cmd.profile.import_failed": "Error al importar el perfil desde {path}.", + "cmd.profile.imported": "Perfil '{name}' importado con éxito.", + "cmd.profile.list.detail": " {fields}", + "cmd.profile.list.empty": "No hay perfiles configurados.", + "cmd.profile.list.header": "Lista de perfiles de configuración:", + "cmd.profile.list.item": " {name}", + "cmd.profile.not_found": "El perfil '{name}' no existe.", + "cmd.remote.deleted": "Archivo remoto eliminado: {path}", + "cmd.remote.empty": "El directorio remoto está vacío: {path}", + "cmd.remote.list_header": "Directorio remoto: {path} ({count} archivos)", + "cmd.remote.rm_requires_path": "Especifique el nombre del archivo remoto a eliminar", + "cmd.remote.select_protocol": "Especifique el protocolo: --sftp o --webdav", + "cmd.report.saved": "Informe de copia de seguridad guardado en: {path}", + "cmd.restore.search.found": "Se encontraron {count} archivos coincidentes:", + "cmd.restore.search.header": "Resultados de búsqueda de '{keyword}' en {path}:", + "cmd.restore.search.no_results": "No se encontraron archivos que coincidan con '{keyword}' en {path}", + "cmd.restore.selective.header": "Restauración selectiva: extrayendo {count} archivos que coinciden con {patterns}", + "cmd.restore.selective.success": "Se extrajeron {count} archivos a {target}", + "cmd.restore.stats.dirs": "Directorios: {count}", + "cmd.restore.stats.files": "Archivos: {count}", + "cmd.restore.stats.format_item": " {ext}: {count} archivos", + "cmd.restore.stats.formats": "Distribución por tipo de archivo:", + "cmd.restore.stats.header": "═══ Estadísticas del paquete de respaldo: {path} ═══", + "cmd.restore.stats.size": "Tamaño total: {size} bytes", + "cmd.restore.tag_not_found": "No se encontró ninguna copia de seguridad con la etiqueta '{tag}'", + "cmd.rm.all.confirm": "¿Eliminar todas las {count} estrategias de copia de seguridad? (s/N): ", + "cmd.rm.all.confirm_words": "y,yes", + "cmd.rm.all.empty": "No hay estrategias de copia de seguridad para eliminar.", + "cmd.rm.all.success": "Se eliminaron todas las {count} estrategias de copia de seguridad.", + "cmd.rm.success": "Estrategia de copia de seguridad eliminada con éxito: {path}", + "cmd.rotate.auto_done": "Rotación automática completada: se eliminaron {deleted} archivos.", + "cmd.rotate.auto_title": "Rotando automáticamente las copias de seguridad en {path}...", + "cmd.rotate.delete_item": " - {name} {size}", + "cmd.rotate.done": "Se rotaron y limpiaron {count} archivos de copia de seguridad.", + "cmd.rotate.dry_run_header": "Modo de previsualización (no se eliminará nada):", + "cmd.rotate.dry_run_item": " ~ {name} {size} {time}", + "cmd.rotate.header": "═══ Rotación de copias de seguridad ═══", + "cmd.rotate.no_action": "No hay archivos que rotar.", + "cmd.rotate.plan_delete": "Se eliminarán {count} archivos:", + "cmd.rotate.plan_keep": "Se conservarán {count} archivos:", + "cmd.rotate.scan_empty": "No se encontraron archivos de copia de seguridad en {path}.", + "cmd.rotate.scan_found": "Se encontraron {count} archivos de copia de seguridad en {path}:", + "cmd.rotate.scan_item": " {name} {size} {time}", + "cmd.save.completed": "Copia de seguridad completada, {count} estrategia(s) procesada(s).", + "cmd.save.summary": "Resumen de copia de seguridad: {total} estrategias, {backed} respaldadas, {skipped} omitidas, tardó {elapsed:.1f}s", + "cmd.save.uptodate": "Todas las estrategias de copia de seguridad están actualizadas, no se necesita copia.", + "cmd.schedule.exported": "Configuración de programación exportada a: {path} ({type})", + "cmd.schedule.install.launchd": "Ejecute lo siguiente después de guardar el archivo plist:", + "cmd.schedule.install.schtasks_cmd": "Ejecute el siguiente comando como administrador para crear la tarea programada:\n {cmdline}", + "cmd.schedule.install.systemd": "Ejecute el siguiente comando como root para instalar el temporizador de systemd:", + "cmd.schedule.schtasks.generated": "Se generó el comando de tarea programada de Windows", + "cmd.schedule.unknown_type": "Tipo de programación desconocido: {type}", + "cmd.search.no_results": "No se encontraron archivos que coincidan con '{pattern}' en {path}", + "cmd.search.no_results_global": "No se encontraron archivos que coincidan con '{pattern}' en ninguna copia de seguridad", + "cmd.search.results": "Se encontraron {count} archivos coincidentes en {path}:", + "cmd.sftp.config_saved": "Configuración SFTP guardada en config.json", + "cmd.sftp.no_default_key": "No se encontró clave SSH predeterminada, configure contraseña o especifique ruta", + "cmd.sftp.parallel_upload": "Subida paralela de {count} archivos...", + "cmd.sftp.progress": "Subiendo", + "cmd.sftp.success": "Subida exitosa: {file}", + "cmd.sftp.test_fail": "Prueba de conexión SFTP falló, verifique configuración", + "cmd.sftp.test_ok": "Prueba de conexión SFTP exitosa", + "cmd.sftp.testing": "Probando conexión a {host}...", + "cmd.sftp.uploading": "Subiendo: {file}", + "cmd.sftp.using_default_key": "Clave SSH predetectada detectada, usando: {path}", + "cmd.status.avg_ratio": "Average compression ratio: {ratio:.1f}%", + "cmd.status.header": "=== Backup Status Dashboard ===", + "cmd.status.history": "History records: {count}", + "cmd.status.strategies": "Total strategies: {count}", + "cmd.status.strategy.format": " Format: {format}", + "cmd.status.strategy.header": "--- Strategy Details ---", + "cmd.status.strategy.item": " {source} -> {target}", + "cmd.status.strategy.last_backup": " Last backup: {time}", + "cmd.status.strategy.never": " Last backup: Never", + "cmd.status.strategy.size": " Target size: {size:.2f} MB", + "cmd.status.total_size": "Total backup size: {size:.2f} MB", + "cmd.upload.parallel": "Subiendo en paralelo a SFTP y WebDAV...", + "cmd.verify.batch_failed": "✗ Fallido: {path}", + "cmd.verify.batch_success": "✓ Verificado: {path}", + "cmd.verify.batch_summary": "Verificación en lote completada: {success} aprobados, {fail} fallidos", + "cmd.verify.checking": "Verificando copia de seguridad: {path}", + "cmd.verify.checksum_mismatch": "La suma de verificación no coincide", + "cmd.verify.detail_header": "=== Detalles de copia de seguridad: {path} ===", + "cmd.verify.detail_info": "Tamaño: {size:.2f} MB | Archivos: {files} | Formato: {fmt} | SHA256: {sha256}...", + "cmd.verify.failed": "Verificación fallida: {path} — se esperaban {expected} archivos, se obtuvieron {actual}", + "cmd.verify.fast_failed": "Verificación rápida fallida: {path}, el archivo podría estar dañado o manipulado", + "cmd.verify.fast_success": "Verificación rápida aprobada: {path}\nSHA256: {sha256}", + "cmd.verify.no_checksum": "No se encontró suma de verificación histórica, verificación completa: {path}", + "cmd.verify.no_file": "Especifique la ruta del archivo o use --all para verificar todas las copias.", + "cmd.verify.no_history": "No se encontró historial de copias de seguridad.", + "cmd.verify.partial_fail": "Warning: {count} backup file(s) failed verification!", + "cmd.verify.split_error": "Error de verificación de archivos divididos: {error}", + "cmd.verify.split_failed": "Verificación de integridad de archivos divididos fallida: {file}", + "cmd.verify.split_success": "Integridad de archivos divididos verificada: {file} ({parts} partes)", + "cmd.verify.success": "Verificación exitosa: {path} ({count} archivos)", + "cmd.versions.empty": "Aún no hay registros de versiones de copias de seguridad.", + "cmd.versions.header": "═══ Historial de versiones de copias de seguridad ═══", + "cmd.versions.source": "📁 {source}", + "cmd.versions.total": "Total {count} versiones, {sources} directorios fuente", + "cmd.watch.start": "Monitorización iniciada, intervalo {interval} min (pulse Ctrl+C para detener)", + "cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)", + "cmd.webdav.config_saved": "Configuración WebDAV guardada en config.json", + "cmd.webdav.parallel_upload": "Subida paralela de {count} archivos a WebDAV...", + "cmd.webdav.success": "Subida WebDAV exitosa: {file}", + "cmd.webdav.test_ok": "Prueba de conexión WebDAV exitosa", + "cmd.webdav.testing": "Probando conexión WebDAV a {url}...", + "cmd.webdav.uploading": "Subiendo a WebDAV: {file}", + "cmd.webhook.available_presets": "Plantillas de notificación IM disponibles:", + "cmd.webhook.preset_set": "Plantilla de notificación {name} configurada:\n{template}", + "cmd.webhook.preset_unknown": "Plantilla desconocida: {name}", + "cmd.wizard.choice": "Seleccione una opción:", + "cmd.wizard.cloud_done": "Configuración de almacenamiento en la nube completada.", + "cmd.wizard.cloud_info": "Almacenamiento en la nube compatible con S3 (AWS S3, MinIO, Backblaze, Tencent COS):", + "cmd.wizard.complete": "¡Configuración de sbackup completada!", + "cmd.wizard.dest": "Ruta de la carpeta destino para los archivos de copia de seguridad:", + "cmd.wizard.email_done": "Notificación por correo configurada.", + "cmd.wizard.email_yn": "¿Desea configurar notificación por correo? (s/N):", + "cmd.wizard.host": "Dirección del servidor:", + "cmd.wizard.ignore": "Patrones a ignorar (separados por coma, predet.: .git,__pycache__):", + "cmd.wizard.lang_set": "Idioma configurado a {lang}", + "cmd.wizard.more": "¿Desea seguir añadiendo? (s/N):", + "cmd.wizard.password": "Contraseña:", + "cmd.wizard.port": "Puerto:", + "cmd.wizard.remote_path": "Ruta remota:", + "cmd.wizard.schedule_info": "Ejecute el siguiente comando para iniciar la copia de seguridad programada (cada {interval} minutos):", + "cmd.wizard.schedule_interval": "Intervalo de copia de seguridad (en minutos, predet.: 60):", + "cmd.wizard.schedule_yn": "¿Desea configurar copias de seguridad programadas? (s/N):", + "cmd.wizard.sftp_done": "Configuración de SFTP completada.", + "cmd.wizard.skip": "Omitir", + "cmd.wizard.source": "Ruta de la carpeta fuente a respaldar:", + "cmd.wizard.step_email": "Paso 4/5: Notificación por correo (opcional)", + "cmd.wizard.step_lang": "Paso 1/5: Seleccionar idioma de la interfaz", + "cmd.wizard.step_remote": "Paso 3/5: Configurar almacenamiento remoto (opcional)", + "cmd.wizard.step_schedule": "Paso 5/5: Copia de seguridad programada (opcional)", + "cmd.wizard.step_strategy": "Paso 2/5: Añadir estrategia de copia de seguridad", + "cmd.wizard.summary": "Se añadieron {strategies} estrategias de copia de seguridad.\nEjecute 'sbackup save' para ejecutar la copia de seguridad de inmediato.", + "cmd.wizard.user": "Nombre de usuario:", + "cmd.wizard.webdav_done": "Configuración de WebDAV completada.", + "cmd.wizard.welcome": "¡Bienvenido al asistente de configuración de sbackup!\nEste asistente le guiará a través de la configuración inicial.", + "cmd.xsearch.no_query": "Especifique criterios de búsqueda: --keyword, --pattern o --ext", + "compress.progress": "Comprimiendo", + "compress.success": "Backup successful: {path} ({original:.2f} MB → {size:.2f} MB, {ratio:.0f}%, {count} files)", + "compress.unit": "archivos", + "diskcheck.disk_free": " Disponible: {size}", + "diskcheck.disk_header": "Disco destino:", + "diskcheck.disk_total": " Capacidad total: {size}", + "diskcheck.disk_used": " En uso: {size}", + "diskcheck.estimated_size": "Tamaño estimado de la copia: {size} (ratio de compresión: {ratio})", + "diskcheck.header": "═══ Verificación de espacio en disco ═══", + "diskcheck.insufficient": "Espacio en disco insuficiente: se necesitan {needed}, disponibles {available}, faltan {missing}", + "diskcheck.source": "Directorio fuente: {path}", + "diskcheck.source_size": "Tamaño total de los archivos fuente: {size}", + "diskcheck.status.insufficient": "Verificación de espacio: ¡insuficiente!\n Espacio estimado necesario: {needed}\n Espacio disponible: {available}\n Faltante: {missing}", + "diskcheck.status.ok": "Verificación de espacio: suficiente (margen de {margin})", + "diskcheck.target": "Ruta destino: {path}", + "dryrun.and_others": "y otros", + "dryrun.excluded_files": "Archivos excluidos", + "dryrun.excluded_files_list": "Lista de archivos excluidos", + "dryrun.exclusion_reasons": "Motivos de exclusión", + "dryrun.file_types": "Tipos de archivo", + "dryrun.included_files": "Archivos incluidos", + "dryrun.included_files_list": "Lista de archivos incluidos", + "dryrun.largest_files": "Archivos más grandes", + "dryrun.more_files": "... y {count} archivos más", + "dryrun.no_extension": "(sin extensión)", + "dryrun.reason.excluded": "Coincide con regla de exclusión", + "dryrun.reason.not_included": "No coincide con regla de inclusión", + "dryrun.reason.too_large": "Supera el tamaño máximo de archivo", + "dryrun.reason.too_small": "Inferior al tamaño mínimo de archivo", + "dryrun.scanned_dir": "Directorio analizado", + "dryrun.skip": "Omitir", + "dryrun.title": "Vista previa (dry-run)", + "dryrun.total_files": "Total de archivos", + "dryrun.total_size": "Tamaño total", + "dryrun.unit_files": "archivos", + "dryrun.warning.disk_space": "Espacio en disco insuficiente: disponible {free}, necesario {need}", + "dryrun.warning.not_dir": "La ruta no es un directorio válido: {path}", + "dryrun.warning.os_error": "Error del sistema: {error}", + "dryrun.warning.permission": "Permisos insuficientes para acceder a: {path}", + "dryrun.warnings": "Advertencias", + "err.argparse.choose_from": "elegir entre", + "err.argparse.invalid_choice": "opción no válida: ", + "err.argparse.invalid_float": "valor de tipo float no válido: ", + "err.argparse.invalid_int": "valor de tipo int no válido: ", + "err.argparse.required": "requerido", + "err.argparse.unrecognized_args": "argumentos no reconocidos: ", + "err.dest.invalid": "Error: La carpeta destino {path} no es una carpeta válida o no existe.", + "err.file.not_found": "Error: El archivo de copia de seguridad {path} no existe.", + "err.folder.invalid": "Error: {path} no es una carpeta válida o no existe.", + "err.invalid_filename": "Nombre de archivo no válido: {filename} (no se permiten separadores de ruta ni secuencias ..)", + "err.lock.conflict": "Error: otra operación de copia de seguridad está en ejecución (el archivo de bloqueo ya existe). Si está seguro de que no hay otro proceso en ejecución, elimine manualmente el archivo de bloqueo.", + "err.os": "Error del sistema: {error}", + "err.permission": "Permiso denegado: No se puede escribir en '{path}'", + "err.sftp.auth": "Error de autenticación SFTP: {host}, verifique usuario y contraseña", + "err.sftp.connect": "No se puede conectar a {host}:{port}: {error}", + "err.sftp.delete": "Error al eliminar archivo remoto {path}: {error}", + "err.sftp.host_key_mismatch": "La clave del host {host} no coincide con known_hosts (posible ataque de intermediario)", + "err.sftp.key_load": "No se puede cargar la clave privada {path}, verifique el formato (RSA/Ed25519/ECDSA)", + "err.sftp.key_needs_passphrase": "La clave {path} requiere frase, ingrésela o use --key-passphrase", + "err.sftp.local_not_found": "Archivo local no encontrado: {path}", + "err.sftp.mkdir": "Error al crear directorio remoto {path}: {error}", + "err.sftp.mkdir_max_depth": "La jerarquía de directorios remotos es demasiado profunda o contiene un bucle: {path}", + "err.sftp.no_host_key": "No se pudo obtener la clave del host {host}", + "err.sftp.not_configured": "SFTP no configurado, ejecute: sbackup sftp config --host --user ", + "err.sftp.not_connected": "SFTP no conectado, llame a connect() primero", + "err.sftp.remote_not_found": "El archivo remoto no existe: {path}", + "err.sftp.ssh": "Error de conexión SFTP: {error}", + "err.sftp.upload": "Error al subir {path}: {error}", + "err.sftp.wrong_passphrase": "Frase incorrecta, reintente (Enter para omitir la clave)", + "err.unknown": "Error desconocido: {error}", + "err.unknown.format": "Error: Format de archivo de copia de seguridad no reconocido {path}", + "err.webdav.auth": "Autenticación WebDAV fallida: {host}", + "err.webdav.connect": "No se puede conectar a WebDAV: {url}, error: {error}", + "err.webdav.delete": "Error al eliminar archivo remoto {path}: {error}", + "err.webdav.invalid_scheme": "Protocolo URL no soportado '{scheme}', solo se permiten http y https.", + "err.webdav.list": "Error al listar archivos remotos {path}: {error}", + "err.webdav.local_not_found": "Archivo local no encontrado: {path}", + "err.webdav.mkdir": "Error al crear directorio remoto WebDAV {path}: {error}", + "err.webdav.not_configured": "WebDAV no configurado, ejecute: sbackup webdav config --url --user ", + "err.webdav.not_found": "El archivo remoto no existe: {path}", + "err.webdav.private_ip": "Se bloqueó la conexión a una dirección de red privada: {host}", + "err.webdav.ssh": "Error de conexión WebDAV: {error}", + "err.webdav.upload": "Subida a WebDAV fallida {path}: {error}", + "err.webdav.xml_too_large": "Respuesta de WebDAV demasiado grande ({size} bytes), posible ataque de XML bomb", + "exit.message": "\nSaliendo.", + "info.already.added": "Info: {path} ya ha sido añadido. No lo duplique.", + "log.cleanup.delete": "Copia de seguridad antigua eliminada : %s", + "log.config.malformed": "El archivo de configuración %s está malformado, usando valores predeterminados", + "log.config.mkdir.error": "No se puede crear el directorio de configuración %s : %s", + "log.config.reset": "El archivo de configuración %s está malformado, reiniciando", + "log.config.write.error": "No se puede escribir el archivo de configuración %s : %s", + "log.data.create": "El archivo de datos no existe, creando : %s", + "log.data.load": "Cargando el archivo de datos existente : %s", + "log.data.read": "Leyendo el archivo de datos : %s", + "log.data.write": "Escribiendo el archivo de datos : %s", + "log.i18n.load.failed": "No se pudo cargar el locale {path}: {error}", + "log.i18n.load.fallback_failed": "No se pudo cargar el locale de respaldo {path}: {error}", + "log.ignore.loaded": "Archivo de reglas de ignorar cargado: %s", + "log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)", + "log.monitor.no_sources": "No valid source directories to monitor, monitor not started", + "log.monitor.source_missing": "Monitor source directory not found, skipping: {path}", + "log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)", + "log.monitor.watching": "Now monitoring: {path}", + "log.parallel.backup": "Copia de seguridad paralela de %d estrategias", + "log.rotation.delete_error": "Error al eliminar %s: %s", + "log.rotation.deleted": "Eliminado: %s (%.2f MB)", + "log.rotation.dry_run_skip": "[dry-run] Se eliminará: %s (%.2f MB)", + "multi_dest.aborted": "Copia multi-destino abortada: la copia local falló", + "multi_dest.complete": "Copia multi-destino completada", + "multi_dest.local.failed": "La copia local falló", + "multi_dest.local.success": "Copia local completada: {path}", + "multi_dest.started": "Copia multi-destino iniciada: {source} -> {targets}", + "multi_dest.uploading": "Subiendo en paralelo a {count} destinos remotos...", + "restore.list.empty": "El archivo está vacío: {path}", + "restore.list.title": "Contenido del archivo: {path}", + "restore.no_match": "No hay archivos que coincidan con \"{pattern}\"", + "restore.progress": "Restaurando", + "restore.skipped_unsafe": "Se omitieron {count} miembros de archivo no seguros (protección contra traversal de ruta)", + "restore.split.detected": "Se detectó una copia de seguridad dividida, {count} volúmenes en total", + "restore.split.merged": "Volúmenes combinados correctamente: {path}", + "restore.split.missing": "Error: falta un volumen, se esperaba {path}", + "restore.success": "Restauración exitosa: {path} ({count} archivos)", + "table.cell.default": "Predet.", + "table.cell.none": "Ninguno", + "table.header.dest": "Carpeta destino", + "table.header.files": "Archivos", + "table.header.format": "Formato", + "table.header.ignore": "Patrones de exclusión", + "table.header.ratio": "Ratio", + "table.header.size": "Tamaño(MB)", + "table.header.source": "Carpeta fuente", + "table.header.time": "Hora de copia", + "task_queue.added": "Tarea añadida: {name} (ID: {id})", + "task_queue.cancelled": "Cancelada por el usuario", + "task_queue.cancelled.fail": "No se pudo cancelar la tarea: no encontrada o no está en estado pendiente", + "task_queue.cancelled.success": "Tarea cancelada: {id}", + "task_queue.clear.done": "Se eliminaron {count} tareas de la cola.", + "task_queue.clear.none": "No hay tareas completadas o fallidas que limpiar.", + "task_queue.list.empty": "La cola de tareas está vacía.", + "task_queue.list.header": "═══ Cola de tareas ({count} tareas) ═══", + "task_queue.list.item": " [{status}] {name} (ID: {id})", + "task_queue.list.item_detail": " Directorio: {folder}", + "task_queue.list.item_error": " Error: {error}", + "task_queue.list.item_result": " Resultado: {result}", + "task_queue.list.item_time": " Creada: {created}", + "task_queue.run.failed": "Error al ejecutar la tarea: {name} — {error}", + "task_queue.run.none": "No hay tareas pendientes en la cola.", + "task_queue.run.success": "Tarea ejecutada con éxito: {name} (resultado: {result})", + "task_queue.run_all.done": "Se ejecutaron {count} tareas: {success} exitosas, {failed} fallidas.", + "task_queue.stats.completed": "Completadas: {completed}", + "task_queue.stats.failed": "Fallidas: {failed}", + "task_queue.stats.header": "═══ Estadísticas de la cola de tareas ═══", + "task_queue.stats.pending": "Pendientes: {pending}", + "task_queue.stats.running": "En ejecución: {running}", + "task_queue.stats.total": "Total: {total}", + "warn.disk.space": "Advertencia: El directorio destino {path} no tiene espacio suficiente (necesita {need:.1f} MB, solo {free:.1f} MB libres)", + "warn.invalid.compresslevel": "Advertencia: Nivel de compresión {level} fuera de rango (0-9), usando el valor predeterminado 6", + "warn.json.backup": "Advertencia: El archivo de datos corrupto se ha respaldado en {path}.", + "warn.json.decode.error": "Advertencia: El archivo de datos {path} está malformado, inicializando como diccionario vacío.", + "warn.json.renamed": "Advertencia: El archivo de datos corrupto se ha renombrado a {path}.", + "warn.no.strategy.found": "Advertencia: Estrategia de copia de seguridad para {path} no encontrada.", + "warn.password_in_cli": "Advertencia: la contraseña pasada por {arg} quedará expuesta en la lista de procesos; se recomienda la introducción interactiva.", + "warn.sftp.no_known_hosts": "Advertencia: no se encontró el archivo known_hosts; no se puede verificar la clave del host {host}, la conexión podría no ser segura", + "warn.source.missing": "Advertencia: La carpeta fuente no existe: {path}", + "warn.webdav.http_plaintext": "Advertencia: se está usando una conexión HTTP sin cifrar a WebDAV ({url}), las credenciales se transmitirán en texto plano. Se recomienda usar HTTPS.", + "warn.zip.overwrite": "Advertencia: Sobrescribiendo el archivo de copia de seguridad existente {path}" +} diff --git a/sbackup/locales/fr_FR.json b/sbackup/locales/fr_FR.json index 89db7af..33b61d5 100644 --- a/sbackup/locales/fr_FR.json +++ b/sbackup/locales/fr_FR.json @@ -1,80 +1,667 @@ -{ - "cmd.add.success": "Stratégie de sauvegarde ajoutée avec succès : {source} -> {dest}", - "cmd.rm.success": "Stratégie de sauvegarde supprimée avec succès : {path}", - "err.folder.invalid": "Erreur : {path} n'est pas un dossier valide ou n'existe pas.", - "err.dest.invalid": "Erreur : Le dossier cible {path} n'est pas un dossier valide ou n'existe pas.", - "info.already.added": "Info : {path} a déjà été ajouté. Veuillez ne pas dupliquer.", - "warn.no.strategy.found": "Attention : Stratégie de sauvegarde pour {path} non trouvée.", - "warn.source.missing": "Attention : Le dossier source n'existe pas : {path}", - "warn.json.decode.error": "Attention : Le fichier de données {path} est malformé, initialisation en dict vide.", - "warn.json.backup": "Attention : Le fichier de données corrompu a été sauvegardé dans {path}.", - "warn.json.renamed": "Attention : Le fichier de données corrompu a été renommé en {path}.", - "compress.success": "Sauvegarde réussie : {path} ({size:.2f} MB, {count} fichiers)", - "compress.progress": "Compression en cours", - "compress.unit": "fichiers", - "err.permission": "Accès refusé : Impossible d'écrire dans '{path}'", - "err.os": "Erreur système : {error}", - "err.unknown": "Erreur inconnue : {error}", - "err.unknown.format": "Erreur : Format de fichier de sauvegarde non reconnu {path}", - "err.argparse.invalid_choice": "choix invalide : ", - "err.argparse.choose_from": "choisir parmi", - "err.argparse.invalid_float": "valeur flottante invalide : ", - "err.argparse.invalid_int": "valeur entière invalide : ", - "err.argparse.unrecognized_args": "arguments non reconnus : ", - "err.argparse.required": "requis", - "warn.zip.overwrite": "Attention : Remplacement du fichier de sauvegarde existant {path}", - "warn.invalid.compresslevel": "Attention : Niveau de compression {level} hors plage (0-9), utilisation de la valeur par défaut 6", - "cmd.save.uptodate": "Toutes les stratégies de sauvegarde sont à jour, aucune sauvegarde nécessaire.", - "cmd.save.completed": "Sauvegarde terminée, {count} stratégie(s) traitée(s).", - "cmd.all.empty": "Aucune stratégie de sauvegarde configurée.", - "exit.message": "\nSortie.", - "table.header.source": "Dossier source", - "table.header.dest": "Dossier cible", - "table.header.ignore": "Modèles d'exclusion", - "table.cell.none": "Aucun", - "table.header.format": "Format", - "table.cell.default": "Défaut", - "restore.progress": "Restauration en cours", - "restore.success": "Restauration réussie : {path} ({count} fichiers)", - "cmd.watch.start": "Surveillance démarrée, intervalle {interval} min (appuyez sur Ctrl+C pour arrêter)", - - "cli.description": "Sbackup v{version} — Outil de sauvegarde incrémentielle léger\n\nSbackup vous aide à gérer facilement les sauvegardes de plusieurs dossiers.\nIl utilise une stratégie de sauvegarde incrémentielle, compressant uniquement les fichiers modifiés,\nsupportant des règles d'exclusion personnalisées et offrant un retour de progression intuitif.\n\nCommandes disponibles :\n add Ajouter une nouvelle stratégie de sauvegarde (source -> destination)\n rm Supprimer une stratégie de sauvegarde existante\n all Afficher toutes les stratégies de sauvegarde configurées\n save Exécuter les tâches de sauvegarde\n version Afficher les informations de version", - "cli.epilog": "Exemples :\n Ajouter une stratégie de sauvegarde :\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Ajouter une stratégie avec format au niveau de l'entrée :\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Supprimer une stratégie de sauvegarde :\n sbackup rm F:/my_folder\n\n Afficher toutes les stratégies :\n sbackup all\n\n Exécuter une sauvegarde (ZIP) :\n sbackup save\n\n Exécuter une sauvegarde (tar.gz) :\n sbackup --format tar.gz save\n\n Exécuter une sauvegarde (7z chiffré) :\n sbackup --format 7z save --password 123456\n\n Conserver les 5 dernières sauvegardes :\n sbackup save --keep 5\n\n Sauvegarde planifiée (toutes les 60 min) :\n sbackup watch --interval 60\n\n Surveillance + nettoyage :\n sbackup watch --interval 120 --keep 10\n\n Restaurer une sauvegarde :\n sbackup restore backup.7z F:/restored\n\n Interface en chinois :\n sbackup --lang zh_CN save\n\n Mode débogage :\n sbackup --debug save", - "cli.help.debug": "Activer le mode débogage avec journalisation détaillée", - "cli.help.help": "Afficher ce message d'aide et quitter", - "cli.help.lang": "Définir la langue de l'interface : zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", - "cli.help.format": "Définir le format d'archivage : zip (défaut) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.subcommands": "Sélectionner une commande à exécuter", - "cli.help.add": "Ajouter une nouvelle stratégie de sauvegarde", - "cli.help.add.source": "Chemin du dossier source : le répertoire à sauvegarder", - "cli.help.add.dest": "Chemin du dossier cible : où les fichiers de sauvegarde seront stockés", - "cli.help.add.ignore": "Fichiers ou dossiers à ignorer (séparés par des virgules, défaut : .git,__pycache__)", - "cli.help.add.format": "Format d'archivage au niveau de l'entrée (uniquement pour cette stratégie, par défaut global) : zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.rm": "Supprimer une stratégie de sauvegarde", - "cli.help.rm.path": "Chemin du dossier source de la stratégie à supprimer", - "cli.help.all": "Afficher toutes les stratégies de sauvegarde", - "cli.help.save": "Exécuter toutes les stratégies de sauvegarde", - "cli.help.save.keep": "Conserver les N derniers fichiers de sauvegarde, 0 pour désactiver le nettoyage", - "cli.help.save.password": "Mot de passe de chiffrement (uniquement pour le format 7z)", - "cli.help.watch": "Exécuter les sauvegardes périodiquement", - "cli.help.watch.interval": "Intervalle de sauvegarde en minutes, défaut 60", - "cli.help.watch.keep": "Conserver les N derniers fichiers de sauvegarde", - "cli.help.watch.password": "Mot de passe de chiffrement (uniquement pour le format 7z)", - "cli.help.restore": "Restaurer depuis un fichier de sauvegarde", - "cli.help.restore.file": "Chemin du fichier de sauvegarde (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", - "cli.help.restore.dir": "Dossier cible pour la restauration", - "cli.help.version": "Afficher les informations de version", - "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicence GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", - - "log.config.malformed": "Le fichier de configuration %s est malformé, utilisation des valeurs par défaut", - "log.config.reset": "Le fichier de configuration %s est malformé, réinitialisation", - "log.config.mkdir.error": "Impossible de créer le dossier de configuration %s : %s", - "log.config.write.error": "Impossible d'écrire le fichier de configuration %s : %s", - "log.data.read": "Lecture du fichier de données : %s", - "log.data.create": "Le fichier de données n'existe pas, création : %s", - "log.data.load": "Chargement du fichier de données existant : %s", - "log.data.write": "Écriture du fichier de données : %s", - "log.cleanup.delete": "Ancienne sauvegarde supprimée : %s", - "log.i18n.load.failed": "Impossible de charger le locale {path} : {error}", - "log.i18n.load.fallback_failed": "Impossible de charger le locale de repli {path} : {error}" -} \ No newline at end of file +{ + "benchmark.empty_source": "Répertoire source vide : {path}", + "benchmark.error": "Échec du benchmark {format} : {error}", + "benchmark.header": "═══ Benchmark de compression ═══", + "benchmark.recommended": "Recommandé : {format} niveau={level} ({size}, meilleur équilibre)", + "benchmark.running": "Test en cours : {format} niveau={level}...", + "benchmark.source_info": "Répertoire source : {path} ({size}, {count} fichiers)", + "cli.description": "Sbackup v{version} — Outil de sauvegarde incrémentielle léger\n\nSbackup vous aide à gérer facilement les sauvegardes de plusieurs dossiers.\nIl utilise une stratégie de sauvegarde incrémentielle, compressant uniquement les fichiers modifiés,\nsupportant des règles d'exclusion personnalisées et offrant un retour de progression intuitif.\n\nCommandes disponibles :\n add Ajouter une nouvelle stratégie de sauvegarde (source -> destination)\n rm Supprimer une stratégie de sauvegarde existante\n all Afficher toutes les stratégies de sauvegarde configurées\n save Exécuter les tâches de sauvegarde\n version Afficher les informations de version", + "cli.epilog": "Exemples:\n Ajouter une stratégie de sauvegarde:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Ajouter avec format au niveau de l'entrée:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Supprimer une stratégie:\n sbackup rm F:/my_folder\n\n Voir toutes les stratégies:\n sbackup all\n\n Exécuter la sauvegarde (ZIP):\n sbackup save\n\n Sauvegarde chiffrée (7z):\n sbackup --format 7z save --password 123456\n\n Garder les 5 dernières sauvegardes:\n sbackup save --keep 5\n\n Surveillance (toutes les 60 min):\n sbackup watch --interval 60\n\n Restaurer:\n sbackup restore backup.7z F:/restored\n\n SFTP distant (détection auto, recommandé):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP distant (mot de passe):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP distant (clé privée):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP distant (clé + phrase de passe):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp\n\n SFTP surveillance et téléchargement:\n sbackup watch --interval 60 --sftp", + "cli.help.add": "Ajouter une nouvelle stratégie de sauvegarde", + "cli.help.add.dest": "Chemin du dossier cible : où les fichiers de sauvegarde seront stockés", + "cli.help.add.format": "Format d'archivage au niveau de l'entrée (uniquement pour cette stratégie, par défaut global) : zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.add.from_gitignore": "Importer les règles d'ignorance depuis le fichier .gitignore", + "cli.help.add.ignore": "Fichiers ou dossiers à ignorer (séparés par des virgules, défaut : .git,__pycache__)", + "cli.help.add.name_template": "Modèle de nom de fichier par entrée (supporte les variables {name} {date} {time}, utilise la valeur par défaut globale si non spécifié)", + "cli.help.add.source": "Chemin du dossier source : le répertoire à sauvegarder", + "cli.help.all": "Afficher toutes les stratégies de sauvegarde", + "cli.help.audit": "Consulter le journal d'audit des sauvegardes", + "cli.help.audit.action": "Action d'audit : log (consulter) / stats (statistiques) / cleanup (nettoyer)", + "cli.help.audit.cleanup": "Nettoyer les anciennes entrées du journal d'audit", + "cli.help.audit.cleanup.keep_days": "Conserver les entrées des N derniers jours (défaut : 90)", + "cli.help.audit.log": "Consulter les entrées du journal d'audit", + "cli.help.audit.log.event": "Filtrer par type d'événement", + "cli.help.audit.log.limit": "Nombre maximum d'entrées à afficher (défaut : 50)", + "cli.help.audit.log.since": "Afficher les entrées après cette date (format ISO 8601)", + "cli.help.audit.log.status": "Filtrer par statut : success / failed / partial", + "cli.help.audit.stats": "Afficher les statistiques du journal d'audit", + "cli.help.benchmark": "Exécuter un benchmark de compression sur le répertoire spécifié pour comparer les performances des différents formats", + "cli.help.benchmark.levels": "Niveaux de compression à tester (séparés par des virgules, défaut : 1,6,9)", + "cli.help.benchmark.password": "Mot de passe de chiffrement (utilisé pour le format 7z)", + "cli.help.benchmark.path": "Chemin du répertoire source à tester", + "cli.help.benchmark.quick": "Mode rapide : tester uniquement le niveau par défaut de chaque format", + "cli.help.checksum": "Détecter les modifications via la somme de contrôle SHA256 du contenu des fichiers (plus précis que mtime, mais plus lent)", + "cli.help.clean": "Nettoyer intelligemment les fichiers de sauvegarde selon toutes les stratégies", + "cli.help.clean.dry_run": "Prévisualiser uniquement les fichiers à supprimer sans les supprimer réellement", + "cli.help.clean.keep": "Conserver les N derniers fichiers de sauvegarde par stratégie", + "cli.help.clean.keep_days": "Supprimer les fichiers de sauvegarde de plus de N jours", + "cli.help.completion": "Générer un script d'auto-complétion pour le Shell", + "cli.help.completion.shell": "Type de Shell : bash (défaut) / zsh / fish / powershell", + "cli.help.compress": "Compresser directement un dossier spécifié (sans utiliser de stratégie)", + "cli.help.compress.dry_run": "Prévisualiser uniquement les fichiers à sauvegarder sans exécuter la compression", + "cli.help.compress.format": "Format de compression : zip (défaut) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.compress.output": "Chemin du fichier de sortie (optionnel)", + "cli.help.compress.password": "Mot de passe de chiffrement (uniquement pour le format 7z)", + "cli.help.compress.source": "Chemin du dossier source à compresser", + "cli.help.config_cmd": "Gestion du fichier de configuration (chiffrer/déchiffrer les champs sensibles)", + "cli.help.config_cmd.action": "Action : lock (chiffrer) / unlock (déchiffrer)", + "cli.help.config_cmd.lock": "Chiffrer les champs sensibles du fichier de configuration avec le mot de passe principal", + "cli.help.config_cmd.lock.password": "Mot de passe principal (saisie interactive si non spécifié)", + "cli.help.config_cmd.unlock": "Déchiffrer les champs sensibles du fichier de configuration avec le mot de passe principal", + "cli.help.config_cmd.unlock.password": "Mot de passe principal (saisie interactive si non spécifié)", + "cli.help.config_cmd.validate": "Valider la configuration actuelle et afficher toutes les erreurs et avertissements", + "cli.help.debug": "Activer le mode débogage avec journalisation détaillée (peut être placé après la sous-commande)", + "cli.help.dedup": "Activer la déduplication au niveau des fichiers basée sur le contenu hash (SHA256) entre les stratégies", + "cli.help.diff": "Comparer les différences entre le répertoire source et la dernière sauvegarde", + "cli.help.diff.detail": "Afficher les différences au niveau des lignes pour les fichiers modifiés", + "cli.help.diff.file": "Chemin du fichier de sauvegarde (cherche automatiquement la dernière si non spécifié)", + "cli.help.diff.password": "Mot de passe de déchiffrement (pour les sauvegardes chiffrées)", + "cli.help.diff.source": "Chemin du dossier source", + "cli.help.diskcheck": "Vérifier l'espace disque avant la sauvegarde (estimer la taille du répertoire source et du fichier de sauvegarde)", + "cli.help.diskcheck.level": "Niveau de compression (0-9, défaut : 6)", + "cli.help.diskcheck.source": "Chemin du répertoire source à analyser", + "cli.help.diskcheck.target": "Chemin du répertoire cible (où sont stockés les fichiers de sauvegarde)", + "cli.help.edit": "Modifier une stratégie de sauvegarde existante (modifie uniquement les champs spécifiés)", + "cli.help.edit.dest": "Modifier le chemin du dossier cible", + "cli.help.edit.format": "Modifier le format de compression : zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.edit.ignore": "Modifier les modèles d'ignorance (séparés par des virgules)", + "cli.help.edit.name_template": "Modifier le modèle de nom de fichier (supporte les variables {name} {date} {time})", + "cli.help.edit.source": "Chemin du dossier source (pour localiser la stratégie à modifier)", + "cli.help.export": "Exporter toutes les stratégies de sauvegarde vers un fichier JSON", + "cli.help.export.file": "Chemin du fichier d'export (défaut : sbackup_export.json)", + "cli.help.follow_symlinks": "Suivre les liens symboliques (défaut : ne pas suivre pour éviter les boucles)", + "cli.help.format": "Définir le format d'archivage : zip (défaut) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.help": "Afficher ce message d'aide et quitter", + "cli.help.hooks": "Gérer et exécuter manuellement les hooks pré/post sauvegarde", + "cli.help.hooks.action": "Action des hooks : run (exécuter)", + "cli.help.hooks.run": "Exécuter manuellement un hook pré ou post sauvegarde", + "cli.help.hooks.run.timeout": "Remplacer le délai d'expiration du hook (en secondes) (défaut : valeur de la configuration)", + "cli.help.hooks.run.type": "Type de hook à exécuter : pre (pré-sauvegarde) ou post (post-sauvegarde)", + "cli.help.ignore": "Générer un fichier de règles d'exclusion .sbackupignore", + "cli.help.ignore.list": "Lister tous les préréglages disponibles", + "cli.help.ignore.output": "Chemin du fichier de sortie (défaut : .sbackupignore)", + "cli.help.ignore.preset": "Modèle de préréglage : node / python / go / rust / java / general (défaut : general)", + "cli.help.import": "Importer des stratégies de sauvegarde depuis un fichier JSON", + "cli.help.import.file": "Fichier JSON à importer", + "cli.help.incremental": "Sauvegarde incrémentielle au niveau des fichiers (compresse uniquement les fichiers modifiés, réduit significativement le temps de sauvegarde)", + "cli.help.info": "Voir les détails du fichier de sauvegarde (format, taille, nombre de fichiers, somme de contrôle, etc.)", + "cli.help.info.file": "Chemin du fichier de sauvegarde", + "cli.help.info.password": "Mot de passe de déchiffrement (pour les sauvegardes 7z chiffrées)", + "cli.help.init": "Générer un fichier de configuration par défaut", + "cli.help.integrity": "Vérification de l'intégrité des données (générer/vérifier un manifeste de contrôle)", + "cli.help.integrity.action": "Action d'intégrité : generate (générer) / verify (vérifier)", + "cli.help.integrity.generate": "Générer un manifeste integrity.json pour le répertoire de sauvegarde", + "cli.help.integrity.path": "Chemin du répertoire de sauvegarde", + "cli.help.integrity.verify": "Vérifier l'intégrité des fichiers de sauvegarde à l'aide du manifeste integrity.json", + "cli.help.lang": "Définir la langue de l'interface : zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", + "cli.help.list": "Voir l'historique des sauvegardes", + "cli.help.list.tags": "Lister tous les tags de sauvegarde", + "cli.help.metadata_export": "Exporter les métadonnées de sauvegarde (historique/journal d'audit) vers un fichier", + "cli.help.metadata_export.action": "Action d'export : history (historique) / audit (audit) / all (complet)", + "cli.help.metadata_export.all": "Exporter un rapport complet (historique + audit + statistiques)", + "cli.help.metadata_export.audit": "Exporter le journal d'audit en CSV ou JSON", + "cli.help.metadata_export.event": "Filtrer par type d'événement", + "cli.help.metadata_export.format": "Format de sortie : csv (défaut pour history/audit) ou json", + "cli.help.metadata_export.history": "Exporter l'historique des sauvegardes en CSV ou JSON", + "cli.help.metadata_export.output": "Chemin du fichier de sortie", + "cli.help.metadata_export.strategy": "Filtrer par nom de stratégie (correspondance avec le chemin source)", + "cli.help.multi_backup": "Sauvegarde multi-cibles simultanée : sauvegarde locale + upload parallèle vers toutes les cibles distantes activées", + "cli.help.multi_backup.dry_run": "Prévisualiser uniquement les fichiers à sauvegarder sans exécuter", + "cli.help.multi_backup.keep": "Conserver les N derniers fichiers de sauvegarde (0 pour désactiver le nettoyage)", + "cli.help.multi_backup.keep_days": "Conserver les fichiers de sauvegarde des N derniers jours", + "cli.help.multi_backup.local_target": "Chemin du dossier cible pour la sauvegarde locale", + "cli.help.multi_backup.name_template": "Modèle de nom de fichier de sauvegarde", + "cli.help.multi_backup.password": "Mot de passe de chiffrement (uniquement pour le format 7z)", + "cli.help.multi_backup.source": "Chemin du dossier source à sauvegarder", + "cli.help.multi_backup.verify": "Vérifier automatiquement l'intégrité après la sauvegarde", + "cli.help.profile": "Gérer les profils de configuration", + "cli.help.profile.action": "Action : list (lister) / create (créer) / delete (supprimer) / use (utiliser) / export (exporter) / import (importer)", + "cli.help.profile.create.name": "Nom du profil à créer à partir de la configuration actuelle", + "cli.help.profile.delete.name": "Nom du profil à supprimer", + "cli.help.profile.export.name": "Nom du profil à exporter", + "cli.help.profile.export.path": "Chemin du fichier d'export", + "cli.help.profile.import.name": "Nom du profil (défaut : nom du fichier)", + "cli.help.profile.import.path": "Chemin du fichier à importer", + "cli.help.profile.use.name": "Nom du profil à utiliser temporairement", + "cli.help.profile_name": "Utiliser le profil spécifié dans toutes les commandes", + "cli.help.remote": "Gestion des fichiers de sauvegarde distants (lister/supprimer sur SFTP ou WebDAV)", + "cli.help.remote.action": "Action distante : list / rm", + "cli.help.remote.list": "Lister les fichiers de sauvegarde distants", + "cli.help.remote.list_path": "Spécifier le chemin du répertoire distant (utilise le chemin par défaut de la configuration si non spécifié)", + "cli.help.remote.rm": "Supprimer un fichier de sauvegarde distant", + "cli.help.remote.rm.filename": "Nom du fichier distant à supprimer", + "cli.help.remote.sftp": "Opérer sur le serveur SFTP", + "cli.help.remote.webdav": "Opérer sur le serveur WebDAV", + "cli.help.report": "Générer un rapport de sauvegarde au format Markdown", + "cli.help.report.format": "Format du rapport : text (texte) ou html (page web)", + "cli.help.report.output": "Chemin du fichier de sortie (affiché dans le terminal si non spécifié)", + "cli.help.restore": "Restaurer depuis un fichier de sauvegarde", + "cli.help.restore.dir": "Dossier cible pour la restauration", + "cli.help.restore.file": "Chemin du fichier de sauvegarde (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", + "cli.help.restore.list": "Lister le contenu de l'archive sans extraire", + "cli.help.restore.password": "Mot de passe de déchiffrement (pour restaurer les sauvegardes 7z chiffrées)", + "cli.help.restore.search": "Rechercher un nom de fichier dans l'archive de sauvegarde (insensible à la casse)", + "cli.help.restore.select": "Restauration sélective : extraire uniquement les fichiers correspondant au motif (supporte les jokers glob comme \"*.py\" ou \"src/main.py\")", + "cli.help.restore.stats": "Afficher les statistiques de l'archive de sauvegarde (nombre de fichiers, taille, répartition des formats)", + "cli.help.restore.tag": "Restaurer une sauvegarde par tag", + "cli.help.rm": "Supprimer une stratégie de sauvegarde", + "cli.help.rm.all": "Supprimer toutes les stratégies de sauvegarde", + "cli.help.rm.path": "Chemin du dossier source de la stratégie à supprimer", + "cli.help.rotate": "Nettoyer les fichiers de sauvegarde selon la politique de rétention", + "cli.help.rotate.backup_dir": "Chemin du répertoire contenant les fichiers de sauvegarde", + "cli.help.rotate.dry_run": "Prévisualiser uniquement les fichiers à supprimer sans les supprimer réellement", + "cli.help.rotate.keep_count": "Conserver les N derniers fichiers de sauvegarde (0 = illimité)", + "cli.help.rotate.keep_daily": "Conserver une sauvegarde par jour pour les N derniers jours (0 = illimité)", + "cli.help.rotate.keep_days": "Conserver les fichiers de sauvegarde des N derniers jours (0 = illimité)", + "cli.help.rotate_list": "Lister tous les fichiers de sauvegarde dans un répertoire", + "cli.help.rotate_list.backup_dir": "Répertoire des fichiers de sauvegarde à analyser", + "cli.help.save": "Exécuter toutes les stratégies de sauvegarde", + "cli.help.save.dry_run": "Prévisualiser les fichiers à sauvegarder sans exécuter la compression", + "cli.help.save.keep": "Conserver les N derniers fichiers de sauvegarde, 0 pour désactiver le nettoyage", + "cli.help.save.keep_days": "Conserver les fichiers de sauvegarde des N derniers jours, 0 pour désactiver le nettoyage par date (défaut : 0)", + "cli.help.save.max_size": "Sauvegarder uniquement les fichiers plus petits que la taille spécifiée (ex : 1GB, 500MB, 100KB)", + "cli.help.save.min_size": "Sauvegarder uniquement les fichiers plus grands que la taille spécifiée (ex : 1KB, 1MB)", + "cli.help.save.multi_dest": "Activer le mode de sauvegarde multi-cibles simultanée", + "cli.help.save.name_template": "Modèle de nom de fichier de sauvegarde (supporte les variables {name} {date} {time})", + "cli.help.save.older_than": "Sauvegarder uniquement les fichiers modifiés il y a plus longtemps que la durée spécifiée (ex : 7d, 30d, 1y)", + "cli.help.save.password": "Mot de passe de chiffrement (uniquement pour le format 7z)", + "cli.help.save.post_hook": "Commande à exécuter après la sauvegarde (utilisable plusieurs fois)", + "cli.help.save.pre_hook": "Commande à exécuter avant la sauvegarde (utilisable plusieurs fois)", + "cli.help.save.sftp": "Télécharger vers le serveur SFTP après la sauvegarde", + "cli.help.save.split": "Taille maximale par volume de sauvegarde (ex : 100MB, 1GB), découpe automatique au-delà", + "cli.help.save.tag": "Ajouter un tag à cette sauvegarde (ex : daily, release)", + "cli.help.save.verify": "Vérifier l'intégrité de la sauvegarde après la completion", + "cli.help.save.webdav": "Télécharger sur le serveur WebDAV après la sauvegarde", + "cli.help.save.webhook": "Envoyer les résultats de sauvegarde en POST à cette URL après la completion", + "cli.help.schedule": "Gestion de la configuration de la planification", + "cli.help.schedule.action": "Action de planification : export (exporter la configuration) / install (installer le service)", + "cli.help.schedule.export": "Exporter le fichier de configuration de planification", + "cli.help.schedule.install": "Installer la sauvegarde planifiée en tant que service système", + "cli.help.schedule.install.interval": "Intervalle de sauvegarde (en minutes, défaut : 60)", + "cli.help.schedule.install.type": "Type de service : systemd / schtasks / launchd", + "cli.help.schedule.install.user": "Utilisateur pour exécuter le service (défaut : utilisateur actuel)", + "cli.help.schedule.interval": "Intervalle de sauvegarde (en minutes, défaut : 60)", + "cli.help.schedule.output": "Chemin du fichier de sortie (affiché dans le terminal si non spécifié)", + "cli.help.schedule.type": "Type de planification : systemd / crontab (défaut : systemd)", + "cli.help.schedule.type.schtasks": "Planificateur de tâches Windows", + "cli.help.search": "Rechercher un nom de fichier dans les fichiers de sauvegarde", + "cli.help.search.in": "Spécifier le chemin du fichier de sauvegarde (recherche dans la dernière sauvegarde de toutes les stratégies si non spécifié)", + "cli.help.search.password": "Mot de passe de déchiffrement (utilisé pour rechercher dans les sauvegardes chiffrées)", + "cli.help.search.pattern": "Motif de recherche (supporte les jokers glob comme *.txt)", + "cli.help.sftp": "Gestion de la sauvegarde SFTP distante (supporte mot de passe et clé SSH)", + "cli.help.sftp.action": "Action SFTP : config / test", + "cli.help.sftp.config": "Configurer les paramètres de connexion SFTP", + "cli.help.sftp.host": "Adresse du serveur SFTP (ex : 192.168.1.100)", + "cli.help.sftp.key_file": "Chemin de la clé privée SSH (RSA/Ed25519/ECDSA, prioritaire sur le mot de passe)", + "cli.help.sftp.key_passphrase": "Phrase de passe de la clé privée (peut être saisie interactivement)", + "cli.help.sftp.password": "Mot de passe SFTP (authentification par mot de passe uniquement)", + "cli.help.sftp.port": "Port SFTP (défaut : 22)", + "cli.help.sftp.remote_path": "Chemin distant cible (défaut : /, crée le répertoire si inexistant)", + "cli.help.sftp.test": "Tester si la connexion SFTP est disponible", + "cli.help.sftp.user": "Nom d'utilisateur SFTP", + "cli.help.status": "Voir le tableau de bord de l'état des sauvegardes", + "cli.help.strict": "Interrompre la sauvegarde si l'espace disque est insuffisant (uniquement un avertissement si non spécifié)", + "cli.help.subcommands": "Sélectionner une commande à exécuter", + "cli.help.task": "Gestion de la file d'attente des tâches (ajouter/exécuter/annuler des sauvegardes)", + "cli.help.task.action": "Action sur les tâches : add (ajouter) / list (lister) / run (exécuter) / run-all (tout exécuter) / cancel (annuler) / clear (effacer) / stats (statistiques)", + "cli.help.task.add": "Ajouter une nouvelle tâche de sauvegarde à la file d'attente", + "cli.help.task.add.folder": "Chemin du dossier à sauvegarder", + "cli.help.task.add.format": "Format de compression : zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.task.add.name": "Nom de la tâche (pour identification)", + "cli.help.task.add.output": "Chemin du fichier de sortie (optionnel)", + "cli.help.task.cancel": "Annuler une tâche en attente", + "cli.help.task.cancel.id": "ID de la tâche à annuler", + "cli.help.task.clear": "Effacer les tâches terminées et échouées de la file d'attente", + "cli.help.task.list": "Lister les tâches de la file d'attente", + "cli.help.task.list.status": "Filtrer par statut : pending / running / completed / failed", + "cli.help.task.run": "Exécuter la prochaine tâche en attente de la file d'attente", + "cli.help.task.run_all": "Exécuter séquentiellement toutes les tâches en attente", + "cli.help.task.stats": "Afficher les statistiques de la file d'attente des tâches", + "cli.help.verify": "Vérifier l'intégrité du fichier de sauvegarde", + "cli.help.verify.all": "Vérifier toutes les sauvegardes historiques en lot", + "cli.help.verify.detail": "Afficher les détails de vérification (taille, format, somme de contrôle, etc.)", + "cli.help.verify.fast": "Vérification rapide (comparer SHA256 historique, sans extraction)", + "cli.help.verify.file": "Fichier de sauvegarde à vérifier", + "cli.help.verify.password": "Mot de passe de déchiffrement (pour les sauvegardes chiffrées)", + "cli.help.verify.split": "Vérifier l'intégrité des fichiers divisés (nécessite le fichier manifeste)", + "cli.help.version": "Afficher les informations de version", + "cli.help.versions": "Voir l'historique des versions de sauvegarde (groupé par répertoire source)", + "cli.help.versions.source": "Spécifier le chemin du répertoire source (affiche tout si non spécifié)", + "cli.help.versions.tag": "Filtrer les versions par tag", + "cli.help.watch": "Exécuter les sauvegardes périodiquement", + "cli.help.watch.debounce": "Temps d'attente (en secondes) après la dernière modification de fichier avant la sauvegarde (défaut : 30)", + "cli.help.watch.dry_run": "Prévisualiser les fichiers à sauvegarder sans exécuter la compression", + "cli.help.watch.interval": "Intervalle de sauvegarde en minutes, défaut 60", + "cli.help.watch.keep": "Conserver les N derniers fichiers de sauvegarde", + "cli.help.watch.keep_days": "Conserver les fichiers de sauvegarde des N derniers jours (défaut : 0)", + "cli.help.watch.max_size": "Sauvegarder uniquement les fichiers plus petits que la taille spécifiée (ex : 1GB, 500MB, 100KB)", + "cli.help.watch.min_size": "Sauvegarder uniquement les fichiers plus grands que la taille spécifiée (ex : 1KB, 1MB)", + "cli.help.watch.name_template": "Modèle de nom de fichier de sauvegarde (supporte les variables {name} {date} {time})", + "cli.help.watch.older_than": "Sauvegarder uniquement les fichiers modifiés il y a plus longtemps que la durée spécifiée (ex : 7d, 30d, 1y)", + "cli.help.watch.password": "Mot de passe de chiffrement (uniquement pour le format 7z)", + "cli.help.watch.post_hook": "Commande à exécuter après la sauvegarde (utilisable plusieurs fois)", + "cli.help.watch.pre_hook": "Commande à exécuter avant la sauvegarde (utilisable plusieurs fois)", + "cli.help.watch.realtime": "Activer la surveillance en temps réel du système de fichiers (déclenche automatiquement la sauvegarde lors de modifications)", + "cli.help.watch.sftp": "Télécharger vers le serveur SFTP après chaque sauvegarde", + "cli.help.watch.split": "Taille maximale par volume de sauvegarde (ex : 100MB, 1GB)", + "cli.help.watch.tag": "Ajouter un tag à cette sauvegarde", + "cli.help.watch.verify": "Vérifier l'intégrité de la sauvegarde après chaque sauvegarde", + "cli.help.watch.webdav": "Télécharger sur le serveur WebDAV après chaque sauvegarde", + "cli.help.watch.webhook": "Envoyer les résultats de sauvegarde en POST à cette URL après chaque sauvegarde", + "cli.help.webdav": "Gestion de sauvegarde distante WebDAV (supporte Jianguoyun, NextCloud, Synology, etc.)", + "cli.help.webdav.action": "Action WebDAV : config / test", + "cli.help.webdav.config": "Configurer les paramètres de connexion WebDAV", + "cli.help.webdav.password": "Mot de passe WebDAV", + "cli.help.webdav.remote_path": "Chemin distant cible (défaut : /, crée le répertoire si inexistant)", + "cli.help.webdav.test": "Tester si la connexion WebDAV est disponible", + "cli.help.webdav.url": "URL du serveur WebDAV (ex : https://dav.jianguoyun.com/dav/)", + "cli.help.webdav.user": "Nom d'utilisateur WebDAV", + "cli.help.webhook_cmd": "Gestion des notifications Webhook", + "cli.help.webhook_cmd.action": "Action Webhook : preset (définir un préréglage) / list (lister les préréglages)", + "cli.help.webhook_cmd.list": "Lister tous les préréglages de notification IM disponibles", + "cli.help.webhook_cmd.preset": "Définir un modèle de préréglage de notification IM", + "cli.help.webhook_cmd.preset.name": "Nom du préréglage : dingtalk (DingTalk) / feishu (Feishu) / wechat (WeChat Enterprise)", + "cli.help.wizard": "Assistant de configuration interactif pour guider la configuration initiale", + "cli.help.xsearch": "Recherche inter-archives : rechercher un nom de fichier dans plusieurs fichiers de sauvegarde", + "cli.help.xsearch.dirs": "Liste de répertoires pour la recherche récursive de fichiers de sauvegarde", + "cli.help.xsearch.ext": "Rechercher par extension (ex : .log, .py)", + "cli.help.xsearch.keyword": "Mot-clé de recherche (insensible à la casse, correspondance sur le nom de fichier)", + "cli.help.xsearch.output_lang": "Langue de sortie : zh_CN (chinois) / en_US (anglais)", + "cli.help.xsearch.password": "Mot de passe de déchiffrement (utilisé pour rechercher dans les sauvegardes chiffrées)", + "cli.help.xsearch.pattern": "Motif glob pour la correspondance de noms de fichiers (ex : \"*.log\")", + "cli.prompt.sftp.host": "Adresse du serveur SFTP :", + "cli.prompt.sftp.key_file": "Chemin de la clé privée SSH (laisser vide pour mot de passe) :", + "cli.prompt.sftp.key_passphrase": "Phrase de passe de la clé (Entrée pour ignorer) :", + "cli.prompt.sftp.password": "Mot de passe SFTP :", + "cli.prompt.sftp.port": "Port SFTP (défaut 22) :", + "cli.prompt.sftp.remote_path": "Chemin distant (défaut /) :", + "cli.prompt.sftp.user": "Nom d'utilisateur SFTP :", + "cli.prompt.webdav.password": "Mot de passe WebDAV :", + "cli.prompt.webdav.remote_path": "Chemin distant (défaut /) :", + "cli.prompt.webdav.url": "URL du serveur WebDAV :", + "cli.prompt.webdav.user": "Nom d'utilisateur WebDAV :", + "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicence GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", + "cmd.add.gitignore_empty": "Aucune règle importée depuis {path}", + "cmd.add.gitignore_imported": "{count} règles d'ignorance importées depuis {path}", + "cmd.add.success": "Stratégie de sauvegarde ajoutée avec succès : {source} -> {dest}", + "cmd.all.empty": "Aucune stratégie de sauvegarde configurée.", + "cmd.audit.cleanup.done": "{count} anciens enregistrements du journal d'audit nettoyés.", + "cmd.audit.cleanup.none": "Aucun ancien enregistrement à nettoyer.", + "cmd.clean.dry_run_header": "Mode prévisualisation (aucune suppression ne sera effectuée) :", + "cmd.clean.no_criteria": "Veuillez spécifier les critères de nettoyage : --keep N ou --keep-days N", + "cmd.clean.nothing": "Aucun fichier de sauvegarde à nettoyer.", + "cmd.clean.summary": "{count} fichiers de sauvegarde nettoyés au total.", + "cmd.config.already_locked": "Le fichier de configuration est déjà chiffré", + "cmd.config.enter_password": "Veuillez saisir le mot de passe principal", + "cmd.config.lock_failed": "Échec du chiffrement", + "cmd.config.locked": "Fichier de configuration chiffré (champs sensibles protégés)", + "cmd.config.no_password": "Le mot de passe ne peut pas être vide", + "cmd.config.not_locked": "Le fichier de configuration n'est pas chiffré", + "cmd.config.unlocked": "Fichier de configuration déchiffré", + "cmd.config.validate.errors": "Erreurs détectées :", + "cmd.config.validate.failed": "Échec de la validation de la configuration, veuillez corriger les erreurs ci-dessus.", + "cmd.config.validate.passed": "Validation de la configuration réussie, aucun problème détecté.", + "cmd.config.validate.passed_with_warnings": "Validation de la configuration réussie (avec avertissements).", + "cmd.config.validate.warnings": "Avertissements détectés :", + "cmd.config.wrong_password": "Mot de passe incorrect, échec du déchiffrement", + "cmd.dedup.saved": "Déduplication : {count} fichiers en double ignorés, {saved:.2f} Mo économisés", + "cmd.diff.added_header": "\nFichiers ajoutés ({count}) :", + "cmd.diff.detail_header": "--- Différences au niveau des lignes ---", + "cmd.diff.empty_backup": "Le fichier de sauvegarde est vide : {path}", + "cmd.diff.header": "=== Comparaison des différences ===\nFichier de sauvegarde : {backup}", + "cmd.diff.modified_header": "\nFichiers possiblement modifiés ({count}) :", + "cmd.diff.no_backup": "Aucun fichier de sauvegarde trouvé dans le répertoire cible : {path}", + "cmd.diff.no_changes": "\nAucune différence, la source correspond à la sauvegarde.", + "cmd.diff.no_detail": "(fichier binaire ou illisible)", + "cmd.diff.removed_header": "\nFichiers supprimés ({count}) :", + "cmd.diff.summary": "\nRésumé : +{added} ajoutés, -{removed} supprimés, ~{modified} modifiés", + "cmd.dry_run.header": "Dry-run preview (no backup will be performed):", + "cmd.dry_run.item": " {source} -> {target} format: {format} files: {count} size: {size} MB", + "cmd.dry_run.nothing": "No strategies need backup (all are up to date).", + "cmd.dry_run.total": "Total: {count} strategies, {files} files, {size} MB", + "cmd.edit.nothing": "Aucun paramètre de modification spécifié.", + "cmd.edit.success": "Stratégie de sauvegarde modifiée avec succès : {path}", + "cmd.export.success": "{count} stratégies de sauvegarde exportées vers {path}", + "cmd.hook.failed": "Échec de l'exécution du hook : {command} — {error}", + "cmd.hook.post_running": "Exécution du hook post-sauvegarde : {command}", + "cmd.hook.pre_running": "Exécution du hook pré-sauvegarde : {command}", + "cmd.hooks.no_hooks": "Aucun hook {type} configuré.", + "cmd.hooks.no_results": "Aucun résultat d'exécution de hook.", + "cmd.hooks.result_line": "[{status}] {command} (code retour={return_code}, durée {duration}s)", + "cmd.hooks.summary": "Total : {total}, réussis : {success}, échoués : {fail}", + "cmd.ignore.available_presets": "Préréglages disponibles :", + "cmd.ignore.cancelled": "Annulé.", + "cmd.ignore.generated": "Règles d'exclusion générées pour le préréglage {preset} : {path}", + "cmd.ignore.overwrite": "Le fichier {path} existe déjà, voulez-vous le remplacer ? (o/N) : ", + "cmd.import.empty": "Aucune stratégie de sauvegarde valide trouvée dans : {path}", + "cmd.import.success": "{count} stratégies de sauvegarde importées, {skipped} existantes ignorées", + "cmd.info.files": "Nombre de fichiers : {count}", + "cmd.info.format": "Format : {format}", + "cmd.info.header": "=== Informations du fichier de sauvegarde ===", + "cmd.info.path": "Chemin : {path}", + "cmd.info.sha256": "SHA256 : {checksum}", + "cmd.info.size": "Taille : {size:.2f} Mo", + "cmd.info.time": "Modifié : {time}", + "cmd.init.exists": "Le fichier de configuration existe déjà : {path}, ignoré.", + "cmd.init.success": "Fichier de configuration généré : {path}", + "cmd.integrity.generate.not_dir": "Erreur : {path} n'est pas un répertoire", + "cmd.integrity.generate.success": "Manifeste de contrôle d'intégrité généré : {path}", + "cmd.integrity.verify.failed": "Échec de la vérification d'intégrité : {path}", + "cmd.integrity.verify.success": "Vérification d'intégrité réussie : {path}", + "cmd.list.empty": "Aucun historique de sauvegarde pour le moment.", + "cmd.list.tags.empty": "Aucun tag pour le moment", + "cmd.list.tags.header": "Tags de sauvegarde :", + "cmd.metadata_export.audit_done": "{count} entrées du journal d'audit exportées vers {path}", + "cmd.metadata_export.combined_done": "Rapport complet exporté ({count} enregistrements) vers {path}", + "cmd.metadata_export.history_done": "{count} enregistrements d'historique exportés vers {path}", + "cmd.profile.activated": "Profil '{name}' activé.", + "cmd.profile.created": "Profil '{name}' créé avec succès.", + "cmd.profile.deleted": "Profil '{name}' supprimé.", + "cmd.profile.exported": "Profil '{name}' exporté vers : {path}", + "cmd.profile.import_failed": "Échec de l'import du profil depuis {path}.", + "cmd.profile.imported": "Profil '{name}' importé avec succès.", + "cmd.profile.list.detail": " {fields}", + "cmd.profile.list.empty": "Aucun profil configuré.", + "cmd.profile.list.header": "Liste des profils de configuration :", + "cmd.profile.list.item": " {name}", + "cmd.profile.not_found": "Le profil '{name}' n'existe pas.", + "cmd.remote.deleted": "Fichier distant supprimé : {path}", + "cmd.remote.empty": "Répertoire distant vide : {path}", + "cmd.remote.list_header": "Répertoire distant : {path} ({count} fichiers)", + "cmd.remote.rm_requires_path": "Veuillez spécifier le nom du fichier distant à supprimer", + "cmd.remote.select_protocol": "Veuillez spécifier le protocole : --sftp ou --webdav", + "cmd.report.saved": "Rapport de sauvegarde enregistré dans : {path}", + "cmd.restore.search.found": "{count} fichiers correspondants trouvés :", + "cmd.restore.search.header": "Résultats de la recherche de '{keyword}' dans {path} :", + "cmd.restore.search.no_results": "Aucun fichier correspondant à '{keyword}' trouvé dans {path}", + "cmd.restore.selective.header": "Restauration sélective : extraction de {count} fichiers correspondant à {patterns}", + "cmd.restore.selective.success": "{count} fichiers extraits vers {target}", + "cmd.restore.stats.dirs": "Répertoires : {count}", + "cmd.restore.stats.files": "Fichiers : {count}", + "cmd.restore.stats.format_item": " {ext} : {count} fichiers", + "cmd.restore.stats.formats": "Répartition des types de fichiers :", + "cmd.restore.stats.header": "═══ Statistiques de l'archive : {path} ═══", + "cmd.restore.stats.size": "Taille totale : {size} octets", + "cmd.restore.tag_not_found": "Aucune sauvegarde trouvée avec le tag '{tag}'", + "cmd.rm.all.confirm": "Supprimer les {count} stratégies de sauvegarde ? (o/N) : ", + "cmd.rm.all.confirm_words": "y,yes", + "cmd.rm.all.empty": "Aucune stratégie de sauvegarde à supprimer.", + "cmd.rm.all.success": "Les {count} stratégies de sauvegarde ont été supprimées.", + "cmd.rm.success": "Stratégie de sauvegarde supprimée avec succès : {path}", + "cmd.rotate.auto_done": "Rotation automatique terminée : {deleted} fichiers supprimés.", + "cmd.rotate.auto_title": "Rotation automatique des sauvegardes dans {path}...", + "cmd.rotate.delete_item": " - {name} {size}", + "cmd.rotate.done": "{count} fichiers de sauvegarde nettoyés par rotation.", + "cmd.rotate.dry_run_header": "Mode prévisualisation (aucune suppression ne sera effectuée) :", + "cmd.rotate.dry_run_item": " ~ {name} {size} {time}", + "cmd.rotate.header": "═══ Rotation des sauvegardes ═══", + "cmd.rotate.no_action": "Aucun fichier à supprimer par rotation.", + "cmd.rotate.plan_delete": "{count} fichiers seront supprimés :", + "cmd.rotate.plan_keep": "{count} fichiers seront conservés :", + "cmd.rotate.scan_empty": "Aucun fichier de sauvegarde trouvé dans {path}.", + "cmd.rotate.scan_found": "{count} fichiers de sauvegarde trouvés dans {path} :", + "cmd.rotate.scan_item": " {name} {size} {time}", + "cmd.save.completed": "Sauvegarde terminée, {count} stratégie(s) traitée(s).", + "cmd.save.summary": "Résumé de sauvegarde : {total} stratégies, {backed} sauvegardées, {skipped} ignorées, durée {elapsed:.1f}s", + "cmd.save.uptodate": "Toutes les stratégies de sauvegarde sont à jour, aucune sauvegarde nécessaire.", + "cmd.schedule.exported": "Configuration de planification exportée vers : {path} ({type})", + "cmd.schedule.install.launchd": "Exécutez la commande suivante après avoir enregistré le fichier plist :", + "cmd.schedule.install.schtasks_cmd": "Exécutez la commande suivante en tant qu'administrateur pour créer la tâche planifiée :\n {cmdline}", + "cmd.schedule.install.systemd": "Exécutez les commandes suivantes en tant que root pour installer le timer systemd :", + "cmd.schedule.schtasks.generated": "Commande de tâche planifiée Windows générée", + "cmd.schedule.unknown_type": "Type de planification inconnu : {type}", + "cmd.search.no_results": "Aucun fichier correspondant à '{pattern}' trouvé dans {path}", + "cmd.search.no_results_global": "Aucun fichier correspondant à '{pattern}' trouvé dans toutes les sauvegardes", + "cmd.search.results": "{count} fichiers correspondants trouvés dans {path} :", + "cmd.sftp.config_saved": "Configuration SFTP sauvegardée dans config.json", + "cmd.sftp.no_default_key": "Aucune clé SSH par défaut trouvée, configurez le mot de passe ou spécifiez le chemin", + "cmd.sftp.parallel_upload": "Upload parallèle de {count} fichiers...", + "cmd.sftp.progress": "Téléchargement", + "cmd.sftp.success": "Téléchargement réussi : {file}", + "cmd.sftp.testing": "Test de connexion à {host}...", + "cmd.sftp.test_fail": "Échec du test de connexion SFTP, vérifiez la configuration", + "cmd.sftp.test_ok": "Test de connexion SFTP réussi !", + "cmd.sftp.uploading": "Téléchargement : {file}", + "cmd.sftp.using_default_key": "Clé SSH par défaut détectée, utilisation : {path}", + "cmd.status.avg_ratio": "Ratio de compression moyen : {ratio:.1f}%", + "cmd.status.header": "=== Tableau de bord de l'état des sauvegardes ===", + "cmd.status.history": "Enregistrements d'historique : {count}", + "cmd.status.strategies": "Stratégies totales : {count}", + "cmd.status.strategy.header": "--- Détails des stratégies ---", + "cmd.status.strategy.format": " Format : {format}", + "cmd.status.strategy.item": " {source} -> {target}", + "cmd.status.strategy.last_backup": " Dernière sauvegarde : {time}", + "cmd.status.strategy.never": " Dernière sauvegarde : Jamais", + "cmd.status.strategy.size": " Taille cible : {size:.2f} Mo", + "cmd.status.total_size": "Taille totale des sauvegardes : {size:.2f} Mo", + "cmd.upload.parallel": "Upload parallèle vers SFTP et WebDAV...", + "cmd.verify.batch_failed": "✗ Échoué : {path}", + "cmd.verify.batch_success": "✓ Vérifié : {path}", + "cmd.verify.batch_summary": "Vérification en lot terminée : {success} réussis, {fail} échoués", + "cmd.verify.checking": "Vérification de la sauvegarde : {path}", + "cmd.verify.checksum_mismatch": "La somme de contrôle ne correspond pas", + "cmd.verify.detail_header": "=== Détails de la sauvegarde : {path} ===", + "cmd.verify.detail_info": "Taille : {size:.2f} MO | Fichiers : {files} | Format : {fmt} | SHA256 : {sha256}...", + "cmd.verify.failed": "Vérification échouée : {path} — {expected} fichiers attendus, {actual} trouvés", + "cmd.verify.fast_failed": "Vérification rapide échouée : {path}, le fichier est peut-être corrompu ou altéré", + "cmd.verify.fast_success": "Vérification rapide réussie : {path}\nSHA256 : {sha256}", + "cmd.verify.no_checksum": "Aucune somme de contrôle historique trouvée, vérification complète : {path}", + "cmd.verify.no_file": "Veuillez spécifier le chemin du fichier ou utiliser --all pour vérifier toutes les sauvegardes.", + "cmd.verify.no_history": "Aucun historique de sauvegarde trouvé.", + "cmd.verify.partial_fail": "Attention : {count} fichier(s) de sauvegarde ont échoué à la vérification !", + "cmd.verify.split_error": "Erreur de vérification des fichiers divisés : {error}", + "cmd.verify.split_failed": "Vérification d'intégrité des fichiers divisés échouée : {file}", + "cmd.verify.split_success": "Intégrité des fichiers divisés vérifiée : {file} ({parts} parties)", + "cmd.verify.success": "Vérification réussie : {path} ({count} fichiers)", + "cmd.versions.empty": "Aucun enregistrement de version de sauvegarde.", + "cmd.versions.header": "═══ Historique des versions de sauvegarde ═══", + "cmd.versions.source": "📁 {source}", + "cmd.versions.total": "{count} versions au total, {sources} répertoires sources", + "cmd.watch.start": "Surveillance démarrée, intervalle {interval} min (appuyez sur Ctrl+C pour arrêter)", + "cmd.watch.start_realtime": "Surveillance en temps réel du système de fichiers démarrée, délai {debounce}s (Ctrl+C pour arrêter)", + "cmd.webdav.config_saved": "Configuration WebDAV sauvegardée dans config.json", + "cmd.webdav.parallel_upload": "Upload parallèle de {count} fichiers vers WebDAV...", + "cmd.webdav.success": "Téléchargement WebDAV réussi : {file}", + "cmd.webdav.testing": "Test de connexion WebDAV à {url}...", + "cmd.webdav.test_ok": "Test de connexion WebDAV réussi !", + "cmd.webdav.uploading": "Téléchargement vers WebDAV : {file}", + "cmd.webhook.available_presets": "Préréglages de notification IM disponibles :", + "cmd.webhook.preset_set": "Modèle de notification {name} défini :\n{template}", + "cmd.webhook.preset_unknown": "Préréglage inconnu : {name}", + "cmd.wizard.choice": "Veuillez saisir votre choix :", + "cmd.wizard.cloud_done": "Configuration du stockage cloud réussie.", + "cmd.wizard.cloud_info": "Stockage cloud compatible S3 (AWS S3, MinIO, Backblaze, Tencent COS) :", + "cmd.wizard.complete": "Configuration de sbackup terminée !", + "cmd.wizard.dest": "Chemin du dossier cible pour les fichiers de sauvegarde :", + "cmd.wizard.email_done": "Notifications par e-mail configurées.", + "cmd.wizard.email_yn": "Configurer les notifications par e-mail ? (o/N) :", + "cmd.wizard.host": "Adresse du serveur :", + "cmd.wizard.ignore": "Motifs à ignorer (séparés par des virgules, défaut : .git,__pycache__) :", + "cmd.wizard.lang_set": "Langue définie sur {lang}", + "cmd.wizard.more": "Continuer à ajouter ? (o/N) :", + "cmd.wizard.password": "Mot de passe :", + "cmd.wizard.port": "Port :", + "cmd.wizard.remote_path": "Chemin distant :", + "cmd.wizard.schedule_info": "Exécutez la commande suivante pour démarrer la sauvegarde planifiée (toutes les {interval} minutes) :", + "cmd.wizard.schedule_interval": "Intervalle de sauvegarde (en minutes, défaut : 60) :", + "cmd.wizard.schedule_yn": "Configurer la sauvegarde planifiée ? (o/N) :", + "cmd.wizard.sftp_done": "Configuration SFTP réussie.", + "cmd.wizard.skip": "Ignorer", + "cmd.wizard.source": "Chemin du dossier source à sauvegarder :", + "cmd.wizard.step_email": "Étape 4/5 : Notifications par e-mail (optionnel)", + "cmd.wizard.step_lang": "Étape 1/5 : Choisir la langue de l'interface", + "cmd.wizard.step_remote": "Étape 3/5 : Configurer le stockage distant (optionnel)", + "cmd.wizard.step_schedule": "Étape 5/5 : Sauvegarde planifiée (optionnel)", + "cmd.wizard.step_strategy": "Étape 2/5 : Ajouter une stratégie de sauvegarde", + "cmd.wizard.summary": "{strategies} stratégie(s) de sauvegarde ajoutée(s).\nExécutez 'sbackup save' pour lancer la sauvegarde immédiatement.", + "cmd.wizard.user": "Nom d'utilisateur :", + "cmd.wizard.webdav_done": "Configuration WebDAV réussie.", + "cmd.wizard.welcome": "Bienvenue dans l'assistant de configuration de sbackup !\nCet assistant vous guidera tout au long de la configuration initiale.", + "cmd.xsearch.no_query": "Veuillez spécifier les critères de recherche : --keyword, --pattern ou --ext", + "compress.progress": "Compression en cours", + "compress.success": "Backup successful: {path} ({original:.2f} MB → {size:.2f} MB, {ratio:.0f}%, {count} files)", + "compress.unit": "fichiers", + "diskcheck.disk_free": " Disponible : {size}", + "diskcheck.disk_header": "Disque cible :", + "diskcheck.disk_total": " Espace total : {size}", + "diskcheck.disk_used": " Utilisé : {size}", + "diskcheck.estimated_size": "Taille estimée de la sauvegarde : {size} (taux de compression : {ratio})", + "diskcheck.header": "═══ Vérification de l'espace disque ═══", + "diskcheck.insufficient": "Espace disque insuffisant : {needed} requis, {available} disponible(s), {missing} manquant(s)", + "diskcheck.source": "Répertoire source : {path}", + "diskcheck.source_size": "Taille totale des fichiers source : {size}", + "diskcheck.status.insufficient": "Vérification d'espace : Insuffisant !\n Estimation pour la sauvegarde : {needed}\n Espace disponible : {available}\n Manquant : {missing}", + "diskcheck.status.ok": "Vérification d'espace : Suffisant (marge de {margin})", + "diskcheck.target": "Chemin cible : {path}", + "dryrun.and_others": "et autres", + "dryrun.excluded_files": "Fichiers exclus", + "dryrun.excluded_files_list": "Liste des fichiers exclus", + "dryrun.exclusion_reasons": "Raisons d'exclusion", + "dryrun.file_types": "Types de fichiers", + "dryrun.included_files": "Fichiers inclus", + "dryrun.included_files_list": "Liste des fichiers inclus", + "dryrun.largest_files": "Plus gros fichiers", + "dryrun.more_files": "... et {count} fichiers supplémentaires", + "dryrun.no_extension": "(sans extension)", + "dryrun.reason.excluded": "Correspond à une règle d'exclusion", + "dryrun.reason.not_included": "Ne correspond à aucune règle d'inclusion", + "dryrun.reason.too_large": "Dépasse la taille maximale de fichier", + "dryrun.reason.too_small": "Inférieur à la taille minimale de fichier", + "dryrun.scanned_dir": "Répertoire analysé", + "dryrun.skip": "Ignorer", + "dryrun.title": "Prévisualisation Dry-run", + "dryrun.total_files": "Nombre total de fichiers", + "dryrun.total_size": "Taille totale", + "dryrun.unit_files": "fichiers", + "dryrun.warning.disk_space": "Espace disque insuffisant : {free} disponible(s), {need} requis", + "dryrun.warning.not_dir": "Le chemin n'est pas un répertoire valide : {path}", + "dryrun.warning.os_error": "Erreur système : {error}", + "dryrun.warning.permission": "Permissions insuffisantes pour accéder à : {path}", + "dryrun.warnings": "Avertissements", + "err.argparse.choose_from": "choisir parmi", + "err.argparse.invalid_choice": "choix invalide : ", + "err.argparse.invalid_float": "valeur flottante invalide : ", + "err.argparse.invalid_int": "valeur entière invalide : ", + "err.argparse.required": "requis", + "err.argparse.unrecognized_args": "arguments non reconnus : ", + "err.dest.invalid": "Erreur : Le dossier cible {path} n'est pas un dossier valide ou n'existe pas.", + "err.file.not_found": "Erreur : Le fichier de sauvegarde {path} n'existe pas.", + "err.folder.invalid": "Erreur : {path} n'est pas un dossier valide ou n'existe pas.", + "err.invalid_filename": "Nom de fichier invalide : {filename} (les séparateurs de chemin et les séquences .. ne sont pas autorisés)", + "err.lock.conflict": "Erreur : une autre opération de sauvegarde est en cours (le fichier de verrouillage existe). Si vous êtes sûr qu'aucun autre processus n'est actif, supprimez manuellement le fichier de verrouillage.", + "err.os": "Erreur système : {error}", + "err.permission": "Accès refusé : Impossible d'écrire dans '{path}'", + "err.sftp.auth": "Échec d'authentification SFTP : {host}, vérifiez le nom d'utilisateur et le mot de passe", + "err.sftp.connect": "Impossible de se connecter à {host}:{port} : {error}", + "err.sftp.delete": "Échec de la suppression du fichier distant {path} : {error}", + "err.sftp.host_key_mismatch": "La clé de l'hôte {host} ne correspond pas à celle de known_hosts (possible attaque man-in-the-middle)", + "err.sftp.key_load": "Impossible de charger la clé privée {path}, vérifiez le format (RSA/Ed25519/ECDSA)", + "err.sftp.key_needs_passphrase": "La clé {path} nécessite une phrase de passe, saisissez-la ou utilisez --key-passphrase", + "err.sftp.local_not_found": "Fichier local introuvable : {path}", + "err.sftp.mkdir": "Échec de la création du répertoire distant {path} : {error}", + "err.sftp.mkdir_max_depth": "Profondeur des répertoires distants trop importante ou boucle détectée : {path}", + "err.sftp.no_host_key": "Impossible d'obtenir la clé de l'hôte {host}", + "err.sftp.not_configured": "SFTP non configuré, exécutez : sbackup sftp config --host --user ", + "err.sftp.not_connected": "SFTP non connecté, appelez connect() d'abord", + "err.sftp.remote_not_found": "Le fichier distant n'existe pas : {path}", + "err.sftp.ssh": "Erreur de connexion SFTP : {error}", + "err.sftp.upload": "Échec du téléchargement {path} : {error}", + "err.sftp.wrong_passphrase": "Phrase de passe incorrecte, réessayez (Entrée pour ignorer la clé)", + "err.unknown": "Erreur inconnue : {error}", + "err.unknown.format": "Erreur : Format de fichier de sauvegarde non reconnu {path}", + "err.webdav.auth": "Authentification WebDAV échouée : {host}", + "err.webdav.connect": "Impossible de se connecter à WebDAV : {url}, erreur : {error}", + "err.webdav.delete": "Échec de la suppression du fichier distant {path} : {error}", + "err.webdav.invalid_scheme": "Protocole URL non pris en charge '{scheme}', seuls http et https sont autorisés.", + "err.webdav.list": "Échec de la liste des fichiers distants {path} : {error}", + "err.webdav.local_not_found": "Fichier local introuvable : {path}", + "err.webdav.mkdir": "Échec de la création du répertoire distant WebDAV {path} : {error}", + "err.webdav.not_found": "Le fichier distant n'existe pas : {path}", + "err.webdav.not_configured": "WebDAV non configuré, exécutez : sbackup webdav config --url --user ", + "err.webdav.private_ip": "Connexion à une adresse IP privée bloquée : {host}", + "err.webdav.ssh": "Erreur de connexion WebDAV : {error}", + "err.webdav.upload": "Échec du téléchargement vers WebDAV {path} : {error}", + "err.webdav.xml_too_large": "Réponse WebDAV trop volumineuse ({size} octets), possible attaque XML bomb", + "exit.message": "\nSortie.", + "info.already.added": "Info : {path} a déjà été ajouté. Veuillez ne pas dupliquer.", + "log.cleanup.delete": "Ancienne sauvegarde supprimée : %s", + "log.config.mkdir.error": "Impossible de créer le dossier de configuration %s : %s", + "log.config.malformed": "Le fichier de configuration %s est malformé, utilisation des valeurs par défaut", + "log.config.reset": "Le fichier de configuration %s est malformé, réinitialisation", + "log.config.write.error": "Impossible d'écrire le fichier de configuration %s : %s", + "log.data.create": "Le fichier de données n'existe pas, création : %s", + "log.data.load": "Chargement du fichier de données existant : %s", + "log.data.read": "Lecture du fichier de données : %s", + "log.data.write": "Écriture du fichier de données : %s", + "log.i18n.load.failed": "Impossible de charger le locale {path} : {error}", + "log.i18n.load.fallback_failed": "Impossible de charger le locale de repli {path} : {error}", + "log.ignore.loaded": "Fichier de règles d'ignorance chargé : %s", + "log.monitor.change": "Modification de fichier détectée : {path} ({dirty_count} répertoires en attente de sauvegarde)", + "log.monitor.no_sources": "Aucun répertoire source valide à surveiller, moniteur non démarré", + "log.monitor.source_missing": "Répertoire source de surveillance introuvable, ignoré : {path}", + "log.monitor.trigger": "Délai d'attente écoulé, déclenchement de la sauvegarde ({dirty_count} répertoires)", + "log.monitor.watching": "Surveillance active : {path}", + "log.parallel.backup": "Sauvegarde parallèle de %d stratégies", + "log.rotation.delete_error": "Échec de la suppression %s : %s", + "log.rotation.deleted": "Supprimé : %s (%.2f Mo)", + "log.rotation.dry_run_skip": "[dry-run] Sera supprimé : %s (%.2f Mo)", + "multi_dest.aborted": "Sauvegarde multi-cibles interrompue : échec de la sauvegarde locale", + "multi_dest.complete": "Sauvegarde multi-cibles terminée", + "multi_dest.local.failed": "Échec de la sauvegarde locale", + "multi_dest.local.success": "Sauvegarde locale terminée : {path}", + "multi_dest.started": "Sauvegarde multi-cibles démarrée : {source} -> {targets}", + "multi_dest.uploading": "Upload parallèle vers {count} cible(s) distante(s)...", + "restore.list.empty": "L'archive est vide : {path}", + "restore.list.title": "Contenu de l'archive : {path}", + "restore.no_match": "Aucun fichier ne correspond à \"{pattern}\"", + "restore.progress": "Restauration en cours", + "restore.skipped_unsafe": "{count} membres d'archive non sûrs ignorés (protection contre la traversée de chemin)", + "restore.split.detected": "Sauvegarde divisée détectée, {count} volumes au total", + "restore.split.merged": "Fusion des volumes terminée : {path}", + "restore.split.missing": "Erreur : volume manquant, {path} attendu", + "restore.success": "Restauration réussie : {path} ({count} fichiers)", + "table.cell.default": "Défaut", + "table.cell.none": "Aucun", + "table.header.dest": "Dossier cible", + "table.header.files": "Fichiers", + "table.header.format": "Format", + "table.header.ignore": "Modèles d'exclusion", + "table.header.ratio": "Ratio", + "table.header.size": "Taille(Mo)", + "table.header.source": "Dossier source", + "table.header.time": "Heure de sauvegarde", + "task_queue.added": "Tâche ajoutée : {name} (ID : {id})", + "task_queue.cancelled": "Annulée par l'utilisateur", + "task_queue.cancelled.fail": "Impossible d'annuler la tâche : introuvable ou non en attente", + "task_queue.cancelled.success": "Tâche annulée : {id}", + "task_queue.clear.done": "{count} tâche(s) effacée(s) de la file d'attente.", + "task_queue.clear.none": "Aucune tâche terminée ou échouée à effacer.", + "task_queue.list.empty": "La file d'attente des tâches est vide.", + "task_queue.list.header": "═══ File d'attente des tâches ({count} tâche(s)) ═══", + "task_queue.list.item": " [{status}] {name} (ID : {id})", + "task_queue.list.item_detail": " Répertoire : {folder}", + "task_queue.list.item_error": " Erreur : {error}", + "task_queue.list.item_result": " Résultat : {result}", + "task_queue.list.item_time": " Créée le : {created}", + "task_queue.run.failed": "Échec de l'exécution de la tâche : {name} — {error}", + "task_queue.run.none": "Aucune tâche en attente dans la file d'attente.", + "task_queue.run.success": "Tâche exécutée avec succès : {name} (résultat : {result})", + "task_queue.run_all.done": "{count} tâche(s) exécutée(s), {success} réussie(s), {failed} échouée(s).", + "task_queue.stats.completed": "Terminées : {completed}", + "task_queue.stats.failed": "Échouées : {failed}", + "task_queue.stats.header": "═══ Statistiques de la file d'attente ═══", + "task_queue.stats.pending": "En attente : {pending}", + "task_queue.stats.running": "En cours : {running}", + "task_queue.stats.total": "Total : {total}", + "warn.disk.space": "Attention : Le répertoire cible {path} n'a pas assez d'espace (besoin de {need:.1f} Mo, seulement {free:.1f} Mo libres)", + "warn.invalid.compresslevel": "Attention : Niveau de compression {level} hors plage (0-9), utilisation de la valeur par défaut 6", + "warn.json.backup": "Attention : Le fichier de données corrompu a été sauvegardé dans {path}.", + "warn.json.decode.error": "Attention : Le fichier de données {path} est malformé, initialisation en dict vide.", + "warn.json.renamed": "Attention : Le fichier de données corrompu a été renommé en {path}.", + "warn.no.strategy.found": "Attention : Stratégie de sauvegarde pour {path} non trouvée.", + "warn.password_in_cli": "Attention : Le mot de passe transmis via {arg} sera visible dans la liste des processus. Il est recommandé d'utiliser la saisie interactive.", + "warn.sftp.no_known_hosts": "Attention : Fichier known_hosts introuvable, impossible de vérifier la clé de l'hôte {host}, la connexion peut ne pas être sécurisée", + "warn.source.missing": "Attention : Le dossier source n'existe pas : {path}", + "warn.webdav.http_plaintext": "Attention : Connexion WebDAV en HTTP non chiffré ({url}), les identifiants seront transmis en clair. Il est recommandé d'utiliser HTTPS.", + "warn.zip.overwrite": "Attention : Remplacement du fichier de sauvegarde existant {path}" +} diff --git a/sbackup/locales/ja_JP.json b/sbackup/locales/ja_JP.json index f99b9aa..1a6b927 100644 --- a/sbackup/locales/ja_JP.json +++ b/sbackup/locales/ja_JP.json @@ -1,80 +1,667 @@ -{ - "cmd.add.success": "バックアップ戦略を追加しました: {source} -> {dest}", - "cmd.rm.success": "バックアップ戦略を削除しました: {path}", - "err.folder.invalid": "エラー: {path} は有効なフォルダではないか、存在しません。", - "err.dest.invalid": "エラー: 目標フォルダ {path} は有効なフォルダではないか、存在しません。", - "info.already.added": "情報: {path} は既に追加されています。重複して追加しないでください。", - "warn.no.strategy.found": "警告: {path} のバックアップ戦略が見つかりません。", - "warn.source.missing": "警告: ソースフォルダが存在しません: {path}", - "warn.json.decode.error": "警告: データファイル {path} が破損しています、空の辞書として初期化します。", - "warn.json.backup": "警告: 破損したデータファイルを {path} にバックアップしました。", - "warn.json.renamed": "警告: 破損したデータファイルを {path} にリネームしました。", - "compress.success": "バックアップ成功: {path} ({size:.2f} MB、{count} ファイル)", - "compress.progress": "圧縮中", - "compress.unit": "ファイル", - "err.permission": "アクセス拒否: '{path}' に書き込みできません", - "err.os": "システムエラー: {error}", - "err.unknown": "不明なエラー: {error}", - "err.unknown.format": "エラー: 不明なバックアップファイル形式 {path}", - "err.argparse.invalid_choice": "無効な選択: ", - "err.argparse.choose_from": "選択可能", - "err.argparse.invalid_float": "無効な浮動小数点値: ", - "err.argparse.invalid_int": "無効な整数値: ", - "err.argparse.unrecognized_args": "未認識の引数: ", - "err.argparse.required": "必須", - "warn.zip.overwrite": "警告: 既存のバックアップファイル {path} を上書きします", - "warn.invalid.compresslevel": "警告: 圧縮レベル {level} が範囲外 (0-9)、デフォルト値 6 を使用します", - "cmd.save.uptodate": "すべてのバックアップ戦略は最新です、バックアップは不要です。", - "cmd.save.completed": "バックアップ完了、{count} 戦略を処理しました。", - "cmd.all.empty": "バックアップ戦略が設定されていません。", - "exit.message": "\n終了します。", - "table.header.source": "ソースフォルダ", - "table.header.dest": "目標フォルダ", - "table.header.ignore": "除外パターン", - "table.cell.none": "なし", - "table.header.format": "形式", - "table.cell.default": "デフォルト", - "restore.progress": "復元中", - "restore.success": "復元成功: {path} ({count} ファイル)", - "cmd.watch.start": "監視開始、間隔 {interval} 分(Ctrl+C で停止)", - - "cli.description": "Sbackup v{version} — 軽量インクリメンタルバックアップツール\n\nSbackup は複数フォルダのバックアップタスクを簡単に管理します。\nインクリメンタルバックアップ戦略を採用し、変更されたファイルのみを圧縮し、\nカスタム除外ルールをサポートし、直感的な進捗フィードバックを提供します。\n\n使用可能なコマンド:\n add 新しいバックアップ戦略を追加(ソース -> 目標)\n rm 既存のバックアップ戦略を削除\n all 設定済みのすべてのバックアップ戦略を表示\n save バックアップタスクを実行\n version バージョン情報を表示", - "cli.epilog": "例:\n バックアップ戦略を追加:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n エントリレベル形式を指定して戦略を追加:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n バックアップ戦略を削除:\n sbackup rm F:/my_folder\n\n すべての戦略を表示:\n sbackup all\n\n バックアップを実行 (ZIP):\n sbackup save\n\n バックアップを実行 (tar.gz):\n sbackup --format tar.gz save\n\n バックアップを実行 (7z 暗号化):\n sbackup --format 7z save --password 123456\n\n 最新5件のバックアップを保持:\n sbackup save --keep 5\n\n 定期バックアップ (60分間隔):\n sbackup watch --interval 60\n\n 監視 + クリーンアップ:\n sbackup watch --interval 120 --keep 10\n\n バックアップを復元:\n sbackup restore backup.7z F:/restored\n\n 中国語インターフェース:\n sbackup --lang zh_CN save\n\n デバッグモード:\n sbackup --debug save", - "cli.help.debug": "詳細なログ出力付きデバッグモードを有効化", - "cli.help.help": "このヘルプメッセージを表示して終了", - "cli.help.lang": "インターフェース言語を設定: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", - "cli.help.format": "パッケージ形式を設定: zip (デフォルト) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.subcommands": "実行するコマンドを選択", - "cli.help.add": "新しいバックアップ戦略を追加", - "cli.help.add.source": "ソースフォルダパス: バックアップするディレクトリ", - "cli.help.add.dest": "目標フォルダパス: バックアップファイルの保存場所", - "cli.help.add.ignore": "除外するファイルやフォルダ (カンマ区切り、デフォルト: .git,__pycache__)", - "cli.help.add.format": "エントリレベルのパッケージ形式(この戦略のみ、未指定時はグローバル設定): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.rm": "バックアップ戦略を削除", - "cli.help.rm.path": "削除する戦略のソースフォルダパス", - "cli.help.all": "すべてのバックアップ戦略を表示", - "cli.help.save": "すべてのバックアップ戦略を実行", - "cli.help.save.keep": "最新 N 件のバックアップファイルを保持、0 でクリーンアップ無効", - "cli.help.save.password": "暗号化パスワード (7z形式のみ)", - "cli.help.watch": "定期的にバックアップを実行", - "cli.help.watch.interval": "バックアップ間隔 (分)、デフォルト 60", - "cli.help.watch.keep": "最新 N 件のバックアップファイルを保持", - "cli.help.watch.password": "暗号化パスワード (7z形式のみ)", - "cli.help.restore": "バックアップファイルから復元", - "cli.help.restore.file": "バックアップファイルパス (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", - "cli.help.restore.dir": "復元目標ディレクトリ", - "cli.help.version": "バージョン情報を表示", - "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nGNU GPL v3.0 ライセンス — https://www.gnu.org/licenses/gpl-3.0.html\n", - - "log.config.malformed": "設定ファイル %s が破損しています、デフォルト値を使用", - "log.config.reset": "設定ファイル %s が破損しています、リセット", - "log.config.mkdir.error": "設定ディレクトリ %s を作成できません: %s", - "log.config.write.error": "設定ファイル %s を書き込みできません: %s", - "log.data.read": "データファイルを読み込み: %s", - "log.data.create": "データファイルが存在しません、作成: %s", - "log.data.load": "既存のデータファイルを読み込み: %s", - "log.data.write": "データファイルを書き込み: %s", - "log.cleanup.delete": "古いバックアップを削除しました: %s", - "log.i18n.load.failed": "ロケール {path} の読み込みに失敗: {error}", - "log.i18n.load.fallback_failed": "フォールバックロケール {path} の読み込みに失敗: {error}" +{ + "cmd.add.success": "バックアップ戦略を追加しました: {source} -> {dest}", + "cmd.rm.success": "バックアップ戦略を削除しました: {path}", + "cmd.edit.success": "バックアップ戦略を編集しました: {path}", + "cmd.edit.nothing": "変更パラメータが指定されていません。", + "err.folder.invalid": "エラー: {path} は有効なフォルダではないか、存在しません。", + "err.file.not_found": "エラー:バックアップファイル {path} が存在しません。", + "err.dest.invalid": "エラー: 目標フォルダ {path} は有効なフォルダではないか、存在しません。", + "info.already.added": "情報: {path} は既に追加されています。重複して追加しないでください。", + "warn.no.strategy.found": "警告: {path} のバックアップ戦略が見つかりません。", + "warn.source.missing": "警告: ソースフォルダが存在しません: {path}", + "warn.json.decode.error": "警告: データファイル {path} が破損しています、空の辞書として初期化します。", + "warn.json.backup": "警告: 破損したデータファイルを {path} にバックアップしました。", + "warn.json.renamed": "警告: 破損したデータファイルを {path} にリネームしました。", + "compress.success": "Backup successful: {path} ({original:.2f} MB → {size:.2f} MB, {ratio:.0f}%, {count} files)", + "compress.progress": "圧縮中", + "compress.unit": "ファイル", + "err.permission": "アクセス拒否: '{path}' に書き込みできません", + "err.os": "システムエラー: {error}", + "err.unknown": "不明なエラー: {error}", + "err.unknown.format": "エラー: 不明なバックアップファイル形式 {path}", + "err.argparse.invalid_choice": "無効な選択: ", + "err.argparse.choose_from": "選択可能", + "err.argparse.invalid_float": "無効な浮動小数点値: ", + "err.argparse.invalid_int": "無効な整数値: ", + "err.argparse.unrecognized_args": "未認識の引数: ", + "err.argparse.required": "必須", + "warn.zip.overwrite": "警告: 既存のバックアップファイル {path} を上書きします", + "warn.invalid.compresslevel": "警告: 圧縮レベル {level} が範囲外 (0-9)、デフォルト値 6 を使用します", + "cmd.save.uptodate": "すべてのバックアップ戦略は最新です、バックアップは不要です。", + "cmd.save.completed": "バックアップ完了、{count} 戦略を処理しました。", + "cmd.all.empty": "バックアップ戦略が設定されていません。", + "cmd.list.empty": "バックアップ履歴はまだありません。", + "cmd.verify.checking": "バックアップを検証中: {path}", + "cmd.verify.success": "検証成功: {path} ({count} ファイル)", + "cmd.verify.failed": "検証失敗: {path} — 予想 {expected} ファイル、実際 {actual} ファイル", + "cmd.info.header": "=== バックアップファイル情報 ===", + "cmd.info.path": "ファイルパス: {path}", + "cmd.info.format": "圧縮形式: {format}", + "cmd.info.size": "ファイルサイズ: {size:.2f} MB", + "cmd.info.files": "ファイル数: {count}", + "cmd.info.time": "更新日時: {time}", + "cmd.info.sha256": "SHA256: {checksum}", + "cmd.rm.all.confirm": "すべての {count} 件のバックアップ戦略を削除しますか?(y/N): ", + "cmd.rm.all.confirm_words": "y,yes", + "cmd.rm.all.success": "{count} 件のバックアップ戦略をすべて削除しました。", + "cmd.rm.all.empty": "削除するバックアップ戦略がありません。", + "cmd.init.success": "設定ファイルを生成しました: {path}", + "cmd.init.exists": "設定ファイルが既に存在します: {path}、スキップします。", + "warn.disk.space": "警告: 宛先ディレクトリ {path} の空き容量が不足しています(必要: {need:.1f} MB、空き: {free:.1f} MB)", + "cmd.save.summary": "バックアップ結果: {total} 戦略、{backed} 件バックアップ済、{skipped} 件スキップ、所要時間 {elapsed:.1f}秒", + "cmd.dry_run.header": "Dry-run preview (no backup will be performed):", + "cmd.dry_run.item": " {source} -> {target} format: {format} files: {count} size: {size} MB", + "cmd.dry_run.total": "Total: {count} strategies, {files} files, {size} MB", + "cmd.dry_run.nothing": "No strategies need backup (all are up to date).", + "cmd.verify.partial_fail": "Warning: {count} backup file(s) failed verification!", + "cmd.status.header": "=== Backup Status Dashboard ===", + "cmd.status.strategies": "Total strategies: {count}", + "cmd.status.history": "History records: {count}", + "cmd.status.total_size": "Total backup size: {size:.2f} MB", + "cmd.status.avg_ratio": "Average compression ratio: {ratio:.1f}%", + "cmd.status.strategy.header": "--- Strategy Details ---", + "cmd.status.strategy.item": " {source} -> {target}", + "cmd.status.strategy.last_backup": " Last backup: {time}", + "cmd.status.strategy.never": " Last backup: Never", + "cmd.status.strategy.format": " Format: {format}", + "cmd.status.strategy.size": " Target size: {size:.2f} MB", + "log.parallel.backup": "%d 件の戦略を並列バックアップ中", + "restore.list.title": "アーカイブ内容: {path}", + "restore.list.empty": "アーカイブが空です: {path}", + "log.ignore.loaded": "無視ルールファイルを読み込みました: %s", + "exit.message": "\n終了します。", + "table.header.source": "ソースフォルダ", + "table.header.dest": "目標フォルダ", + "table.header.ignore": "除外パターン", + "table.header.time": "バックアップ時刻", + "table.header.size": "サイズ(MB)", + "table.header.ratio": "Ratio", + "table.header.files": "ファイル数", + "table.cell.none": "なし", + "table.header.format": "形式", + "table.cell.default": "デフォルト", + "restore.progress": "復元中", + "restore.success": "復元成功: {path} ({count} ファイル)", + "restore.skipped_unsafe": "安全でないアーカイブメンバー {count} 件をスキップしました(パストラバーサル保護)", + "cmd.watch.start": "監視開始、間隔 {interval} 分(Ctrl+C で停止)", + "cli.description": "Sbackup v{version} — 軽量インクリメンタルバックアップツール\n\nSbackup は複数フォルダのバックアップタスクを簡単に管理します。\nインクリメンタルバックアップ戦略を採用し、変更されたファイルのみを圧縮し、\nカスタム除外ルールをサポートし、直感的な進捗フィードバックを提供します。\n\n使用可能なコマンド:\n add 新しいバックアップ戦略を追加(ソース -> 目標)\n rm 既存のバックアップ戦略を削除\n all 設定済みのすべてのバックアップ戦略を表示\n save バックアップタスクを実行\n version バージョン情報を表示", + "cli.epilog": "例:\n バックアップ戦略を追加:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 7z 暗号化バックアップ:\n sbackup --format 7z save --password 123456\n\n 復元:\n sbackup restore backup.7z F:/restored\n\n SFTP リモート (自動検出、推奨):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP リモート (パスワード):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP リモート (キー):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP リモート (キー + パスフレーズ):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp", + "cli.help.debug": "デバッグモード(サブコマンドの後に指定可能)", + "cli.help.help": "このヘルプメッセージを表示して終了", + "cli.help.lang": "インターフェース言語を設定: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", + "cli.help.format": "パッケージ形式を設定: zip (デフォルト) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.follow_symlinks": "Follow symbolic links (default: do not follow to avoid loops)", + "cli.help.incremental": "File-level incremental backup (only compress changed files, significantly reduces backup time)", + "cli.help.subcommands": "実行するコマンドを選択", + "cli.help.add": "新しいバックアップ戦略を追加", + "cli.help.add.source": "ソースフォルダパス: バックアップするディレクトリ", + "cli.help.add.dest": "目標フォルダパス: バックアップファイルの保存場所", + "cli.help.add.ignore": "除外するファイルやフォルダ (カンマ区切り、デフォルト: .git,__pycache__)", + "cli.help.add.format": "エントリレベルのパッケージ形式(この戦略のみ、未指定時はグローバル設定): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.add.name_template": "Per-entry filename template (supports {name} {date} {time} variables, uses global default if not specified)", + "cli.help.rm": "バックアップ戦略を削除", + "cli.help.rm.path": "削除する戦略のソースフォルダパス", + "cli.help.all": "すべてのバックアップ戦略を表示", + "cli.help.save": "すべてのバックアップ戦略を実行", + "cli.help.save.keep": "最新 N 件のバックアップファイルを保持、0 でクリーンアップ無効", + "cli.help.save.password": "暗号化パスワード (7z形式のみ)", + "cli.help.watch": "定期的にバックアップを実行", + "cli.help.watch.interval": "バックアップ間隔 (分)、デフォルト 60", + "cli.help.watch.keep": "最新 N 件のバックアップファイルを保持", + "cli.help.watch.password": "暗号化パスワード (7z形式のみ)", + "cli.help.restore": "バックアップファイルから復元", + "cli.help.restore.file": "バックアップファイルパス (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", + "cli.help.restore.dir": "復元目標ディレクトリ", + "cli.help.restore.password": "復号化パスワード(暗号化された7zバックアップの復元時に使用)", + "cli.help.restore.list": "展開せずにアーカイブ内容を一覧表示", + "cli.help.list": "バックアップ履歴を表示", + "cli.help.verify": "バックアップファイルの整合性を検証", + "cli.help.verify.file": "検証するバックアップファイル", + "cli.help.verify.fast": "高速検証(履歴SHA256比較、展開不要)", + "cli.help.verify.all": "すべての履歴バックアップを一括検証", + "cli.help.verify.detail": "詳細な検証情報を表示(サイズ、形式、チェックサムなど)", + "cli.help.verify.split": "分割ファイルの整合性を検証(マニフェストファイルが必要)", + "cli.help.verify.password": "復号化パスワード(暗号化バックアップ用)", + "cmd.verify.fast_success": "高速検証成功: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "高速検証失敗: {path}、ファイルが破損または改ざんされている可能性があります", + "cmd.verify.no_checksum": "履歴チェックサムが見つかりません、完全検証にフォールバック: {path}", + "cmd.verify.checksum_mismatch": "チェックサムが一致しません", + "cmd.verify.no_history": "バックアップ履歴が見つかりません。", + "cmd.verify.no_file": "バックアップファイルを指定するか、--allですべてのバックアップを検証してください。", + "cmd.verify.detail_header": "=== バックアップ詳細: {path} ===", + "cmd.verify.detail_info": "サイズ: {size:.2f} MB | ファイル数: {files} | 形式: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ 検証済み: {path}", + "cmd.verify.batch_failed": "✗ 失敗: {path}", + "cmd.verify.batch_summary": "一括検証完了: {success}件成功、{fail}件失敗", + "cmd.verify.split_success": "分割ファイル整合性検証成功: {file} ({parts} パート)", + "cmd.verify.split_failed": "分割ファイル整合性検証失敗: {file}", + "cmd.verify.split_error": "分割ファイル検証エラー: {error}", + "cli.help.info": "バックアップファイルの詳細を表示(形式、サイズ、ファイル数、チェックサムなど)", + "cli.help.info.file": "バックアップファイルのパス", + "cli.help.info.password": "復号化パスワード(暗号化された7zバックアップ用)", + "cli.help.export": "Export all backup strategies to a JSON file", + "cli.help.export.file": "Export file path (default: sbackup_export.json)", + "cli.help.import": "Import backup strategies from a JSON file", + "cli.help.import.file": "JSON file to import", + "cli.help.status": "View backup status dashboard", + "cmd.export.success": "Exported {count} backup strategies to {path}", + "cmd.import.success": "Imported {count} backup strategies, skipped {skipped} existing", + "cmd.import.empty": "No valid backup strategies found in: {path}", + "cli.help.save.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.save.verify": "Verify backup integrity after completion", + "cli.help.save.name_template": "Backup filename template (supports {name} {date} {time} variables)", + "cli.help.save.webhook": "POST backup results to this URL after completion", + "cli.help.save.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", + "cli.help.save.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", + "cli.help.save.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", + "cli.help.watch.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.watch.verify": "Verify backup integrity after each backup", + "cli.help.watch.name_template": "Backup filename template (supports {name} {date} {time} variables)", + "cli.help.watch.webhook": "POST backup results to this URL after each backup", + "cli.help.watch.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", + "cli.help.watch.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", + "cli.help.watch.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", + "cli.help.init": "デフォルト設定ファイルテンプレートを生成", + "cli.help.rm.all": "すべてのバックアップ戦略を削除", + "cli.help.edit": "既存のバックアップ戦略を編集(指定したフィールドのみ変更)", + "cli.help.edit.source": "ソースフォルダパス(戦略を特定するため)", + "cli.help.edit.dest": "ターゲットフォルダパスを変更", + "cli.help.edit.ignore": "無視パターンを変更(カンマ区切り)", + "cli.help.edit.format": "パッケージ形式を変更: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.edit.name_template": "ファイル名テンプレートを変更({name} {date} {time} 変数対応)", + "cmd.diff.header": "═══ 差分比較 ═══\nバックアップファイル: {backup}", + "cmd.diff.added_header": "\n追加されたファイル ({count}):", + "cmd.diff.removed_header": "\n削除されたファイル ({count}):", + "cmd.diff.modified_header": "\n変更された可能性のあるファイル ({count}):", + "cmd.diff.no_changes": "\n差分なし、ソースはバックアップと一致しています。", + "cmd.diff.summary": "\n差分概要: +{added} 追加, -{removed} 削除, ~{modified} 変更", + "cmd.diff.no_backup": "ターゲットディレクトリにバックアップファイルが見つかりません: {path}", + "cmd.diff.empty_backup": "バックアップファイルが空です: {path}", + "cli.help.diff": "ソースディレクトリと最新バックアップを比較", + "cli.help.diff.source": "ソースフォルダパス", + "cli.help.diff.file": "バックアップファイルパス(未指定の場合は最新を自動検索)", + "cli.help.diff.password": "復号化パスワード(暗号化バックアップの場合)", + "cli.help.remote": "リモートバックアップファイル管理(SFTPまたはWebDAV上のファイルを一覧/削除)", + "cli.help.remote.action": "リモート操作: list / rm", + "cli.help.remote.list": "リモートバックアップファイルを一覧表示", + "cli.help.remote.rm": "リモートバックアップファイルを削除", + "cli.help.remote.rm.filename": "削除するリモートファイル名", + "cli.help.remote.sftp": "SFTPサーバーで操作", + "cli.help.remote.webdav": "WebDAVサーバーで操作", + "cli.help.version": "バージョン情報を表示", + "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nGNU GPL v3.0 ライセンス — https://www.gnu.org/licenses/gpl-3.0.html\n", + "log.config.malformed": "設定ファイル %s が破損しています、デフォルト値を使用", + "log.config.reset": "設定ファイル %s が破損しています、リセット", + "log.config.mkdir.error": "設定ディレクトリ %s を作成できません: %s", + "log.config.write.error": "設定ファイル %s を書き込みできません: %s", + "log.data.read": "データファイルを読み込み: %s", + "log.data.create": "データファイルが存在しません、作成: %s", + "log.data.load": "既存のデータファイルを読み込み: %s", + "log.data.write": "データファイルを書き込み: %s", + "log.cleanup.delete": "古いバックアップを削除しました: %s", + "log.i18n.load.failed": "ロケール {path} の読み込みに失敗: {error}", + "log.i18n.load.fallback_failed": "フォールバックロケール {path} の読み込みに失敗: {error}", + "err.sftp.auth": "SFTP 認証失敗: {host}、ユーザー名とパスワードを確認してください", + "err.sftp.ssh": "SFTP 接続エラー: {error}", + "err.sftp.connect": "{host}:{port} に接続できません: {error}", + "err.sftp.not_connected": "SFTP が接続されていません。先に connect() を呼び出してください", + "err.sftp.local_not_found": "ローカルファイルが見つかりません: {path}", + "err.sftp.upload": "アップロード失敗 {path}: {error}", + "err.sftp.mkdir": "リモートディレクトリの作成に失敗 {path}: {error}", + "err.sftp.not_configured": "SFTP未設定、実行: sbackup sftp config --host <アドレス> --user <ユーザー>", + "cmd.sftp.uploading": "アップロード中: {file}", + "cmd.sftp.progress": "アップロード", + "cmd.sftp.success": "アップロード成功: {file}", + "cmd.sftp.config_saved": "SFTP設定をconfig.jsonに保存しました", + "cmd.sftp.testing": "{host} に接続テスト中...", + "cmd.sftp.test_ok": "SFTP接続テスト成功!", + "cmd.sftp.test_fail": "SFTP接続テスト失敗、設定を確認してください", + "cli.help.save.sftp": "バックアップ後にSFTPサーバーにアップロード", + "cli.help.watch.sftp": "各バックアップ後にSFTPサーバーにアップロード", + "cli.help.sftp": "SFTP リモートバックアップ管理(パスワード/SSHキー対応)", + "cli.help.sftp.action": "SFTP アクション: config / test", + "cli.help.sftp.config": "SFTP接続パラメータを設定", + "cli.help.sftp.test": "SFTP接続テスト", + "cli.help.sftp.host": "SFTPサーバーアドレス (例: 192.168.1.100)", + "cli.help.sftp.port": "SFTP ポート(デフォルト 22)", + "cli.help.sftp.user": "SFTPユーザー名", + "cli.help.sftp.password": "SFTPパスワード(パスワード認証のみ)", + "cli.help.sftp.remote_path": "リモート対象パス(デフォルト: /)", + "cli.prompt.sftp.host": "SFTP サーバーアドレス:", + "cli.prompt.sftp.port": "SFTP ポート(デフォルト 22):", + "cli.prompt.sftp.user": "SFTP ユーザー名:", + "cli.prompt.sftp.password": "SFTP パスワード:", + "cli.prompt.sftp.remote_path": "リモートパス(デフォルト /):", + "err.sftp.key_load": "秘密鍵 {path} を読み込めません。形式を確認してください (RSA/Ed25519/ECDSA)", + "cli.help.sftp.key_file": "SSH秘密鍵パス(RSA/Ed25519/ECDSA対応、パスワードより優先)", + "cli.help.sftp.key_passphrase": "秘密鍵パスフレーズ(対話式入力可能)", + "cli.prompt.sftp.key_file": "SSH秘密鍵パス (パスワードの場合は空):", + "cli.prompt.sftp.key_passphrase": "秘密鍵パスフレーズ (スキップする場合はEnter):", + "cmd.sftp.using_default_key": "デフォルトSSH鍵を検出、使用: {path}", + "cmd.sftp.no_default_key": "デフォルトSSH鍵が見つかりません、パスワード設定またはキー指定", + "err.sftp.key_needs_passphrase": "鍵 {path} にパスフレーズが必要、入力または --key-passphrase を使用", + "err.sftp.wrong_passphrase": "パスフレーズ間違い、再入力 (Enterでスキップ)", + "cli.help.webdav": "WebDAV リモートバックアップ管理(Jianguoyun、NextCloud、Synologyなど対応)", + "cli.help.webdav.action": "WebDAV アクション: config / test", + "cli.help.webdav.config": "WebDAV 接続パラメータを設定", + "cli.help.webdav.test": "WebDAV 接続テスト", + "cli.help.webdav.url": "WebDAV サーバーURL(例: https://dav.jianguoyun.com/dav/)", + "cli.help.webdav.user": "WebDAV ユーザー名", + "cli.help.webdav.password": "WebDAV パスワード", + "cli.help.webdav.remote_path": "リモート対象パス(デフォルト: /)", + "cli.help.save.webdav": "バックアップ後にWebDAVサーバーにアップロード", + "cli.help.watch.webdav": "各バックアップ後にWebDAVサーバーにアップロード", + "cli.prompt.webdav.url": "WebDAV サーバーURL:", + "cli.prompt.webdav.user": "WebDAV ユーザー名:", + "cli.prompt.webdav.password": "WebDAV パスワード:", + "cli.prompt.webdav.remote_path": "リモートパス(デフォルト /):", + "cmd.webdav.config_saved": "WebDAV設定をconfig.jsonに保存しました", + "cmd.webdav.testing": "WebDAV {url} に接続テスト中...", + "cmd.webdav.test_ok": "WebDAV接続テスト成功!", + "cmd.webdav.uploading": "WebDAVにアップロード中: {file}", + "cmd.webdav.success": "WebDAVアップロード成功: {file}", + "err.webdav.auth": "WebDAV認証失敗: {host}", + "err.webdav.connect": "WebDAVに接続できません: {url}、エラー: {error}", + "err.webdav.ssh": "WebDAV接続エラー: {error}", + "err.webdav.not_configured": "WebDAV未設定、実行してください: sbackup webdav config --url <アドレス> --user <ユーザー名>", + "err.webdav.local_not_found": "ローカルファイルが見つかりません: {path}", + "err.webdav.upload": "WebDAVへのアップロード失敗 {path}: {error}", + "err.webdav.mkdir": "WebDAVリモートディレクトリ作成失敗 {path}: {error}", + "cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)", + "cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)", + "cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)", + "log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)", + "log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)", + "log.monitor.source_missing": "Monitor source directory not found, skipping: {path}", + "log.monitor.watching": "Now monitoring: {path}", + "log.monitor.no_sources": "No valid source directories to monitor, monitor not started", + "benchmark.empty_source": "ソースディレクトリが空です: {path}", + "benchmark.error": "ベンチマーク失敗 {format}: {error}", + "benchmark.header": "═══ 圧縮ベンチマーク ═══", + "benchmark.recommended": "推奨: {format} レベル={level} ({size}、最適バランス)", + "benchmark.running": "テスト中 {format} レベル={level}...", + "benchmark.source_info": "ソースディレクトリ: {path} ({size}、{count} ファイル)", + "cli.help.add.from_gitignore": ".gitignore ファイルから除外ルールをインポート", + "cli.help.audit": "バックアップ監査ログを表示", + "cli.help.audit.action": "監査アクション: log(照会) / stats(統計) / cleanup(クリーンアップ)", + "cli.help.audit.cleanup": "古い監査ログエントリをクリーンアップ", + "cli.help.audit.cleanup.keep_days": "最近 N 日分のエントリを保持(デフォルト: 90)", + "cli.help.audit.log": "監査ログエントリを照会", + "cli.help.audit.log.event": "イベントタイプでフィルタ", + "cli.help.audit.log.limit": "最大表示件数(デフォルト: 50)", + "cli.help.audit.log.since": "この日付以降のエントリを表示(ISO 8601 形式)", + "cli.help.audit.log.status": "ステータスでフィルタ: success / failed / partial", + "cli.help.audit.stats": "監査ログ統計を表示", + "cli.help.benchmark": "指定ディレクトリで圧縮ベンチマークを実行し、フォーマットのパフォーマンスを比較", + "cli.help.benchmark.levels": "テストする圧縮レベル(カンマ区切り、デフォルト: 1,6,9)", + "cli.help.benchmark.password": "暗号化パスワード(7z フォーマット用)", + "cli.help.benchmark.path": "テスト対象のソースディレクトリパス", + "cli.help.benchmark.quick": "クイックモード: 各フォーマットのデフォルトレベルのみテスト", + "cli.help.checksum": "ファイル内容のSHA256チェックサムで変更を検出(mtimeより正確だが遅い)", + "cli.help.clean": "すべての戦略のバックアップファイルをスマートにクリーンアップ", + "cli.help.clean.dry_run": "削除対象ファイルをプレビューするだけで、実際に削除しない", + "cli.help.clean.keep": "各戦略で最新 N 件のバックアップファイルを保持", + "cli.help.clean.keep_days": "N 日以上前のバックアップファイルを削除", + "cli.help.completion": "シェル自動補完スクリプトを生成", + "cli.help.completion.shell": "シェルタイプ: bash(デフォルト) / zsh / fish / powershell", + "cli.help.compress": "指定フォルダを直接圧縮(戦略を使用しない)", + "cli.help.compress.dry_run": "バックアップ対象ファイルをプレビューするだけで、実際に圧縮しない", + "cli.help.compress.format": "圧縮形式: zip(デフォルト) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.compress.output": "出力ファイルパス(オプション)", + "cli.help.compress.password": "暗号化パスワード(7z フォーマットのみ対応)", + "cli.help.compress.source": "圧縮元フォルダパス", + "cli.help.config_cmd": "設定ファイル管理(機密フィールドの暗号化/復号化)", + "cli.help.config_cmd.action": "アクション: lock(暗号化) / unlock(復号化)", + "cli.help.config_cmd.lock": "マスターパスワードで設定ファイルの機密フィールドを暗号化", + "cli.help.config_cmd.lock.password": "マスターパスワード(未指定時は対話式入力)", + "cli.help.config_cmd.unlock": "マスターパスワードで設定ファイルの機密フィールドを復号化", + "cli.help.config_cmd.unlock.password": "マスターパスワード(未指定時は対話式入力)", + "cli.help.config_cmd.validate": "現在の設定を検証し、エラーと警告をすべて表示", + "cli.help.dedup": "戦略をまたぐコンテンツベースハッシュ(SHA256)によるファイルレベルの重複排除を有効化", + "cli.help.diff.detail": "変更ファイルの行レベル差分を表示", + "cli.help.diskcheck": "バックアップ前にディスク容量をチェック(ソースディレクトリとバックアップファイルのサイズを推定)", + "cli.help.diskcheck.level": "圧縮レベル (0-9、デフォルト: 6)", + "cli.help.diskcheck.source": "スキャン対象のソースディレクトリパス", + "cli.help.diskcheck.target": "ターゲットディレクトリパス(バックアップファイルの保存先)", + "cli.help.hooks": "バックアップ前/後フックを管理・手動実行", + "cli.help.hooks.action": "フックアクション: run(実行)", + "cli.help.hooks.run": "バックアップ前または後フックを手動実行", + "cli.help.hooks.run.timeout": "フックのタイムアウト時間(秒)を上書き(デフォルト: 設定値を使用)", + "cli.help.hooks.run.type": "実行するフックタイプ: pre(前) または post(後)", + "cli.help.ignore": ".sbackupignore 除外ルールファイルを生成", + "cli.help.ignore.list": "利用可能なプリセットをすべて表示", + "cli.help.ignore.output": "出力ファイルパス(デフォルト: .sbackupignore)", + "cli.help.ignore.preset": "プリセットテンプレート: node / python / go / rust / java / general(デフォルト: general)", + "cli.help.integrity": "データ整合性チェック(チェックリストの生成/検証)", + "cli.help.integrity.action": "整合性アクション: generate(生成) / verify(検証)", + "cli.help.integrity.generate": "バックアップディレクトリの integrity.json チェックリストを生成", + "cli.help.integrity.path": "バックアップディレクトリパス", + "cli.help.integrity.verify": "integrity.json チェックリストに基づいてバックアップファイルの整合性を検証", + "cli.help.list.tags": "すべてのバックアップタグを表示", + "cli.help.metadata_export": "バックアップメタデータ(履歴/監査ログ)をファイルにエクスポート", + "cli.help.metadata_export.action": "エクスポートアクション: history(履歴) / audit(監査) / all(統合)", + "cli.help.metadata_export.all": "統合レポートをエクスポート(履歴 + 監査 + 統計)", + "cli.help.metadata_export.audit": "監査ログを CSV または JSON にエクスポート", + "cli.help.metadata_export.event": "イベントタイプでフィルタ", + "cli.help.metadata_export.format": "出力形式: csv(history/audit デフォルト)または json", + "cli.help.metadata_export.history": "バックアップ履歴を CSV または JSON にエクスポート", + "cli.help.metadata_export.output": "出力ファイルパス", + "cli.help.metadata_export.strategy": "戦略名でフィルタ(source パスにマッチ)", + "cli.help.multi_backup": "マルチターゲット同時バックアップ: ローカル + 有効なすべてのリモートターゲットへ並列アップロード", + "cli.help.multi_backup.dry_run": "バックアップ対象ファイルをプレビューするだけで、実際に実行しない", + "cli.help.multi_backup.keep": "最新 N 件のバックアップファイルを保持(0 でクリーンアップ無効)", + "cli.help.multi_backup.keep_days": "最新 N 日分のバックアップファイルを保持", + "cli.help.multi_backup.local_target": "ローカルバックアップ先フォルダパス", + "cli.help.multi_backup.name_template": "バックアップファイル名テンプレート", + "cli.help.multi_backup.password": "暗号化パスワード(7z フォーマットのみ対応)", + "cli.help.multi_backup.source": "バックアップ元フォルダパス", + "cli.help.multi_backup.verify": "バックアップ後に自動で整合性を検証", + "cli.help.profile": "設定プロファイルを管理", + "cli.help.profile.action": "アクション: list(一覧) / create(作成) / delete(削除) / use(使用) / export(エクスポート) / import(インポート)", + "cli.help.profile.create.name": "現在の設定から作成するプロファイル名", + "cli.help.profile.delete.name": "削除するプロファイル名", + "cli.help.profile.export.name": "エクスポートするプロファイル名", + "cli.help.profile.export.path": "エクスポートファイルパス", + "cli.help.profile.import.name": "プロファイル名(デフォルトはファイル名を使用)", + "cli.help.profile.import.path": "インポートファイルパス", + "cli.help.profile.use.name": "一時的に使用するプロファイル名", + "cli.help.profile_name": "すべてのコマンドで指定プロファイルを使用", + "cli.help.remote.list_path": "リモートディレクトリパスを指定(未指定時は設定のデフォルトパスを使用)", + "cli.help.report": "Markdown形式のバックアップレポートを生成", + "cli.help.report.format": "レポート形式: text(テキスト) または html(ウェブページ)", + "cli.help.report.output": "出力ファイルパス(未指定時はターミナルに表示)", + "cli.help.restore.search": "アーカイブ内でファイル名を検索(大文字小文字を区別しない)", + "cli.help.restore.select": "選択復元: パターンに一致するファイルのみ展開(globワイルドカード対応、例: \"*.py\" または \"src/main.py\")", + "cli.help.restore.stats": "アーカイブの統計情報を表示(ファイル数、サイズ、形式分布)", + "cli.help.restore.tag": "タグで指定したバックアップを復元", + "cli.help.rotate": "保持ポリシーに基づいてバックアップファイルをローテーション・クリーンアップ", + "cli.help.rotate.backup_dir": "バックアップファイルを含むディレクトリパス", + "cli.help.rotate.dry_run": "削除対象ファイルをプレビューするだけで、実際に削除しない", + "cli.help.rotate.keep_count": "最新 N 件のバックアップファイルを保持(0 = 無制限)", + "cli.help.rotate.keep_daily": "最近 N 日分、毎日1件ずつバックアップを保持(0 = 無制限)", + "cli.help.rotate.keep_days": "最新 N 日分のバックアップファイルを保持(0 = 無制限)", + "cli.help.rotate_list": "ディレクトリ内のバックアップファイルをすべて一覧表示", + "cli.help.rotate_list.backup_dir": "スキャン対象のバックアップファイルディレクトリ", + "cli.help.save.keep_days": "最新 N 日分のバックアップファイルを保持、0 で時間によるクリーンアップ無効(デフォルト: 0)", + "cli.help.save.multi_dest": "マルチターゲット同時バックアップモードを有効化", + "cli.help.save.post_hook": "バックアップ後に実行するコマンド(複数回指定可能)", + "cli.help.save.pre_hook": "バックアップ前に実行するコマンド(複数回指定可能)", + "cli.help.save.split": "バックアップファイルの分割サイズ(例: 100MB, 1GB)、このサイズを超えると自動分割", + "cli.help.save.tag": "このバックアップにタグを付与(例: daily、release)", + "cli.help.schedule": "スケジュール設定の管理", + "cli.help.schedule.action": "スケジュールアクション: export(設定エクスポート) / install(サービスインストール)", + "cli.help.schedule.export": "スケジュール設定ファイルをエクスポート", + "cli.help.schedule.install": "定期バックアップをシステムサービスとしてインストール", + "cli.help.schedule.install.interval": "バックアップ間隔(分、デフォルト: 60)", + "cli.help.schedule.install.type": "サービスタイプ: systemd / schtasks / launchd", + "cli.help.schedule.install.user": "サービス実行ユーザー(デフォルト: 現在のユーザー)", + "cli.help.schedule.interval": "バックアップ間隔(分、デフォルト: 60)", + "cli.help.schedule.output": "出力ファイルパス(未指定時はターミナルに表示)", + "cli.help.schedule.type": "スケジュールタイプ: systemd / crontab(デフォルト: systemd)", + "cli.help.schedule.type.schtasks": "Windows タスクスケジューラ", + "cli.help.search": "バックアップファイル内でファイル名を検索", + "cli.help.search.in": "バックアップファイルパスを指定(未指定時は全戦略の最新バックアップを検索)", + "cli.help.search.password": "復号化パスワード(暗号化バックアップの検索時に使用)", + "cli.help.search.pattern": "検索パターン(globワイルドカード対応、例: *.txt)", + "cli.help.strict": "ディスク容量不足時にバックアップを中止(未指定時は警告のみ)", + "cli.help.task": "タスクキュー管理(バックアップタスクの追加/実行/キャンセル)", + "cli.help.task.action": "タスクアクション: add(追加) / list(一覧) / run(実行) / run-all(すべて実行) / cancel(キャンセル) / clear(クリア) / stats(統計)", + "cli.help.task.add": "新しいバックアップタスクをキューに追加", + "cli.help.task.add.folder": "バックアップ対象フォルダパス", + "cli.help.task.add.format": "圧縮形式: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.task.add.name": "タスク名(識別用)", + "cli.help.task.add.output": "出力ファイルパス(オプション)", + "cli.help.task.cancel": "保留中のタスクをキャンセル", + "cli.help.task.cancel.id": "キャンセルするタスク ID", + "cli.help.task.clear": "完了および失敗したタスクをキューからクリア", + "cli.help.task.list": "キュー内のタスクを一覧表示", + "cli.help.task.list.status": "ステータスでフィルタ: pending / running / completed / failed", + "cli.help.task.run": "キュー内の次の保留中タスクを実行", + "cli.help.task.run_all": "保留中のタスクをすべて順番に実行", + "cli.help.task.stats": "タスクキューの統計を表示", + "cli.help.versions": "バックアップバージョン履歴を表示(ソースディレクトリ別にグループ化)", + "cli.help.versions.source": "ソースディレクトリパスを指定(未指定時はすべて表示)", + "cli.help.versions.tag": "タグでバージョンをフィルタ", + "cli.help.watch.keep_days": "最新 N 日分のバックアップファイルを保持(デフォルト: 0)", + "cli.help.watch.post_hook": "バックアップ後に実行するコマンド(複数回指定可能)", + "cli.help.watch.pre_hook": "バックアップ前に実行するコマンド(複数回指定可能)", + "cli.help.watch.split": "バックアップファイルの分割サイズ(例: 100MB, 1GB)", + "cli.help.watch.tag": "このバックアップにタグを付与", + "cli.help.webhook_cmd": "Webhook 通知管理", + "cli.help.webhook_cmd.action": "Webhook アクション: preset(プリセット設定) / list(プリセット一覧)", + "cli.help.webhook_cmd.list": "利用可能な IM 通知プリセットをすべて表示", + "cli.help.webhook_cmd.preset": "IM 通知プリセットテンプレートを設定", + "cli.help.webhook_cmd.preset.name": "プリセット名: dingtalk(ディングトーク) / feishu(フェイシュウ) / wechat(WeChat企業版)", + "cli.help.wizard": "対話式設定ウィザードで初回セットアップをガイド", + "cli.help.xsearch": "クロスアーカイブ検索: 複数のバックアップファイル内でファイル名を検索", + "cli.help.xsearch.dirs": "バックアップファイルを再帰的に検索するディレクトリリスト", + "cli.help.xsearch.ext": "拡張子で検索(例: .log, .py)", + "cli.help.xsearch.keyword": "検索キーワード(大文字小文字を区別しない、ファイル名にマッチ)", + "cli.help.xsearch.output_lang": "出力言語: zh_CN(中国語) / en_US(英語)", + "cli.help.xsearch.password": "復号化パスワード(暗号化バックアップの検索時に使用)", + "cli.help.xsearch.pattern": "Glob パターンでファイル名をマッチ(例: \"*.log\")", + "cmd.add.gitignore_empty": "{path} からルールをインポートしませんでした", + "cmd.add.gitignore_imported": "{path} から {count} 件の除外ルールをインポートしました", + "cmd.audit.cleanup.done": "古い監査ログレコード {count} 件をクリーンアップしました。", + "cmd.audit.cleanup.none": "クリーンアップ対象の古いレコードはありません。", + "cmd.clean.dry_run_header": "プレビューモード(実際には削除しません):", + "cmd.clean.no_criteria": "クリーンアップ条件を指定してください: --keep N または --keep-days N", + "cmd.clean.nothing": "クリーンアップ対象のバックアップファイルはありません。", + "cmd.clean.summary": "バックアップファイル {count} 件をクリーンアップしました。", + "cmd.config.already_locked": "設定ファイルは既に暗号化されています", + "cmd.config.enter_password": "マスターパスワードを入力してください", + "cmd.config.lock_failed": "暗号化に失敗しました", + "cmd.config.locked": "設定ファイルを暗号化しました(機密フィールドを保護)", + "cmd.config.no_password": "パスワードは空にできません", + "cmd.config.not_locked": "設定ファイルは暗号化されていません", + "cmd.config.unlocked": "設定ファイルを復号化しました", + "cmd.config.validate.errors": "エラーが見つかりました:", + "cmd.config.validate.failed": "設定の検証に失敗しました。上記のエラーを修正してください。", + "cmd.config.validate.passed": "設定の検証に合格しました。問題は見つかりませんでした。", + "cmd.config.validate.passed_with_warnings": "設定の検証に合格しました(ただし警告あり)。", + "cmd.config.validate.warnings": "警告が見つかりました:", + "cmd.config.wrong_password": "パスワードが正しくありません。復号化に失敗しました", + "cmd.dedup.saved": "重複排除: 重複ファイル {count} 件をスキップ、{saved:.2f} MB 節約", + "cmd.diff.detail_header": "--- 行レベル差分 ---", + "cmd.diff.no_detail": "(バイナリファイルまたは読み取り不可)", + "cmd.hook.failed": "フック実行失敗: {command} — {error}", + "cmd.hook.post_running": "後フック実行中: {command}", + "cmd.hook.pre_running": "前フック実行中: {command}", + "cmd.hooks.no_hooks": "{type} フックは設定されていません。", + "cmd.hooks.no_results": "フック実行結果がありません。", + "cmd.hooks.result_line": "[{status}] {command} (リターンコード={return_code}、所要時間{duration}秒)", + "cmd.hooks.summary": "合計 {total} 件、成功 {success} 件、失敗 {fail} 件", + "cmd.ignore.available_presets": "利用可能なプリセット:", + "cmd.ignore.cancelled": "キャンセルしました。", + "cmd.ignore.generated": "{preset} プリセットの除外ルールを生成しました: {path}", + "cmd.ignore.overwrite": "ファイル {path} は既に存在します。上書きしますか?(y/N): ", + "cmd.integrity.generate.not_dir": "エラー: {path} はディレクトリではありません", + "cmd.integrity.generate.success": "整合性チェックリストを生成しました: {path}", + "cmd.integrity.verify.failed": "整合性検証に失敗しました: {path}", + "cmd.integrity.verify.success": "整合性検証に合格しました: {path}", + "cmd.list.tags.empty": "タグがありません", + "cmd.list.tags.header": "バックアップタグ:", + "cmd.metadata_export.audit_done": "監査ログ {count} 件を {path} にエクスポートしました", + "cmd.metadata_export.combined_done": "統合レポート({count} 件のレコード)を {path} にエクスポートしました", + "cmd.metadata_export.history_done": "履歴レコード {count} 件を {path} にエクスポートしました", + "cmd.profile.activated": "プロファイル '{name}' を有効化しました。", + "cmd.profile.created": "プロファイル '{name}' を作成しました。", + "cmd.profile.deleted": "プロファイル '{name}' を削除しました。", + "cmd.profile.exported": "プロファイル '{name}' を {path} にエクスポートしました", + "cmd.profile.import_failed": "{path} からのプロファイルインポートに失敗しました。", + "cmd.profile.imported": "プロファイル '{name}' をインポートしました。", + "cmd.profile.list.detail": " {fields}", + "cmd.profile.list.empty": "プロファイルが設定されていません。", + "cmd.profile.list.header": "設定プロファイル一覧:", + "cmd.profile.list.item": " {name}", + "cmd.profile.not_found": "プロファイル '{name}' は存在しません。", + "cmd.remote.deleted": "リモートファイルを削除しました: {path}", + "cmd.remote.empty": "リモートディレクトリが空です: {path}", + "cmd.remote.list_header": "リモートディレクトリ: {path} ({count} ファイル)", + "cmd.remote.rm_requires_path": "削除するリモートファイル名を指定してください", + "cmd.remote.select_protocol": "プロトコルを指定してください: --sftp または --webdav", + "cmd.report.saved": "バックアップレポートを保存しました: {path}", + "cmd.restore.search.found": "一致するファイル {count} 件が見つかりました:", + "cmd.restore.search.header": "{path} で '{keyword}' を検索した結果:", + "cmd.restore.search.no_results": "{path} で '{keyword}' に一致するファイルは見つかりませんでした", + "cmd.restore.selective.header": "選択復元: {patterns} に一致する {count} ファイルを展開", + "cmd.restore.selective.success": "{count} ファイルを {target} に展開しました", + "cmd.restore.stats.dirs": "ディレクトリ数: {count}", + "cmd.restore.stats.files": "ファイル数: {count}", + "cmd.restore.stats.format_item": " {ext}: {count} ファイル", + "cmd.restore.stats.formats": "ファイルタイプ分布:", + "cmd.restore.stats.header": "═══ アーカイブ統計情報: {path} ═══", + "cmd.restore.stats.size": "合計サイズ: {size} バイト", + "cmd.restore.tag_not_found": "タグ '{tag}' のバックアップが見つかりません", + "cmd.rotate.auto_done": "自動ローテーション完了: {deleted} ファイルを削除しました。", + "cmd.rotate.auto_title": "{path} のバックアップを自動ローテーション中...", + "cmd.rotate.delete_item": " - {name} {size}", + "cmd.rotate.done": "バックアップファイル {count} 件をローテーション・クリーンアップしました。", + "cmd.rotate.dry_run_header": "プレビューモード(実際には削除しません):", + "cmd.rotate.dry_run_item": " ~ {name} {size} {time}", + "cmd.rotate.header": "═══ バックアップローテーション ═══", + "cmd.rotate.no_action": "ローテーション対象のファイルはありません。", + "cmd.rotate.plan_delete": "{count} ファイルを削除します:", + "cmd.rotate.plan_keep": "{count} ファイルを保持します:", + "cmd.rotate.scan_empty": "{path} にバックアップファイルが見つかりません。", + "cmd.rotate.scan_found": "{path} にバックアップファイル {count} 件が見つかりました:", + "cmd.rotate.scan_item": " {name} {size} {time}", + "cmd.schedule.exported": "スケジュール設定をエクスポートしました: {path} ({type})", + "cmd.schedule.install.launchd": "plist ファイル保存後に実行:", + "cmd.schedule.install.schtasks_cmd": "管理者として以下のコマンドを実行してタスクスケジューラを作成:\n {cmdline}", + "cmd.schedule.install.systemd": "root として以下のコマンドを実行して systemd タイマーをインストール:", + "cmd.schedule.schtasks.generated": "Windows タスクスケジューラコマンドを生成しました", + "cmd.schedule.unknown_type": "不明なスケジュールタイプ: {type}", + "cmd.search.no_results": "{path} で '{pattern}' に一致するファイルは見つかりませんでした", + "cmd.search.no_results_global": "すべてのバックアップで '{pattern}' に一致するファイルは見つかりませんでした", + "cmd.search.results": "{path} で一致するファイル {count} 件が見つかりました:", + "cmd.sftp.parallel_upload": "ファイル {count} 件を並列アップロード中...", + "cmd.upload.parallel": "SFTP と WebDAV へ並列アップロード中...", + "cmd.versions.empty": "バックアップバージョンの記録がありません。", + "cmd.versions.header": "═══ バックアップバージョン履歴 ═══", + "cmd.versions.source": "📁 {source}", + "cmd.versions.total": "合計 {count} バージョン、{sources} ソースディレクトリ", + "cmd.webdav.parallel_upload": "ファイル {count} 件を WebDAV に並列アップロード中...", + "cmd.webhook.available_presets": "利用可能な IM 通知プリセット:", + "cmd.webhook.preset_set": "{name} 通知テンプレートを設定しました:\n{template}", + "cmd.webhook.preset_unknown": "不明なプリセット: {name}", + "cmd.wizard.choice": "オプションを入力してください:", + "cmd.wizard.cloud_done": "クラウドストレージの設定が完了しました。", + "cmd.wizard.cloud_info": "S3 互換クラウドストレージ (AWS S3, MinIO, Backblaze, テンセントCOS):", + "cmd.wizard.complete": "sbackup のセットアップが完了しました!", + "cmd.wizard.dest": "バックアップファイルの保存先フォルダパス:", + "cmd.wizard.email_done": "メール通知を設定しました。", + "cmd.wizard.email_yn": "メール通知を設定しますか?(y/N):", + "cmd.wizard.host": "サーバーアドレス:", + "cmd.wizard.ignore": "除外パターン(カンマ区切り、デフォルト: .git,__pycache__):", + "cmd.wizard.lang_set": "言語を {lang} に設定しました", + "cmd.wizard.more": "追加を続けますか?(y/N):", + "cmd.wizard.password": "パスワード:", + "cmd.wizard.port": "ポート:", + "cmd.wizard.remote_path": "リモートパス:", + "cmd.wizard.schedule_info": "以下のコマンドで定期バックアップを開始({interval} 分間隔):", + "cmd.wizard.schedule_interval": "バックアップ間隔(分、デフォルト: 60):", + "cmd.wizard.schedule_yn": "定期バックアップを設定しますか?(y/N):", + "cmd.wizard.sftp_done": "SFTP 設定が完了しました。", + "cmd.wizard.skip": "スキップ", + "cmd.wizard.source": "バックアップ元フォルダパス:", + "cmd.wizard.step_email": "ステップ 4/5: メール通知(オプション)", + "cmd.wizard.step_lang": "ステップ 1/5: インターフェース言語を選択", + "cmd.wizard.step_remote": "ステップ 3/5: リモートストレージ設定(オプション)", + "cmd.wizard.step_schedule": "ステップ 5/5: 定期バックアップ(オプション)", + "cmd.wizard.step_strategy": "ステップ 2/5: バックアップ戦略を追加", + "cmd.wizard.summary": "バックアップ戦略 {strategies} 件を追加しました。\n'sbackup save' を実行してバックアップを即座に実行できます。", + "cmd.wizard.user": "ユーザー名:", + "cmd.wizard.webdav_done": "WebDAV 設定が完了しました。", + "cmd.wizard.welcome": "sbackup セットアップウィザードへようこそ!\nこのウィザードが初期設定をガイドします。", + "cmd.xsearch.no_query": "検索条件を指定してください: --keyword、--pattern または --ext", + "diskcheck.disk_free": " 空き容量: {size}", + "diskcheck.disk_header": "ターゲットディスク:", + "diskcheck.disk_total": " 総容量: {size}", + "diskcheck.disk_used": " 使用済: {size}", + "diskcheck.estimated_size": "推定バックアップサイズ: {size}(圧縮率: {ratio})", + "diskcheck.header": "═══ ディスク容量チェック ═══", + "diskcheck.insufficient": "ディスク容量不足: 必要 {needed}、空き {available}、不足 {missing}", + "diskcheck.source": "ソースディレクトリ: {path}", + "diskcheck.source_size": "ソースファイル合計サイズ: {size}", + "diskcheck.status.insufficient": "容量チェック: 不足!\n 推定バックアップ必要量: {needed}\n 空き容量: {available}\n 不足分: {missing}", + "diskcheck.status.ok": "容量チェック: 十分(余裕 {margin})", + "diskcheck.target": "ターゲットパス: {path}", + "dryrun.and_others": "その他", + "dryrun.excluded_files": "除外ファイル", + "dryrun.excluded_files_list": "除外ファイル一覧", + "dryrun.exclusion_reasons": "除外理由", + "dryrun.file_types": "ファイルタイプ", + "dryrun.included_files": "含むファイル", + "dryrun.included_files_list": "含むファイル一覧", + "dryrun.largest_files": "最大ファイル", + "dryrun.more_files": "... 他 {count} ファイル", + "dryrun.no_extension": "(拡張子なし)", + "dryrun.reason.excluded": "除外ルールに一致", + "dryrun.reason.not_included": "含むルールに一致しない", + "dryrun.reason.too_large": "最大ファイルサイズを超過", + "dryrun.reason.too_small": "最小ファイルサイズ未満", + "dryrun.scanned_dir": "スキャンディレクトリ", + "dryrun.skip": "スキップ", + "dryrun.title": "Dry-run プレビュー", + "dryrun.total_files": "ファイル合計", + "dryrun.total_size": "合計サイズ", + "dryrun.unit_files": "ファイル", + "dryrun.warning.disk_space": "ディスク容量不足: 空き {free}、必要 {need}", + "dryrun.warning.not_dir": "有効なディレクトリではありません: {path}", + "dryrun.warning.os_error": "システムエラー: {error}", + "dryrun.warning.permission": "アクセス権限がありません: {path}", + "dryrun.warnings": "警告", + "err.invalid_filename": "無効なファイル名: {filename}(パス区切り文字と .. シーケンスは使用できません)", + "err.lock.conflict": "エラー: 別のバックアップ操作が実行中です(ロックファイルが存在します)。他のプロセスが実行されていないことが確認できる場合は、ロックファイルを手動で削除してください。", + "err.sftp.delete": "リモートファイルの削除に失敗 {path}: {error}", + "err.sftp.host_key_mismatch": "ホスト {host} のキーが known_hosts と一致しません(中間者攻撃の可能性あり)", + "err.sftp.mkdir_max_depth": "リモートディレクトリの階層が深すぎるか、循環参照があります: {path}", + "err.sftp.no_host_key": "ホスト {host} のキーを取得できません", + "err.sftp.remote_not_found": "リモートファイルが存在しません: {path}", + "err.webdav.delete": "リモートファイルの削除に失敗 {path}: {error}", + "err.webdav.invalid_scheme": "サポートされていない URL スキーム '{scheme}'。http と https のみ使用可能です。", + "err.webdav.list": "リモートファイルの一覧取得に失敗 {path}: {error}", + "err.webdav.not_found": "リモートファイルが存在しません: {path}", + "err.webdav.private_ip": "プライベートアドレスへの接続をブロックしました: {host}", + "err.webdav.xml_too_large": "WebDAV レスポンスが大きすぎます({size} バイト)。XML 爆弾攻撃の可能性があります", + "log.rotation.delete_error": "削除失敗 %s: %s", + "log.rotation.deleted": "削除しました: %s (%.2f MB)", + "log.rotation.dry_run_skip": "[dry-run] 削除予定: %s (%.2f MB)", + "multi_dest.aborted": "マルチターゲットバックアップ中止: ローカルバックアップに失敗", + "multi_dest.complete": "マルチターゲットバックアップ完了", + "multi_dest.local.failed": "ローカルバックアップに失敗", + "multi_dest.local.success": "ローカルバックアップ完了: {path}", + "multi_dest.started": "マルチターゲットバックアップ開始: {source} -> {targets}", + "multi_dest.uploading": "{count} 個のリモートターゲットへ並列アップロード中...", + "restore.no_match": "\"{pattern}\" に一致するファイルがありません", + "restore.split.detected": "分割バックアップを検出、合計 {count} ボリューム", + "restore.split.merged": "分割ファイルのマージ完了: {path}", + "restore.split.missing": "エラー: 分割ファイルがありません。{path} が必要です", + "task_queue.added": "タスクを追加しました: {name} (ID: {id})", + "task_queue.cancelled": "ユーザーがキャンセルしました", + "task_queue.cancelled.fail": "タスクをキャンセルできません: 見つからないか、保留中ではありません", + "task_queue.cancelled.success": "タスクをキャンセルしました: {id}", + "task_queue.clear.done": "キューからタスク {count} 件をクリアしました。", + "task_queue.clear.none": "完了または失敗したタスクはクリア不要です。", + "task_queue.list.empty": "タスクキューは空です。", + "task_queue.list.header": "═══ タスクキュー ({count} タスク) ═══", + "task_queue.list.item": " [{status}] {name} (ID: {id})", + "task_queue.list.item_detail": " ディレクトリ: {folder}", + "task_queue.list.item_error": " エラー: {error}", + "task_queue.list.item_result": " 結果: {result}", + "task_queue.list.item_time": " 作成日時: {created}", + "task_queue.run.failed": "タスク実行失敗: {name} — {error}", + "task_queue.run.none": "キューに保留中のタスクがありません。", + "task_queue.run.success": "タスク実行成功: {name} (結果: {result})", + "task_queue.run_all.done": "タスク {count} 件を実行、{success} 件成功、{failed} 件失敗。", + "task_queue.stats.completed": "完了: {completed}", + "task_queue.stats.failed": "失敗: {failed}", + "task_queue.stats.header": "═══ タスクキュー統計 ═══", + "task_queue.stats.pending": "保留中: {pending}", + "task_queue.stats.running": "実行中: {running}", + "task_queue.stats.total": "合計: {total}", + "warn.password_in_cli": "警告: {arg} で渡されたパスワードはプロセス一覧に表示されます。対話式入力を推奨します。", + "warn.sftp.no_known_hosts": "警告: known_hosts ファイルが見つかりません。{host} のホストキーを検証できず、接続が安全でない可能性があります", + "warn.webdav.http_plaintext": "警告: 平文 HTTP で WebDAV ({url}) に接続しています。認証情報が平文で送信されます。HTTPS の使用を推奨します。" } \ No newline at end of file diff --git a/sbackup/locales/ko_KR.json b/sbackup/locales/ko_KR.json index f5e97ae..ec6d106 100644 --- a/sbackup/locales/ko_KR.json +++ b/sbackup/locales/ko_KR.json @@ -1,80 +1,667 @@ -{ - "cmd.add.success": "백업 전략 추가 성공: {source} -> {dest}", - "cmd.rm.success": "백업 전략 삭제 성공: {path}", - "err.folder.invalid": "오류: {path}는 유효한 폴더가 아니거나 존재하지 않습니다.", - "err.dest.invalid": "오류: 대상 폴더 {path}는 유효한 폴더가 아니거나 존재하지 않습니다.", - "info.already.added": "알림: {path}가 이미 추가되었습니다. 중복 추가하지 마세요.", - "warn.no.strategy.found": "경고: {path}의 백업 전략을 찾을 수 없습니다.", - "warn.source.missing": "경고: 원본 폴더가 존재하지 않습니다: {path}", - "warn.json.decode.error": "경고: 데이터 파일 {path}가 손상되었습니다, 빈 딕셔너리로 초기화합니다.", - "warn.json.backup": "경고: 손상된 데이터 파일을 {path}에 백업했습니다.", - "warn.json.renamed": "경고: 손상된 데이터 파일을 {path}로 이름 변경했습니다.", - "compress.success": "백업 성공: {path} ({size:.2f} MB, {count} 파일)", - "compress.progress": "압축 중", - "compress.unit": "파일", - "err.permission": "접근 거부: '{path}'에 쓸 수 없습니다", - "err.os": "시스템 오류: {error}", - "err.unknown": "알 수 없는 오류: {error}", - "err.unknown.format": "오류: 인식할 수 없는 백업 파일 형식 {path}", - "err.argparse.invalid_choice": "잘못된 선택: ", - "err.argparse.choose_from": "선택 가능", - "err.argparse.invalid_float": "잘못된 실수 값: ", - "err.argparse.invalid_int": "잘못된 정수 값: ", - "err.argparse.unrecognized_args": "인식되지 않은 인수: ", - "err.argparse.required": "필수", - "warn.zip.overwrite": "경고: 기존 백업 파일 {path}을 덮어씁니다", - "warn.invalid.compresslevel": "경고: 압축 수준 {level}이 범위 초과 (0-9), 기본값 6 사용", - "cmd.save.uptodate": "모든 백업 전략이 최신 상태입니다, 백업이 필요하지 않습니다.", - "cmd.save.completed": "백업 완료, {count}개 전략 처리됨.", - "cmd.all.empty": "구성된 백업 전략이 없습니다.", - "exit.message": "\n종료합니다.", - "table.header.source": "원본 폴더", - "table.header.dest": "대상 폴더", - "table.header.ignore": "제외 패턴", - "table.cell.none": "없음", - "table.header.format": "형식", - "table.cell.default": "기본값", - "restore.progress": "복원 중", - "restore.success": "복원 성공: {path} ({count} 파일)", - "cmd.watch.start": "감시 시작, 간격 {interval}분 (Ctrl+C로 중지)", - - "cli.description": "Sbackup v{version} — 경량 증분 백업 도구\n\nSbackup는 여러 폴더의 백업 작업을 쉽게 관리합니다.\n증분 백업 전략을 사용하여 변경된 파일만 압축하고,\n사용자 정의 제외 규칙을 지원하며 직관적인 진행 피드백을 제공합니다.\n\n사용 가능한 명령:\n add 새 백업 전략 추가 (원본 -> 대상)\n rm 기존 백업 전략 삭제\n all 구성된 모든 백업 전략 표시\n save 백업 작업 실행\n version 버전 정보 표시", - "cli.epilog": "예시:\n 백업 전략 추가:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 항목별 형식 지정하여 전략 추가:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n 백업 전략 삭제:\n sbackup rm F:/my_folder\n\n 모든 전략 표시:\n sbackup all\n\n 백업 실행 (ZIP):\n sbackup save\n\n 백업 실행 (tar.gz):\n sbackup --format tar.gz save\n\n 백업 실행 (7z 암호화):\n sbackup --format 7z save --password 123456\n\n 최근 5개 백업 유지:\n sbackup save --keep 5\n\n 정기 백업 (60분 간격):\n sbackup watch --interval 60\n\n 감시 + 정리:\n sbackup watch --interval 120 --keep 10\n\n 백업 복원:\n sbackup restore backup.7z F:/restored\n\n 중국어 인터페이스:\n sbackup --lang zh_CN save\n\n 디버그 모드:\n sbackup --debug save", - "cli.help.debug": "상세 로깅 디버그 모드 활성화", - "cli.help.help": "이 도움말 메시지를 표시하고 종료", - "cli.help.lang": "인터페이스 언어 설정: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", - "cli.help.format": "패키징 형식 설정: zip (기본값) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.subcommands": "실행할 명령 선택", - "cli.help.add": "새 백업 전략 추가", - "cli.help.add.source": "원본 폴더 경로: 백업할 디렉토리", - "cli.help.add.dest": "대상 폴더 경로: 백업 파일이 저장될 위치", - "cli.help.add.ignore": "제외할 파일 또는 폴더 (쉼표로 구분, 기본값: .git,__pycache__)", - "cli.help.add.format": "항목별 패키징 형식 (이 전략만, 미지정시 전역 설정): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.rm": "백업 전략 삭제", - "cli.help.rm.path": "삭제할 전략의 원본 폴더 경로", - "cli.help.all": "모든 백업 전략 표시", - "cli.help.save": "모든 백업 전략 실행", - "cli.help.save.keep": "최근 N개 백업 파일 유지, 0은 정리 비활성화", - "cli.help.save.password": "암호화 비밀번호 (7z 형식만 지원)", - "cli.help.watch": "정기적으로 백업 실행", - "cli.help.watch.interval": "백업 간격 (분), 기본값 60", - "cli.help.watch.keep": "최근 N개 백업 파일 유지", - "cli.help.watch.password": "암호화 비밀번호 (7z 형식만 지원)", - "cli.help.restore": "백업 파일에서 복원", - "cli.help.restore.file": "백업 파일 경로 (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", - "cli.help.restore.dir": "복원 대상 디렉토리", - "cli.help.version": "버전 정보 표시", - "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nGNU GPL v3.0 라이선스 — https://www.gnu.org/licenses/gpl-3.0.html\n", - - "log.config.malformed": "구성 파일 %s가 손상되었습니다, 기본값 사용", - "log.config.reset": "구성 파일 %s가 손상되었습니다, 재설정", - "log.config.mkdir.error": "구성 디렉토리 %s를 생성할 수 없습니다: %s", - "log.config.write.error": "구성 파일 %s를 쓸 수 없습니다: %s", - "log.data.read": "데이터 파일 읽기: %s", - "log.data.create": "데이터 파일이 없습니다, 생성: %s", - "log.data.load": "기존 데이터 파일 로드: %s", - "log.data.write": "데이터 파일 쓰기: %s", - "log.cleanup.delete": "오래된 백업 삭제됨: %s", - "log.i18n.load.failed": "로케일 {path} 로드 실패: {error}", - "log.i18n.load.fallback_failed": "폴백 로케일 {path} 로드 실패: {error}" -} \ No newline at end of file +{ + "cmd.add.success": "백업 전략 추가 성공: {source} -> {dest}", + "cmd.rm.success": "백업 전략 삭제 성공: {path}", + "cmd.edit.success": "백업 전략이 성공적으로 수정되었습니다: {path}", + "cmd.edit.nothing": "수정할 매개변수가 지정되지 않았습니다.", + "err.folder.invalid": "오류: {path}는 유효한 폴더가 아니거나 존재하지 않습니다.", + "err.file.not_found": "오류: 백업 파일 {path}이(가) 존재하지 않습니다.", + "err.dest.invalid": "오류: 대상 폴더 {path}는 유효한 폴더가 아니거나 존재하지 않습니다.", + "info.already.added": "알림: {path}가 이미 추가되었습니다. 중복 추가하지 마세요.", + "warn.no.strategy.found": "경고: {path}의 백업 전략을 찾을 수 없습니다.", + "warn.source.missing": "경고: 원본 폴더가 존재하지 않습니다: {path}", + "warn.json.decode.error": "경고: 데이터 파일 {path}가 손상되었습니다, 빈 딕셔너리로 초기화합니다.", + "warn.json.backup": "경고: 손상된 데이터 파일을 {path}에 백업했습니다.", + "warn.json.renamed": "경고: 손상된 데이터 파일을 {path}로 이름 변경했습니다.", + "compress.success": "Backup successful: {path} ({original:.2f} MB → {size:.2f} MB, {ratio:.0f}%, {count} files)", + "compress.progress": "압축 중", + "compress.unit": "파일", + "err.permission": "접근 거부: '{path}'에 쓸 수 없습니다", + "err.os": "시스템 오류: {error}", + "err.unknown": "알 수 없는 오류: {error}", + "err.unknown.format": "오류: 인식할 수 없는 백업 파일 형식 {path}", + "err.argparse.invalid_choice": "잘못된 선택: ", + "err.argparse.choose_from": "선택 가능", + "err.argparse.invalid_float": "잘못된 실수 값: ", + "err.argparse.invalid_int": "잘못된 정수 값: ", + "err.argparse.unrecognized_args": "인식되지 않은 인수: ", + "err.argparse.required": "필수", + "warn.zip.overwrite": "경고: 기존 백업 파일 {path}을 덮어씁니다", + "warn.invalid.compresslevel": "경고: 압축 수준 {level}이 범위 초과 (0-9), 기본값 6 사용", + "cmd.save.uptodate": "모든 백업 전략이 최신 상태입니다, 백업이 필요하지 않습니다.", + "cmd.save.completed": "백업 완료, {count}개 전략 처리됨.", + "cmd.all.empty": "구성된 백업 전략이 없습니다.", + "cmd.list.empty": "아직 백업 기록이 없습니다.", + "cmd.verify.checking": "백업 파일 검증 중: {path}", + "cmd.verify.success": "검증 성공: {path} ({count}개 파일)", + "cmd.verify.failed": "검증 실패: {path} — 예상 {expected}개 파일, 실제 {actual}개", + "cmd.info.header": "═══ 백업 파일 정보 ═══", + "cmd.info.path": "경로: {path}", + "cmd.info.format": "형식: {format}", + "cmd.info.size": "크기: {size:.2f} MB", + "cmd.info.files": "파일 수: {count}", + "cmd.info.time": "수정 시간: {time}", + "cmd.info.sha256": "SHA256: {checksum}", + "cmd.rm.all.confirm": "모든 백업 전략 {count}개를 삭제하시겠습니까? (y/N): ", + "cmd.rm.all.confirm_words": "y,yes", + "cmd.rm.all.success": "모든 백업 전략 {count}개를 삭제했습니다.", + "cmd.rm.all.empty": "삭제할 백업 전략이 없습니다.", + "cmd.init.success": "설정 파일이 생성되었습니다: {path}", + "cmd.init.exists": "설정 파일이 이미 존재합니다: {path}, 건너뜁니다.", + "warn.disk.space": "경고: 대상 디렉토리 {path}의 공간이 부족합니다 (필요: {need:.1f} MB, 남음: {free:.1f} MB)", + "cmd.save.summary": "백업 요약: {total}개 전략, {backed}개 백업됨, {skipped}개 건너뜀, 소요 시간 {elapsed:.1f}초", + "cmd.dry_run.header": "Dry-run preview (no backup will be performed):", + "cmd.dry_run.item": " {source} -> {target} format: {format} files: {count} size: {size} MB", + "cmd.dry_run.total": "Total: {count} strategies, {files} files, {size} MB", + "cmd.dry_run.nothing": "No strategies need backup (all are up to date).", + "cmd.verify.partial_fail": "Warning: {count} backup file(s) failed verification!", + "cmd.status.header": "=== Backup Status Dashboard ===", + "cmd.status.strategies": "Total strategies: {count}", + "cmd.status.history": "History records: {count}", + "cmd.status.total_size": "Total backup size: {size:.2f} MB", + "cmd.status.avg_ratio": "Average compression ratio: {ratio:.1f}%", + "cmd.status.strategy.header": "--- Strategy Details ---", + "cmd.status.strategy.item": " {source} -> {target}", + "cmd.status.strategy.last_backup": " Last backup: {time}", + "cmd.status.strategy.never": " Last backup: Never", + "cmd.status.strategy.format": " Format: {format}", + "cmd.status.strategy.size": " Target size: {size:.2f} MB", + "log.parallel.backup": "%d개 전략을 병렬 백업 중", + "restore.list.title": "압축 파일 내용: {path}", + "restore.list.empty": "압축 파일이 비어 있습니다: {path}", + "log.ignore.loaded": "무시 규칙 파일 로드됨: %s", + "exit.message": "\n종료합니다.", + "table.header.source": "원본 폴더", + "table.header.dest": "대상 폴더", + "table.header.ignore": "제외 패턴", + "table.header.time": "백업 시간", + "table.header.size": "크기(MB)", + "table.header.ratio": "Ratio", + "table.header.files": "파일 수", + "table.cell.none": "없음", + "table.header.format": "형식", + "table.cell.default": "기본값", + "restore.progress": "복원 중", + "restore.success": "복원 성공: {path} ({count} 파일)", + "restore.skipped_unsafe": "안전하지 않은 아카이브 멤버 {count}개 건너뜀 (경로 순회 방지)", + "cmd.watch.start": "감시 시작, 간격 {interval}분 (Ctrl+C로 중지)", + "cli.description": "Sbackup v{version} — 경량 증분 백업 도구\n\nSbackup는 여러 폴더의 백업 작업을 쉽게 관리합니다.\n증분 백업 전략을 사용하여 변경된 파일만 압축하고,\n사용자 정의 제외 규칙을 지원하며 직관적인 진행 피드백을 제공합니다.\n\n사용 가능한 명령:\n add 새 백업 전략 추가 (원본 -> 대상)\n rm 기존 백업 전략 삭제\n all 구성된 모든 백업 전략 표시\n save 백업 작업 실행\n version 버전 정보 표시", + "cli.epilog": "예제:\n 백업 전략 추가:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 7z 암호화 백업:\n sbackup --format 7z save --password 123456\n\n 복원:\n sbackup restore backup.7z F:/restored\n\n SFTP 원격 (자동 감지, 권장):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP 원격 (비밀번호):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP 원격 (키):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP 원격 (키 + 암호문):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp", + "cli.help.debug": "디버그 모드 (서브명령 뒤에 배치 가능)", + "cli.help.help": "이 도움말 메시지를 표시하고 종료", + "cli.help.lang": "인터페이스 언어 설정: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", + "cli.help.format": "패키징 형식 설정: zip (기본값) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.follow_symlinks": "Follow symbolic links (default: do not follow to avoid loops)", + "cli.help.incremental": "File-level incremental backup (only compress changed files, significantly reduces backup time)", + "cli.help.subcommands": "실행할 명령 선택", + "cli.help.add": "새 백업 전략 추가", + "cli.help.add.source": "원본 폴더 경로: 백업할 디렉토리", + "cli.help.add.dest": "대상 폴더 경로: 백업 파일이 저장될 위치", + "cli.help.add.ignore": "제외할 파일 또는 폴더 (쉼표로 구분, 기본값: .git,__pycache__)", + "cli.help.add.format": "항목별 패키징 형식 (이 전략만, 미지정시 전역 설정): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.add.name_template": "Per-entry filename template (supports {name} {date} {time} variables, uses global default if not specified)", + "cli.help.rm": "백업 전략 삭제", + "cli.help.rm.path": "삭제할 전략의 원본 폴더 경로", + "cli.help.all": "모든 백업 전략 표시", + "cli.help.save": "모든 백업 전략 실행", + "cli.help.save.keep": "최근 N개 백업 파일 유지, 0은 정리 비활성화", + "cli.help.save.password": "암호화 비밀번호 (7z 형식만 지원)", + "cli.help.watch": "정기적으로 백업 실행", + "cli.help.watch.interval": "백업 간격 (분), 기본값 60", + "cli.help.watch.keep": "최근 N개 백업 파일 유지", + "cli.help.watch.password": "암호화 비밀번호 (7z 형식만 지원)", + "cli.help.restore": "백업 파일에서 복원", + "cli.help.restore.file": "백업 파일 경로 (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", + "cli.help.restore.dir": "복원 대상 디렉토리", + "cli.help.restore.password": "복호화 비밀번호 (암호화된 7z 백업 복원 시 사용)", + "cli.help.restore.list": "압축 해제 없이 내용 보기", + "cli.help.list": "백업 기록 보기", + "cli.help.verify": "백업 파일 무결성 검증", + "cli.help.verify.file": "검증할 백업 파일", + "cli.help.verify.fast": "빠른 검증 (SHA256 비교, 압축 해제 불필요)", + "cli.help.verify.all": "모든 백업 기록 일괄 검증", + "cli.help.verify.detail": "상세 검증 정보 표시 (크기, 형식, 체크섬 등)", + "cli.help.verify.split": "분할 파일 무결성 검증 (매니페스트 파일 필요)", + "cli.help.verify.password": "복호화 비밀번호 (암호화된 백업용)", + "cmd.verify.fast_success": "빠른 검증 성공: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "빠른 검증 실패: {path}, 파일이 손상되었거나 변조되었을 수 있습니다", + "cmd.verify.no_checksum": "기록된 체크섬을 찾을 수 없어 전체 검증으로 대체: {path}", + "cmd.verify.checksum_mismatch": "체크섬 불일치", + "cmd.verify.no_history": "백업 기록을 찾을 수 없습니다.", + "cmd.verify.no_file": "백업 파일을 지정하거나 --all로 모든 백업을 검증하세요.", + "cmd.verify.detail_header": "=== 백업 상세: {path} ===", + "cmd.verify.detail_info": "크기: {size:.2f} MB | 파일 수: {files} | 형식: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ 검증 완료: {path}", + "cmd.verify.batch_failed": "✗ 실패: {path}", + "cmd.verify.batch_summary": "일괄 검증 완료: {success}개 성공, {fail}개 실패", + "cmd.verify.split_success": "분할 파일 무결성 검증 성공: {file} ({parts}개 파트)", + "cmd.verify.split_failed": "분할 파일 무결성 검증 실패: {file}", + "cmd.verify.split_error": "분할 파일 검증 오류: {error}", + "cli.help.export": "Export all backup strategies to a JSON file", + "cli.help.export.file": "Export file path (default: sbackup_export.json)", + "cli.help.import": "Import backup strategies from a JSON file", + "cli.help.import.file": "JSON file to import", + "cli.help.status": "View backup status dashboard", + "cmd.export.success": "Exported {count} backup strategies to {path}", + "cmd.import.success": "Imported {count} backup strategies, skipped {skipped} existing", + "cmd.import.empty": "No valid backup strategies found in: {path}", + "cli.help.save.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.save.verify": "Verify backup integrity after completion", + "cli.help.save.name_template": "Backup filename template (supports {name} {date} {time} variables)", + "cli.help.save.webhook": "POST backup results to this URL after completion", + "cli.help.save.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", + "cli.help.save.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", + "cli.help.save.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", + "cli.help.watch.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.watch.verify": "Verify backup integrity after each backup", + "cli.help.watch.name_template": "Backup filename template (supports {name} {date} {time} variables)", + "cli.help.watch.webhook": "POST backup results to this URL after each backup", + "cli.help.watch.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", + "cli.help.watch.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", + "cli.help.watch.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", + "cli.help.init": "기본 설정 파일 템플릿 생성", + "cli.help.rm.all": "모든 백업 전략 삭제", + "cli.help.edit": "기존 백업 전략 편집 (지정된 필드만 수정)", + "cli.help.edit.source": "소스 폴더 경로 (전략을 찾기 위한)", + "cli.help.edit.dest": "대상 폴더 경로 변경", + "cli.help.edit.ignore": "무시 패턴 변경 (쉼표로 구분)", + "cli.help.edit.format": "압축 형식 변경: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.edit.name_template": "파일명 템플릿 변경 ({name} {date} {time} 변수 지원)", + "cli.help.info": "백업 파일 상세 정보 보기 (형식, 크기, 파일 수, 체크섬 등)", + "cli.help.info.file": "백업 파일 경로", + "cli.help.info.password": "복호화 비밀번호 (암호화된 7z 백업용)", + "cmd.diff.header": "═══ 차이 비교 ═══\n백업 파일: {backup}", + "cmd.diff.added_header": "\n추가된 파일 ({count}):", + "cmd.diff.removed_header": "\n삭제된 파일 ({count}):", + "cmd.diff.modified_header": "\n수정 가능성이 있는 파일 ({count}):", + "cmd.diff.no_changes": "\n차이 없음, 소스가 백업과 일치합니다.", + "cmd.diff.summary": "\n차이 요약: +{added} 추가, -{removed} 삭제, ~{modified} 수정", + "cmd.diff.no_backup": "대상 디렉토리에서 백업 파일을 찾을 수 없습니다: {path}", + "cmd.diff.empty_backup": "백업 파일이 비어 있습니다: {path}", + "cli.help.diff": "소스 디렉토리와 최신 백업 비교", + "cli.help.diff.source": "소스 폴더 경로", + "cli.help.diff.file": "백업 파일 경로 (지정하지 않으면 최신 파일 자동 검색)", + "cli.help.diff.password": "복호화 비밀번호 (암호화된 백업의 경우)", + "cli.help.remote": "원격 백업 파일 관리 (SFTP 또는 WebDAV에서 파일 목록/삭제)", + "cli.help.remote.action": "원격 작업: list / rm", + "cli.help.remote.list": "원격 백업 파일 목록 표시", + "cli.help.remote.rm": "원격 백업 파일 삭제", + "cli.help.remote.rm.filename": "삭제할 원격 파일명", + "cli.help.remote.sftp": "SFTP 서버에서 작업", + "cli.help.remote.webdav": "WebDAV 서버에서 작업", + "cli.help.version": "버전 정보 표시", + "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nGNU GPL v3.0 라이선스 — https://www.gnu.org/licenses/gpl-3.0.html\n", + "log.config.malformed": "구성 파일 %s가 손상되었습니다, 기본값 사용", + "log.config.reset": "구성 파일 %s가 손상되었습니다, 재설정", + "log.config.mkdir.error": "구성 디렉토리 %s를 생성할 수 없습니다: %s", + "log.config.write.error": "구성 파일 %s를 쓸 수 없습니다: %s", + "log.data.read": "데이터 파일 읽기: %s", + "log.data.create": "데이터 파일이 없습니다, 생성: %s", + "log.data.load": "기존 데이터 파일 로드: %s", + "log.data.write": "데이터 파일 쓰기: %s", + "log.cleanup.delete": "오래된 백업 삭제됨: %s", + "log.i18n.load.failed": "로케일 {path} 로드 실패: {error}", + "log.i18n.load.fallback_failed": "폴백 로케일 {path} 로드 실패: {error}", + "err.sftp.auth": "SFTP 인증 실패: {host}, 사용자 이름과 비밀번호를 확인하세요", + "err.sftp.ssh": "SFTP 연결 오류: {error}", + "err.sftp.connect": "{host}:{port}에 연결할 수 없습니다: {error}", + "err.sftp.not_connected": "SFTP가 연결되지 않았습니다. 먼저 connect()를 호출하세요", + "err.sftp.local_not_found": "로컬 파일을 찾을 수 없습니다: {path}", + "err.sftp.upload": "업로드 실패 {path}: {error}", + "err.sftp.mkdir": "원격 디렉토리 생성 실패 {path}: {error}", + "err.sftp.not_configured": "SFTP 미구성, 실행: sbackup sftp config --host <주소> --user <사용자>", + "cmd.sftp.uploading": "업로드 중: {file}", + "cmd.sftp.progress": "업로드", + "cmd.sftp.success": "업로드 성공: {file}", + "cmd.sftp.config_saved": "SFTP 구성이 config.json에 저장되었습니다", + "cmd.sftp.testing": "{host}에 연결 테스트 중...", + "cmd.sftp.test_ok": "SFTP 연결 테스트 성공!", + "cmd.sftp.test_fail": "SFTP 연결 테스트 실패, 구성을 확인하세요", + "cli.help.save.sftp": "백업 후 SFTP 서버에 업로드", + "cli.help.watch.sftp": "매 백업 후 SFTP 서버에 업로드", + "cli.help.sftp": "SFTP 원격 백업 관리 (비밀번호/SSH키 지원)", + "cli.help.sftp.action": "SFTP 작업: config / test", + "cli.help.sftp.config": "SFTP 연결 매개변수 구성", + "cli.help.sftp.test": "SFTP 연결 테스트", + "cli.help.sftp.host": "SFTP 서버 주소 (예: 192.168.1.100)", + "cli.help.sftp.port": "SFTP 포트 (기본값 22)", + "cli.help.sftp.user": "SFTP 사용자 이름", + "cli.help.sftp.password": "SFTP 비밀번호 (비밀번호 인증만)", + "cli.help.sftp.remote_path": "원격 대상 경로 (기본: /)", + "cli.prompt.sftp.host": "SFTP 서버 주소:", + "cli.prompt.sftp.port": "SFTP 포트 (기본값 22):", + "cli.prompt.sftp.user": "SFTP 사용자 이름:", + "cli.prompt.sftp.password": "SFTP 비밀번호:", + "cli.prompt.sftp.remote_path": "원격 경로 (기본값 /):", + "err.sftp.key_load": "개인 키 {path}를 로드할 수 없습니다. 형식을 확인하세요 (RSA/Ed25519/ECDSA)", + "cli.help.sftp.key_file": "SSH 개인 키 경로 (RSA/Ed25519/ECDSA, 비밀번호보다 우선)", + "cli.help.sftp.key_passphrase": "개인 키 암호문 (대화형 입력 가능)", + "cli.prompt.sftp.key_file": "SSH 개인 키 경로 (비밀번호 사용 시 비워둠):", + "cli.prompt.sftp.key_passphrase": "개인 키 암호문 (건너뛰려면 Enter):", + "cmd.sftp.using_default_key": "기본 SSH 키 감지, 사용: {path}", + "cmd.sftp.no_default_key": "기본 SSH 키를 찾을 수 없습니다. 비밀번호 설정 또는 키 경로 지정", + "err.sftp.key_needs_passphrase": "키 {path}에 암호문이 필요, 입력 또는 --key-passphrase 사용", + "err.sftp.wrong_passphrase": "암호문 잘못됨, 다시 시도 (Enter로 키 건너뜀)", + "cli.help.webdav": "WebDAV 원격 백업 관리 (지안궈윤, NextCloud, Synology 등 지원)", + "cli.help.webdav.action": "WebDAV 작업: config / test", + "cli.help.webdav.config": "WebDAV 연결 매개변수 구성", + "cli.help.webdav.test": "WebDAV 연결 테스트", + "cli.help.webdav.url": "WebDAV 서버 URL (예: https://dav.jianguoyun.com/dav/)", + "cli.help.webdav.user": "WebDAV 사용자 이름", + "cli.help.webdav.password": "WebDAV 비밀번호", + "cli.help.webdav.remote_path": "원격 대상 경로 (기본: /)", + "cli.help.save.webdav": "백업 후 WebDAV 서버에 업로드", + "cli.help.watch.webdav": "각 백업 후 WebDAV 서버에 업로드", + "cli.prompt.webdav.url": "WebDAV 서버 URL:", + "cli.prompt.webdav.user": "WebDAV 사용자 이름:", + "cli.prompt.webdav.password": "WebDAV 비밀번호:", + "cli.prompt.webdav.remote_path": "원격 경로 (기본 /):", + "cmd.webdav.config_saved": "WebDAV 구성이 config.json에 저장되었습니다", + "cmd.webdav.testing": "WebDAV {url}에 연결 테스트 중...", + "cmd.webdav.test_ok": "WebDAV 연결 테스트 성공!", + "cmd.webdav.uploading": "WebDAV에 업로드 중: {file}", + "cmd.webdav.success": "WebDAV 업로드 성공: {file}", + "err.webdav.auth": "WebDAV 인증 실패: {host}", + "err.webdav.connect": "WebDAV에 연결할 수 없습니다: {url}, 오류: {error}", + "err.webdav.ssh": "WebDAV 연결 오류: {error}", + "err.webdav.not_configured": "WebDAV 미구성, 실행하세요: sbackup webdav config --url <주소> --user <사용자이름>", + "err.webdav.local_not_found": "로컬 파일을 찾을 수 없습니다: {path}", + "err.webdav.upload": "WebDAV 업로드 실패 {path}: {error}", + "err.webdav.mkdir": "WebDAV 원격 디렉토리 생성 실패 {path}: {error}", + "cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)", + "cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)", + "cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)", + "log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)", + "log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)", + "log.monitor.source_missing": "Monitor source directory not found, skipping: {path}", + "log.monitor.watching": "Now monitoring: {path}", + "log.monitor.no_sources": "No valid source directories to monitor, monitor not started", + "benchmark.empty_source": "소스 디렉토리가 비어 있습니다: {path}", + "benchmark.error": "벤치마크 실패 {format}: {error}", + "benchmark.header": "═══ 압축 벤치마크 ═══", + "benchmark.recommended": "추천: {format} 레벨={level} ({size}, 최적 균형)", + "benchmark.running": "{format} 레벨={level} 테스트 중...", + "benchmark.source_info": "소스 디렉토리: {path} ({size}, {count}개 파일)", + "cli.help.add.from_gitignore": ".gitignore 파일에서 무시 규칙 가져오기", + "cli.help.audit": "백업 감사 로그 조회", + "cli.help.audit.action": "감사 작업: log(조회) / stats(통계) / cleanup(정리)", + "cli.help.audit.cleanup": "오래된 감사 로그 항목 정리", + "cli.help.audit.cleanup.keep_days": "최근 N일간의 항목 유지 (기본값: 90)", + "cli.help.audit.log": "감사 로그 항목 조회", + "cli.help.audit.log.event": "이벤트 유형별 필터링", + "cli.help.audit.log.limit": "최대 표시 항목 수 (기본값: 50)", + "cli.help.audit.log.since": "이 날짜 이후의 항목 표시 (ISO 8601 형식)", + "cli.help.audit.log.status": "상태별 필터링: success / failed / partial", + "cli.help.audit.stats": "감사 로그 통계 표시", + "cli.help.benchmark": "지정된 디렉토리에서 압축 벤치마크 실행, 다양한 형식의 성능 비교", + "cli.help.benchmark.levels": "테스트할 압축 수준 (쉼표 구분, 기본값: 1,6,9)", + "cli.help.benchmark.password": "암호화 비밀번호 (7z 형식용)", + "cli.help.benchmark.path": "테스트할 소스 디렉토리 경로", + "cli.help.benchmark.quick": "빠른 모드: 각 형식의 기본 수준만 테스트", + "cli.help.checksum": "파일 내용 SHA256 체크섬으로 변경 감지 (mtime보다 정밀하지만 느림)", + "cli.help.clean": "모든 전략의 백업 파일 스마트 정리", + "cli.help.clean.dry_run": "삭제할 파일 미리보기, 실제로 삭제하지 않음", + "cli.help.clean.keep": "각 전략에서 최근 N개 백업 파일 유지", + "cli.help.clean.keep_days": "N일 이전의 백업 파일 삭제", + "cli.help.completion": "셸 자동 완성 스크립트 생성", + "cli.help.completion.shell": "셸 유형: bash(기본값) / zsh / fish / powershell", + "cli.help.compress": "지정된 폴더 직접 압축 (전략 미사용)", + "cli.help.compress.dry_run": "압축할 파일 미리보기, 실제로 실행하지 않음", + "cli.help.compress.format": "압축 형식: zip(기본값) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.compress.output": "출력 파일 경로 (선택사항)", + "cli.help.compress.password": "암호화 비밀번호 (7z 형식만 지원)", + "cli.help.compress.source": "압축할 소스 폴더 경로", + "cli.help.config_cmd": "설정 파일 관리 (민감한 필드 암호화/복호화)", + "cli.help.config_cmd.action": "작업: lock(암호화) / unlock(복호화)", + "cli.help.config_cmd.lock": "마스터 비밀번호로 설정 파일의 민감한 필드 암호화", + "cli.help.config_cmd.lock.password": "마스터 비밀번호 (미지정 시 대화형 입력)", + "cli.help.config_cmd.unlock": "마스터 비밀번호로 설정 파일의 민감한 필드 복호화", + "cli.help.config_cmd.unlock.password": "마스터 비밀번호 (미지정 시 대화형 입력)", + "cli.help.config_cmd.validate": "현재 설정 검증 및 모든 오류와 경고 표시", + "cli.help.dedup": "전략 간 콘텐츠 해시(SHA256) 기반 파일 수준 중복 제거 활성화", + "cli.help.diff.detail": "수정된 파일의 줄 수준 차이 표시", + "cli.help.diskcheck": "백업 전 디스크 공간 확인 (소스 디렉토리 크기와 백업 파일 크기 추정)", + "cli.help.diskcheck.level": "압축 수준 (0-9, 기본값: 6)", + "cli.help.diskcheck.source": "스캔할 소스 디렉토리 경로", + "cli.help.diskcheck.target": "대상 디렉토리 경로 (백업 파일 저장 위치)", + "cli.help.hooks": "백업 전/후 훅 관리 및 수동 실행", + "cli.help.hooks.action": "훅 작업: run(실행)", + "cli.help.hooks.run": "백업 전 또는 후 훅 수동 실행", + "cli.help.hooks.run.timeout": "훅 타임아웃(초) 재정의 (기본값: 설정값 사용)", + "cli.help.hooks.run.type": "실행할 훅 유형: pre(전) 또는 post(후)", + "cli.help.ignore": ".sbackupignore 제외 규칙 파일 생성", + "cli.help.ignore.list": "사용 가능한 모든 프리셋 목록", + "cli.help.ignore.output": "출력 파일 경로 (기본값: .sbackupignore)", + "cli.help.ignore.preset": "프리셋 템플릿: node / python / go / rust / java / general (기본값: general)", + "cli.help.integrity": "데이터 무결성 검증 (체크리스트 생성/검증)", + "cli.help.integrity.action": "무결성 작업: generate(생성) / verify(검증)", + "cli.help.integrity.generate": "백업 디렉토리의 integrity.json 체크리스트 생성", + "cli.help.integrity.path": "백업 디렉토리 경로", + "cli.help.integrity.verify": "integrity.json 체크리스트로 백업 파일 무결성 검증", + "cli.help.list.tags": "모든 백업 태그 목록", + "cli.help.metadata_export": "백업 메타데이터(이력/감사 로그)를 파일로 내보내기", + "cli.help.metadata_export.action": "내보내기 작업: history(이력) / audit(감사) / all(통합)", + "cli.help.metadata_export.all": "통합 보고서 내보내기 (이력 + 감사 + 통계)", + "cli.help.metadata_export.audit": "감사 로그를 CSV 또는 JSON으로 내보내기", + "cli.help.metadata_export.event": "이벤트 유형별 필터링", + "cli.help.metadata_export.format": "출력 형식: csv (history/audit 기본값) 또는 json", + "cli.help.metadata_export.history": "백업 이력을 CSV 또는 JSON으로 내보내기", + "cli.help.metadata_export.output": "출력 파일 경로", + "cli.help.metadata_export.strategy": "전략 이름별 필터링 (source 경로 매칭)", + "cli.help.multi_backup": "다중 대상 동시 백업: 로컬 백업 + 활성화된 모든 원격 대상에 병렬 업로드", + "cli.help.multi_backup.dry_run": "백업할 파일 미리보기, 실제로 실행하지 않음", + "cli.help.multi_backup.keep": "최근 N개 백업 파일 유지 (0은 정리 안 함)", + "cli.help.multi_backup.keep_days": "최근 N일간의 백업 파일 유지", + "cli.help.multi_backup.local_target": "로컬 백업 대상 폴더 경로", + "cli.help.multi_backup.name_template": "백업 파일명 템플릿", + "cli.help.multi_backup.password": "암호화 비밀번호 (7z 형식만 지원)", + "cli.help.multi_backup.source": "백업할 소스 폴더 경로", + "cli.help.multi_backup.verify": "백업 후 자동 무결성 검증", + "cli.help.profile": "설정 프로필 관리", + "cli.help.profile.action": "작업: list(목록) / create(생성) / delete(삭제) / use(사용) / export(내보내기) / import(가져오기)", + "cli.help.profile.create.name": "현재 설정에서 프로필을 생성할 이름", + "cli.help.profile.delete.name": "삭제할 프로필 이름", + "cli.help.profile.export.name": "내보낼 프로필 이름", + "cli.help.profile.export.path": "내보내기 파일 경로", + "cli.help.profile.import.name": "프로필 이름 (기본값: 파일명 사용)", + "cli.help.profile.import.path": "가져오기 파일 경로", + "cli.help.profile.use.name": "임시로 사용할 프로필 이름", + "cli.help.profile_name": "모든 명령에서 지정된 프로필 사용", + "cli.help.remote.list_path": "원격 디렉토리 경로 지정 (미지정 시 설정된 기본 경로 사용)", + "cli.help.report": "Markdown 형식의 백업 보고서 생성", + "cli.help.report.format": "보고서 형식: text(텍스트) 또는 html(웹페이지)", + "cli.help.report.output": "출력 파일 경로 (미지정 시 터미널에 출력)", + "cli.help.restore.search": "백업 파일 내에서 파일명 검색 (대소문자 구분 없음)", + "cli.help.restore.select": "선택적 복원: 패턴과 일치하는 파일만 추출 (glob 와일드카드 지원, 예: \"*.py\" 또는 \"src/main.py\")", + "cli.help.restore.stats": "백업 파일 통계 표시 (파일 수, 크기, 형식 분포)", + "cli.help.restore.tag": "태그로 지정된 백업 복원", + "cli.help.rotate": "보존 정책에 따라 백업 파일 회전 정리", + "cli.help.rotate.backup_dir": "백업 파일이 포함된 디렉토리 경로", + "cli.help.rotate.dry_run": "삭제할 파일 미리보기, 실제로 삭제하지 않음", + "cli.help.rotate.keep_count": "최근 N개 백업 파일 유지 (0 = 무제한)", + "cli.help.rotate.keep_daily": "최근 N일간 매일 하나의 백업 유지 (0 = 무제한)", + "cli.help.rotate.keep_days": "최근 N일간의 백업 파일 유지 (0 = 무제한)", + "cli.help.rotate_list": "디렉토리의 모든 백업 파일 목록", + "cli.help.rotate_list.backup_dir": "스캔할 백업 파일 디렉토리", + "cli.help.save.keep_days": "최근 N일간의 백업 파일 유지, 0은 시간 기반 정리 안 함 (기본값: 0)", + "cli.help.save.multi_dest": "다중 대상 동시 백업 모드 활성화", + "cli.help.save.post_hook": "백업 후 실행할 명령 (여러 번 사용 가능)", + "cli.help.save.pre_hook": "백업 전 실행할 명령 (여러 번 사용 가능)", + "cli.help.save.split": "백업 파일 분할 크기 (예: 100MB, 1GB), 이 크기를 초과하면 자동 분할", + "cli.help.save.tag": "이번 백업에 태그 추가 (예: daily, release)", + "cli.help.schedule": "예약 스케줄 설정 관리", + "cli.help.schedule.action": "스케줄 작업: export(설정 내보내기) / install(서비스 설치)", + "cli.help.schedule.export": "예약 스케줄 설정 파일 내보내기", + "cli.help.schedule.install": "예약 백업을 시스템 서비스로 설치", + "cli.help.schedule.install.interval": "백업 간격 (분, 기본값: 60)", + "cli.help.schedule.install.type": "서비스 유형: systemd / schtasks / launchd", + "cli.help.schedule.install.user": "서비스 실행 사용자 (기본값: 현재 사용자)", + "cli.help.schedule.interval": "백업 간격 (분, 기본값: 60)", + "cli.help.schedule.output": "출력 파일 경로 (미지정 시 터미널에 출력)", + "cli.help.schedule.type": "스케줄 유형: systemd / crontab (기본값: systemd)", + "cli.help.schedule.type.schtasks": "Windows 예약 작업", + "cli.help.search": "백업 파일에서 파일명 검색", + "cli.help.search.in": "백업 파일 경로 지정 (미지정 시 모든 전략의 최신 백업 검색)", + "cli.help.search.password": "복호화 비밀번호 (암호화된 백업 검색 시 사용)", + "cli.help.search.pattern": "검색 패턴 (glob 와일드카드 지원, 예: *.txt)", + "cli.help.strict": "디스크 공간 부족 시 백업 중단 (미지정 시 경고만 표시)", + "cli.help.task": "작업 큐 관리 (백업 작업 추가/실행/취소)", + "cli.help.task.action": "작업: add(추가) / list(목록) / run(실행) / run-all(모두 실행) / cancel(취소) / clear(비우기) / stats(통계)", + "cli.help.task.add": "큐에 새 백업 작업 추가", + "cli.help.task.add.folder": "백업할 폴더 경로", + "cli.help.task.add.format": "압축 형식: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.task.add.name": "작업 이름 (식별용)", + "cli.help.task.add.output": "출력 파일 경로 (선택사항)", + "cli.help.task.cancel": "대기 중인 작업 취소", + "cli.help.task.cancel.id": "취소할 작업 ID", + "cli.help.task.clear": "큐에서 완료 및 실패한 작업 정리", + "cli.help.task.list": "큐의 작업 목록", + "cli.help.task.list.status": "상태별 필터링: pending / running / completed / failed", + "cli.help.task.run": "큐에서 다음 대기 작업 실행", + "cli.help.task.run_all": "모든 대기 작업 순차 실행", + "cli.help.task.stats": "작업 큐 통계 표시", + "cli.help.versions": "백업 버전 이력 조회 (소스 디렉토리별 그룹화)", + "cli.help.versions.source": "소스 디렉토리 경로 지정 (미지정 시 전체 표시)", + "cli.help.versions.tag": "태그별 버전 필터링", + "cli.help.watch.keep_days": "최근 N일간의 백업 파일 유지 (기본값: 0)", + "cli.help.watch.post_hook": "백업 후 실행할 명령 (여러 번 사용 가능)", + "cli.help.watch.pre_hook": "백업 전 실행할 명령 (여러 번 사용 가능)", + "cli.help.watch.split": "백업 파일 분할 크기 (예: 100MB, 1GB)", + "cli.help.watch.tag": "이번 백업에 태그 추가", + "cli.help.webhook_cmd": "Webhook 알림 관리", + "cli.help.webhook_cmd.action": "Webhook 작업: preset(프리셋 설정) / list(프리셋 목록)", + "cli.help.webhook_cmd.list": "사용 가능한 모든 IM 알림 프리셋 목록", + "cli.help.webhook_cmd.preset": "IM 알림 프리셋 템플릿 설정", + "cli.help.webhook_cmd.preset.name": "프리셋 이름: dingtalk(DingTalk) / feishu(Feishu) / wechat(WeCom)", + "cli.help.wizard": "대화형 설정 마법사, 초기 설정 안내", + "cli.help.xsearch": "크로스 아카이브 검색: 여러 백업 파일에서 파일명 검색", + "cli.help.xsearch.dirs": "백업 파일을 재귀 검색할 디렉토리 목록", + "cli.help.xsearch.ext": "확장자별 검색 (예: .log, .py)", + "cli.help.xsearch.keyword": "검색 키워드 (대소문자 구분 없음, 파일명 매칭)", + "cli.help.xsearch.output_lang": "출력 언어: zh_CN(중국어) / en_US(영어)", + "cli.help.xsearch.password": "복호화 비밀번호 (암호화된 백업 검색 시 사용)", + "cli.help.xsearch.pattern": "Glob 패턴 파일명 매칭 (예: \"*.log\")", + "cmd.add.gitignore_empty": "{path}에서 가져온 규칙이 없습니다", + "cmd.add.gitignore_imported": "{path}에서 무시 규칙 {count}개를 가져왔습니다", + "cmd.audit.cleanup.done": "오래된 감사 로그 {count}개를 정리했습니다.", + "cmd.audit.cleanup.none": "정리할 오래된 기록이 없습니다.", + "cmd.clean.dry_run_header": "미리보기 모드 (실제로 삭제하지 않음):", + "cmd.clean.no_criteria": "정리 조건을 지정하세요: --keep N 또는 --keep-days N", + "cmd.clean.nothing": "정리할 백업 파일이 없습니다.", + "cmd.clean.summary": "총 {count}개의 백업 파일을 정리했습니다.", + "cmd.config.already_locked": "설정 파일이 이미 암호화되어 있습니다", + "cmd.config.enter_password": "마스터 비밀번호를 입력하세요", + "cmd.config.lock_failed": "암호화 실패", + "cmd.config.locked": "설정 파일이 암호화되었습니다 (민감한 필드 보호됨)", + "cmd.config.no_password": "비밀번호는 비워둘 수 없습니다", + "cmd.config.not_locked": "설정 파일이 암호화되지 않았습니다", + "cmd.config.unlocked": "설정 파일이 복호화되었습니다", + "cmd.config.validate.errors": "발견된 오류:", + "cmd.config.validate.failed": "설정 검증 실패, 위의 오류를 수정하세요.", + "cmd.config.validate.passed": "설정 검증 통과, 문제가 발견되지 않았습니다.", + "cmd.config.validate.passed_with_warnings": "설정 검증 통과 (경고 존재).", + "cmd.config.validate.warnings": "발견된 경고:", + "cmd.config.wrong_password": "비밀번호 오류, 복호화 실패", + "cmd.dedup.saved": "중복 제거: 중복 파일 {count}개 건너뜀, {saved:.2f} MB 절약", + "cmd.diff.detail_header": "--- 줄 수준 차이 ---", + "cmd.diff.no_detail": "(바이너리 파일 또는 읽을 수 없음)", + "cmd.hook.failed": "훅 실행 실패: {command} — {error}", + "cmd.hook.post_running": "후 훅 실행 중: {command}", + "cmd.hook.pre_running": "전 훅 실행 중: {command}", + "cmd.hooks.no_hooks": "{type} 훅이 설정되지 않았습니다.", + "cmd.hooks.no_results": "훅 실행 결과가 없습니다.", + "cmd.hooks.result_line": "[{status}] {command} (반환코드={return_code}, 소요{duration}초)", + "cmd.hooks.summary": "총 {total}개, 성공 {success}개, 실패 {fail}개", + "cmd.ignore.available_presets": "사용 가능한 프리셋:", + "cmd.ignore.cancelled": "취소되었습니다.", + "cmd.ignore.generated": "{preset} 프리셋의 제외 규칙이 생성되었습니다: {path}", + "cmd.ignore.overwrite": "파일 {path}이(가) 이미 존재합니다. 덮어쓰시겠습니까? (y/N): ", + "cmd.integrity.generate.not_dir": "오류: {path}는 디렉토리가 아닙니다", + "cmd.integrity.generate.success": "무결성 체크리스트가 생성되었습니다: {path}", + "cmd.integrity.verify.failed": "무결성 검증 실패: {path}", + "cmd.integrity.verify.success": "무결성 검증 통과: {path}", + "cmd.list.tags.empty": "태그가 없습니다", + "cmd.list.tags.header": "백업 태그:", + "cmd.metadata_export.audit_done": "감사 로그 {count}개를 {path}에 내보냈습니다", + "cmd.metadata_export.combined_done": "통합 보고서({count}개 기록)를 {path}에 내보냈습니다", + "cmd.metadata_export.history_done": "이력 {count}개를 {path}에 내보냈습니다", + "cmd.profile.activated": "프로필 '{name}'이(가) 활성화되었습니다.", + "cmd.profile.created": "프로필 '{name}'이(가) 생성되었습니다.", + "cmd.profile.deleted": "프로필 '{name}'이(가) 삭제되었습니다.", + "cmd.profile.exported": "프로필 '{name}'이(가) 내보내졌습니다: {path}", + "cmd.profile.import_failed": "{path}에서 프로필을 가져오지 못했습니다.", + "cmd.profile.imported": "프로필 '{name}'이(가) 가져와졌습니다.", + "cmd.profile.list.detail": " {fields}", + "cmd.profile.list.empty": "설정된 프로필이 없습니다.", + "cmd.profile.list.header": "설정 프로필 목록:", + "cmd.profile.list.item": " {name}", + "cmd.profile.not_found": "프로필 '{name}'이(가) 존재하지 않습니다.", + "cmd.remote.deleted": "원격 파일 삭제됨: {path}", + "cmd.remote.empty": "원격 디렉토리가 비어 있습니다: {path}", + "cmd.remote.list_header": "원격 디렉토리: {path} ({count}개 파일)", + "cmd.remote.rm_requires_path": "삭제할 원격 파일명을 지정하세요", + "cmd.remote.select_protocol": "프로토콜을 지정하세요: --sftp 또는 --webdav", + "cmd.report.saved": "백업 보고서가 저장되었습니다: {path}", + "cmd.restore.search.found": "일치하는 파일 {count}개를 찾았습니다:", + "cmd.restore.search.header": "{path}에서 '{keyword}' 검색 결과:", + "cmd.restore.search.no_results": "{path}에서 '{keyword}'과(와) 일치하는 파일을 찾지 못했습니다", + "cmd.restore.selective.header": "선택적 복원: {patterns}과(와) 일치하는 파일 {count}개 추출", + "cmd.restore.selective.success": "파일 {count}개를 {target}에 추출했습니다", + "cmd.restore.stats.dirs": "디렉토리 수: {count}", + "cmd.restore.stats.files": "파일 수: {count}", + "cmd.restore.stats.format_item": " {ext}: {count}개 파일", + "cmd.restore.stats.formats": "파일 유형 분포:", + "cmd.restore.stats.header": "═══ 백업 파일 통계: {path} ═══", + "cmd.restore.stats.size": "총 크기: {size} 바이트", + "cmd.restore.tag_not_found": "태그 '{tag}'에 해당하는 백업을 찾지 못했습니다", + "cmd.rotate.auto_done": "자동 회전 완료: {deleted}개 파일 삭제됨.", + "cmd.rotate.auto_title": "{path}의 백업을 자동 회전 중...", + "cmd.rotate.delete_item": " - {name} {size}", + "cmd.rotate.done": "백업 파일 {count}개를 회전 정리했습니다.", + "cmd.rotate.dry_run_header": "미리보기 모드 (실제로 삭제하지 않음):", + "cmd.rotate.dry_run_item": " ~ {name} {size} {time}", + "cmd.rotate.header": "═══ 백업 회전 ═══", + "cmd.rotate.no_action": "회전할 파일이 없습니다.", + "cmd.rotate.plan_delete": "{count}개 파일 삭제 예정:", + "cmd.rotate.plan_keep": "{count}개 파일 유지 예정:", + "cmd.rotate.scan_empty": "{path}에서 백업 파일을 찾지 못했습니다.", + "cmd.rotate.scan_found": "{path}에서 백업 파일 {count}개를 찾았습니다:", + "cmd.rotate.scan_item": " {name} {size} {time}", + "cmd.schedule.exported": "스케줄 설정이 내보내졌습니다: {path} ({type})", + "cmd.schedule.install.launchd": "plist 파일 저장 후 실행:", + "cmd.schedule.install.schtasks_cmd": "관리자 권한으로 다음 명령을 실행하여 예약 작업을 생성하세요:\n {cmdline}", + "cmd.schedule.install.systemd": "root 권한으로 다음 명령을 실행하여 systemd 타이머를 설치하세요:", + "cmd.schedule.schtasks.generated": "Windows 예약 작업 명령이 생성되었습니다", + "cmd.schedule.unknown_type": "알 수 없는 스케줄 유형: {type}", + "cmd.search.no_results": "{path}에서 '{pattern}'과(와) 일치하는 파일을 찾지 못했습니다", + "cmd.search.no_results_global": "모든 백업에서 '{pattern}'과(와) 일치하는 파일을 찾지 못했습니다", + "cmd.search.results": "{path}에서 일치하는 파일 {count}개를 찾았습니다:", + "cmd.sftp.parallel_upload": "파일 {count}개 병렬 업로드 중...", + "cmd.upload.parallel": "SFTP와 WebDAV에 병렬 업로드 중...", + "cmd.versions.empty": "백업 버전 기록이 없습니다.", + "cmd.versions.header": "═══ 백업 버전 이력 ═══", + "cmd.versions.source": "📁 {source}", + "cmd.versions.total": "총 {count}개 버전, {sources}개 소스 디렉토리", + "cmd.webdav.parallel_upload": "WebDAV에 파일 {count}개 병렬 업로드 중...", + "cmd.webhook.available_presets": "사용 가능한 IM 알림 프리셋:", + "cmd.webhook.preset_set": "{name} 알림 템플릿이 설정되었습니다:\n{template}", + "cmd.webhook.preset_unknown": "알 수 없는 프리셋: {name}", + "cmd.wizard.choice": "옵션을 입력하세요:", + "cmd.wizard.cloud_done": "클라우드 저장소 설정이 완료되었습니다.", + "cmd.wizard.cloud_info": "S3 호환 클라우드 저장소 (AWS S3, MinIO, Backblaze, Tencent COS):", + "cmd.wizard.complete": "sbackup 설정이 완료되었습니다!", + "cmd.wizard.dest": "백업 파일 대상 폴더 경로:", + "cmd.wizard.email_done": "이메일 알림이 설정되었습니다.", + "cmd.wizard.email_yn": "이메일 알림을 설정하시겠습니까? (y/N):", + "cmd.wizard.host": "서버 주소:", + "cmd.wizard.ignore": "무시할 패턴 (쉼표 구분, 기본값: .git,__pycache__):", + "cmd.wizard.lang_set": "언어가 {lang}(으)로 설정되었습니다", + "cmd.wizard.more": "계속 추가하시겠습니까? (y/N):", + "cmd.wizard.password": "비밀번호:", + "cmd.wizard.port": "포트:", + "cmd.wizard.remote_path": "원격 경로:", + "cmd.wizard.schedule_info": "다음 명령을 실행하여 예약 백업을 시작하세요 (매 {interval}분):", + "cmd.wizard.schedule_interval": "백업 간격 (분, 기본값: 60):", + "cmd.wizard.schedule_yn": "예약 백업을 설정하시겠습니까? (y/N):", + "cmd.wizard.sftp_done": "SFTP 설정이 완료되었습니다.", + "cmd.wizard.skip": "건너뛰기", + "cmd.wizard.source": "백업할 소스 폴더 경로:", + "cmd.wizard.step_email": "단계 4/5: 이메일 알림 (선택사항)", + "cmd.wizard.step_lang": "단계 1/5: 인터페이스 언어 선택", + "cmd.wizard.step_remote": "단계 3/5: 원격 저장소 설정 (선택사항)", + "cmd.wizard.step_schedule": "단계 5/5: 예약 백업 (선택사항)", + "cmd.wizard.step_strategy": "단계 2/5: 백업 전략 추가", + "cmd.wizard.summary": "백업 전략 {strategies}개가 추가되었습니다.\n'sbackup save'를 실행하여 즉시 백업하세요.", + "cmd.wizard.user": "사용자 이름:", + "cmd.wizard.webdav_done": "WebDAV 설정이 완료되었습니다.", + "cmd.wizard.welcome": "sbackup 설정 마법사에 오신 것을 환영합니다!\n이 마법사가 초기 설정을 안내합니다.", + "cmd.xsearch.no_query": "검색 조건을 지정하세요: --keyword, --pattern 또는 --ext", + "diskcheck.disk_free": " 여유 공간: {size}", + "diskcheck.disk_header": "대상 디스크:", + "diskcheck.disk_total": " 총 공간: {size}", + "diskcheck.disk_used": " 사용 중: {size}", + "diskcheck.estimated_size": "예상 백업 크기: {size} (압축률: {ratio})", + "diskcheck.header": "═══ 디스크 공간 확인 ═══", + "diskcheck.insufficient": "디스크 공간 부족: 필요 {needed}, 사용 가능 {available}, 부족 {missing}", + "diskcheck.source": "소스 디렉토리: {path}", + "diskcheck.source_size": "소스 파일 총 크기: {size}", + "diskcheck.status.insufficient": "공간 확인: 부족!\n 예상 백업 필요: {needed}\n 사용 가능 공간: {available}\n 부족: {missing}", + "diskcheck.status.ok": "공간 확인: 충분 (여유 {margin})", + "diskcheck.target": "대상 경로: {path}", + "dryrun.and_others": "기타", + "dryrun.excluded_files": "제외된 파일", + "dryrun.excluded_files_list": "제외된 파일 목록", + "dryrun.exclusion_reasons": "제외 사유", + "dryrun.file_types": "파일 유형", + "dryrun.included_files": "포함된 파일", + "dryrun.included_files_list": "포함된 파일 목록", + "dryrun.largest_files": "최대 파일", + "dryrun.more_files": "... 외 {count}개 파일", + "dryrun.no_extension": "(확장자 없음)", + "dryrun.reason.excluded": "제외 규칙 매칭", + "dryrun.reason.not_included": "포함 규칙 미매칭", + "dryrun.reason.too_large": "최대 파일 크기 초과", + "dryrun.reason.too_small": "최소 파일 크기 미달", + "dryrun.scanned_dir": "스캔된 디렉토리", + "dryrun.skip": "건너뜀", + "dryrun.title": "Dry-run 미리보기", + "dryrun.total_files": "총 파일 수", + "dryrun.total_size": "총 크기", + "dryrun.unit_files": "개 파일", + "dryrun.warning.disk_space": "디스크 공간 부족: 여유 {free}, 필요 {need}", + "dryrun.warning.not_dir": "유효한 디렉토리가 아닌 경로: {path}", + "dryrun.warning.os_error": "시스템 오류: {error}", + "dryrun.warning.permission": "권한 부족, 접근 불가: {path}", + "dryrun.warnings": "경고", + "err.invalid_filename": "유효하지 않은 파일명: {filename} (경로 구분자 및 .. 시퀀스 허용 안 됨)", + "err.lock.conflict": "오류: 다른 백업 작업이 실행 중입니다 (잠금 파일 존재). 다른 프로세스가 없다고 확인되면 잠금 파일을 수동으로 삭제하세요.", + "err.sftp.delete": "원격 파일 삭제 실패 {path}: {error}", + "err.sftp.host_key_mismatch": "호스트 {host}의 키가 known_hosts와 일치하지 않습니다 (중간자 공격 가능성)", + "err.sftp.mkdir_max_depth": "원격 디렉토리 계층이 너무 깊거나 순환 구조가 있습니다: {path}", + "err.sftp.no_host_key": "호스트 {host}의 키를 가져올 수 없습니다", + "err.sftp.remote_not_found": "원격 파일이 존재하지 않습니다: {path}", + "err.webdav.delete": "원격 파일 삭제 실패 {path}: {error}", + "err.webdav.invalid_scheme": "지원하지 않는 URL 프로토콜 '{scheme}', http와 https만 허용됩니다.", + "err.webdav.list": "원격 파일 목록 조회 실패 {path}: {error}", + "err.webdav.not_found": "원격 파일이 존재하지 않습니다: {path}", + "err.webdav.private_ip": "사설 IP 주소로의 연결이 차단되었습니다: {host}", + "err.webdav.xml_too_large": "WebDAV 응답이 너무 큽니다 ({size} 바이트), XML 폭탄 공격 가능성", + "log.rotation.delete_error": "삭제 실패 %s: %s", + "log.rotation.deleted": "삭제됨: %s (%.2f MB)", + "log.rotation.dry_run_skip": "[dry-run] 삭제 예정: %s (%.2f MB)", + "multi_dest.aborted": "다중 대상 백업 중단: 로컬 백업 실패", + "multi_dest.complete": "다중 대상 백업 완료", + "multi_dest.local.failed": "로컬 백업 실패", + "multi_dest.local.success": "로컬 백업 완료: {path}", + "multi_dest.started": "다중 대상 백업 시작: {source} -> {targets}", + "multi_dest.uploading": "{count}개 원격 대상에 병렬 업로드 중...", + "restore.no_match": "\"{pattern}\"과(와) 일치하는 파일이 없습니다", + "restore.split.detected": "분할 백업 감지됨, 총 {count}개 파트", + "restore.split.merged": "분할 파일 병합 완료: {path}", + "restore.split.missing": "오류: 분할 파일 누락, 예상 경로 {path}", + "task_queue.added": "작업 추가됨: {name} (ID: {id})", + "task_queue.cancelled": "사용자 취소", + "task_queue.cancelled.fail": "작업을 취소할 수 없습니다: 찾을 수 없거나 대기 상태가 아님", + "task_queue.cancelled.success": "작업 취소됨: {id}", + "task_queue.clear.done": "큐에서 작업 {count}개를 정리했습니다.", + "task_queue.clear.none": "정리할 완료 또는 실패한 작업이 없습니다.", + "task_queue.list.empty": "작업 큐가 비어 있습니다.", + "task_queue.list.header": "═══ 작업 큐 ({count}개 작업) ═══", + "task_queue.list.item": " [{status}] {name} (ID: {id})", + "task_queue.list.item_detail": " 폴더: {folder}", + "task_queue.list.item_error": " 오류: {error}", + "task_queue.list.item_result": " 결과: {result}", + "task_queue.list.item_time": " 생성 시간: {created}", + "task_queue.run.failed": "작업 실행 실패: {name} — {error}", + "task_queue.run.none": "큐에 대기 중인 작업이 없습니다.", + "task_queue.run.success": "작업 실행 성공: {name} (결과: {result})", + "task_queue.run_all.done": "총 {count}개 작업 실행, {success}개 성공, {failed}개 실패.", + "task_queue.stats.completed": "완료: {completed}", + "task_queue.stats.failed": "실패: {failed}", + "task_queue.stats.header": "═══ 작업 큐 통계 ═══", + "task_queue.stats.pending": "대기: {pending}", + "task_queue.stats.running": "실행 중: {running}", + "task_queue.stats.total": "전체: {total}", + "warn.password_in_cli": "경고: {arg}을(를) 통해 전달된 비밀번호가 프로세스 목록에 노출됩니다. 대화형 입력을 권장합니다.", + "warn.sftp.no_known_hosts": "경고: known_hosts 파일을 찾을 수 없습니다. {host}의 호스트 키를 검증할 수 없으며 연결이 불안전할 수 있습니다", + "warn.webdav.http_plaintext": "경고: 평문 HTTP로 WebDAV에 연결합니다 ({url}). 인증 정보가 평문으로 전송됩니다. HTTPS 사용을 권장합니다." +} diff --git a/sbackup/locales/pt_BR.json b/sbackup/locales/pt_BR.json index 227d2cb..56c514c 100644 --- a/sbackup/locales/pt_BR.json +++ b/sbackup/locales/pt_BR.json @@ -1,80 +1,667 @@ -{ - "cmd.add.success": "Estratégia de backup adicionada com sucesso: {source} -> {dest}", - "cmd.rm.success": "Estratégia de backup removida com sucesso: {path}", - "err.folder.invalid": "Erro: {path} não é uma pasta válida ou não existe.", - "err.dest.invalid": "Erro: A pasta de destino {path} não é uma pasta válida ou não existe.", - "info.already.added": "Info: {path} já foi adicionado. Não duplique.", - "warn.no.strategy.found": "Aviso: Estratégia de backup para {path} não encontrada.", - "warn.source.missing": "Aviso: A pasta de origem não existe: {path}", - "warn.json.decode.error": "Aviso: Arquivo de dados {path} malformado, inicializando como diccionário vazio.", - "warn.json.backup": "Aviso: Arquivo de dados corrompido copiado para {path}.", - "warn.json.renamed": "Aviso: Arquivo de dados corrompido renomeado para {path}.", - "compress.success": "Backup realizado com sucesso: {path} ({size:.2f} MB, {count} arquivos)", - "compress.progress": "Comprimindo", - "compress.unit": "arquivos", - "err.permission": "Permissão negada: Não é possível escrever em '{path}'", - "err.os": "Erro do sistema: {error}", - "err.unknown": "Erro desconhecido: {error}", - "err.unknown.format": "Erro: Formato de arquivo de backup não reconhecido {path}", - "err.argparse.invalid_choice": "opção inválida: ", - "err.argparse.choose_from": "escolher entre", - "err.argparse.invalid_float": "valor float inválido: ", - "err.argparse.invalid_int": "valor int inválido: ", - "err.argparse.unrecognized_args": "argumentos não reconhecidos: ", - "err.argparse.required": "obrigatório", - "warn.zip.overwrite": "Aviso: Sobrescrevendo arquivo de backup existente {path}", - "warn.invalid.compresslevel": "Aviso: Nível de compressão {level} fora do intervalo (0-9), usando valor padrão 6", - "cmd.save.uptodate": "Todas as estratégias de backup estão atualizadas, backup não necessário.", - "cmd.save.completed": "Backup concluído, {count} estratégia(s) processada(s).", - "cmd.all.empty": "Nenhuma estratégia de backup configurada.", - "exit.message": "\nSaindo.", - "table.header.source": "Pasta de origem", - "table.header.dest": "Pasta de destino", - "table.header.ignore": "Padrões de exclusão", - "table.cell.none": "Nenhum", - "table.header.format": "Formato", - "table.cell.default": "Padrão", - "restore.progress": "Restaurando", - "restore.success": "Restauração concluída: {path} ({count} arquivos)", - "cmd.watch.start": "Monitoramento iniciado, intervalo {interval} min (Ctrl+C para parar)", - - "cli.description": "Sbackup v{version} — Ferramenta de backup incremental leve\n\nSbackup ajuda você a gerenciar facilmente backups de múltiplas pastas.\nEle usa uma estratégia de backup incremental, comprimindo apenas arquivos modificados,\nsuportando regras de exclusão personalizadas e oferecendo feedback de progresso intuitivo.\n\nComandos disponíveis:\n add Adicionar nova estratégia de backup (origem -> destino)\n rm Remover estratégia de backup existente\n all Mostrar todas as estratégias de backup configuradas\n save Executar tarefas de backup\n version Mostrar informações de versão", - "cli.epilog": "Exemplos:\n Adicionar estratégia de backup:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Adicionar estratégia com formato por entrada:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Remover estratégia de backup:\n sbackup rm F:/my_folder\n\n Mostrar todas as estratégias:\n sbackup all\n\n Executar backup (ZIP):\n sbackup save\n\n Executar backup (tar.gz):\n sbackup --format tar.gz save\n\n Executar backup (7z criptografado):\n sbackup --format 7z save --password 123456\n\n Manter os últimos 5 backups:\n sbackup save --keep 5\n\n Backup programado (cada 60 min):\n sbackup watch --interval 60\n\n Monitoramento + limpeza:\n sbackup watch --interval 120 --keep 10\n\n Restaurar backup:\n sbackup restore backup.7z F:/restored\n\n Interface em chinês:\n sbackup --lang zh_CN save\n\n Modo de depuração:\n sbackup --debug save", - "cli.help.debug": "Ativar modo de depuração com registro detalhado", - "cli.help.help": "Mostrar esta mensagem de ajuda e sair", - "cli.help.lang": "Definir idioma da interface: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", - "cli.help.format": "Definir formato de pacote: zip (padrão) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.subcommands": "Selecionar um comando para executar", - "cli.help.add": "Adicionar nova estratégia de backup", - "cli.help.add.source": "Caminho da pasta de origem: diretório a ser copiado", - "cli.help.add.dest": "Caminho da pasta de destino: onde os arquivos de backup serão armazenados", - "cli.help.add.ignore": "Arquivos ou pastas a ignorar (separados por vírgula, padrão: .git,__pycache__)", - "cli.help.add.format": "Formato de pacote por entrada (apenas para esta estratégia, padrão global): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.rm": "Remover estratégia de backup", - "cli.help.rm.path": "Caminho da pasta de origem da estratégia a remover", - "cli.help.all": "Mostrar todas as estratégias de backup", - "cli.help.save": "Executar todas as estratégias de backup", - "cli.help.save.keep": "Manter os últimos N arquivos de backup, 0 desativa limpeza", - "cli.help.save.password": "Senha de criptografia (apenas para formato 7z)", - "cli.help.watch": "Executar backups periodicamente", - "cli.help.watch.interval": "Intervalo de backup em minutos, padrão 60", - "cli.help.watch.keep": "Manter os últimos N arquivos de backup", - "cli.help.watch.password": "Senha de criptografia (apenas para formato 7z)", - "cli.help.restore": "Restaurar de um arquivo de backup", - "cli.help.restore.file": "Caminho do arquivo de backup (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", - "cli.help.restore.dir": "Diretório de destino para restauração", - "cli.help.version": "Mostrar informações de versão", - "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicença GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", - - "log.config.malformed": "Arquivo de configuração %s malformado, usando valores padrão", - "log.config.reset": "Arquivo de configuração %s malformado, redefinindo", - "log.config.mkdir.error": "Não é possível criar diretório de configuração %s: %s", - "log.config.write.error": "Não é possível escrever arquivo de configuração %s: %s", - "log.data.read": "Lendo arquivo de dados: %s", - "log.data.create": "Arquivo de dados não existe, criando: %s", - "log.data.load": "Carregando arquivo de dados existente: %s", - "log.data.write": "Escrevendo arquivo de dados: %s", - "log.cleanup.delete": "Backup antigo excluído: %s", - "log.i18n.load.failed": "Falha ao carregar locale {path}: {error}", - "log.i18n.load.fallback_failed": "Falha ao carregar locale de fallback {path}: {error}" -} \ No newline at end of file +{ + "benchmark.empty_source": "Diretório de origem está vazio: {path}", + "benchmark.error": "Falha no benchmark {format}: {error}", + "benchmark.header": "═══ Benchmark de compactação ═══", + "benchmark.recommended": "Recomendado: {format} nível={level} ({size}, melhor equilíbrio)", + "benchmark.running": "Testando {format} nível={level}...", + "benchmark.source_info": "Diretório de origem: {path} ({size}, {count} arquivos)", + "cli.description": "Sbackup v{version} — Ferramenta de backup incremental leve\n\nSbackup ajuda você a gerenciar facilmente backups de múltiplas pastas.\nEle usa uma estratégia de backup incremental, comprimindo apenas arquivos modificados,\nsuportando regras de exclusão personalizadas e oferecendo feedback de progresso intuitivo.\n\nComandos disponíveis:\n add Adicionar nova estratégia de backup (origem -> destino)\n rm Remover estratégia de backup existente\n all Mostrar todas as estratégias de backup configuradas\n save Executar tarefas de backup\n version Mostrar informações de versão", + "cli.epilog": "Exemplos:\n Adicionar estratégia de backup:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Backup criptografado (7z):\n sbackup --format 7z save --password 123456\n\n Restaurar:\n sbackup restore backup.7z F:/restored\n\n SFTP remoto (auto-detecção, recomendado):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP remoto (senha):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP remoto (chave):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP remoto (chave + frase):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp", + "cli.help.add": "Adicionar nova estratégia de backup", + "cli.help.add.dest": "Caminho da pasta de destino: onde os arquivos de backup serão armazenados", + "cli.help.add.format": "Formato de pacote por entrada (apenas para esta estratégia, padrão global): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.add.from_gitignore": "Importar regras de exclusão do arquivo .gitignore", + "cli.help.add.ignore": "Arquivos ou pastas a ignorar (separados por vírgula, padrão: .git,__pycache__)", + "cli.help.add.name_template": "Modelo de nome de arquivo por entrada (suporta variáveis {name} {date} {time}, usa padrão global se não especificado)", + "cli.help.add.source": "Caminho da pasta de origem: diretório a ser copiado", + "cli.help.all": "Mostrar todas as estratégias de backup", + "cli.help.audit": "Ver log de auditoria de backup", + "cli.help.audit.action": "Ação de auditoria: log (consultar) / stats (estatísticas) / cleanup (limpar)", + "cli.help.audit.cleanup": "Limpar entradas antigas do log de auditoria", + "cli.help.audit.cleanup.keep_days": "Manter entradas dos últimos N dias (padrão: 90)", + "cli.help.audit.log": "Consultar entradas do log de auditoria", + "cli.help.audit.log.event": "Filtrar por tipo de evento", + "cli.help.audit.log.limit": "Número máximo de entradas a exibir (padrão: 50)", + "cli.help.audit.log.since": "Exibir entradas após esta data (formato ISO 8601)", + "cli.help.audit.log.status": "Filtrar por status: success / failed / partial", + "cli.help.audit.stats": "Exibir estatísticas do log de auditoria", + "cli.help.benchmark": "Executar benchmark de compactação no diretório especificado, comparando desempenho de diferentes formatos", + "cli.help.benchmark.levels": "Níveis de compactação a testar (separados por vírgula, padrão: 1,6,9)", + "cli.help.benchmark.password": "Senha de criptografia (usada pelo formato 7z)", + "cli.help.benchmark.path": "Caminho do diretório de origem para testar", + "cli.help.benchmark.quick": "Modo rápido: testar apenas o nível padrão de cada formato", + "cli.help.checksum": "Detectar alterações usando checksum SHA256 do conteúdo do arquivo (mais preciso que mtime, porém mais lento)", + "cli.help.clean": "Limpar arquivos de backup de todas as estratégias de forma inteligente", + "cli.help.clean.dry_run": "Apenas visualizar os arquivos que serão excluídos, sem realmente excluir", + "cli.help.clean.keep": "Manter os últimos N arquivos de backup por estratégia", + "cli.help.clean.keep_days": "Excluir arquivos de backup com mais de N dias", + "cli.help.completion": "Gerar script de autocompletar para Shell", + "cli.help.completion.shell": "Tipo de Shell: bash (padrão) / zsh / fish / powershell", + "cli.help.compress": "Compactar a pasta especificada diretamente (sem usar estratégia)", + "cli.help.compress.dry_run": "Apenas visualizar os arquivos que serão compactados, sem realmente executar", + "cli.help.compress.format": "Formato de compactação: zip (padrão) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.compress.output": "Caminho do arquivo de saída (opcional)", + "cli.help.compress.password": "Senha de criptografia (apenas formato 7z suportado)", + "cli.help.compress.source": "Caminho da pasta de origem para compactar", + "cli.help.config_cmd": "Gerenciamento do arquivo de configuração (criptografar/descriptografar campos sensíveis)", + "cli.help.config_cmd.action": "Ação: lock (criptografar) / unlock (descriptografar)", + "cli.help.config_cmd.lock": "Criptografar campos sensíveis do arquivo de configuração com a senha mestra", + "cli.help.config_cmd.lock.password": "Senha mestra (se não especificada, entrada interativa)", + "cli.help.config_cmd.unlock": "Descriptografar campos sensíveis do arquivo de configuração com a senha mestra", + "cli.help.config_cmd.unlock.password": "Senha mestra (se não especificada, entrada interativa)", + "cli.help.config_cmd.validate": "Validar a configuração atual e exibir todos os erros e avisos", + "cli.help.debug": "Modo de depuração com log detalhado (após subcomando)", + "cli.help.dedup": "Ativar desduplicação de arquivos baseada em hash de conteúdo (SHA256) entre estratégias", + "cli.help.diff": "Comparar diretório de origem com o backup mais recente", + "cli.help.diff.detail": "Exibir diferenças linha a linha dos arquivos modificados", + "cli.help.diff.file": "Caminho do arquivo de backup (encontra automaticamente o mais recente se não especificado)", + "cli.help.diff.password": "Senha de descriptografia (para backups criptografados)", + "cli.help.diff.source": "Caminho da pasta de origem", + "cli.help.diskcheck": "Verificar espaço em disco antes do backup (estimar tamanho do diretório de origem e do arquivo de backup)", + "cli.help.diskcheck.level": "Nível de compactação (0-9, padrão: 6)", + "cli.help.diskcheck.source": "Caminho do diretório de origem a escanear", + "cli.help.diskcheck.target": "Caminho do diretório de destino (onde os backups são armazenados)", + "cli.help.edit": "Editar uma estratégia de backup existente (modifica apenas os campos especificados)", + "cli.help.edit.dest": "Alterar caminho da pasta de destino", + "cli.help.edit.format": "Alterar formato de compactação: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.edit.ignore": "Alterar padrões de exclusão (separados por vírgula)", + "cli.help.edit.name_template": "Alterar modelo de nome de arquivo (suporta variáveis {name} {date} {time})", + "cli.help.edit.source": "Caminho da pasta de origem (para localizar a estratégia)", + "cli.help.export": "Exportar todas as estratégias de backup para um arquivo JSON", + "cli.help.export.file": "Caminho do arquivo de exportação (padrão: sbackup_export.json)", + "cli.help.follow_symlinks": "Seguir links simbólicos (padrão: não seguir para evitar loops)", + "cli.help.format": "Definir formato de pacote: zip (padrão) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.help": "Mostrar esta mensagem de ajuda e sair", + "cli.help.hooks": "Gerenciar e executar manualmente hooks pré/pós-backup", + "cli.help.hooks.action": "Ação do hook: run (executar)", + "cli.help.hooks.run": "Executar manualmente hooks pré ou pós-backup", + "cli.help.hooks.run.timeout": "Substituir tempo de espera do hook em segundos (padrão: usar valor da configuração)", + "cli.help.hooks.run.type": "Tipo de hook a executar: pre (pré) ou post (pós)", + "cli.help.ignore": "Gerar arquivo de regras de exclusão .sbackupignore", + "cli.help.ignore.list": "Listar todos os presets disponíveis", + "cli.help.ignore.output": "Caminho do arquivo de saída (padrão: .sbackupignore)", + "cli.help.ignore.preset": "Modelo de preset: node / python / go / rust / java / general (padrão: general)", + "cli.help.import": "Importar estratégias de backup de um arquivo JSON", + "cli.help.import.file": "Arquivo JSON para importar", + "cli.help.init": "Gerar arquivo de configuração padrão", + "cli.help.incremental": "Backup incremental por arquivo (compacta apenas arquivos modificados, reduz significativamente o tempo de backup)", + "cli.help.info": "Ver detalhes do arquivo de backup (formato, tamanho, quantidade de arquivos, checksum, etc.)", + "cli.help.info.file": "Caminho do arquivo de backup", + "cli.help.info.password": "Senha de descriptografia (para backups 7z criptografados)", + "cli.help.integrity": "Verificação de integridade de dados (gerar/verificar manifesto de checksums)", + "cli.help.integrity.action": "Ação de integridade: generate (gerar) / verify (verificar)", + "cli.help.integrity.generate": "Gerar manifesto integrity.json para o diretório de backup", + "cli.help.integrity.path": "Caminho do diretório de backup", + "cli.help.integrity.verify": "Verificar integridade dos arquivos de backup com base no manifesto integrity.json", + "cli.help.lang": "Definir idioma da interface: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", + "cli.help.list": "Ver histórico de backups", + "cli.help.list.tags": "Listar todas as tags de backup", + "cli.help.metadata_export": "Exportar metadados de backup (histórico/log de auditoria) para arquivo", + "cli.help.metadata_export.action": "Ação de exportação: history (histórico) / audit (auditoria) / all (completo)", + "cli.help.metadata_export.all": "Exportar relatório completo (histórico + auditoria + estatísticas)", + "cli.help.metadata_export.audit": "Exportar log de auditoria para CSV ou JSON", + "cli.help.metadata_export.event": "Filtrar por tipo de evento", + "cli.help.metadata_export.format": "Formato de saída: csv (padrão para history/audit) ou json", + "cli.help.metadata_export.history": "Exportar histórico de backups para CSV ou JSON", + "cli.help.metadata_export.output": "Caminho do arquivo de saída", + "cli.help.metadata_export.strategy": "Filtrar por nome de estratégia (corresponde ao caminho de origem)", + "cli.help.multi_backup": "Backup para múltiplos destinos simultaneamente: backup local + upload paralelo para todos os destinos remotos habilitados", + "cli.help.multi_backup.dry_run": "Apenas visualizar os arquivos que serão salvos, sem realmente executar", + "cli.help.multi_backup.keep": "Manter os últimos N arquivos de backup (0 para não limpar)", + "cli.help.multi_backup.keep_days": "Manter arquivos de backup dos últimos N dias", + "cli.help.multi_backup.local_target": "Caminho da pasta de destino do backup local", + "cli.help.multi_backup.name_template": "Modelo de nome do arquivo de backup", + "cli.help.multi_backup.password": "Senha de criptografia (apenas formato 7z suportado)", + "cli.help.multi_backup.source": "Caminho da pasta de origem para backup", + "cli.help.multi_backup.verify": "Verificar integridade automaticamente após o backup", + "cli.help.profile": "Gerenciar Perfis de configuração", + "cli.help.profile.action": "Ação: list (listar) / create (criar) / delete (excluir) / use (usar) / export (exportar) / import (importar)", + "cli.help.profile.create.name": "Nome do Perfil a criar a partir da configuração atual", + "cli.help.profile.delete.name": "Nome do Perfil a excluir", + "cli.help.profile.export.name": "Nome do Perfil a exportar", + "cli.help.profile.export.path": "Caminho do arquivo de exportação", + "cli.help.profile.import.name": "Nome do Perfil (padrão: usar nome do arquivo)", + "cli.help.profile.import.path": "Caminho do arquivo para importar", + "cli.help.profile.use.name": "Nome do Perfil a usar temporariamente", + "cli.help.profile_name": "Usar o Perfil especificado em todos os comandos", + "cli.help.remote": "Gerenciamento de arquivos de backup remotos (listar/excluir no SFTP ou WebDAV)", + "cli.help.remote.action": "Ação remota: list / rm", + "cli.help.remote.list": "Listar arquivos de backup remotos", + "cli.help.remote.list_path": "Especificar caminho do diretório remoto (se não especificado, usa o caminho padrão configurado)", + "cli.help.remote.rm": "Excluir um arquivo de backup remoto", + "cli.help.remote.rm.filename": "Nome do arquivo remoto a excluir", + "cli.help.remote.sftp": "Operar no servidor SFTP", + "cli.help.remote.webdav": "Operar no servidor WebDAV", + "cli.help.report": "Gerar relatório de backup em formato Markdown", + "cli.help.report.format": "Formato do relatório: text (texto) ou html (página web)", + "cli.help.report.output": "Caminho do arquivo de saída (se não especificado, imprime no terminal)", + "cli.help.restore": "Restaurar de um arquivo de backup", + "cli.help.restore.dir": "Diretório de destino para restauração", + "cli.help.restore.file": "Caminho do arquivo de backup (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", + "cli.help.restore.list": "Listar conteúdo do arquivo sem extrair", + "cli.help.restore.password": "Senha de descriptografia (para restaurar backups 7z criptografados)", + "cli.help.restore.search": "Pesquisar nome de arquivo dentro do pacote de backup (sem diferenciar maiúsculas/minúsculas)", + "cli.help.restore.select": "Restauração seletiva: extrair apenas arquivos que correspondam ao padrão (suporta curingas glob, ex: \"*.py\" ou \"src/main.py\")", + "cli.help.restore.stats": "Exibir estatísticas do pacote de backup (contagem de arquivos, tamanho, distribuição de formatos)", + "cli.help.restore.tag": "Restaurar backup pela tag especificada", + "cli.help.rm": "Remover estratégia de backup", + "cli.help.rm.all": "Excluir todas as estratégias de backup", + "cli.help.rm.path": "Caminho da pasta de origem da estratégia a remover", + "cli.help.rotate": "Limpar e rotacionar arquivos de backup de acordo com a política de retenção", + "cli.help.rotate.backup_dir": "Caminho do diretório que contém os arquivos de backup", + "cli.help.rotate.dry_run": "Apenas visualizar os arquivos que serão excluídos, sem realmente excluir", + "cli.help.rotate.keep_count": "Manter os últimos N arquivos de backup (0 = sem limite)", + "cli.help.rotate.keep_daily": "Manter um backup por dia dos últimos N dias (0 = sem limite)", + "cli.help.rotate.keep_days": "Manter arquivos de backup dos últimos N dias (0 = sem limite)", + "cli.help.rotate_list": "Listar todos os arquivos de backup em um diretório", + "cli.help.rotate_list.backup_dir": "Diretório para escanear arquivos de backup", + "cli.help.save": "Executar todas as estratégias de backup", + "cli.help.save.dry_run": "Visualizar os arquivos que serão salvos sem realmente compactar", + "cli.help.save.keep": "Manter os últimos N arquivos de backup, 0 desativa limpeza", + "cli.help.save.keep_days": "Manter arquivos de backup dos últimos N dias, 0 para não limpar por tempo (padrão: 0)", + "cli.help.save.max_size": "Apenas salvar arquivos menores que o tamanho especificado (ex: 1GB, 500MB, 100KB)", + "cli.help.save.min_size": "Apenas salvar arquivos maiores que o tamanho especificado (ex: 1KB, 1MB)", + "cli.help.save.multi_dest": "Ativar modo de backup para múltiplos destinos simultaneamente", + "cli.help.save.name_template": "Modelo de nome do arquivo de backup (suporta variáveis {name} {date} {time})", + "cli.help.save.older_than": "Apenas salvar arquivos modificados há mais tempo que o especificado (ex: 7d, 30d, 1y)", + "cli.help.save.password": "Senha de criptografia (apenas para formato 7z)", + "cli.help.save.post_hook": "Comando a executar após o backup (pode ser usado múltiplas vezes)", + "cli.help.save.pre_hook": "Comando a executar antes do backup (pode ser usado múltiplas vezes)", + "cli.help.save.sftp": "Upload para servidor SFTP após backup", + "cli.help.save.split": "Tamanho de cada parte do arquivo de backup (ex: 100MB, 1GB), divide automaticamente ao ultrapassar", + "cli.help.save.tag": "Adicionar tag a este backup (ex: daily, release)", + "cli.help.save.verify": "Verificar integridade do backup após a conclusão", + "cli.help.save.webhook": "Enviar resultados do backup via POST para esta URL após a conclusão", + "cli.help.save.webdav": "Fazer upload para o servidor WebDAV após o backup", + "cli.help.schedule": "Gerenciamento de configuração de agendamento", + "cli.help.schedule.action": "Ação de agendamento: export (exportar configuração) / install (instalar serviço)", + "cli.help.schedule.export": "Exportar arquivo de configuração de agendamento", + "cli.help.schedule.install": "Instalar backup agendado como serviço do sistema", + "cli.help.schedule.install.interval": "Intervalo de backup em minutos (padrão: 60)", + "cli.help.schedule.install.type": "Tipo de serviço: systemd / schtasks / launchd", + "cli.help.schedule.install.user": "Usuário para executar o serviço (padrão: usuário atual)", + "cli.help.schedule.interval": "Intervalo de backup em minutos (padrão: 60)", + "cli.help.schedule.output": "Caminho do arquivo de saída (se não especificado, imprime no terminal)", + "cli.help.schedule.type": "Tipo de agendamento: systemd / crontab (padrão: systemd)", + "cli.help.schedule.type.schtasks": "Tarefa agendada do Windows", + "cli.help.search": "Pesquisar nome de arquivo nos arquivos de backup", + "cli.help.search.in": "Especificar caminho do arquivo de backup (se não especificado, pesquisa na última cópia de todas as estratégias)", + "cli.help.search.password": "Senha de descriptografia (ao pesquisar backups criptografados)", + "cli.help.search.pattern": "Padrão de pesquisa (suporta curingas glob, ex: *.txt)", + "cli.help.sftp": "Gerenciamento SFTP remoto (senha e chave SSH)", + "cli.help.sftp.action": "Ação SFTP: config / test", + "cli.help.sftp.config": "Configurar parâmetros de conexão SFTP", + "cli.help.sftp.host": "Endereço do servidor SFTP (ex: 192.168.1.100)", + "cli.help.sftp.key_file": "Caminho da chave privada SSH (RSA/Ed25519/ECDSA, prioridade sobre senha)", + "cli.help.sftp.key_passphrase": "Frase da chave privada (entrada interativa)", + "cli.help.sftp.password": "Senha SFTP (autenticação por senha apenas)", + "cli.help.sftp.port": "Porta SFTP (padrão 22)", + "cli.help.sftp.remote_path": "Caminho remoto destino (padrão: /)", + "cli.help.sftp.test": "Testar conexão SFTP", + "cli.help.sftp.user": "Usuário SFTP", + "cli.help.status": "Ver painel de status do backup", + "cli.help.strict": "Abortar backup quando não houver espaço suficiente em disco (se não especificado, apenas avisa)", + "cli.help.subcommands": "Selecionar um comando para executar", + "cli.help.task": "Gerenciamento da fila de tarefas (adicionar/executar/cancelar tarefas de backup)", + "cli.help.task.action": "Ação da tarefa: add (adicionar) / list (listar) / run (executar) / run-all (executar todas) / cancel (cancelar) / clear (limpar) / stats (estatísticas)", + "cli.help.task.add": "Adicionar nova tarefa de backup à fila", + "cli.help.task.add.folder": "Caminho da pasta para backup", + "cli.help.task.add.format": "Formato de compactação: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.task.add.name": "Nome da tarefa (para identificação)", + "cli.help.task.add.output": "Caminho do arquivo de saída (opcional)", + "cli.help.task.cancel": "Cancelar uma tarefa pendente", + "cli.help.task.cancel.id": "ID da tarefa a cancelar", + "cli.help.task.clear": "Limpar tarefas concluídas e falhadas da fila", + "cli.help.task.list": "Listar tarefas na fila", + "cli.help.task.list.status": "Filtrar por status: pending / running / completed / failed", + "cli.help.task.run": "Executar a próxima tarefa pendente na fila", + "cli.help.task.run_all": "Executar todas as tarefas pendentes em sequência", + "cli.help.task.stats": "Exibir estatísticas da fila de tarefas", + "cli.help.verify": "Verificar integridade do arquivo de backup", + "cli.help.verify.all": "Verificar todos os backups históricos em lote", + "cli.help.verify.detail": "Mostrar informações detalhadas de verificação (tamanho, formato, soma de verificação, etc.)", + "cli.help.verify.fast": "Verificação rápida (comparar SHA256 histórico, sem extração)", + "cli.help.verify.file": "Arquivo de backup para verificar", + "cli.help.verify.password": "Senha de descriptografia (para backups criptografados)", + "cli.help.verify.split": "Verificar integridade de arquivos divididos (requer arquivo de manifesto)", + "cli.help.version": "Mostrar informações de versão", + "cli.help.versions": "Ver histórico de versões de backup (agrupado por diretório de origem)", + "cli.help.versions.source": "Especificar caminho do diretório de origem (se não especificado, mostra todos)", + "cli.help.versions.tag": "Filtrar versões por tag", + "cli.help.watch": "Executar backups periodicamente", + "cli.help.watch.debounce": "Segundos de debounce para aguardar após a última alteração de arquivo antes do backup (padrão: 30)", + "cli.help.watch.dry_run": "Visualizar os arquivos que serão salvos sem realmente compactar", + "cli.help.watch.interval": "Intervalo de backup em minutos, padrão 60", + "cli.help.watch.keep": "Manter os últimos N arquivos de backup", + "cli.help.watch.keep_days": "Manter arquivos de backup dos últimos N dias (padrão: 0)", + "cli.help.watch.max_size": "Apenas salvar arquivos menores que o tamanho especificado (ex: 1GB, 500MB, 100KB)", + "cli.help.watch.min_size": "Apenas salvar arquivos maiores que o tamanho especificado (ex: 1KB, 1MB)", + "cli.help.watch.name_template": "Modelo de nome do arquivo de backup (suporta variáveis {name} {date} {time})", + "cli.help.watch.older_than": "Apenas salvar arquivos modificados há mais tempo que o especificado (ex: 7d, 30d, 1y)", + "cli.help.watch.password": "Senha de criptografia (apenas para formato 7z)", + "cli.help.watch.post_hook": "Comando a executar após o backup (pode ser usado múltiplas vezes)", + "cli.help.watch.pre_hook": "Comando a executar antes do backup (pode ser usado múltiplas vezes)", + "cli.help.watch.realtime": "Ativar monitoramento de sistema de arquivos em tempo real (aciona backup automaticamente ao detectar alterações)", + "cli.help.watch.sftp": "Upload para servidor SFTP após cada backup", + "cli.help.watch.split": "Tamanho de cada parte do arquivo de backup (ex: 100MB, 1GB)", + "cli.help.watch.tag": "Adicionar tag a este backup", + "cli.help.watch.verify": "Verificar integridade do backup após cada backup", + "cli.help.watch.webhook": "Enviar resultados do backup via POST para esta URL após cada backup", + "cli.help.watch.webdav": "Fazer upload para o servidor WebDAV após cada backup", + "cli.help.webdav": "Gerenciamento de backup remoto WebDAV (suporta Jianguoyun, NextCloud, Synology, etc.)", + "cli.help.webdav.action": "Ação WebDAV: config / test", + "cli.help.webdav.config": "Configurar parâmetros de conexão WebDAV", + "cli.help.webdav.password": "Senha WebDAV", + "cli.help.webdav.remote_path": "Caminho remoto destino (padrão: /)", + "cli.help.webdav.test": "Testar se a conexão WebDAV está disponível", + "cli.help.webdav.url": "URL do servidor WebDAV (ex: https://dav.jianguoyun.com/dav/)", + "cli.help.webdav.user": "Usuário WebDAV", + "cli.help.webhook_cmd": "Gerenciamento de notificações Webhook", + "cli.help.webhook_cmd.action": "Ação Webhook: preset (configurar preset) / list (listar presets)", + "cli.help.webhook_cmd.list": "Listar todos os presets de notificação IM disponíveis", + "cli.help.webhook_cmd.preset": "Definir modelo de preset de notificação IM", + "cli.help.webhook_cmd.preset.name": "Nome do preset: dingtalk / feishu / wechat", + "cli.help.wizard": "Assistente de configuração interativo, guia a conclusão da configuração inicial", + "cli.help.xsearch": "Pesquisa entre arquivos: pesquisar nome de arquivo em múltiplos backups", + "cli.help.xsearch.dirs": "Lista de diretórios para pesquisar recursivamente arquivos de backup", + "cli.help.xsearch.ext": "Pesquisar por extensão (ex: .log, .py)", + "cli.help.xsearch.keyword": "Palavra-chave de pesquisa (sem diferenciar maiúsculas/minúsculas, corresponde ao nome do arquivo)", + "cli.help.xsearch.output_lang": "Idioma de saída: zh_CN (chinês) / en_US (inglês)", + "cli.help.xsearch.password": "Senha de descriptografia (ao pesquisar backups criptografados)", + "cli.help.xsearch.pattern": "Padrão Glob para corresponder nomes de arquivo (ex: \"*.log\")", + "cli.prompt.sftp.host": "Endereço do servidor SFTP:", + "cli.prompt.sftp.key_file": "Caminho da chave privada SSH (vazio para senha):", + "cli.prompt.sftp.key_passphrase": "Frase da chave (Enter para pular):", + "cli.prompt.sftp.password": "Senha SFTP:", + "cli.prompt.sftp.port": "Porta SFTP (padrão 22):", + "cli.prompt.sftp.remote_path": "Caminho remoto (padrão /):", + "cli.prompt.sftp.user": "Usuário SFTP:", + "cli.prompt.webdav.password": "Senha WebDAV:", + "cli.prompt.webdav.remote_path": "Caminho remoto (padrão /):", + "cli.prompt.webdav.url": "URL do servidor WebDAV:", + "cli.prompt.webdav.user": "Usuário WebDAV:", + "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicença GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", + "cmd.add.gitignore_empty": "Nenhuma regra importada de {path}", + "cmd.add.gitignore_imported": "Importadas {count} regras de exclusão de {path}", + "cmd.add.success": "Estratégia de backup adicionada com sucesso: {source} -> {dest}", + "cmd.all.empty": "Nenhuma estratégia de backup configurada.", + "cmd.audit.cleanup.done": "Foram limpas {count} entradas antigas do log de auditoria.", + "cmd.audit.cleanup.none": "Não há entradas antigas para limpar.", + "cmd.clean.dry_run_header": "Modo de visualização (nada será excluído):", + "cmd.clean.no_criteria": "Especifique critérios de limpeza: --keep N ou --keep-days N", + "cmd.clean.nothing": "Não há arquivos de backup para limpar.", + "cmd.clean.summary": "Total de {count} arquivos de backup limpos.", + "cmd.config.already_locked": "O arquivo de configuração já está criptografado", + "cmd.config.enter_password": "Insira a senha mestra", + "cmd.config.lock_failed": "Falha na criptografia", + "cmd.config.locked": "Arquivo de configuração criptografado (campos sensíveis protegidos)", + "cmd.config.no_password": "A senha não pode estar vazia", + "cmd.config.not_locked": "O arquivo de configuração não está criptografado", + "cmd.config.unlocked": "Arquivo de configuração descriptografado", + "cmd.config.validate.errors": "Erros encontrados:", + "cmd.config.validate.failed": "Validação da configuração falhou. Corrija os erros acima.", + "cmd.config.validate.passed": "Validação da configuração aprovada, nenhum problema encontrado.", + "cmd.config.validate.passed_with_warnings": "Validação da configuração aprovada (com avisos).", + "cmd.config.validate.warnings": "Avisos encontrados:", + "cmd.config.wrong_password": "Senha incorreta, falha na descriptografia", + "cmd.dedup.saved": "Desduplicação: {count} arquivos duplicados ignorados, {saved:.2f} MB economizados", + "cmd.diff.added_header": "\nArquivos adicionados ({count}):", + "cmd.diff.detail_header": "--- Diferenças linha a linha ---", + "cmd.diff.empty_backup": "O arquivo de backup está vazio: {path}", + "cmd.diff.header": "=== Comparação de Diferenças ===\nArquivo de backup: {backup}", + "cmd.diff.modified_header": "\nArquivos possivelmente modificados ({count}):", + "cmd.diff.no_backup": "Nenhum arquivo de backup encontrado no diretório de destino: {path}", + "cmd.diff.no_changes": "\nSem diferenças, a fonte corresponde ao backup.", + "cmd.diff.no_detail": "(arquivo binário ou ilegível)", + "cmd.diff.removed_header": "\nArquivos removidos ({count}):", + "cmd.diff.summary": "\nResumo: +{added} adicionados, -{removed} removidos, ~{modified} modificados", + "cmd.dry_run.header": "Visualização do dry-run (nenhum backup será executado):", + "cmd.dry_run.item": " {source} -> {target} formato: {format} arquivos: {count} tamanho: {size} MB", + "cmd.dry_run.nothing": "Nenhuma estratégia precisa de backup (todas estão atualizadas).", + "cmd.dry_run.total": "Total: {count} estratégias, {files} arquivos, {size} MB", + "cmd.edit.nothing": "Nenhum parâmetro de modificação especificado.", + "cmd.edit.success": "Estratégia de backup editada com sucesso: {path}", + "cmd.export.success": "{count} estratégias de backup exportadas para {path}", + "cmd.hook.failed": "Falha na execução do hook: {command} — {error}", + "cmd.hook.post_running": "Executando hook pós-backup: {command}", + "cmd.hook.pre_running": "Executando hook pré-backup: {command}", + "cmd.hooks.no_hooks": "Nenhum hook {type} configurado.", + "cmd.hooks.no_results": "Nenhum resultado de execução de hook.", + "cmd.hooks.result_line": "[{status}] {command} (código de retorno={return_code}, {duration}s)", + "cmd.hooks.summary": "Total: {total}, sucesso: {success}, falha: {fail}", + "cmd.ignore.available_presets": "Presets disponíveis:", + "cmd.ignore.cancelled": "Cancelado.", + "cmd.ignore.generated": "Regras de exclusão do preset {preset} geradas: {path}", + "cmd.ignore.overwrite": "O arquivo {path} já existe. Sobrescrever? (s/N): ", + "cmd.import.empty": "Nenhuma estratégia de backup válida encontrada em: {path}", + "cmd.import.success": "{count} estratégias de backup importadas, {skipped} existentes ignoradas", + "cmd.info.files": "Quantidade de arquivos: {count}", + "cmd.info.format": "Formato: {format}", + "cmd.info.header": "=== Informações do arquivo de backup ===", + "cmd.info.path": "Caminho: {path}", + "cmd.info.sha256": "SHA256: {checksum}", + "cmd.info.size": "Tamanho: {size:.2f} MB", + "cmd.info.time": "Modificado: {time}", + "cmd.init.exists": "Arquivo de configuração já existe: {path}, ignorando.", + "cmd.init.success": "Arquivo de configuração gerado: {path}", + "cmd.integrity.generate.not_dir": "Erro: {path} não é um diretório", + "cmd.integrity.generate.success": "Manifesto de integridade gerado: {path}", + "cmd.integrity.verify.failed": "Verificação de integridade falhou: {path}", + "cmd.integrity.verify.success": "Verificação de integridade aprovada: {path}", + "cmd.list.empty": "Nenhum histórico de backup ainda.", + "cmd.list.tags.empty": "Nenhuma tag ainda", + "cmd.list.tags.header": "Tags de backup:", + "cmd.metadata_export.audit_done": "{count} entradas de auditoria exportadas para {path}", + "cmd.metadata_export.combined_done": "Relatório completo exportado ({count} registros) para {path}", + "cmd.metadata_export.history_done": "{count} registros de histórico exportados para {path}", + "cmd.profile.activated": "Perfil '{name}' ativado.", + "cmd.profile.created": "Perfil '{name}' criado com sucesso.", + "cmd.profile.deleted": "Perfil '{name}' excluído.", + "cmd.profile.exported": "Perfil '{name}' exportado para: {path}", + "cmd.profile.import_failed": "Falha ao importar Perfil de {path}.", + "cmd.profile.imported": "Perfil '{name}' importado com sucesso.", + "cmd.profile.list.detail": " {fields}", + "cmd.profile.list.empty": "Nenhum Perfil configurado.", + "cmd.profile.list.header": "Lista de Perfis de configuração:", + "cmd.profile.list.item": " {name}", + "cmd.profile.not_found": "Perfil '{name}' não existe.", + "cmd.remote.deleted": "Arquivo remoto excluído: {path}", + "cmd.remote.empty": "Diretório remoto está vazio: {path}", + "cmd.remote.list_header": "Diretório remoto: {path} ({count} arquivos)", + "cmd.remote.rm_requires_path": "Especifique o nome do arquivo remoto a excluir", + "cmd.remote.select_protocol": "Especifique o protocolo: --sftp ou --webdav", + "cmd.report.saved": "Relatório de backup salvo em: {path}", + "cmd.restore.search.found": "{count} arquivos encontrados:", + "cmd.restore.search.header": "Resultados da pesquisa por '{keyword}' em {path}:", + "cmd.restore.search.no_results": "Nenhum arquivo correspondente a '{keyword}' encontrado em {path}", + "cmd.restore.selective.header": "Restauração seletiva: extraindo {count} arquivos que correspondem a {patterns}", + "cmd.restore.selective.success": "{count} arquivos extraídos para {target}", + "cmd.restore.stats.dirs": "Diretórios: {count}", + "cmd.restore.stats.files": "Arquivos: {count}", + "cmd.restore.stats.format_item": " {ext}: {count} arquivos", + "cmd.restore.stats.formats": "Distribuição de tipos de arquivo:", + "cmd.restore.stats.header": "═══ Estatísticas do pacote de backup: {path} ═══", + "cmd.restore.stats.size": "Tamanho total: {size} bytes", + "cmd.restore.tag_not_found": "Nenhum backup encontrado com a tag '{tag}'", + "cmd.rm.all.confirm": "Excluir todas as {count} estratégias de backup? (s/N): ", + "cmd.rm.all.confirm_words": "y,yes", + "cmd.rm.all.empty": "Nenhuma estratégia de backup para excluir.", + "cmd.rm.all.success": "Todas as {count} estratégias de backup foram excluídas.", + "cmd.rm.success": "Estratégia de backup removida com sucesso: {path}", + "cmd.rotate.auto_done": "Rotação automática concluída: {deleted} arquivos excluídos.", + "cmd.rotate.auto_title": "Rotacionando backups em {path}...", + "cmd.rotate.delete_item": " - {name} {size}", + "cmd.rotate.done": "{count} arquivos de backup rotacionados.", + "cmd.rotate.dry_run_header": "Modo de visualização (nada será excluído):", + "cmd.rotate.dry_run_item": " ~ {name} {size} {time}", + "cmd.rotate.header": "═══ Rotação de backups ═══", + "cmd.rotate.no_action": "Não há arquivos para rotacionar.", + "cmd.rotate.plan_delete": "{count} arquivos serão excluídos:", + "cmd.rotate.plan_keep": "{count} arquivos serão mantidos:", + "cmd.rotate.scan_empty": "Nenhum arquivo de backup encontrado em {path}.", + "cmd.rotate.scan_found": "{count} arquivos de backup encontrados em {path}:", + "cmd.rotate.scan_item": " {name} {size} {time}", + "cmd.save.completed": "Backup concluído, {count} estratégia(s) processada(s).", + "cmd.save.summary": "Resumo do backup: {total} estratégias, {backed} salvas, {skipped} ignoradas, levou {elapsed:.1f}s", + "cmd.save.uptodate": "Todas as estratégias de backup estão atualizadas, backup não necessário.", + "cmd.schedule.exported": "Configuração de agendamento exportada para: {path} ({type})", + "cmd.schedule.install.launchd": "Após salvar o arquivo plist, execute:", + "cmd.schedule.install.schtasks_cmd": "Execute o comando abaixo como administrador para criar a tarefa agendada:\n {cmdline}", + "cmd.schedule.install.systemd": "Execute o comando abaixo como root para instalar o timer do systemd:", + "cmd.schedule.schtasks.generated": "Comando de tarefa agendada do Windows gerado", + "cmd.schedule.unknown_type": "Tipo de agendamento desconhecido: {type}", + "cmd.search.no_results": "Nenhum arquivo correspondente a '{pattern}' encontrado em {path}", + "cmd.search.no_results_global": "Nenhum arquivo correspondente a '{pattern}' encontrado em nenhum backup", + "cmd.search.results": "{count} arquivos encontrados em {path}:", + "cmd.sftp.config_saved": "Configuração SFTP salva em config.json", + "cmd.sftp.no_default_key": "Nenhuma chave SSH padrão encontrada, configure senha ou caminho", + "cmd.sftp.parallel_upload": "Enviando {count} arquivos em paralelo...", + "cmd.sftp.progress": "Upload", + "cmd.sftp.success": "Upload bem-sucedido: {file}", + "cmd.sftp.test_fail": "Teste de conexão SFTP falhou, verifique configuração", + "cmd.sftp.test_ok": "Teste de conexão SFTP bem-sucedido!", + "cmd.sftp.testing": "Testando conexão com {host}...", + "cmd.sftp.uploading": "Enviando: {file}", + "cmd.sftp.using_default_key": "Chave SSH padrão detectada, usando: {path}", + "cmd.status.avg_ratio": "Taxa de compactação média: {ratio:.1f}%", + "cmd.status.header": "=== Painel de Status do Backup ===", + "cmd.status.history": "Registros no histórico: {count}", + "cmd.status.strategies": "Total de estratégias: {count}", + "cmd.status.strategy.format": " Formato: {format}", + "cmd.status.strategy.header": "--- Detalhes da Estratégia ---", + "cmd.status.strategy.item": " {source} -> {target}", + "cmd.status.strategy.last_backup": " Último backup: {time}", + "cmd.status.strategy.never": " Último backup: Nunca", + "cmd.status.strategy.size": " Tamanho de destino: {size:.2f} MB", + "cmd.status.total_size": "Tamanho total do backup: {size:.2f} MB", + "cmd.upload.parallel": "Enviando em paralelo para SFTP e WebDAV...", + "cmd.verify.batch_failed": "✗ Falhou: {path}", + "cmd.verify.batch_success": "✓ Verificado: {path}", + "cmd.verify.batch_summary": "Verificação em lote concluída: {success} aprovados, {fail} falharam", + "cmd.verify.checking": "Verificando backup: {path}", + "cmd.verify.checksum_mismatch": "Soma de verificação não corresponde", + "cmd.verify.detail_header": "=== Detalhes do backup: {path} ===", + "cmd.verify.detail_info": "Tamanho: {size:.2f} MB | Arquivos: {files} | Formato: {fmt} | SHA256: {sha256}...", + "cmd.verify.failed": "Verificação falhou: {path} — esperado {expected} arquivos, obtido {actual}", + "cmd.verify.fast_failed": "Verificação rápida falhou: {path}, arquivo pode estar corrompido ou adulterado", + "cmd.verify.fast_success": "Verificação rápida aprovada: {path}\nSHA256: {sha256}", + "cmd.verify.no_checksum": "Soma de verificação histórica não encontrada, verificação completa: {path}", + "cmd.verify.no_file": "Especifique o caminho do arquivo ou use --all para verificar todos os backups.", + "cmd.verify.no_history": "Nenhum histórico de backup encontrado.", + "cmd.verify.partial_fail": "Aviso: {count} arquivo(s) de backup falharam na verificação!", + "cmd.verify.split_error": "Erro na verificação de arquivos divididos: {error}", + "cmd.verify.split_failed": "Verificação de integridade de arquivos divididos falhou: {file}", + "cmd.verify.split_success": "Integridade de arquivos divididos verificada: {file} ({parts} partes)", + "cmd.verify.success": "Verificação bem-sucedida: {path} ({count} arquivos)", + "cmd.versions.empty": "Nenhum registro de versão de backup.", + "cmd.versions.header": "═══ Histórico de versões de backup ═══", + "cmd.versions.source": "📁 {source}", + "cmd.versions.total": "Total de {count} versões, {sources} diretórios de origem", + "cmd.watch.start": "Monitoramento iniciado, intervalo {interval} min (Ctrl+C para parar)", + "cmd.watch.start_realtime": "Monitoramento de sistema de arquivos em tempo real iniciado, debounce {debounce}s (Ctrl+C para parar)", + "cmd.webdav.config_saved": "Configuração WebDAV salva em config.json", + "cmd.webdav.parallel_upload": "Enviando {count} arquivos em paralelo para WebDAV...", + "cmd.webdav.success": "Upload WebDAV bem-sucedido: {file}", + "cmd.webdav.test_ok": "Teste de conexão WebDAV bem-sucedido!", + "cmd.webdav.testing": "Testando conexão WebDAV com {url}...", + "cmd.webdav.uploading": "Fazendo upload para WebDAV: {file}", + "cmd.webhook.available_presets": "Presets de notificação IM disponíveis:", + "cmd.webhook.preset_set": "Modelo de notificação {name} definido:\n{template}", + "cmd.webhook.preset_unknown": "Preset desconhecido: {name}", + "cmd.wizard.choice": "Insira sua opção:", + "cmd.wizard.cloud_done": "Configuração de armazenamento em nuvem concluída.", + "cmd.wizard.cloud_info": "Armazenamento em nuvem compatível com S3 (AWS S3, MinIO, Backblaze, Tencent COS):", + "cmd.wizard.complete": "Configuração do sbackup concluída!", + "cmd.wizard.dest": "Caminho da pasta de destino para os backups:", + "cmd.wizard.email_done": "Notificação por e-mail configurada.", + "cmd.wizard.email_yn": "Deseja configurar notificação por e-mail? (s/N):", + "cmd.wizard.host": "Endereço do servidor:", + "cmd.wizard.ignore": "Padrões para ignorar (separados por vírgula, padrão: .git,__pycache__):", + "cmd.wizard.lang_set": "Idioma definido para {lang}", + "cmd.wizard.more": "Deseja adicionar mais? (s/N):", + "cmd.wizard.password": "Senha:", + "cmd.wizard.port": "Porta:", + "cmd.wizard.remote_path": "Caminho remoto:", + "cmd.wizard.schedule_info": "Execute o comando abaixo para iniciar backup agendado (a cada {interval} minutos):", + "cmd.wizard.schedule_interval": "Intervalo de backup em minutos (padrão: 60):", + "cmd.wizard.schedule_yn": "Deseja configurar backup agendado? (s/N):", + "cmd.wizard.sftp_done": "Configuração SFTP concluída.", + "cmd.wizard.skip": "Pular", + "cmd.wizard.source": "Caminho da pasta de origem para backup:", + "cmd.wizard.step_email": "Passo 4/5: Notificação por e-mail (opcional)", + "cmd.wizard.step_lang": "Passo 1/5: Escolher idioma da interface", + "cmd.wizard.step_remote": "Passo 3/5: Configurar armazenamento remoto (opcional)", + "cmd.wizard.step_schedule": "Passo 5/5: Backup agendado (opcional)", + "cmd.wizard.step_strategy": "Passo 2/5: Adicionar estratégia de backup", + "cmd.wizard.summary": "{strategies} estratégias de backup adicionadas.\nExecute 'sbackup save' para executar o backup agora.", + "cmd.wizard.user": "Usuário:", + "cmd.wizard.webdav_done": "Configuração WebDAV concluída.", + "cmd.wizard.welcome": "Bem-vindo ao assistente de configuração do sbackup!\nEste assistente irá guiá-lo pela configuração inicial.", + "cmd.xsearch.no_query": "Especifique critérios de pesquisa: --keyword, --pattern ou --ext", + "compress.progress": "Comprimindo", + "compress.success": "Backup concluído: {path} ({original:.2f} MB → {size:.2f} MB, {ratio:.0f}%, {count} arquivos)", + "compress.unit": "arquivos", + "diskcheck.disk_free": " Livre: {size}", + "diskcheck.disk_header": "Disco de destino:", + "diskcheck.disk_total": " Total: {size}", + "diskcheck.disk_used": " Usado: {size}", + "diskcheck.estimated_size": "Tamanho estimado do backup: {size} (taxa de compactação: {ratio})", + "diskcheck.header": "═══ Verificação de espaço em disco ═══", + "diskcheck.insufficient": "Espaço em disco insuficiente: precisa de {needed}, disponível {available}, faltam {missing}", + "diskcheck.source": "Diretório de origem: {path}", + "diskcheck.source_size": "Tamanho total dos arquivos de origem: {size}", + "diskcheck.status.insufficient": "Verificação de espaço: insuficiente!\n Estimativa necessária: {needed}\n Espaço disponível: {available}\n Falta: {missing}", + "diskcheck.status.ok": "Verificação de espaço: suficiente (margem de {margin})", + "diskcheck.target": "Caminho de destino: {path}", + "dryrun.and_others": "e outros", + "dryrun.excluded_files": "Arquivos excluídos", + "dryrun.excluded_files_list": "Lista de arquivos excluídos", + "dryrun.exclusion_reasons": "Motivos da exclusão", + "dryrun.file_types": "Tipos de arquivo", + "dryrun.included_files": "Arquivos incluídos", + "dryrun.included_files_list": "Lista de arquivos incluídos", + "dryrun.largest_files": "Maiores arquivos", + "dryrun.more_files": "... e mais {count} arquivos", + "dryrun.no_extension": "(sem extensão)", + "dryrun.reason.excluded": "Corresponde à regra de exclusão", + "dryrun.reason.not_included": "Não corresponde à regra de inclusão", + "dryrun.reason.too_large": "Excede o tamanho máximo de arquivo", + "dryrun.reason.too_small": "Menor que o tamanho mínimo de arquivo", + "dryrun.scanned_dir": "Diretório escaneado", + "dryrun.skip": "Pular", + "dryrun.title": "Visualização dry-run", + "dryrun.total_files": "Total de arquivos", + "dryrun.total_size": "Tamanho total", + "dryrun.unit_files": "arquivos", + "dryrun.warning.disk_space": "Espaço em disco insuficiente: disponível {free}, necessário {need}", + "dryrun.warning.not_dir": "O caminho não é um diretório válido: {path}", + "dryrun.warning.os_error": "Erro do sistema: {error}", + "dryrun.warning.permission": "Permissão insuficiente para acessar: {path}", + "dryrun.warnings": "Avisos", + "err.argparse.choose_from": "escolher entre", + "err.argparse.invalid_choice": "opção inválida: ", + "err.argparse.invalid_float": "valor float inválido: ", + "err.argparse.invalid_int": "valor int inválido: ", + "err.argparse.required": "obrigatório", + "err.argparse.unrecognized_args": "argumentos não reconhecidos: ", + "err.dest.invalid": "Erro: A pasta de destino {path} não é uma pasta válida ou não existe.", + "err.file.not_found": "Erro: O arquivo de backup {path} não existe.", + "err.folder.invalid": "Erro: {path} não é uma pasta válida ou não existe.", + "err.invalid_filename": "Nome de arquivo inválido: {filename} (separadores de caminho e sequências .. não são permitidos)", + "err.lock.conflict": "Erro: outra operação de backup está em execução (arquivo de lock já existe). Se tiver certeza de que nenhum outro processo está rodando, exclua o arquivo de lock manualmente.", + "err.os": "Erro do sistema: {error}", + "err.permission": "Permissão negada: Não é possível escrever em '{path}'", + "err.sftp.auth": "Falha na autenticação SFTP: {host}, verifique usuário e senha", + "err.sftp.connect": "Não foi possível conectar a {host}:{port}: {error}", + "err.sftp.delete": "Falha ao excluir arquivo remoto {path}: {error}", + "err.sftp.host_key_mismatch": "A chave do host {host} não corresponde ao known_hosts (possível ataque man-in-the-middle)", + "err.sftp.key_load": "Não foi possível carregar a chave privada {path}, verifique o formato (RSA/Ed25519/ECDSA)", + "err.sftp.key_needs_passphrase": "A chave {path} requer frase, insira ou use --key-passphrase", + "err.sftp.local_not_found": "Arquivo local não encontrado: {path}", + "err.sftp.mkdir": "Falha ao criar diretório remoto {path}: {error}", + "err.sftp.mkdir_max_depth": "Níveis de diretório remoto muito profundos ou em loop: {path}", + "err.sftp.no_host_key": "Não foi possível obter a chave do host {host}", + "err.sftp.not_configured": "SFTP não configurado, execute: sbackup sftp config --host --user ", + "err.sftp.not_connected": "SFTP não conectado, chame connect() primeiro", + "err.sftp.remote_not_found": "Arquivo remoto não existe: {path}", + "err.sftp.ssh": "Erro de conexão SFTP: {error}", + "err.sftp.upload": "Falha no upload {path}: {error}", + "err.sftp.wrong_passphrase": "Frase incorreta, tente novamente (Enter para pular chave)", + "err.unknown": "Erro desconhecido: {error}", + "err.unknown.format": "Erro: Formato de arquivo de backup não reconhecido {path}", + "err.webdav.auth": "Autenticação WebDAV falhou: {host}", + "err.webdav.connect": "Não foi possível conectar ao WebDAV: {url}, erro: {error}", + "err.webdav.delete": "Falha ao excluir arquivo remoto {path}: {error}", + "err.webdav.invalid_scheme": "Protocolo de URL '{scheme}' não suportado, apenas http e https são permitidos.", + "err.webdav.list": "Falha ao listar arquivos remotos {path}: {error}", + "err.webdav.local_not_found": "Arquivo local não encontrado: {path}", + "err.webdav.mkdir": "Falha ao criar diretório remoto WebDAV {path}: {error}", + "err.webdav.not_configured": "WebDAV não configurado, execute: sbackup webdav config --url --user ", + "err.webdav.not_found": "Arquivo remoto não existe: {path}", + "err.webdav.private_ip": "Conexão com endereço de rede interna bloqueada: {host}", + "err.webdav.ssh": "Erro de conexão WebDAV: {error}", + "err.webdav.upload": "Upload para WebDAV falhou {path}: {error}", + "err.webdav.xml_too_large": "Resposta WebDAV muito grande ({size} bytes), possível ataque XML bomb", + "exit.message": "\nSaindo.", + "info.already.added": "Info: {path} já foi adicionado. Não duplique.", + "log.cleanup.delete": "Backup antigo excluído: %s", + "log.config.mkdir.error": "Não é possível criar diretório de configuração %s: %s", + "log.config.malformed": "Arquivo de configuração %s malformado, usando valores padrão", + "log.config.reset": "Arquivo de configuração %s malformado, redefinindo", + "log.config.write.error": "Não é possível escrever arquivo de configuração %s: %s", + "log.data.create": "Arquivo de dados não existe, criando: %s", + "log.data.load": "Carregando arquivo de dados existente: %s", + "log.data.read": "Lendo arquivo de dados: %s", + "log.data.write": "Escrevendo arquivo de dados: %s", + "log.i18n.load.failed": "Falha ao carregar locale {path}: {error}", + "log.i18n.load.fallback_failed": "Falha ao carregar locale de fallback {path}: {error}", + "log.ignore.loaded": "Arquivo de regras de ignorar carregado: %s", + "log.monitor.change": "Alteração de arquivo detectada: {path} ({dirty_count} diretórios pendentes de backup)", + "log.monitor.no_sources": "Nenhum diretório de origem válido para monitorar, monitor não iniciado", + "log.monitor.source_missing": "Diretório de origem do monitor não encontrado, ignorando: {path}", + "log.monitor.trigger": "Tempo de debounce esgotado, acionando backup ({dirty_count} diretórios)", + "log.monitor.watching": "Monitorando: {path}", + "log.parallel.backup": "Backup paralelo de %d estratégias", + "log.rotation.delete_error": "Falha ao excluir %s: %s", + "log.rotation.deleted": "Excluído: %s (%.2f MB)", + "log.rotation.dry_run_skip": "[dry-run] Será excluído: %s (%.2f MB)", + "multi_dest.aborted": "Backup de múltiplos destinos abortado: backup local falhou", + "multi_dest.complete": "Backup de múltiplos destinos concluído", + "multi_dest.local.failed": "Backup local falhou", + "multi_dest.local.success": "Backup local concluído: {path}", + "multi_dest.started": "Backup de múltiplos destinos iniciado: {source} -> {targets}", + "multi_dest.uploading": "Enviando em paralelo para {count} destinos remotos...", + "restore.list.empty": "O arquivo está vazio: {path}", + "restore.list.title": "Conteúdo do arquivo: {path}", + "restore.no_match": "Nenhum arquivo correspondente a \"{pattern}\"", + "restore.progress": "Restaurando", + "restore.skipped_unsafe": "{count} membros de arquivo inseguros ignorados (proteção contra path traversal)", + "restore.split.detected": "Backup dividido detectado, total de {count} partes", + "restore.split.merged": "Partes do backup mescladas: {path}", + "restore.split.missing": "Erro: parte do arquivo faltando, esperado {path}", + "restore.success": "Restauração concluída: {path} ({count} arquivos)", + "table.cell.default": "Padrão", + "table.cell.none": "Nenhum", + "table.header.dest": "Pasta de destino", + "table.header.files": "Arquivos", + "table.header.format": "Formato", + "table.header.ignore": "Padrões de exclusão", + "table.header.ratio": "Ratio", + "table.header.size": "Tamanho(MB)", + "table.header.source": "Pasta de origem", + "table.header.time": "Hora do backup", + "task_queue.added": "Tarefa adicionada: {name} (ID: {id})", + "task_queue.cancelled": "Cancelado pelo usuário", + "task_queue.cancelled.fail": "Não foi possível cancelar a tarefa: não encontrada ou não está pendente", + "task_queue.cancelled.success": "Tarefa cancelada: {id}", + "task_queue.clear.done": "{count} tarefas removidas da fila.", + "task_queue.clear.none": "Nenhuma tarefa concluída ou falhada para limpar.", + "task_queue.list.empty": "A fila de tarefas está vazia.", + "task_queue.list.header": "═══ Fila de tarefas ({count} tarefas) ═══", + "task_queue.list.item": " [{status}] {name} (ID: {id})", + "task_queue.list.item_detail": " Pasta: {folder}", + "task_queue.list.item_error": " Erro: {error}", + "task_queue.list.item_result": " Resultado: {result}", + "task_queue.list.item_time": " Criado em: {created}", + "task_queue.run.failed": "Falha na execução da tarefa: {name} — {error}", + "task_queue.run.none": "Nenhuma tarefa pendente na fila.", + "task_queue.run.success": "Tarefa executada com sucesso: {name} (resultado: {result})", + "task_queue.run_all.done": "{count} tarefas executadas, {success} com sucesso, {failed} com falha.", + "task_queue.stats.completed": "Concluídas: {completed}", + "task_queue.stats.failed": "Falhadas: {failed}", + "task_queue.stats.header": "═══ Estatísticas da fila de tarefas ═══", + "task_queue.stats.pending": "Pendentes: {pending}", + "task_queue.stats.running": "Em execução: {running}", + "task_queue.stats.total": "Total: {total}", + "warn.disk.space": "Aviso: O diretório de destino {path} não tem espaço suficiente (precisa de {need:.1f} MB, apenas {free:.1f} MB livres)", + "warn.invalid.compresslevel": "Aviso: Nível de compactação {level} fora do intervalo (0-9), usando valor padrão 6", + "warn.json.backup": "Aviso: Arquivo de dados corrompido copiado para {path}.", + "warn.json.decode.error": "Aviso: Arquivo de dados {path} malformado, inicializando como dicionário vazio.", + "warn.json.renamed": "Aviso: Arquivo de dados corrompido renomeado para {path}.", + "warn.no.strategy.found": "Aviso: Estratégia de backup para {path} não encontrada.", + "warn.password_in_cli": "Aviso: Senhas passadas via {arg} ficam expostas na lista de processos. Recomenda-se usar entrada interativa.", + "warn.sftp.no_known_hosts": "Aviso: Arquivo known_hosts não encontrado, não é possível verificar a chave do host {host}, a conexão pode ser insegura", + "warn.source.missing": "Aviso: A pasta de origem não existe: {path}", + "warn.webdav.http_plaintext": "Aviso: Usando conexão HTTP sem criptografia para WebDAV ({url}), as credenciais serão transmitidas em texto plano. Recomenda-se usar HTTPS.", + "warn.zip.overwrite": "Aviso: Sobrescrevendo arquivo de backup existente {path}" +} diff --git a/sbackup/locales/ru_RU.json b/sbackup/locales/ru_RU.json index dcf4dd7..9b6310a 100644 --- a/sbackup/locales/ru_RU.json +++ b/sbackup/locales/ru_RU.json @@ -1,80 +1,667 @@ -{ - "cmd.add.success": "Стратегия резервного копирования добавлена: {source} -> {dest}", - "cmd.rm.success": "Стратегия резервного копирования удалена: {path}", - "err.folder.invalid": "Ошибка: {path} не является допустимой папкой или не существует.", - "err.dest.invalid": "Ошибка: Целевая папка {path} не является допустимой папкой или не существует.", - "info.already.added": "Информация: {path} уже добавлен. Не добавляйте повторно.", - "warn.no.strategy.found": "Предупреждение: Стратегия резервного копирования для {path} не найдена.", - "warn.source.missing": "Предупреждение: Исходная папка не существует: {path}", - "warn.json.decode.error": "Предупреждение: Файл данных {path} повреждён, инициализация пустым словарём.", - "warn.json.backup": "Предупреждение: Повреждённый файл данных сохранён в {path}.", - "warn.json.renamed": "Предупреждение: Повреждённый файл данных переименован в {path}.", - "compress.success": "Резервное копирование выполнено: {path} ({size:.2f} МБ, {count} файлов)", - "compress.progress": "Сжатие", - "compress.unit": "файлов", - "err.permission": "Отказ в доступе: Невозможно записать в '{path}'", - "err.os": "Системная ошибка: {error}", - "err.unknown": "Неизвестная ошибка: {error}", - "err.unknown.format": "Ошибка: Неизвестный формат файла резервной копии {path}", - "err.argparse.invalid_choice": "недопустимый выбор: ", - "err.argparse.choose_from": "допустимые значения", - "err.argparse.invalid_float": "недопустимое значение типа float: ", - "err.argparse.invalid_int": "недопустимое целочисленное значение: ", - "err.argparse.unrecognized_args": "неопознанные аргументы: ", - "err.argparse.required": "обязательный", - "warn.zip.overwrite": "Предупреждение: Перезапись существующего файла резервной копии {path}", - "warn.invalid.compresslevel": "Предупреждение: Уровень сжатия {level} вне диапазона (0-9), используется значение 6", - "cmd.save.uptodate": "Все стратегии резервного копирования актуальны, копирование не требуется.", - "cmd.save.completed": "Резервное копирование завершено, обработано {count} стратегий.", - "cmd.all.empty": "Нет настроенных стратегий резервного копирования.", - "exit.message": "\nВыход.", - "table.header.source": "Исходная папка", - "table.header.dest": "Целевая папка", - "table.header.ignore": "Шаблоны исключения", - "table.cell.none": "Нет", - "table.header.format": "Формат", - "table.cell.default": "По умолч.", - "restore.progress": "Восстановление", - "restore.success": "Восстановление выполнено: {path} ({count} файлов)", - "cmd.watch.start": "Автоматическое копирование запущено, интервал {interval} мин (Ctrl+C для остановки)", - - "cli.description": "Sbackup v{version} — Лёгкий инструмент инкрементального резервного копирования\n\nSbackup помогает легко управлять резервным копированием нескольких папок.\nОн использует стратегию инкрементального копирования, сжимая только изменённые файлы,\nподдерживает пользовательские правила исключения и предоставляет наглядный ход выполнения.\n\nДоступные команды:\n add Добавить новую стратегию резервного копирования (источник -> цель)\n rm Удалить существующую стратегию\n all Показать все настроенные стратегии\n save Выполнить резервное копирование\n version Показать информацию о версии", - "cli.epilog": "Примеры:\n Добавить стратегию:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Добавить стратегию с форматом уровня записи:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n Удалить стратегию:\n sbackup rm F:/my_folder\n\n Показать все стратегии:\n sbackup all\n\n Выполнить копирование (ZIP):\n sbackup save\n\n Выполнить копирование (tar.gz):\n sbackup --format tar.gz save\n\n Выполнить копирование (7z с шифрованием):\n sbackup --format 7z save --password 123456\n\n Сохранить последние 5 копий:\n sbackup save --keep 5\n\n Автоматическое копирование (каждые 60 мин):\n sbackup watch --interval 60\n\n Автоматическое копирование + очистка:\n sbackup watch --interval 120 --keep 10\n\n Восстановление из копии:\n sbackup restore backup.7z F:/restored\n\n Китайский интерфейс:\n sbackup --lang zh_CN save\n\n Режим отладки:\n sbackup --debug save", - "cli.help.debug": "Включить режим отладки с подробным журналированием", - "cli.help.help": "Показать эту справку и выйти", - "cli.help.lang": "Выбрать язык интерфейса: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", - "cli.help.format": "Выбрать формат архивации: zip (по умолч.) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.subcommands": "Выбрать команду для выполнения", - "cli.help.add": "Добавить новую стратегию резервного копирования", - "cli.help.add.source": "Путь к исходной папке: каталог для резервного копирования", - "cli.help.add.dest": "Путь к целевой папке: место хранения файлов резервных копий", - "cli.help.add.ignore": "Файлы или папки для исключения (через запятую, по умолч.: .git,__pycache__)", - "cli.help.add.format": "Формат архивации уровня записи (только для этой стратегии, по умолчанию глобальный): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.rm": "Удалить стратегию резервного копирования", - "cli.help.rm.path": "Путь к исходной папке стратегии для удаления", - "cli.help.all": "Показать все стратегии резервного копирования", - "cli.help.save": "Выполнить все стратегии резервного копирования", - "cli.help.save.keep": "Сохранить последние N файлов копий, 0 — без очистки", - "cli.help.save.password": "Пароль шифрования (только для формата 7z)", - "cli.help.watch": "Выполнять резервное копирование периодически", - "cli.help.watch.interval": "Интервал копирования в минутах, по умолч. 60", - "cli.help.watch.keep": "Сохранить последние N файлов копий", - "cli.help.watch.password": "Пароль шифрования (только для формата 7z)", - "cli.help.restore": "Восстановить из файла резервной копии", - "cli.help.restore.file": "Путь к файлу резервной копии (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", - "cli.help.restore.dir": "Целевой каталог для восстановления", - "cli.help.version": "Показать информацию о версии", - "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nЛицензия GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", - - "log.config.malformed": "Файл конфигурации %s повреждён, используются значения по умолчанию", - "log.config.reset": "Файл конфигурации %s повреждён, сброс", - "log.config.mkdir.error": "Невозможно создать каталог конфигурации %s : %s", - "log.config.write.error": "Невозможно записать файл конфигурации %s : %s", - "log.data.read": "Чтение файла данных : %s", - "log.data.create": "Файл данных не существует, создание : %s", - "log.data.load": "Загрузка существующего файла данных : %s", - "log.data.write": "Запись файла данных : %s", - "log.cleanup.delete": "Удалена старая резервная копия : %s", - "log.i18n.load.failed": "Не удалось загрузить локаль {path}: {error}", - "log.i18n.load.fallback_failed": "Не удалось загрузить локаль возврата {path}: {error}" +{ + "cmd.add.success": "Стратегия резервного копирования добавлена: {source} -> {dest}", + "cmd.rm.success": "Стратегия резервного копирования удалена: {path}", + "cmd.edit.success": "Стратегия резервного копирования успешно изменена: {path}", + "cmd.edit.nothing": "Не указаны параметры для изменения.", + "err.folder.invalid": "Ошибка: {path} не является допустимой папкой или не существует.", + "err.file.not_found": "Ошибка: Файл резервной копии {path} не существует.", + "err.dest.invalid": "Ошибка: Целевая папка {path} не является допустимой папкой или не существует.", + "info.already.added": "Информация: {path} уже добавлен. Не добавляйте повторно.", + "warn.no.strategy.found": "Предупреждение: Стратегия резервного копирования для {path} не найдена.", + "warn.source.missing": "Предупреждение: Исходная папка не существует: {path}", + "warn.json.decode.error": "Предупреждение: Файл данных {path} повреждён, инициализация пустым словарём.", + "warn.json.backup": "Предупреждение: Повреждённый файл данных сохранён в {path}.", + "warn.json.renamed": "Предупреждение: Повреждённый файл данных переименован в {path}.", + "compress.success": "Backup successful: {path} ({original:.2f} MB → {size:.2f} MB, {ratio:.0f}%, {count} files)", + "compress.progress": "Сжатие", + "compress.unit": "файлов", + "err.permission": "Отказ в доступе: Невозможно записать в '{path}'", + "err.os": "Системная ошибка: {error}", + "err.unknown": "Неизвестная ошибка: {error}", + "err.unknown.format": "Ошибка: Неизвестный формат файла резервной копии {path}", + "err.argparse.invalid_choice": "недопустимый выбор: ", + "err.argparse.choose_from": "допустимые значения", + "err.argparse.invalid_float": "недопустимое значение типа float: ", + "err.argparse.invalid_int": "недопустимое целочисленное значение: ", + "err.argparse.unrecognized_args": "неопознанные аргументы: ", + "err.argparse.required": "обязательный", + "warn.zip.overwrite": "Предупреждение: Перезапись существующего файла резервной копии {path}", + "warn.invalid.compresslevel": "Предупреждение: Уровень сжатия {level} вне диапазона (0-9), используется значение 6", + "cmd.save.uptodate": "Все стратегии резервного копирования актуальны, копирование не требуется.", + "cmd.save.completed": "Резервное копирование завершено, обработано {count} стратегий.", + "cmd.all.empty": "Нет настроенных стратегий резервного копирования.", + "cmd.list.empty": "История резервного копирования пока пуста.", + "cmd.verify.checking": "Проверка резервной копии: {path}", + "cmd.verify.success": "Проверка пройдена: {path} ({count} файлов)", + "cmd.verify.failed": "Проверка не пройдена: {path} — ожидается {expected} файлов, получено {actual}", + "cmd.info.header": "=== Информация о файле резервной копии ===", + "cmd.info.path": "Путь: {path}", + "cmd.info.format": "Формат: {format}", + "cmd.info.size": "Размер: {size:.2f} МБ", + "cmd.info.files": "Количество файлов: {count}", + "cmd.info.time": "Изменён: {time}", + "cmd.info.sha256": "SHA256: {checksum}", + "cmd.rm.all.confirm": "Удалить все {count} стратегий резервного копирования? (д/Н): ", + "cmd.rm.all.confirm_words": "y,yes", + "cmd.rm.all.success": "Удалены все {count} стратегий резервного копирования.", + "cmd.rm.all.empty": "Нет стратегий для удаления.", + "cmd.init.success": "Файл конфигурации создан: {path}", + "cmd.init.exists": "Файл конфигурации уже существует: {path}, пропуск.", + "warn.disk.space": "Внимание: В целевом каталоге {path} недостаточно места (нужно {need:.1f} МБ, свободно {free:.1f} МБ)", + "cmd.save.summary": "Итоги копирования: {total} стратегий, {backed} скопировано, {skipped} пропущено, заняло {elapsed:.1f}с", + "cmd.dry_run.header": "Dry-run preview (no backup will be performed):", + "cmd.dry_run.item": " {source} -> {target} format: {format} files: {count} size: {size} MB", + "cmd.dry_run.total": "Total: {count} strategies, {files} files, {size} MB", + "cmd.dry_run.nothing": "No strategies need backup (all are up to date).", + "cmd.verify.partial_fail": "Warning: {count} backup file(s) failed verification!", + "cmd.status.header": "=== Backup Status Dashboard ===", + "cmd.status.strategies": "Total strategies: {count}", + "cmd.status.history": "History records: {count}", + "cmd.status.total_size": "Total backup size: {size:.2f} MB", + "cmd.status.avg_ratio": "Average compression ratio: {ratio:.1f}%", + "cmd.status.strategy.header": "--- Strategy Details ---", + "cmd.status.strategy.item": " {source} -> {target}", + "cmd.status.strategy.last_backup": " Last backup: {time}", + "cmd.status.strategy.never": " Last backup: Never", + "cmd.status.strategy.format": " Format: {format}", + "cmd.status.strategy.size": " Target size: {size:.2f} MB", + "log.parallel.backup": "Параллельное копирование %d стратегий", + "restore.list.title": "Содержимое архива: {path}", + "restore.list.empty": "Архив пуст: {path}", + "log.ignore.loaded": "Загружен файл правил игнорирования: %s", + "exit.message": "\nВыход.", + "table.header.source": "Исходная папка", + "table.header.dest": "Целевая папка", + "table.header.ignore": "Шаблоны исключения", + "table.header.time": "Время копии", + "table.header.size": "Размер(МБ)", + "table.header.ratio": "Ratio", + "table.header.files": "Файлы", + "table.cell.none": "Нет", + "table.header.format": "Формат", + "table.cell.default": "По умолч.", + "restore.progress": "Восстановление", + "restore.success": "Восстановление выполнено: {path} ({count} файлов)", + "restore.skipped_unsafe": "Пропущено {count} небезопасных элементов архива (защита от обхода пути)", + "cmd.watch.start": "Автоматическое копирование запущено, интервал {interval} мин (Ctrl+C для остановки)", + "cli.description": "Sbackup v{version} — Лёгкий инструмент инкрементального резервного копирования\n\nSbackup помогает легко управлять резервным копированием нескольких папок.\nОн использует стратегию инкрементального копирования, сжимая только изменённые файлы,\nподдерживает пользовательские правила исключения и предоставляет наглядный ход выполнения.\n\nДоступные команды:\n add Добавить новую стратегию резервного копирования (источник -> цель)\n rm Удалить существующую стратегию\n all Показать все настроенные стратегии\n save Выполнить резервное копирование\n version Показать информацию о версии", + "cli.epilog": "Примеры:\n Добавить стратегию:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n Резервное копирование (7z шифрование):\n sbackup --format 7z save --password 123456\n\n Восстановить:\n sbackup restore backup.7z F:/restored\n\n SFTP (авто-детект ключа, рекомендуется):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP (пароль):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP (ключ): \n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP (ключ + фраза):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp", + "cli.help.debug": "Режим отладки с подробным логированием (после подкоманды)", + "cli.help.help": "Показать эту справку и выйти", + "cli.help.lang": "Выбрать язык интерфейса: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", + "cli.help.format": "Выбрать формат архивации: zip (по умолч.) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.follow_symlinks": "Follow symbolic links (default: do not follow to avoid loops)", + "cli.help.incremental": "File-level incremental backup (only compress changed files, significantly reduces backup time)", + "cli.help.subcommands": "Выбрать команду для выполнения", + "cli.help.add": "Добавить новую стратегию резервного копирования", + "cli.help.add.source": "Путь к исходной папке: каталог для резервного копирования", + "cli.help.add.dest": "Путь к целевой папке: место хранения файлов резервных копий", + "cli.help.add.ignore": "Файлы или папки для исключения (через запятую, по умолч.: .git,__pycache__)", + "cli.help.add.format": "Формат архивации уровня записи (только для этой стратегии, по умолчанию глобальный): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.add.name_template": "Per-entry filename template (supports {name} {date} {time} variables, uses global default if not specified)", + "cli.help.rm": "Удалить стратегию резервного копирования", + "cli.help.rm.path": "Путь к исходной папке стратегии для удаления", + "cli.help.all": "Показать все стратегии резервного копирования", + "cli.help.save": "Выполнить все стратегии резервного копирования", + "cli.help.save.keep": "Сохранить последние N файлов копий, 0 — без очистки", + "cli.help.save.password": "Пароль шифрования (только для формата 7z)", + "cli.help.watch": "Выполнять резервное копирование периодически", + "cli.help.watch.interval": "Интервал копирования в минутах, по умолч. 60", + "cli.help.watch.keep": "Сохранить последние N файлов копий", + "cli.help.watch.password": "Пароль шифрования (только для формата 7z)", + "cli.help.restore": "Восстановить из файла резервной копии", + "cli.help.restore.file": "Путь к файлу резервной копии (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", + "cli.help.restore.dir": "Целевой каталог для восстановления", + "cli.help.restore.password": "Пароль расшифровки (для восстановления зашифрованных резервных копий 7z)", + "cli.help.restore.list": "Показать содержимое архива без распаковки", + "cli.help.list": "Просмотреть историю резервного копирования", + "cli.help.verify": "Проверить целостность файла резервной копии", + "cli.help.verify.file": "Файл резервной копии для проверки", + "cli.help.verify.fast": "Быстрая проверка (сравнение SHA256, без распаковки)", + "cli.help.verify.all": "Пакетная проверка всех исторических резервных копий", + "cli.help.verify.detail": "Показать подробную информацию (размер, формат, контрольная сумма и т.д.)", + "cli.help.verify.split": "Проверить целостность разделенных файлов (требуется файл манифеста)", + "cli.help.verify.password": "Пароль расшифровки (для зашифрованных копий)", + "cmd.verify.fast_success": "Быстрая проверка пройдена: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "Быстрая проверка не пройдена: {path}, файл может быть поврежден или изменен", + "cmd.verify.no_checksum": "Историческая контрольная сумма не найдена, полная проверка: {path}", + "cmd.verify.checksum_mismatch": "Контрольная сумма не совпадает", + "cmd.verify.no_history": "История резервных копий не найдена.", + "cmd.verify.no_file": "Укажите путь к файлу или используйте --all для проверки всех копий.", + "cmd.verify.detail_header": "=== Детали резервной копии: {path} ===", + "cmd.verify.detail_info": "Размер: {size:.2f} МБ | Файлов: {files} | Формат: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ Проверено: {path}", + "cmd.verify.batch_failed": "✗ Ошибка: {path}", + "cmd.verify.batch_summary": "Пакетная проверка завершена: {success} успешно, {fail} с ошибками", + "cmd.verify.split_success": "Целостность разделенных файлов подтверждена: {file} ({parts} частей)", + "cmd.verify.split_failed": "Проверка целостности разделенных файлов не пройдена: {file}", + "cmd.verify.split_error": "Ошибка проверки разделенных файлов: {error}", + "cli.help.info": "Просмотреть подробную информацию о файле резервной копии (формат, размер, количество файлов, контрольная сумма и т.д.)", + "cli.help.info.file": "Путь к файлу резервной копии", + "cli.help.info.password": "Пароль расшифровки (для зашифрованных резервных копий 7z)", + "cli.help.export": "Export all backup strategies to a JSON file", + "cli.help.export.file": "Export file path (default: sbackup_export.json)", + "cli.help.import": "Import backup strategies from a JSON file", + "cli.help.import.file": "JSON file to import", + "cli.help.status": "View backup status dashboard", + "cmd.export.success": "Exported {count} backup strategies to {path}", + "cmd.import.success": "Imported {count} backup strategies, skipped {skipped} existing", + "cmd.import.empty": "No valid backup strategies found in: {path}", + "cli.help.save.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.save.verify": "Verify backup integrity after completion", + "cli.help.save.name_template": "Backup filename template (supports {name} {date} {time} variables)", + "cli.help.save.webhook": "POST backup results to this URL after completion", + "cli.help.save.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", + "cli.help.save.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", + "cli.help.save.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", + "cli.help.watch.dry_run": "Preview files to be backed up without actually compressing", + "cli.help.watch.verify": "Verify backup integrity after each backup", + "cli.help.watch.name_template": "Backup filename template (supports {name} {date} {time} variables)", + "cli.help.watch.webhook": "POST backup results to this URL after each backup", + "cli.help.watch.max_size": "Only backup files smaller than specified size (e.g. 1GB, 500MB, 100KB)", + "cli.help.watch.min_size": "Only backup files larger than specified size (e.g. 1KB, 1MB)", + "cli.help.watch.older_than": "Only backup files modified longer ago than specified (e.g. 7d, 30d, 1y)", + "cli.help.init": "Создать шаблон файла конфигурации", + "cli.help.rm.all": "Удалить все стратегии резервного копирования", + "cli.help.edit": "Изменить существующую стратегию резервного копирования (изменяет только указанные поля)", + "cli.help.edit.source": "Путь к исходной папке (для поиска стратегии)", + "cli.help.edit.dest": "Изменить путь к целевой папке", + "cli.help.edit.ignore": "Изменить шаблоны исключений (через запятую)", + "cli.help.edit.format": "Изменить формат упаковки: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.edit.name_template": "Изменить шаблон имени файла (поддержка переменных {name} {date} {time})", + "cmd.diff.header": "=== Сравнение различий ===\nФайл резервной копии: {backup}", + "cmd.diff.added_header": "\nДобавленные файлы ({count}):", + "cmd.diff.removed_header": "\nУдалённые файлы ({count}):", + "cmd.diff.modified_header": "\nВозможно изменённые файлы ({count}):", + "cmd.diff.no_changes": "\nНет различий, источник совпадает с резервной копией.", + "cmd.diff.summary": "\nИтого: +{added} добавлено, -{removed} удалено, ~{modified} изменено", + "cmd.diff.no_backup": "Файлы резервных копий не найдены в целевом каталоге: {path}", + "cmd.diff.empty_backup": "Файл резервной копии пуст: {path}", + "cli.help.diff": "Сравнить исходный каталог с последней резервной копией", + "cli.help.diff.source": "Путь к исходной папке", + "cli.help.diff.file": "Путь к файлу резервной копии (автопос最新的, если не указан)", + "cli.help.diff.password": "Пароль расшифровки (для зашифрованных копий)", + "cli.help.remote": "Управление файлами резервных копий на удалённом сервере (просмотр/удаление через SFTP или WebDAV)", + "cli.help.remote.action": "Удалённое действие: list / rm", + "cli.help.remote.list": "Список удалённых файлов резервных копий", + "cli.help.remote.rm": "Удалить удалённый файл резервной копии", + "cli.help.remote.rm.filename": "Имя удаляемого удалённого файла", + "cli.help.remote.sftp": "Работать с SFTP сервером", + "cli.help.remote.webdav": "Работать с WebDAV сервером", + "cli.help.version": "Показать информацию о версии", + "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nЛицензия GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", + "log.config.malformed": "Файл конфигурации %s повреждён, используются значения по умолчанию", + "log.config.reset": "Файл конфигурации %s повреждён, сброс", + "log.config.mkdir.error": "Невозможно создать каталог конфигурации %s : %s", + "log.config.write.error": "Невозможно записать файл конфигурации %s : %s", + "log.data.read": "Чтение файла данных : %s", + "log.data.create": "Файл данных не существует, создание : %s", + "log.data.load": "Загрузка существующего файла данных : %s", + "log.data.write": "Запись файла данных : %s", + "log.cleanup.delete": "Удалена старая резервная копия : %s", + "log.i18n.load.failed": "Не удалось загрузить локаль {path}: {error}", + "log.i18n.load.fallback_failed": "Не удалось загрузить локаль возврата {path}: {error}", + "err.sftp.auth": "Ошибка аутентификации SFTP: {host}, проверьте имя пользователя и пароль", + "err.sftp.ssh": "Ошибка подключения SFTP: {error}", + "err.sftp.connect": "Не удалось подключиться к {host}:{port}: {error}", + "err.sftp.not_connected": "SFTP не подключен, сначала вызовите connect()", + "err.sftp.local_not_found": "Локальный файл не найден: {path}", + "err.sftp.upload": "Ошибка загрузки {path}: {error}", + "err.sftp.mkdir": "Не удалось создать удаленный каталог {path}: {error}", + "err.sftp.not_configured": "SFTP не настроен, выполните: sbackup sftp config --host <адрес> --user <имя>", + "cmd.sftp.uploading": "Загрузка: {file}", + "cmd.sftp.progress": "Загрузка", + "cmd.sftp.success": "Загрузка успешна: {file}", + "cmd.sftp.config_saved": "Конфигурация SFTP сохранена в config.json", + "cmd.sftp.testing": "Проверка подключения к {host}...", + "cmd.sftp.test_ok": "Проверка SFTP-подключения успешна!", + "cmd.sftp.test_fail": "Проверка SFTP-подключения не удалась, проверьте конфигурацию", + "cli.help.save.sftp": "Загрузить на SFTP-сервер после резервного копирования", + "cli.help.watch.sftp": "Загружать на SFTP-сервер после каждого копирования", + "cli.help.sftp": "Управление SFTP удалённым копированием (пароль и SSH-ключ)", + "cli.help.sftp.action": "Действие SFTP: config / test", + "cli.help.sftp.config": "Настроить параметры подключения SFTP", + "cli.help.sftp.test": "Проверить доступность SFTP-подключения", + "cli.help.sftp.host": "Адрес SFTP-сервера (напр: 192.168.1.100)", + "cli.help.sftp.port": "Порт SFTP (по умолчанию 22)", + "cli.help.sftp.user": "Имя пользователя SFTP", + "cli.help.sftp.password": "Пароль SFTP (только аутентификация паролем)", + "cli.help.sftp.remote_path": "Удалённый путь назначения (по умолчанию: /)", + "cli.prompt.sftp.host": "Адрес SFTP-сервера:", + "cli.prompt.sftp.port": "Порт SFTP (по умолчанию 22):", + "cli.prompt.sftp.user": "Имя пользователя SFTP:", + "cli.prompt.sftp.password": "Пароль SFTP:", + "cli.prompt.sftp.remote_path": "Удаленный путь (по умолчанию /):", + "err.sftp.key_load": "Не удалось загрузить приватный ключ {path}, проверьте формат (RSA/Ed25519/ECDSA)", + "cli.help.sftp.key_file": "Путь к приватному ключу SSH (RSA/Ed25519/ECDSA, приоритет над паролем)", + "cli.help.sftp.key_passphrase": "Парольная фраза ключа (вводится интерактивно)", + "cli.prompt.sftp.key_file": "Путь к приватному ключу SSH (пусто для пароля):", + "cli.prompt.sftp.key_passphrase": "Парольная фраза ключа (Enter чтобы пропустить):", + "cmd.sftp.using_default_key": "Обнаружен стандартный SSH-ключ, используется: {path}", + "cmd.sftp.no_default_key": "Стандартный SSH-ключ не найден, настройте пароль или укажите путь", + "err.sftp.key_needs_passphrase": "Ключ {path} требует фразу, введите или используйте --key-passphrase", + "err.sftp.wrong_passphrase": "Неверная фраза, попробуйте снова (Enter чтобы пропустить ключ)", + "cli.help.webdav": "Удалённое резервное копирование WebDAV (поддерживает Jianguoyun, NextCloud, Synology и др.)", + "cli.help.webdav.action": "Действие WebDAV: config / test", + "cli.help.webdav.config": "Настроить параметры подключения WebDAV", + "cli.help.webdav.test": "Проверить доступность WebDAV-подключения", + "cli.help.webdav.url": "URL WebDAV-сервера (напр: https://dav.jianguoyun.com/dav/)", + "cli.help.webdav.user": "Имя пользователя WebDAV", + "cli.help.webdav.password": "Пароль WebDAV", + "cli.help.webdav.remote_path": "Удалённый путь назначения (по умолчанию: /)", + "cli.help.save.webdav": "Загрузить на WebDAV-сервер после резервного копирования", + "cli.help.watch.webdav": "Загружать на WebDAV-сервер после каждого резервного копирования", + "cli.prompt.webdav.url": "URL WebDAV-сервера:", + "cli.prompt.webdav.user": "Имя пользователя WebDAV:", + "cli.prompt.webdav.password": "Пароль WebDAV:", + "cli.prompt.webdav.remote_path": "Удалённый путь (по умолчанию /):", + "cmd.webdav.config_saved": "Конфигурация WebDAV сохранена в config.json", + "cmd.webdav.testing": "Проверка подключения к WebDAV: {url}...", + "cmd.webdav.test_ok": "Проверка WebDAV-подключения успешна!", + "cmd.webdav.uploading": "Загрузка на WebDAV: {file}", + "cmd.webdav.success": "Загрузка на WebDAV успешна: {file}", + "err.webdav.auth": "Аутентификация WebDAV не удалась: {host}", + "err.webdav.connect": "Не удаётся подключиться к WebDAV: {url}, ошибка: {error}", + "err.webdav.ssh": "Ошибка подключения WebDAV: {error}", + "err.webdav.not_configured": "WebDAV не настроен, выполните: sbackup webdav config --url <адрес> --user <имя_пользователя>", + "err.webdav.local_not_found": "Локальный файл не найден: {path}", + "err.webdav.upload": "Ошибка загрузки на WebDAV {path}: {error}", + "err.webdav.mkdir": "Ошибка создания удалённого каталога WebDAV {path}: {error}", + "cli.help.watch.realtime": "Enable real-time filesystem monitoring (auto-trigger backup on file changes)", + "cli.help.watch.debounce": "Debounce seconds to wait after last file change before backup (default: 30)", + "cmd.watch.start_realtime": "Real-time filesystem monitoring started, debounce {debounce}s (Ctrl+C to stop)", + "log.monitor.change": "File change detected: {path} ({dirty_count} dirs pending backup)", + "log.monitor.trigger": "Debounce timeout, triggering backup ({dirty_count} dirs)", + "log.monitor.source_missing": "Monitor source directory not found, skipping: {path}", + "log.monitor.watching": "Now monitoring: {path}", + "log.monitor.no_sources": "No valid source directories to monitor, monitor not started", + "benchmark.empty_source": "Исходная директория пуста: {path}", + "benchmark.error": "Ошибка тестирования формата {format}: {error}", + "benchmark.header": "═══ Тестирование сжатия ═══", + "benchmark.recommended": "Рекомендуется: {format} уровень={level} ({size}, оптимальный баланс)", + "benchmark.running": "Тестирование {format} уровень={level}...", + "benchmark.source_info": "Исходная директория: {path} ({size}, {count} файлов)", + "cli.help.add.from_gitignore": "Импортировать правила игнорирования из файла .gitignore", + "cli.help.audit": "Просмотреть журнал аудита резервных копий", + "cli.help.audit.action": "Действие аудита: log(запрос) / stats(статистика) / cleanup(очистка)", + "cli.help.audit.cleanup": "Очистить старые записи журнала аудита", + "cli.help.audit.cleanup.keep_days": "Хранить записи за последние N дней (по умолчанию: 90)", + "cli.help.audit.log": "Запросить записи журнала аудита", + "cli.help.audit.log.event": "Фильтр по типу события", + "cli.help.audit.log.limit": "Максимальное количество записей (по умолчанию: 50)", + "cli.help.audit.log.since": "Показать записи после указанной даты (формат ISO 8601)", + "cli.help.audit.log.status": "Фильтр по статусу: success / failed / partial", + "cli.help.audit.stats": "Показать статистику журнала аудита", + "cli.help.benchmark": "Запустить тестирование сжатия для указанной директории, сравнить производительность форматов", + "cli.help.benchmark.levels": "Уровни сжатия для тестирования (через запятую, по умолчанию: 1,6,9)", + "cli.help.benchmark.password": "Пароль шифрования (для формата 7z)", + "cli.help.benchmark.path": "Путь к исходной директории для тестирования", + "cli.help.benchmark.quick": "Быстрый режим: тестировать только уровень по умолчанию для каждого формата", + "cli.help.checksum": "Обнаруживать изменения по контрольной сумме SHA256 содержимого файла (точнее mtime, но медленнее)", + "cli.help.clean": "Интеллектуальная очистка файлов резервных копий по всем стратегиям", + "cli.help.clean.dry_run": "Только предпросмотр файлов для удаления, без фактического удаления", + "cli.help.clean.keep": "Сохранить последние N файлов резервных копий для каждой стратегии", + "cli.help.clean.keep_days": "Удалить файлы резервных копий старше N дней", + "cli.help.completion": "Сгенерировать скрипт автодополнения оболочки", + "cli.help.completion.shell": "Тип оболочки: bash(по умолчанию) / zsh / fish / powershell", + "cli.help.compress": "Сжать указанную папку напрямую (без использования стратегии)", + "cli.help.compress.dry_run": "Только предпросмотр файлов для резервного копирования, без фактического сжатия", + "cli.help.compress.format": "Формат сжатия: zip(по умолчанию) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.compress.output": "Путь к выходному файлу (необязательно)", + "cli.help.compress.password": "Пароль шифрования (поддерживается только форматом 7z)", + "cli.help.compress.source": "Путь к исходной папке для сжатия", + "cli.help.config_cmd": "Управление файлом конфигурации (шифрование/расшифровка конфиденциальных полей)", + "cli.help.config_cmd.action": "Действие: lock(шифрование) / unlock(расшифровка)", + "cli.help.config_cmd.lock": "Зашифровать конфиденциальные поля в файле конфигурации мастер-паролем", + "cli.help.config_cmd.lock.password": "Мастер-пароль (если не указан, вводится интерактивно)", + "cli.help.config_cmd.unlock": "Расшифровать конфиденциальные поля в файле конфигурации мастер-паролем", + "cli.help.config_cmd.unlock.password": "Мастер-пароль (если не указан, вводится интерактивно)", + "cli.help.config_cmd.validate": "Проверить текущую конфигурацию и показать все ошибки и предупреждения", + "cli.help.dedup": "Включить кросс-стратегическое дублирование файлов на основе хеша содержимого (SHA256)", + "cli.help.diff.detail": "Показать построчные различия изменённых файлов", + "cli.help.diskcheck": "Проверить дисковое пространство перед резервным копированием (оценка размера исходной директории и файла копии)", + "cli.help.diskcheck.level": "Уровень сжатия (0-9, по умолчанию: 6)", + "cli.help.diskcheck.source": "Путь к исходной директории для сканирования", + "cli.help.diskcheck.target": "Путь к целевой директории (место хранения резервных копий)", + "cli.help.hooks": "Управление и ручной запуск хуков перед/после резервного копирования", + "cli.help.hooks.action": "Действие с хуками: run(запуск)", + "cli.help.hooks.run": "Вручную запустить хук перед или после резервного копирования", + "cli.help.hooks.run.timeout": "Переопределить тайм-аут хука в секундах (по умолчанию: из конфигурации)", + "cli.help.hooks.run.type": "Тип хука: pre(перед) или post(после)", + "cli.help.ignore": "Сгенерировать файл правил исключения .sbackupignore", + "cli.help.ignore.list": "Показать все доступные пресеты", + "cli.help.ignore.output": "Путь к выходному файлу (по умолчанию: .sbackupignore)", + "cli.help.ignore.preset": "Шаблон пресета: node / python / go / rust / java / general (по умолчанию: general)", + "cli.help.integrity": "Проверка целостности данных (генерация/проверка контрольного манифеста)", + "cli.help.integrity.action": "Действие: generate(генерация) / verify(проверка)", + "cli.help.integrity.generate": "Сгенерировать контрольный манифест integrity.json для директории резервных копий", + "cli.help.integrity.path": "Путь к директории резервных копий", + "cli.help.integrity.verify": "Проверить целостность файлов резервных копий по манифесту integrity.json", + "cli.help.list.tags": "Показать все теги резервных копий", + "cli.help.metadata_export": "Экспорт метаданных резервных копий (история/журнал аудита) в файл", + "cli.help.metadata_export.action": "Действие экспорта: history(история) / audit(аудит) / all(комплексный)", + "cli.help.metadata_export.all": "Экспорт комплексного отчёта (история + аудит + статистика)", + "cli.help.metadata_export.audit": "Экспорт журнала аудита в CSV или JSON", + "cli.help.metadata_export.event": "Фильтр по типу события", + "cli.help.metadata_export.format": "Формат вывода: csv (по умолчанию для history/audit) или json", + "cli.help.metadata_export.history": "Экспорт истории резервных копий в CSV или JSON", + "cli.help.metadata_export.output": "Путь к выходному файлу", + "cli.help.metadata_export.strategy": "Фильтр по имени стратегии (соответствует пути source)", + "cli.help.multi_backup": "Одновременное резервное копирование: локальная копия + параллельная загрузка на все включённые удалённые цели", + "cli.help.multi_backup.dry_run": "Только предпросмотр файлов, без фактического выполнения", + "cli.help.multi_backup.keep": "Сохранить последние N файлов резервных копий (0 — не очищать)", + "cli.help.multi_backup.keep_days": "Сохранить файлы резервных копий за последние N дней", + "cli.help.multi_backup.local_target": "Путь к локальной папке для резервного копирования", + "cli.help.multi_backup.name_template": "Шаблон имени файла резервной копии", + "cli.help.multi_backup.password": "Пароль шифрования (поддерживается только форматом 7z)", + "cli.help.multi_backup.source": "Путь к исходной папке для резервного копирования", + "cli.help.multi_backup.verify": "Автоматическая проверка целостности после резервного копирования", + "cli.help.profile": "Управление профилями конфигурации", + "cli.help.profile.action": "Действие: list(список) / create(создать) / delete(удалить) / use(использовать) / export(экспорт) / import(импорт)", + "cli.help.profile.create.name": "Имя профиля для создания из текущей конфигурации", + "cli.help.profile.delete.name": "Имя профиля для удаления", + "cli.help.profile.export.name": "Имя профиля для экспорта", + "cli.help.profile.export.path": "Путь к файлу экспорта", + "cli.help.profile.import.name": "Имя профиля (по умолчанию — имя файла)", + "cli.help.profile.import.path": "Путь к файлу импорта", + "cli.help.profile.use.name": "Имя профиля для временного использования", + "cli.help.profile_name": "Использовать указанный профиль во всех командах", + "cli.help.remote.list_path": "Указать путь к удалённой директории (если не указан, используется путь из конфигурации)", + "cli.help.report": "Сгенерировать отчёт о резервном копировании в формате Markdown", + "cli.help.report.format": "Формат отчёта: text(текст) или html(веб-страница)", + "cli.help.report.output": "Путь к выходному файлу (если не указан, выводится в терминал)", + "cli.help.restore.search": "Поиск имени файла внутри архива (без учёта регистра)", + "cli.help.restore.select": "Избирательное восстановление: извлечь только файлы по шаблону (поддержка glob, например \"*.py\" или \"src/main.py\")", + "cli.help.restore.stats": "Показать статистику архива (количество файлов, размер, распределение форматов)", + "cli.help.restore.tag": "Восстановить резервную копию по тегу", + "cli.help.rotate": "Ротация и очистка файлов резервных копий по политике хранения", + "cli.help.rotate.backup_dir": "Путь к директории с файлами резервных копий", + "cli.help.rotate.dry_run": "Только предпросмотр файлов для удаления, без фактического удаления", + "cli.help.rotate.keep_count": "Сохранить последние N файлов резервных копий (0 = без ограничений)", + "cli.help.rotate.keep_daily": "Сохранить по одной копии за каждый из последних N дней (0 = без ограничений)", + "cli.help.rotate.keep_days": "Сохранить файлы резервных копий за последние N дней (0 = без ограничений)", + "cli.help.rotate_list": "Показать все файлы резервных копий в директории", + "cli.help.rotate_list.backup_dir": "Директория для сканирования файлов резервных копий", + "cli.help.save.keep_days": "Сохранить файлы резервных копий за последние N дней, 0 — не очищать по времени (по умолчанию: 0)", + "cli.help.save.multi_dest": "Включить режим одновременного резервного копирования на несколько целей", + "cli.help.save.post_hook": "Команда для выполнения после резервного копирования (можно указать несколько раз)", + "cli.help.save.pre_hook": "Команда для выполнения перед резервным копированием (можно указать несколько раз)", + "cli.help.save.split": "Размер части разделённого файла резервной копии (например, 100MB, 1GB), автоматическое разделение при превышении", + "cli.help.save.tag": "Добавить тег к этой резервной копии (например, daily, release)", + "cli.help.schedule": "Управление расписанием автоматического резервного копирования", + "cli.help.schedule.action": "Действие расписания: export(экспорт конфигурации) / install(установка службы)", + "cli.help.schedule.export": "Экспортировать конфигурацию расписания", + "cli.help.schedule.install": "Установить автоматическое резервное копирование как системную службу", + "cli.help.schedule.install.interval": "Интервал резервного копирования (минуты, по умолчанию: 60)", + "cli.help.schedule.install.type": "Тип службы: systemd / schtasks / launchd", + "cli.help.schedule.install.user": "Пользователь для запуска службы (по умолчанию: текущий пользователь)", + "cli.help.schedule.interval": "Интервал резервного копирования (минуты, по умолчанию: 60)", + "cli.help.schedule.output": "Путь к выходному файлу (если не указан, выводится в терминал)", + "cli.help.schedule.type": "Тип расписания: systemd / crontab (по умолчанию: systemd)", + "cli.help.schedule.type.schtasks": "Планировщик задач Windows", + "cli.help.search": "Поиск имени файла в архиве резервной копии", + "cli.help.search.in": "Путь к файлу резервной копии (если не указан, поиск по последней копии всех стратегий)", + "cli.help.search.password": "Пароль расшифровки (для поиска в зашифрованных резервных копиях)", + "cli.help.search.pattern": "Шаблон поиска (поддержка glob, например *.txt)", + "cli.help.strict": "Прервать резервное копирование при нехватке дискового пространства (по умолчанию — только предупреждение)", + "cli.help.task": "Управление очередью задач (добавление/выполнение/отмена задач резервного копирования)", + "cli.help.task.action": "Действие: add(добавить) / list(список) / run(выполнить) / run-all(все) / cancel(отменить) / clear(очистить) / stats(статистика)", + "cli.help.task.add": "Добавить новую задачу резервного копирования в очередь", + "cli.help.task.add.folder": "Путь к папке для резервного копирования", + "cli.help.task.add.format": "Формат сжатия: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.task.add.name": "Имя задачи (для идентификации)", + "cli.help.task.add.output": "Путь к выходному файлу (необязательно)", + "cli.help.task.cancel": "Отменить ожидающую задачу", + "cli.help.task.cancel.id": "ID задачи для отмены", + "cli.help.task.clear": "Очистить из очереди завершённые и неудачные задачи", + "cli.help.task.list": "Показать задачи в очереди", + "cli.help.task.list.status": "Фильтр по статусу: pending / running / completed / failed", + "cli.help.task.run": "Выполнить следующую ожидающую задачу из очереди", + "cli.help.task.run_all": "Последовательно выполнить все ожидающие задачи", + "cli.help.task.stats": "Показать статистику очереди задач", + "cli.help.versions": "Просмотреть историю версий резервных копий (сгруппировано по исходным директориям)", + "cli.help.versions.source": "Указать путь к исходной директории (если не указан, показать все)", + "cli.help.versions.tag": "Фильтр версий по тегу", + "cli.help.watch.keep_days": "Сохранить файлы резервных копий за последние N дней (по умолчанию: 0)", + "cli.help.watch.post_hook": "Команда для выполнения после резервного копирования (можно указать несколько раз)", + "cli.help.watch.pre_hook": "Команда для выполнения перед резервным копированием (можно указать несколько раз)", + "cli.help.watch.split": "Размер части разделённого файла (например, 100MB, 1GB)", + "cli.help.watch.tag": "Добавить тег к этой резервной копии", + "cli.help.webhook_cmd": "Управление webhook-уведомлениями", + "cli.help.webhook_cmd.action": "Действие webhook: preset(установить пресет) / list(список пресетов)", + "cli.help.webhook_cmd.list": "Показать все доступные пресеты IM-уведомлений", + "cli.help.webhook_cmd.preset": "Установить шаблон пресета IM-уведомлений", + "cli.help.webhook_cmd.preset.name": "Имя пресета: dingtalk / feishu / wechat", + "cli.help.wizard": "Интерактивный мастер настройки, пошаговое руководство для первоначальной настройки", + "cli.help.xsearch": "Кросс-архивный поиск: поиск имён файлов в нескольких архивах резервных копий", + "cli.help.xsearch.dirs": "Список директорий для рекурсивного поиска файлов резервных копий", + "cli.help.xsearch.ext": "Поиск по расширению (например, .log, .py)", + "cli.help.xsearch.keyword": "Ключевое слово поиска (без учёта регистра, совпадение с именем файла)", + "cli.help.xsearch.output_lang": "Язык вывода: zh_CN(китайский) / en_US(английский)", + "cli.help.xsearch.password": "Пароль расшифровки (для поиска в зашифрованных резервных копиях)", + "cli.help.xsearch.pattern": "Шаблон glob для поиска имён файлов (например, \"*.log\")", + "cmd.add.gitignore_empty": "Не импортировано ни одного правила из {path}", + "cmd.add.gitignore_imported": "Импортировано {count} правил игнорирования из {path}", + "cmd.audit.cleanup.done": "Очищено {count} старых записей журнала аудита.", + "cmd.audit.cleanup.none": "Нет старых записей для очистки.", + "cmd.clean.dry_run_header": "Режим предпросмотра (файлы не будут удалены):", + "cmd.clean.no_criteria": "Укажите условия очистки: --keep N или --keep-days N", + "cmd.clean.nothing": "Нет файлов резервных копий для очистки.", + "cmd.clean.summary": "Очищено {count} файлов резервных копий.", + "cmd.config.already_locked": "Файл конфигурации уже зашифрован", + "cmd.config.enter_password": "Введите мастер-пароль", + "cmd.config.lock_failed": "Ошибка шифрования", + "cmd.config.locked": "Файл конфигурации зашифрован (конфиденциальные поля защищены)", + "cmd.config.no_password": "Пароль не может быть пустым", + "cmd.config.not_locked": "Файл конфигурации не зашифрован", + "cmd.config.unlocked": "Файл конфигурации расшифрован", + "cmd.config.validate.errors": "Обнаружены ошибки:", + "cmd.config.validate.failed": "Проверка конфигурации не пройдена, исправьте указанные ошибки.", + "cmd.config.validate.passed": "Проверка конфигурации пройдена, проблем не обнаружено.", + "cmd.config.validate.passed_with_warnings": "Проверка конфигурации пройдена (есть предупреждения).", + "cmd.config.validate.warnings": "Обнаружены предупреждения:", + "cmd.config.wrong_password": "Неверный пароль, расшифровка не удалась", + "cmd.dedup.saved": "Дедупликация: пропущено {count} дубликатов, сэкономлено {saved:.2f} МБ", + "cmd.diff.detail_header": "--- Построчные различия ---", + "cmd.diff.no_detail": "(двоичный файл или не удалось прочитать)", + "cmd.hook.failed": "Ошибка выполнения хука: {command} — {error}", + "cmd.hook.post_running": "Выполнение хука после копирования: {command}", + "cmd.hook.pre_running": "Выполнение хука перед копированием: {command}", + "cmd.hooks.no_hooks": "Хуки {type} не настроены.", + "cmd.hooks.no_results": "Нет результатов выполнения хуков.", + "cmd.hooks.result_line": "[{status}] {command} (код={return_code}, {duration}с)", + "cmd.hooks.summary": "Всего {total}, успешно {success}, ошибок {fail}", + "cmd.ignore.available_presets": "Доступные пресеты:", + "cmd.ignore.cancelled": "Отменено.", + "cmd.ignore.generated": "Сгенерированы правила исключения для пресета {preset}: {path}", + "cmd.ignore.overwrite": "Файл {path} уже существует, перезаписать? (д/Н): ", + "cmd.integrity.generate.not_dir": "Ошибка: {path} не является директорией", + "cmd.integrity.generate.success": "Контрольный манифест целостности сгенерирован: {path}", + "cmd.integrity.verify.failed": "Проверка целостности не пройдена: {path}", + "cmd.integrity.verify.success": "Проверка целостности пройдена: {path}", + "cmd.list.tags.empty": "Теги отсутствуют", + "cmd.list.tags.header": "Теги резервных копий:", + "cmd.metadata_export.audit_done": "Экспортировано {count} записей аудита в {path}", + "cmd.metadata_export.combined_done": "Экспортирован комплексный отчёт ({count} записей) в {path}", + "cmd.metadata_export.history_done": "Экспортировано {count} записей истории в {path}", + "cmd.profile.activated": "Профиль '{name}' активирован.", + "cmd.profile.created": "Профиль '{name}' успешно создан.", + "cmd.profile.deleted": "Профиль '{name}' удалён.", + "cmd.profile.exported": "Профиль '{name}' экспортирован в: {path}", + "cmd.profile.import_failed": "Ошибка импорта профиля из {path}.", + "cmd.profile.imported": "Профиль '{name}' успешно импортирован.", + "cmd.profile.list.detail": " {fields}", + "cmd.profile.list.empty": "Профили не настроены.", + "cmd.profile.list.header": "Список профилей конфигурации:", + "cmd.profile.list.item": " {name}", + "cmd.profile.not_found": "Профиль '{name}' не существует.", + "cmd.remote.deleted": "Удалённый файл удалён: {path}", + "cmd.remote.empty": "Удалённая директория пуста: {path}", + "cmd.remote.list_header": "Удалённая директория: {path} ({count} файлов)", + "cmd.remote.rm_requires_path": "Укажите имя удалённого файла для удаления", + "cmd.remote.select_protocol": "Укажите протокол: --sftp или --webdav", + "cmd.report.saved": "Отчёт о резервном копировании сохранён в: {path}", + "cmd.restore.search.found": "Найдено {count} совпадений:", + "cmd.restore.search.header": "Результаты поиска '{keyword}' в {path}:", + "cmd.restore.search.no_results": "Файлы, соответствующие '{keyword}', не найдены в {path}", + "cmd.restore.selective.header": "Избирательное восстановление: извлечение {count} файлов, соответствующих {patterns}", + "cmd.restore.selective.success": "Извлечено {count} файлов в {target}", + "cmd.restore.stats.dirs": "Директорий: {count}", + "cmd.restore.stats.files": "Файлов: {count}", + "cmd.restore.stats.format_item": " {ext}: {count} файлов", + "cmd.restore.stats.formats": "Распределение по типам файлов:", + "cmd.restore.stats.header": "═══ Статистика архива: {path} ═══", + "cmd.restore.stats.size": "Общий размер: {size} байт", + "cmd.restore.tag_not_found": "Резервная копия с тегом '{tag}' не найдена", + "cmd.rotate.auto_done": "Автоматическая ротация завершена: удалено {deleted} файлов.", + "cmd.rotate.auto_title": "Автоматическая ротация файлов в {path}...", + "cmd.rotate.delete_item": " - {name} {size}", + "cmd.rotate.done": "Ротация завершена: очищено {count} файлов резервных копий.", + "cmd.rotate.dry_run_header": "Режим предпросмотра (файлы не будут удалены):", + "cmd.rotate.dry_run_item": " ~ {name} {size} {time}", + "cmd.rotate.header": "═══ Ротация резервных копий ═══", + "cmd.rotate.no_action": "Нет файлов для ротации.", + "cmd.rotate.plan_delete": "Будет удалено {count} файлов:", + "cmd.rotate.plan_keep": "Будет сохранено {count} файлов:", + "cmd.rotate.scan_empty": "Файлы резервных копий не найдены в {path}.", + "cmd.rotate.scan_found": "Найдено {count} файлов резервных копий в {path}:", + "cmd.rotate.scan_item": " {name} {size} {time}", + "cmd.schedule.exported": "Конфигурация расписания экспортирована в: {path} ({type})", + "cmd.schedule.install.launchd": "После сохранения plist-файла выполните:", + "cmd.schedule.install.schtasks_cmd": "Запустите от имени администратора для создания задачи:\n {cmdline}", + "cmd.schedule.install.systemd": "Выполните от имени root для установки systemd-таймера:", + "cmd.schedule.schtasks.generated": "Сгенерирована команда планировщика задач Windows", + "cmd.schedule.unknown_type": "Неизвестный тип расписания: {type}", + "cmd.search.no_results": "Файлы, соответствующие '{pattern}', не найдены в {path}", + "cmd.search.no_results_global": "Файлы, соответствующие '{pattern}', не найдены ни в одной резервной копии", + "cmd.search.results": "Найдено {count} файлов, соответствующих '{pattern}', в {path}:", + "cmd.sftp.parallel_upload": "Параллельная загрузка {count} файлов...", + "cmd.upload.parallel": "Параллельная загрузка на SFTP и WebDAV...", + "cmd.versions.empty": "История версий резервных копий пуста.", + "cmd.versions.header": "═══ История версий резервных копий ═══", + "cmd.versions.source": "📁 {source}", + "cmd.versions.total": "Всего {count} версий, {sources} исходных директорий", + "cmd.webdav.parallel_upload": "Параллельная загрузка {count} файлов на WebDAV...", + "cmd.webhook.available_presets": "Доступные пресеты IM-уведомлений:", + "cmd.webhook.preset_set": "Установлен шаблон уведомлений {name}:\n{template}", + "cmd.webhook.preset_unknown": "Неизвестный пресет: {name}", + "cmd.wizard.choice": "Введите ваш выбор:", + "cmd.wizard.cloud_done": "Облачное хранилище успешно настроено.", + "cmd.wizard.cloud_info": "S3-совместимое облачное хранилище (AWS S3, MinIO, Backblaze, Tencent COS):", + "cmd.wizard.complete": "Настройка sbackup завершена!", + "cmd.wizard.dest": "Путь к целевой папке для файлов резервных копий:", + "cmd.wizard.email_done": "Уведомления по электронной почте настроены.", + "cmd.wizard.email_yn": "Настроить уведомления по электронной почте? (д/Н):", + "cmd.wizard.host": "Адрес сервера:", + "cmd.wizard.ignore": "Шаблоны для игнорирования (через запятую, по умолчанию: .git,__pycache__):", + "cmd.wizard.lang_set": "Язык интерфейса установлен: {lang}", + "cmd.wizard.more": "Продолжить добавление? (д/Н):", + "cmd.wizard.password": "Пароль:", + "cmd.wizard.port": "Порт:", + "cmd.wizard.remote_path": "Удалённый путь:", + "cmd.wizard.schedule_info": "Выполните следующую команду для запуска автоматического резервного копирования (каждые {interval} мин.):", + "cmd.wizard.schedule_interval": "Интервал резервного копирования (минуты, по умолчанию: 60):", + "cmd.wizard.schedule_yn": "Настроить автоматическое резервное копирование? (д/Н):", + "cmd.wizard.sftp_done": "SFTP успешно настроен.", + "cmd.wizard.skip": "Пропустить", + "cmd.wizard.source": "Путь к исходной папке для резервного копирования:", + "cmd.wizard.step_email": "Шаг 4/5: Уведомления по электронной почте (необязательно)", + "cmd.wizard.step_lang": "Шаг 1/5: Выбор языка интерфейса", + "cmd.wizard.step_remote": "Шаг 3/5: Настройка удалённого хранилища (необязательно)", + "cmd.wizard.step_schedule": "Шаг 5/5: Автоматическое резервное копирование (необязательно)", + "cmd.wizard.step_strategy": "Шаг 2/5: Добавление стратегии резервного копирования", + "cmd.wizard.summary": "Добавлено {strategies} стратегий резервного копирования.\nВыполните 'sbackup save' для немедленного запуска.", + "cmd.wizard.user": "Имя пользователя:", + "cmd.wizard.webdav_done": "WebDAV успешно настроен.", + "cmd.wizard.welcome": "Добро пожаловать в мастер настройки sbackup!\nЭтот мастер поможет вам выполнить первоначальную настройку.", + "cmd.xsearch.no_query": "Укажите условия поиска: --keyword, --pattern или --ext", + "diskcheck.disk_free": " Свободно: {size}", + "diskcheck.disk_header": "Целевой диск:", + "diskcheck.disk_total": " Всего: {size}", + "diskcheck.disk_used": " Занято: {size}", + "diskcheck.estimated_size": "Оценочный размер копии: {size} (коэффициент сжатия: {ratio})", + "diskcheck.header": "═══ Проверка дискового пространства ═══", + "diskcheck.insufficient": "Недостаточно места: нужно {needed}, доступно {available}, не хватает {missing}", + "diskcheck.source": "Исходная директория: {path}", + "diskcheck.source_size": "Общий размер исходных файлов: {size}", + "diskcheck.status.insufficient": "Проверка пространства: недостаточно!\n Оценка потребности: {needed}\n Доступно: {available}\n Не хватает: {missing}", + "diskcheck.status.ok": "Проверка пространства: достаточно (запас {margin})", + "diskcheck.target": "Целевой путь: {path}", + "dryrun.and_others": "и другие", + "dryrun.excluded_files": "Исключённые файлы", + "dryrun.excluded_files_list": "Список исключённых файлов", + "dryrun.exclusion_reasons": "Причины исключения", + "dryrun.file_types": "Типы файлов", + "dryrun.included_files": "Включённые файлы", + "dryrun.included_files_list": "Список включённых файлов", + "dryrun.largest_files": "Крупнейшие файлы", + "dryrun.more_files": "... и ещё {count} файлов", + "dryrun.no_extension": "(без расширения)", + "dryrun.reason.excluded": "Совпадает с правилом исключения", + "dryrun.reason.not_included": "Не совпадает с правилом включения", + "dryrun.reason.too_large": "Превышает максимальный размер файла", + "dryrun.reason.too_small": "Меньше минимального размера файла", + "dryrun.scanned_dir": "Сканированная директория", + "dryrun.skip": "Пропустить", + "dryrun.title": "Предпросмотр (dry-run)", + "dryrun.total_files": "Всего файлов", + "dryrun.total_size": "Общий размер", + "dryrun.unit_files": "файлов", + "dryrun.warning.disk_space": "Недостаточно места: доступно {free}, требуется {need}", + "dryrun.warning.not_dir": "Путь не является допустимой директорией: {path}", + "dryrun.warning.os_error": "Системная ошибка: {error}", + "dryrun.warning.permission": "Недостаточно прав доступа: {path}", + "dryrun.warnings": "Предупреждения", + "err.invalid_filename": "Недопустимое имя файла: {filename} (разделители пути и последовательность .. запрещены)", + "err.lock.conflict": "Ошибка: другая операция резервного копирования уже выполняется (файл блокировки существует). Если вы уверены, что других процессов нет, удалите файл блокировки вручную.", + "err.sftp.delete": "Ошибка удаления удалённого файла {path}: {error}", + "err.sftp.host_key_mismatch": "Ключ хоста {host} не совпадает с known_hosts (возможна атака «человек посередине»)", + "err.sftp.mkdir_max_depth": "Слишком глубокая вложенность удалённых директорий или зацикливание: {path}", + "err.sftp.no_host_key": "Не удалось получить ключ хоста {host}", + "err.sftp.remote_not_found": "Удалённый файл не существует: {path}", + "err.webdav.delete": "Ошибка удаления удалённого файла {path}: {error}", + "err.webdav.invalid_scheme": "Неподдерживаемый протокол URL '{scheme}', допускаются только http и https.", + "err.webdav.list": "Ошибка получения списка удалённых файлов {path}: {error}", + "err.webdav.not_found": "Удалённый файл не существует: {path}", + "err.webdav.private_ip": "Подключение к адресу частной сети заблокировано: {host}", + "err.webdav.xml_too_large": "Ответ WebDAV слишком большой ({size} байт), возможна XML-бомба", + "log.rotation.delete_error": "Ошибка удаления %s: %s", + "log.rotation.deleted": "Удалено: %s (%.2f МБ)", + "log.rotation.dry_run_skip": "[dry-run] Будет удалено: %s (%.2f МБ)", + "multi_dest.aborted": "Многоканальное резервное копирование прервано: локальное копирование не удалось", + "multi_dest.complete": "Многоканальное резервное копирование завершено", + "multi_dest.local.failed": "Локальное резервное копирование не удалось", + "multi_dest.local.success": "Локальное резервное копирование завершено: {path}", + "multi_dest.started": "Многоканальное резервное копирование начато: {source} -> {targets}", + "multi_dest.uploading": "Параллельная загрузка на {count} удалённых целей...", + "restore.no_match": "Нет файлов, соответствующих \"{pattern}\"", + "restore.split.detected": "Обнаружена разделённая резервная копия, всего {count} частей", + "restore.split.merged": "Части успешно объединены: {path}", + "restore.split.missing": "Ошибка: отсутствует часть резервной копии, ожидается {path}", + "task_queue.added": "Задача добавлена: {name} (ID: {id})", + "task_queue.cancelled": "Отменено пользователем", + "task_queue.cancelled.fail": "Не удалось отменить задачу: не найдена или не в статусе ожидания", + "task_queue.cancelled.success": "Задача отменена: {id}", + "task_queue.clear.done": "Очищено {count} задач из очереди.", + "task_queue.clear.none": "Нет завершённых или неудачных задач для очистки.", + "task_queue.list.empty": "Очередь задач пуста.", + "task_queue.list.header": "═══ Очередь задач ({count} задач) ═══", + "task_queue.list.item": " [{status}] {name} (ID: {id})", + "task_queue.list.item_detail": " Директория: {folder}", + "task_queue.list.item_error": " Ошибка: {error}", + "task_queue.list.item_result": " Результат: {result}", + "task_queue.list.item_time": " Создано: {created}", + "task_queue.run.failed": "Ошибка выполнения задачи: {name} — {error}", + "task_queue.run.none": "В очереди нет ожидающих задач.", + "task_queue.run.success": "Задача выполнена успешно: {name} (результат: {result})", + "task_queue.run_all.done": "Выполнено {count} задач: {success} успешно, {failed} с ошибками.", + "task_queue.stats.completed": "Завершено: {completed}", + "task_queue.stats.failed": "Ошибок: {failed}", + "task_queue.stats.header": "═══ Статистика очереди задач ═══", + "task_queue.stats.pending": "Ожидают: {pending}", + "task_queue.stats.running": "Выполняются: {running}", + "task_queue.stats.total": "Всего: {total}", + "warn.password_in_cli": "Внимание: пароль, переданный через {arg}, виден в списке процессов. Рекомендуется использовать интерактивный ввод.", + "warn.sftp.no_known_hosts": "Внимание: файл known_hosts не найден, невозможно проверить ключ хоста {host}. Подключение может быть небезопасным.", + "warn.webdav.http_plaintext": "Внимание: используется незащищённое HTTP-подключение к WebDAV ({url}), данные передаются в открытом виде. Рекомендуется использовать HTTPS." } \ No newline at end of file diff --git a/sbackup/locales/zh_CN.json b/sbackup/locales/zh_CN.json index 6692578..4c550fc 100644 --- a/sbackup/locales/zh_CN.json +++ b/sbackup/locales/zh_CN.json @@ -1,80 +1,667 @@ -{ - "cmd.add.success": "备份策略添加成功: {source} -> {dest}", - "cmd.rm.success": "备份策略删除成功: {path}", - "err.folder.invalid": "错误:{path} 不是有效的文件夹名或不存在。", - "err.dest.invalid": "错误:目标文件夹 {path} 不是有效的文件夹或不存在。", - "info.already.added": "提示:{path} 已经添加过了,请勿重复添加。", - "warn.no.strategy.found": "警告:未找到 {path} 的备份策略。", - "warn.source.missing": "警告:源文件夹不存在: {path}", - "warn.json.decode.error": "警告:数据文件 {path} 格式错误,初始化为空字典。", - "warn.json.backup": "警告:已备份损坏的数据文件到 {path}", - "warn.json.renamed": "警告:已将损坏的数据文件重命名为 {path}。", - "compress.success": "成功备份: {path} ({size:.2f} MB, {count} 个文件)", - "compress.progress": "正在压缩", - "compress.unit": "文件", - "err.permission": "权限不足:无法写入 '{path}'", - "err.os": "系统错误:{error}", - "err.unknown": "未知错误:{error}", - "err.unknown.format": "错误:无法识别的备份文件格式 {path}", - "err.argparse.invalid_choice": "无效的选择: ", - "err.argparse.choose_from": "可选值", - "err.argparse.invalid_float": "无效的浮点数值: ", - "err.argparse.invalid_int": "无效的整数值: ", - "err.argparse.unrecognized_args": "未识别的参数: ", - "err.argparse.required": "必需参数", - "warn.zip.overwrite": "警告:将覆盖已有的备份文件 {path}", - "warn.invalid.compresslevel": "警告:压缩级别 {level} 超出范围 (0-9),使用默认值 6", - "cmd.save.uptodate": "所有备份策略均已是最新,无需备份。", - "cmd.save.completed": "备份完成,共处理 {count} 个策略。", - "cmd.all.empty": "没有配置任何备份策略。", - "exit.message": "\n已退出。", - "table.header.source": "源文件夹路径", - "table.header.dest": "目标文件夹路径", - "table.header.ignore": "忽略模式", - "table.cell.none": "无", - "table.header.format": "格式", - "table.cell.default": "默认", - "restore.progress": "正在还原", - "restore.success": "还原成功: {path} ({count} 个文件)", - "cmd.watch.start": "定时备份已启动,间隔 {interval} 分钟(按 Ctrl+C 停止)", - - "cli.description": "Sbackup v{version} — 轻量级增量备份工具\n\nSbackup 帮助您轻松管理多文件夹的备份任务。它采用增量备份策略,\n仅对已修改的文件进行压缩,支持自定义忽略规则,并提供直观的进度反馈。\n\n可用命令:\n add 添加新的备份策略 (源目录 -> 目标目录)\n rm 删除现有的备份策略\n all 显示所有已配置的备份策略详情\n save 根据策略执行备份任务\n version 查看版本信息", - "cli.epilog": "示例:\n 添加备份策略:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 添加策略并指定条目级格式:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n 删除备份策略:\n sbackup rm F:/my_folder\n\n 查看所有策略:\n sbackup all\n\n 执行备份 (ZIP):\n sbackup save\n\n 执行备份 (tar.gz):\n sbackup --format tar.gz save\n\n 执行备份 (7z 加密):\n sbackup --format 7z save --password 123456\n\n 保留最近 5 个备份:\n sbackup save --keep 5\n\n 定时备份 (每 60 分钟):\n sbackup watch --interval 60\n\n 定时备份 + 清理:\n sbackup watch --interval 120 --keep 10\n\n 还原备份:\n sbackup restore backup.7z F:/restored\n\n 英文界面:\n sbackup --lang en_US save\n\n 调试模式:\n sbackup --debug save", - "cli.help.debug": "开启调试模式,输出详细的运行日志和状态信息", - "cli.help.help": "显示此帮助信息并退出", - "cli.help.lang": "设置界面语言: zh_CN / en_US / fr_FR / es_ES / ru_RU / de_DE / ja_JP / pt_BR / ko_KR", - "cli.help.format": "设置打包格式: zip (默认) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.subcommands": "选择要执行的命令", - "cli.help.add": "添加新的备份策略", - "cli.help.add.source": "源文件夹路径: 需要被备份的目录", - "cli.help.add.dest": "目标文件夹路径: 存放生成的备份文件的目录", - "cli.help.add.ignore": "需要忽略的文件或文件夹名称 (使用逗号分隔,默认: .git,__pycache__)", - "cli.help.add.format": "条目级打包格式(仅作用于该备份策略,不指定则使用全局默认): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", - "cli.help.rm": "删除备份策略", - "cli.help.rm.path": "需要删除备份策略的源文件夹路径", - "cli.help.all": "查看所有备份策略", - "cli.help.save": "执行所有备份策略", - "cli.help.save.keep": "保留最近 N 个备份文件,0 表示不清理", - "cli.help.save.password": "加密密码(仅 7z 格式支持)", - "cli.help.watch": "定时执行备份", - "cli.help.watch.interval": "备份间隔(分钟),默认 60", - "cli.help.watch.keep": "保留最近 N 个备份文件", - "cli.help.watch.password": "加密密码(仅 7z 格式支持)", - "cli.help.restore": "从备份文件还原", - "cli.help.restore.file": "备份文件路径 (.zip / .tar / .tar.gz / .tar.bz2 / .tar.xz / .tar.zst / .7z)", - "cli.help.restore.dir": "还原目标目录", - "cli.help.version": "查看版本信息", - "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", - - "log.config.malformed": "配置文件 %s 格式错误,使用默认配置", - "log.config.reset": "配置文件 %s 格式错误,重置为空", - "log.config.mkdir.error": "无法创建配置目录 %s: %s", - "log.config.write.error": "无法写入配置文件 %s: %s", - "log.data.read": "读取数据文件: %s", - "log.data.create": "数据文件不存在,创建新文件: %s", - "log.data.load": "加载现有数据文件: %s", - "log.data.write": "写入数据文件: %s", - "log.cleanup.delete": "已删除旧备份: %s", - "log.i18n.load.failed": "无法加载语言包 {path}: {error}", - "log.i18n.load.fallback_failed": "无法加载回退语言包 {path}: {error}" -} +{ + "cmd.add.success": "备份策略添加成功: {source} -> {dest}", + "cmd.rm.success": "备份策略删除成功: {path}", + "cmd.edit.success": "备份策略编辑成功: {path}", + "cmd.edit.nothing": "未指定任何修改参数。", + "err.folder.invalid": "错误:{path} 不是有效的文件夹名或不存在。", + "err.file.not_found": "错误:备份文件 {path} 不存在。", + "err.dest.invalid": "错误:目标文件夹 {path} 不是有效的文件夹或不存在。", + "info.already.added": "提示:{path} 已经添加过了,请勿重复添加。", + "warn.no.strategy.found": "警告:未找到 {path} 的备份策略。", + "warn.source.missing": "警告:源文件夹不存在: {path}", + "warn.json.decode.error": "警告:数据文件 {path} 格式错误,初始化为空字典。", + "warn.json.backup": "警告:已备份损坏的数据文件到 {path}", + "warn.json.renamed": "警告:已将损坏的数据文件重命名为 {path}。", + "warn.password_in_cli": "警告:通过 {arg} 传递的密码会暴露在进程列表中,建议使用交互式输入。", + "warn.webdav.http_plaintext": "警告:使用明文 HTTP 连接 WebDAV ({url}),凭据将以明文传输。建议使用 HTTPS。", + "compress.success": "成功备份: {path} ({original:.2f} MB -> {size:.2f} MB, {ratio:.0f}%, {count} 个文件)", + "compress.progress": "正在压缩", + "compress.unit": "文件", + "err.permission": "权限不足:无法写入 '{path}'", + "err.os": "系统错误:{error}", + "err.unknown": "未知错误:{error}", + "err.unknown.format": "错误:无法识别的备份文件格式 {path}", + "err.argparse.invalid_choice": "无效的选择: ", + "err.argparse.choose_from": "可选值", + "err.argparse.invalid_float": "无效的浮点数值: ", + "err.argparse.invalid_int": "无效的整数值: ", + "err.argparse.unrecognized_args": "未识别的参数: ", + "err.argparse.required": "必需参数", + "err.lock.conflict": "错误:另一个备份操作正在运行(锁文件已存在)。如果确定没有其他进程在运行,请手动删除锁文件。", + "warn.zip.overwrite": "警告:将覆盖已有的备份文件 {path}", + "warn.invalid.compresslevel": "警告:压缩级别 {level} 超出范围 (0-9),使用默认值 6", + "cmd.save.uptodate": "所有备份策略均已是最新,无需备份。", + "cmd.save.completed": "备份完成,共处理 {count} 个策略。", + "cmd.all.empty": "没有配置任何备份策略。", + "cmd.list.empty": "暂无备份历史记录。", + "cmd.verify.checking": "正在校验备份文件: {path}", + "cmd.verify.success": "校验通过: {path} ({count} 个文件)", + "cmd.verify.failed": "校验失败: {path} — 预期 {expected} 个文件,实际 {actual} 个", + "cmd.rm.all.confirm": "确定要删除所有 {count} 个备份策略吗?(y/N): ", + "cmd.rm.all.confirm_words": "y,yes,是", + "cmd.rm.all.success": "已删除所有 {count} 个备份策略。", + "cmd.rm.all.empty": "没有可删除的备份策略。", + "cmd.init.success": "配置文件已生成: {path}", + "cmd.init.exists": "配置文件已存在: {path},跳过生成。", + "warn.disk.space": "警告:目标目录 {path} 剩余空间不足(需要 {need:.1f} MB,仅剩 {free:.1f} MB)", + "cmd.save.summary": "备份摘要: {total} 个策略, {backed} 个已备份, {skipped} 个已跳过, 耗时 {elapsed:.1f}秒", + "cmd.upload.parallel": "正在并行上传到 SFTP 和 WebDAV...", + "cmd.dry_run.header": "预览模式(不会实际执行备份):", + "cmd.dry_run.item": " {source} -> {target} 格式: {format} 文件: {count} 大小: {size} MB", + "cmd.dry_run.total": "共 {count} 个策略, {files} 个文件, 总大小 {size} MB", + "cmd.dry_run.nothing": "没有需要备份的策略(所有策略均已是最新)。", + "cmd.verify.partial_fail": "警告: {count} 个备份文件校验失败!", + "cmd.status.header": "═══ 备份状态仪表盘 ═══", + "cmd.status.strategies": "策略总数: {count}", + "cmd.status.history": "历史记录: {count} 条", + "cmd.status.total_size": "备份总大小: {size:.2f} MB", + "cmd.status.avg_ratio": "平均压缩率: {ratio:.1f}%", + "cmd.status.strategy.header": "─── 策略详情 ───", + "cmd.status.strategy.item": " {source} -> {target}", + "cmd.status.strategy.last_backup": " 上次备份: {time}", + "cmd.status.strategy.never": " 上次备份: 从未备份", + "cmd.status.strategy.format": " 格式: {format}", + "cmd.status.strategy.size": " 目标大小: {size:.2f} MB", + "log.parallel.backup": "并行备份 %d 个策略", + "restore.list.title": "备份内容: {path}", + "restore.list.empty": "备份文件为空: {path}", + "log.ignore.loaded": "已加载忽略规则文件: %s", + "exit.message": "\n已退出。", + "table.header.source": "源文件夹路径", + "table.header.dest": "目标文件夹路径", + "table.header.ignore": "忽略模式", + "table.header.time": "备份时间", + "table.header.size": "大小(MB)", + "table.header.ratio": "压缩率", + "table.header.files": "文件数", + "table.cell.none": "无", + "table.header.format": "格式", + "table.cell.default": "默认", + "restore.progress": "正在还原", + "restore.success": "还原成功: {path} ({count} 个文件)", + "restore.skipped_unsafe": "已跳过 {count} 个不安全的归档成员(路径遍历防护)", + "err.invalid_filename": "无效的文件名: {filename}(不允许路径分隔符和 .. 序列)", + "err.sftp.no_host_key": "无法获取主机 {host} 的密钥", + "err.sftp.host_key_mismatch": "主机 {host} 的密钥与 known_hosts 不匹配(可能遭受中间人攻击)", + "warn.sftp.no_known_hosts": "警告: 未找到 known_hosts 文件,无法验证 {host} 的主机密钥,连接可能不安全", + "err.sftp.mkdir_max_depth": "远程目录层级过深或存在循环: {path}", + "err.webdav.xml_too_large": "WebDAV 响应过大({size} 字节),可能存在 XML 炸弹攻击", + "cmd.watch.start": "定时备份已启动,间隔 {interval} 分钟(按 Ctrl+C 停止)", + "cli.description": "Sbackup v{version} — 轻量级增量备份工具\n\nSbackup 帮助您轻松管理多文件夹的备份任务。它采用增量备份策略,\n仅对已修改的文件进行压缩,支持自定义忽略规则,并提供直观的进度反馈。\n\n可用命令:\n add 添加新的备份策略 (源目录 -> 目标目录)\n rm 删除现有的备份策略\n all 显示所有已配置的备份策略详情\n save 根据策略执行备份任务\n version 查看版本信息", + "cli.epilog": "示例:\n 添加备份策略:\n sbackup add F:/my_folder F:/backup -i node_modules,.git\n\n 添加策略并指定条目级格式:\n sbackup add F:/my_folder F:/backup --format tar.gz\n\n 删除备份策略:\n sbackup rm F:/my_folder\n\n 查看所有策略:\n sbackup all\n\n 执行备份 (ZIP):\n sbackup save\n\n 执行备份 (tar.gz):\n sbackup --format tar.gz save\n\n 执行备份 (7z 加密):\n sbackup --format 7z save --password 123456\n\n 保留最近 5 个备份:\n sbackup save --keep 5\n\n 定时备份 (每 60 分钟):\n sbackup watch --interval 60\n\n 定时备份 + 清理:\n sbackup watch --interval 120 --keep 10\n\n 还原备份:\n sbackup restore backup.7z F:/restored\n\n 英文界面:\n sbackup --lang en_US save\n\n 调试模式:\n sbackup --debug save\n\n SFTP 远程备份 (自动检测私钥,推荐):\n sbackup sftp config --host 192.168.1.100 --user admin --remote-path /backups\n sbackup sftp test\n sbackup save --sftp\n\n SFTP 远程备份 (密码认证):\n sbackup sftp config --host 192.168.1.100 --user admin --password secret\n sbackup save --sftp\n\n SFTP 远程备份 (指定私钥):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa\n sbackup save --sftp\n\n SFTP 远程备份 (私钥 + 密码短语):\n sbackup sftp config --host 192.168.1.100 --user admin --key-file ~/.ssh/id_rsa --key-passphrase mypass\n sbackup save --sftp\n\n SFTP 定时备份并上传:\n sbackup watch --interval 60 --sftp", + "cli.help.debug": "开启调试模式,输出详细的运行日志和状态信息(可放在子命令之后)", + "cli.help.help": "显示此帮助信息并退出", + "cli.help.lang": "设置界面语言: zh_CN(中) / en_US(英) / fr_FR(法) / es_ES(西) / ru_RU(俄) / de_DE(德) / ja_JP(日) / pt_BR(葡) / ko_KR(韩)", + "cli.help.format": "设置打包格式: zip(默认) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z(支持加密)", + "cli.help.follow_symlinks": "跟随符号链接(默认不跟随,避免循环链接)", + "cli.help.incremental": "文件级增量备份(仅压缩实际变化的文件,大幅减少备份时间)", + "cli.help.checksum": "使用文件内容 SHA256 校验和检测变化(比 mtime 更精确,但较慢)", + "cli.help.subcommands": "选择要执行的命令", + "cli.help.add": "添加新的备份策略", + "cli.help.add.source": "源文件夹路径: 需要被备份的目录", + "cli.help.add.dest": "目标文件夹路径: 存放生成的备份文件的目录", + "cli.help.add.ignore": "需要忽略的文件或文件夹名称 (使用逗号分隔,默认: .git,__pycache__)", + "cli.help.add.format": "条目级打包格式(仅作用于该备份策略,不指定则使用全局默认): zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.add.name_template": "条目级文件名模板(支持 {name} {date} {time} 变量,不指定则使用全局默认)", + "cli.help.add.from_gitignore": "从 .gitignore 文件导入忽略规则", + "cmd.add.gitignore_imported": "已从 {path} 导入 {count} 条忽略规则", + "cmd.add.gitignore_empty": "未从 {path} 导入任何规则", + "cli.help.rm": "删除备份策略", + "cli.help.rm.path": "需要删除备份策略的源文件夹路径", + "cli.help.all": "查看所有备份策略", + "cli.help.save": "执行所有备份策略", + "cli.help.save.keep": "保留最近 N 个备份文件,0 表示不清理(默认: 0)", + "cli.help.save.keep_days": "保留最近 N 天的备份文件,0 表示不按时间清理(默认: 0)", + "cli.help.save.password": "加密密码(仅 7z 格式支持,也可在 config.json 中配置)", + "cli.help.watch": "定时执行备份", + "cli.help.watch.interval": "备份间隔(分钟),默认 60", + "cli.help.watch.keep": "保留最近 N 个备份文件(默认: 0)", + "cli.help.watch.keep_days": "保留最近 N 天的备份文件(默认: 0)", + "cli.help.watch.password": "加密密码(仅 7z 格式支持)", + "cli.help.restore": "从备份文件还原(支持 ZIP / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z)", + "cli.help.restore.file": "备份文件路径", + "cli.help.restore.dir": "还原目标目录", + "cli.help.restore.password": "解密密码(还原加密的 7z 备份时使用)", + "cli.help.restore.list": "仅列出备份内容,不解压", + "cli.help.list": "查看备份历史记录", + "cli.help.verify": "校验备份文件完整性", + "cli.help.verify.file": "要校验的备份文件路径", + "cli.help.verify.fast": "快速校验(使用历史 SHA256 比对,无需解压)", + "cli.help.verify.all": "批量验证所有历史备份", + "cli.help.verify.detail": "显示详细验证信息(大小、格式、校验和等)", + "cli.help.verify.split": "验证分卷文件完整性(需要清单文件)", + "cli.help.verify.password": "解密密码(校验加密备份时使用)", + "cmd.verify.fast_success": "快速校验通过: {path}\nSHA256: {sha256}", + "cmd.verify.fast_failed": "快速校验失败: {path},文件可能已损坏或被篡改", + "cmd.verify.no_checksum": "未找到历史校验和,回退到完整验证: {path}", + "cmd.verify.checksum_mismatch": "校验和不匹配", + "cmd.verify.no_history": "没有备份历史记录。", + "cmd.verify.no_file": "请指定备份文件路径或使用 --all 验证所有备份。", + "cmd.verify.detail_header": "═══ 备份详情: {path} ═══", + "cmd.verify.detail_info": "大小: {size:.2f} MB | 文件数: {files} | 格式: {fmt} | SHA256: {sha256}...", + "cmd.verify.batch_success": "✓ 校验通过: {path}", + "cmd.verify.batch_failed": "✗ 校验失败: {path}", + "cmd.verify.batch_summary": "批量验证完成: {success} 个通过, {fail} 个失败", + "cmd.verify.split_success": "分卷完整性验证通过: {file} ({parts} 个分卷)", + "cmd.verify.split_failed": "分卷完整性验证失败: {file}", + "cmd.verify.split_error": "分卷验证错误: {error}", + "cli.help.export": "导出所有备份策略到 JSON 文件", + "cli.help.export.file": "导出文件路径(默认: sbackup_export.json)", + "cli.help.import": "从 JSON 文件导入备份策略", + "cli.help.import.file": "要导入的 JSON 文件路径", + "cli.help.status": "查看备份状态仪表盘", + "cli.help.ignore": "生成 .sbackupignore 排除规则文件", + "cli.help.ignore.preset": "预设模板: node / python / go / rust / java / general(默认: general)", + "cli.help.ignore.output": "输出文件路径(默认: .sbackupignore)", + "cli.help.ignore.list": "列出所有可用预设", + "cmd.ignore.available_presets": "可用预设:", + "cmd.ignore.overwrite": "文件 {path} 已存在,是否覆盖?(y/N): ", + "cmd.ignore.cancelled": "已取消。", + "cmd.ignore.generated": "已生成 {preset} 预设的排除规则: {path}", + "cli.help.report": "生成 Markdown 格式的备份报告", + "cli.help.report.output": "输出文件路径(不指定则打印到终端)", + "cli.help.search": "在备份文件中搜索文件名", + "cli.help.search.pattern": "搜索模式(支持 glob 通配符,如 *.txt)", + "cli.help.search.in": "指定备份文件路径(不指定则搜索所有策略的最新备份)", + "cli.help.search.password": "解密密码(搜索加密备份时使用)", + "cli.help.versions": "查看备份版本历史(按源目录分组)", + "cli.help.versions.source": "指定源目录路径(不指定则显示全部)", + "cmd.versions.header": "═══ 备份版本历史 ═══", + "cmd.versions.source": "📁 {source}", + "cmd.versions.empty": "暂无备份版本记录。", + "cmd.versions.total": "共 {count} 个版本,{sources} 个源目录", + "cli.help.schedule": "定时调度配置管理", + "cli.help.schedule.action": "调度操作: export(导出配置) / install(安装服务)", + "cli.help.schedule.export": "导出定时调度配置文件", + "cli.help.schedule.type": "调度类型: systemd / crontab(默认: systemd)", + "cli.help.schedule.interval": "备份间隔(分钟,默认: 60)", + "cli.help.schedule.output": "输出文件路径(不指定则打印到终端)", + "cmd.schedule.exported": "调度配置已导出到: {path} ({type})", + "cmd.schedule.unknown_type": "未知调度类型: {type}", + "cli.help.webhook_cmd": "Webhook 通知管理", + "cli.help.webhook_cmd.action": "Webhook 操作: preset(设置预设) / list(列出预设)", + "cli.help.webhook_cmd.preset": "设置 IM 通知预设模板", + "cli.help.webhook_cmd.preset.name": "预设名称: dingtalk(钉钉) / feishu(飞书) / wechat(企业微信)", + "cli.help.webhook_cmd.list": "列出所有可用的 IM 通知预设", + "cmd.webhook.preset_set": "已设置 {name} 通知模板:\n{template}", + "cmd.webhook.preset_unknown": "未知预设: {name}", + "cmd.webhook.available_presets": "可用 IM 通知预设:", + "cli.help.config_cmd": "配置文件管理(加密/解密敏感字段)", + "cli.help.config_cmd.action": "操作: lock(加密) / unlock(解密)", + "cli.help.config_cmd.lock": "用主密码加密配置文件中的敏感字段", + "cli.help.config_cmd.lock.password": "主密码(不指定则交互式输入)", + "cli.help.config_cmd.unlock": "用主密码解密配置文件中的敏感字段", + "cli.help.config_cmd.unlock.password": "主密码(不指定则交互式输入)", + "cmd.config.locked": "配置文件已加密(敏感字段已保护)", + "cmd.config.unlocked": "配置文件已解密", + "cmd.config.already_locked": "配置文件已经加密过了", + "cmd.config.not_locked": "配置文件未加密", + "cmd.config.enter_password": "请输入主密码", + "cmd.config.no_password": "密码不能为空", + "cmd.config.wrong_password": "密码错误,解密失败", + "cmd.config.lock_failed": "加密失败", + "cli.help.config_cmd.validate": "校验当前配置并显示所有错误和警告", + "cmd.config.validate.errors": "发现错误:", + "cmd.config.validate.warnings": "发现警告:", + "cmd.config.validate.passed": "配置校验通过,未发现问题。", + "cmd.config.validate.passed_with_warnings": "配置校验通过(但存在警告)。", + "cmd.config.validate.failed": "配置校验失败,请修复上述错误。", + "cli.help.clean": "智能清理所有策略的备份文件", + "cli.help.clean.keep": "每个策略保留最近 N 个备份文件", + "cli.help.clean.keep_days": "删除 N 天前的备份文件", + "cli.help.clean.dry_run": "仅预览将删除的文件,不实际删除", + "cmd.clean.no_criteria": "请指定清理条件: --keep N 或 --keep-days N", + "cmd.clean.nothing": "没有需要清理的备份文件。", + "cmd.clean.dry_run_header": "预览模式(不会实际删除):", + "cmd.clean.summary": "共清理 {count} 个备份文件。", + "cli.help.completion": "生成 Shell 自动补全脚本", + "cli.help.completion.shell": "Shell 类型: bash(默认) / zsh / fish / powershell", + "cmd.report.saved": "备份报告已保存到: {path}", + "cmd.search.results": "在 {path} 中找到 {count} 个匹配文件:", + "cmd.search.no_results": "在 {path} 中未找到匹配 '{pattern}' 的文件", + "cmd.search.no_results_global": "在所有备份中未找到匹配 '{pattern}' 的文件", + "cmd.export.success": "已导出 {count} 个备份策略到 {path}", + "cmd.import.success": "已导入 {count} 个备份策略,跳过 {skipped} 个已存在的策略", + "cmd.import.empty": "导入文件中没有有效的备份策略: {path}", + "cli.help.save.dry_run": "仅预览将备份的文件,不实际执行压缩", + "cli.help.save.verify": "备份完成后自动校验备份文件完整性", + "cli.help.save.name_template": "备份文件名模板(支持 {name} {date} {time} 变量)", + "cli.help.save.webhook": "备份完成后 POST 结果到此 URL", + "cli.help.save.max_size": "仅备份小于指定大小的文件(如 1GB, 500MB, 100KB)", + "cli.help.save.min_size": "仅备份大于指定大小的文件(如 1KB, 1MB)", + "cli.help.save.older_than": "仅备份比指定时间更早修改的文件(如 7d, 30d, 1y)", + "cli.help.save.split": "备份文件分卷大小(如 100MB, 1GB),超过此大小自动分割", + "cli.help.save.tag": "为本次备份添加标签(如 daily、release)", + "cli.help.watch.dry_run": "仅预览将备份的文件,不实际执行压缩", + "cli.help.watch.verify": "每次备份完成后自动校验备份文件完整性", + "cli.help.watch.name_template": "备份文件名模板(支持 {name} {date} {time} 变量)", + "cli.help.watch.webhook": "每次备份完成后 POST 结果到此 URL", + "cli.help.watch.max_size": "仅备份小于指定大小的文件(如 1GB, 500MB, 100KB)", + "cli.help.watch.min_size": "仅备份大于指定大小的文件(如 1KB, 1MB)", + "cli.help.watch.older_than": "仅备份比指定时间更早修改的文件(如 7d, 30d, 1y)", + "cli.help.watch.split": "备份文件分卷大小(如 100MB, 1GB)", + "cli.help.watch.tag": "为本次备份添加标签", + "cli.help.init": "生成默认配置文件模板", + "cli.help.rm.all": "删除所有备份策略", + "cli.help.edit": "编辑已有备份策略(仅修改指定的字段)", + "cli.help.edit.source": "源文件夹路径(用于定位要编辑的策略)", + "cli.help.edit.dest": "修改目标文件夹路径", + "cli.help.edit.ignore": "修改忽略模式(逗号分隔)", + "cli.help.edit.format": "修改打包格式: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.edit.name_template": "修改文件名模板(支持 {name} {date} {time} 变量)", + "cli.help.info": "查看备份文件详细信息(格式、大小、文件数、校验和等)", + "cli.help.info.file": "备份文件路径", + "cli.help.info.password": "解密密码(加密的 7z 备份使用)", + "cmd.info.header": "═══ 备份文件信息 ═══", + "cmd.info.path": "文件路径: {path}", + "cmd.info.format": "压缩格式: {format}", + "cmd.info.size": "文件大小: {size:.2f} MB", + "cmd.info.files": "文件数量: {count}", + "cmd.info.time": "修改时间: {time}", + "cmd.info.sha256": "SHA256: {checksum}", + "cmd.diff.header": "═══ 差异对比 ═══\n备份文件: {backup}", + "cmd.diff.added_header": "\n新增文件 ({count}):", + "cmd.diff.removed_header": "\n删除文件 ({count}):", + "cmd.diff.modified_header": "\n可能修改的文件 ({count}):", + "cmd.diff.no_changes": "\n无差异,源目录与备份一致。", + "cmd.diff.summary": "\n差异摘要: +{added} 新增, -{removed} 删除, ~{modified} 修改", + "cmd.diff.no_backup": "目标目录中未找到备份文件: {path}", + "cmd.diff.empty_backup": "备份文件为空: {path}", + "cli.help.diff": "对比源目录与最近一次备份的差异", + "cli.help.diff.source": "源文件夹路径", + "cli.help.diff.file": "指定备份文件路径(不指定则自动查找最新备份)", + "cli.help.diff.password": "解密密码(对比加密备份时使用)", + "cli.help.diff.detail": "显示修改文件的行级差异", + "cmd.diff.detail_header": "--- 行级差异 ---", + "cmd.diff.no_detail": "(二进制文件或无法读取)", + "cli.help.remote": "远程备份文件管理(列出/删除 SFTP 或 WebDAV 上的备份文件)", + "cli.help.remote.action": "远程操作: list(列出文件) / rm(删除文件)", + "cli.help.remote.list": "列出远程备份文件", + "cli.help.remote.rm": "删除远程备份文件", + "cli.help.remote.rm.filename": "要删除的远程文件名", + "cli.help.remote.sftp": "操作 SFTP 服务器", + "cli.help.remote.webdav": "操作 WebDAV 服务器", + "cli.help.version": "查看版本信息", + "cli.version": "\nSbackup v{version} — Copyright © 2026 xiatianxuan\nLicensed under GNU GPL v3.0 — https://www.gnu.org/licenses/gpl-3.0.html\n", + "log.config.malformed": "配置文件 %s 格式错误,使用默认配置", + "log.config.reset": "配置文件 %s 格式错误,重置为空", + "log.config.mkdir.error": "无法创建配置目录 %s: %s", + "log.config.write.error": "无法写入配置文件 %s: %s", + "log.data.read": "读取数据文件: %s", + "log.data.create": "数据文件不存在,创建新文件: %s", + "log.data.load": "加载现有数据文件: %s", + "log.data.write": "写入数据文件: %s", + "log.cleanup.delete": "已删除旧备份: %s", + "log.i18n.load.failed": "无法加载语言包 {path}: {error}", + "log.i18n.load.fallback_failed": "无法加载回退语言包 {path}: {error}", + "err.sftp.auth": "SFTP 认证失败: {host},请检查用户名和密码", + "err.sftp.ssh": "SFTP 连接错误: {error}", + "err.sftp.connect": "无法连接到 {host}:{port}: {error}", + "err.sftp.not_connected": "SFTP 未连接,请先调用 connect()", + "err.sftp.local_not_found": "本地文件不存在: {path}", + "err.sftp.upload": "上传文件失败 {path}: {error}", + "err.sftp.mkdir": "创建远程目录失败 {path}: {error}", + "err.sftp.not_configured": "SFTP 未配置,请先运行: sbackup sftp config --host <地址> --user <用户名>", + "cmd.sftp.uploading": "正在上传: {file}", + "cmd.sftp.progress": "上传进度", + "cmd.sftp.success": "上传成功: {file}", + "cmd.sftp.parallel_upload": "并行上传 {count} 个文件...", + "cmd.sftp.config_saved": "SFTP 配置已保存到 config.json", + "cmd.sftp.testing": "正在测试连接到 {host}...", + "cmd.sftp.test_ok": "SFTP 连接测试成功!", + "cmd.sftp.test_fail": "SFTP 连接测试失败,请检查配置", + "cli.help.save.sftp": "备份完成后上传到 SFTP 服务器(自动检测私钥或使用密码认证)", + "cli.help.watch.sftp": "每次备份后上传到 SFTP 服务器", + "cli.help.sftp": "SFTP 远程备份管理(支持密码认证和 SSH 私钥认证)", + "cli.help.sftp.action": "SFTP 操作: config(配置) / test(测试连接)", + "cli.help.sftp.config": "配置 SFTP 连接参数(支持密码认证和 SSH 私钥认证)", + "cli.help.sftp.test": "测试 SFTP 连接是否可用", + "cli.help.sftp.host": "SFTP 服务器地址(例如: 192.168.1.100 或 example.com)", + "cli.help.sftp.port": "SFTP 端口号(默认: 22)", + "cli.help.sftp.user": "SFTP 用户名", + "cli.help.sftp.password": "SFTP 密码(仅密码认证时使用)", + "cli.help.sftp.remote_path": "远程目标路径(默认: /,会自动创建不存在的目录)", + "cli.prompt.sftp.host": "SFTP 服务器地址:", + "cli.prompt.sftp.port": "SFTP 端口 (默认 22):", + "cli.prompt.sftp.user": "SFTP 用户名:", + "cli.prompt.sftp.password": "SFTP 密码:", + "cli.prompt.sftp.remote_path": "远程路径 (默认 /):", + "err.sftp.key_load": "无法加载私钥文件 {path},请确认文件格式正确(支持 RSA/Ed25519/ECDSA)", + "cli.help.sftp.key_file": "SSH 私钥文件路径(支持 RSA/Ed25519/ECDSA,优先于密码认证)", + "cli.help.sftp.key_passphrase": "私钥密码短语(私钥有密码保护时使用,可通过交互式输入)", + "cli.prompt.sftp.key_file": "SSH 私钥文件路径 (留空使用密码认证):", + "cli.prompt.sftp.key_passphrase": "私钥密码短语 (直接回车放弃):", + "cmd.sftp.using_default_key": "检测到默认 SSH 私钥,将使用: {path}", + "cmd.sftp.no_default_key": "未找到默认 SSH 私钥,请配置密码或指定私钥路径", + "err.sftp.key_needs_passphrase": "私钥 {path} 需要密码短语,请输入或使用 --key-passphrase 参数指定", + "err.sftp.wrong_passphrase": "密码短语错误,请重试(直接回车放弃使用私钥)", + "cli.help.webdav": "WebDAV 远程备份管理(支持坚果云、NextCloud、群晖等)", + "cli.help.webdav.action": "WebDAV 操作: config(配置) / test(测试连接)", + "cli.help.webdav.config": "配置 WebDAV 连接参数", + "cli.help.webdav.test": "测试 WebDAV 连接是否可用", + "cli.help.webdav.url": "WebDAV 服务器 URL(例如: https://dav.jianguoyun.com/dav/)", + "cli.help.webdav.user": "WebDAV 用户名", + "cli.help.webdav.password": "WebDAV 密码", + "cli.help.webdav.remote_path": "远程目标路径(默认: /,会自动创建不存在的目录)", + "cli.help.save.webdav": "备份完成后上传到 WebDAV 服务器", + "cli.help.watch.webdav": "每次备份后上传到 WebDAV 服务器", + "cli.prompt.webdav.url": "WebDAV 服务器 URL:", + "cli.prompt.webdav.user": "WebDAV 用户名:", + "cli.prompt.webdav.password": "WebDAV 密码:", + "cli.prompt.webdav.remote_path": "远程路径 (默认 /):", + "cmd.webdav.config_saved": "WebDAV 配置已保存到 config.json", + "cmd.webdav.testing": "正在测试 WebDAV 连接到 {url}...", + "cmd.webdav.test_ok": "WebDAV 连接测试成功!", + "cmd.webdav.uploading": "正在上传到 WebDAV: {file}", + "cmd.webdav.success": "WebDAV 上传成功: {file}", + "cmd.webdav.parallel_upload": "并行上传 {count} 个文件到 WebDAV...", + "err.webdav.auth": "WebDAV 认证失败: {host},请检查用户名和密码", + "err.webdav.connect": "无法连接到 WebDAV: {url},错误: {error}", + "err.webdav.ssh": "WebDAV 连接错误: {error}", + "err.webdav.not_configured": "WebDAV 未配置,请先运行: sbackup webdav config --url <地址> --user <用户名>", + "err.webdav.local_not_found": "本地文件不存在: {path}", + "err.webdav.upload": "上传文件到 WebDAV 失败 {path}: {error}", + "err.webdav.mkdir": "创建 WebDAV 远程目录失败 {path}: {error}", + "err.webdav.invalid_scheme": "不支持的 URL 协议 '{scheme}',仅允许 http 和 https。", + "err.webdav.private_ip": "已阻止连接到内网地址: {host}", + "cmd.remote.empty": "远程目录为空: {path}", + "cmd.remote.list_header": "远程目录: {path} ({count} 个文件)", + "cmd.remote.deleted": "已删除远程文件: {path}", + "cmd.remote.select_protocol": "请指定协议: --sftp 或 --webdav", + "cmd.remote.rm_requires_path": "请指定要删除的远程文件名", + "cli.help.remote.list_path": "指定远程目录路径(不指定则使用配置的默认路径)", + "err.sftp.remote_not_found": "远程文件不存在: {path}", + "err.sftp.delete": "删除远程文件失败 {path}: {error}", + "err.webdav.not_found": "远程文件不存在: {path}", + "err.webdav.delete": "删除远程文件失败 {path}: {error}", + "err.webdav.list": "列出远程文件失败 {path}: {error}", + "restore.no_match": "没有匹配 \"{pattern}\" 的文件", + "cli.help.restore.select": "选择性还原:仅提取匹配模式的文件(支持 glob 通配符,如 \"*.py\" 或 \"src/main.py\")", + "cli.help.schedule.type.schtasks": "Windows 计划任务", + "cmd.schedule.schtasks.generated": "Windows 计划任务命令已生成", + "cmd.hook.pre_running": "正在执行前置钩子: {command}", + "cmd.hook.post_running": "正在执行后置钩子: {command}", + "cmd.hook.failed": "钩子执行失败: {command} — {error}", + "cli.help.save.pre_hook": "备份前执行的命令(可多次使用)", + "cli.help.save.post_hook": "备份后执行的命令(可多次使用)", + "cli.help.watch.pre_hook": "备份前执行的命令(可多次使用)", + "cli.help.watch.post_hook": "备份后执行的命令(可多次使用)", + "restore.split.detected": "检测到分卷备份,共 {count} 个分卷", + "restore.split.missing": "错误:分卷文件缺失,期望 {path}", + "restore.split.merged": "分卷合并完成: {path}", + "cli.help.watch.realtime": "启用文件系统实时监控(文件变化后自动触发备份)", + "cli.help.watch.debounce": "防抖秒数,文件停变后等待此时间再备份(默认: 30)", + "cmd.watch.start_realtime": "文件系统实时监控已启动,防抖 {debounce} 秒(按 Ctrl+C 停止)", + "log.monitor.change": "检测到文件变化: {path}({dirty_count} 个目录待备份)", + "log.monitor.trigger": "防抖超时,触发备份({dirty_count} 个目录)", + "log.monitor.source_missing": "监控源目录不存在,跳过: {path}", + "log.monitor.watching": "开始实时监控: {path}", + "log.monitor.no_sources": "没有有效的监控源目录,监控未启动", + "cli.help.schedule.install": "安装定时备份为系统服务", + "cli.help.schedule.install.type": "服务类型: systemd / schtasks / launchd", + "cli.help.schedule.install.interval": "备份间隔(分钟,默认: 60)", + "cli.help.schedule.install.user": "运行服务的用户(默认: 当前用户)", + "cmd.schedule.install.systemd": "以 root 身份执行以下命令安装 systemd 定时器:", + "cmd.schedule.install.launchd": "保存 plist 文件后运行:", + "cmd.schedule.install.schtasks_cmd": "以管理员身份运行以下命令创建计划任务:\n {cmdline}", + "cli.help.restore.tag": "按标签还原指定的备份", + "cli.help.list.tags": "列出所有备份标签", + "cli.help.versions.tag": "按标签筛选版本", + "cmd.restore.tag_not_found": "未找到标签为 '{tag}' 的备份", + "cmd.list.tags.header": "备份标签:", + "cmd.list.tags.empty": "暂无标签", + "cli.help.dedup": "启用跨策略的基于内容哈希(SHA256)文件级去重", + "cmd.dedup.saved": "去重: 跳过 {count} 个重复文件,节省 {saved:.2f} MB", + "cli.help.report.format": "报告格式: text(文本) 或 html(网页)", + "cli.help.wizard": "交互式配置向导,引导完成首次设置", + "cmd.wizard.welcome": "欢迎使用 sbackup 设置向导!\n本向导将引导您完成初始配置。", + "cmd.wizard.step_lang": "步骤 1/5: 选择界面语言", + "cmd.wizard.choice": "请输入选项:", + "cmd.wizard.lang_set": "已设置语言为 {lang}", + "cmd.wizard.step_strategy": "步骤 2/5: 添加备份策略", + "cmd.wizard.source": "要备份的源文件夹路径:", + "cmd.wizard.dest": "备份文件的目标文件夹路径:", + "cmd.wizard.ignore": "要忽略的模式(逗号分隔,默认: .git,__pycache__):", + "cmd.wizard.more": "是否继续添加?(y/N):", + "cmd.wizard.step_remote": "步骤 3/5: 配置远程存储(可选)", + "cmd.wizard.skip": "跳过", + "cmd.wizard.step_email": "步骤 4/5: 邮件通知(可选)", + "cmd.wizard.email_yn": "是否配置邮件通知?(y/N):", + "cmd.wizard.step_schedule": "步骤 5/5: 定时备份(可选)", + "cmd.wizard.schedule_yn": "是否配置定时备份?(y/N):", + "cmd.wizard.schedule_interval": "备份间隔(分钟,默认: 60):", + "cmd.wizard.schedule_info": "运行以下命令启动定时备份(每 {interval} 分钟):", + "cmd.wizard.complete": "sbackup 设置完成!", + "cmd.wizard.summary": "已添加 {strategies} 个备份策略。\n运行 'sbackup save' 立即执行备份。", + "cmd.wizard.host": "服务器地址:", + "cmd.wizard.port": "端口:", + "cmd.wizard.user": "用户名:", + "cmd.wizard.password": "密码:", + "cmd.wizard.remote_path": "远程路径:", + "cmd.wizard.sftp_done": "SFTP 配置成功。", + "cmd.wizard.webdav_done": "WebDAV 配置成功。", + "cmd.wizard.cloud_info": "S3 兼容云存储(AWS S3, MinIO, Backblaze, 腾讯COS):", + "cmd.wizard.cloud_done": "云存储配置成功。", + "cmd.wizard.email_done": "邮件通知已配置。", + "cli.help.task": "任务队列管理(添加/执行/取消备份任务)", + "cli.help.task.action": "任务操作: add(添加) / list(列表) / run(执行) / run-all(执行全部) / cancel(取消) / clear(清除) / stats(统计)", + "cli.help.task.add": "添加新的备份任务到队列", + "cli.help.task.add.name": "任务名称(用于标识)", + "cli.help.task.add.folder": "要备份的文件夹路径", + "cli.help.task.add.format": "压缩格式: zip / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.task.add.output": "输出文件路径(可选)", + "cli.help.task.list": "列出队列中的任务", + "cli.help.task.list.status": "按状态过滤: pending / running / completed / failed", + "cli.help.task.run": "执行队列中下一个待处理的任务", + "cli.help.task.run_all": "顺序执行所有待处理的任务", + "cli.help.task.cancel": "取消一个待处理的任务", + "cli.help.task.cancel.id": "要取消的任务 ID", + "cli.help.task.clear": "清除队列中已完成和失败的任务", + "cli.help.task.stats": "显示任务队列统计信息", + "task_queue.added": "任务已添加: {name} (ID: {id})", + "task_queue.cancelled": "用户取消", + "task_queue.cancelled.success": "任务已取消: {id}", + "task_queue.cancelled.fail": "无法取消任务: 未找到或非待处理状态", + "task_queue.run.success": "任务执行成功: {name} (结果: {result})", + "task_queue.run.failed": "任务执行失败: {name} — {error}", + "task_queue.run.none": "队列中没有待处理的任务。", + "task_queue.run_all.done": "共执行 {count} 个任务,{success} 个成功,{failed} 个失败。", + "task_queue.clear.done": "已从队列清除 {count} 个任务。", + "task_queue.clear.none": "没有已完成或失败的任务需要清除。", + "task_queue.stats.header": "═══ 任务队列统计 ═══", + "task_queue.stats.total": "总数: {total}", + "task_queue.stats.pending": "待处理: {pending}", + "task_queue.stats.running": "运行中: {running}", + "task_queue.stats.completed": "已完成: {completed}", + "task_queue.stats.failed": "失败: {failed}", + "task_queue.list.header": "═══ 任务队列 ({count} 个任务) ═══", + "task_queue.list.empty": "任务队列为空。", + "task_queue.list.item": " [{status}] {name} (ID: {id})", + "task_queue.list.item_detail": " 目录: {folder}", + "task_queue.list.item_result": " 结果: {result}", + "task_queue.list.item_error": " 错误: {error}", + "task_queue.list.item_time": " 创建时间: {created}", + "cli.help.integrity": "数据完整性校验(生成/校验校验清单)", + "cli.help.integrity.action": "完整性操作: generate(生成) / verify(校验)", + "cli.help.integrity.generate": "为备份目录生成 integrity.json 校验清单", + "cli.help.integrity.verify": "根据 integrity.json 校验清单验证备份文件完整性", + "cli.help.integrity.path": "备份目录路径", + "cmd.integrity.generate.success": "完整性校验清单已生成: {path}", + "cmd.integrity.generate.not_dir": "错误: {path} 不是一个目录", + "cmd.integrity.verify.success": "完整性校验通过: {path}", + "cmd.integrity.verify.failed": "完整性校验失败: {path}", + "cli.help.diskcheck": "备份前检查磁盘空间(估算源目录大小和备份文件大小)", + "cli.help.diskcheck.source": "要扫描的源目录路径", + "cli.help.diskcheck.target": "目标目录路径(备份文件存放位置)", + "cli.help.diskcheck.level": "压缩级别 (0-9, 默认: 6)", + "cli.help.strict": "磁盘空间不足时中止备份(不指定则仅警告)", + "diskcheck.header": "═══ 磁盘空间检查 ═══", + "diskcheck.source": "源目录: {path}", + "diskcheck.target": "目标路径: {path}", + "diskcheck.source_size": "源文件总大小: {size}", + "diskcheck.estimated_size": "估算备份大小: {size} (压缩比: {ratio})", + "diskcheck.disk_header": "目标磁盘:", + "diskcheck.disk_total": " 总空间: {size}", + "diskcheck.disk_used": " 已用: {size}", + "diskcheck.disk_free": " 可用: {size}", + "diskcheck.status.ok": "空间检查: 充足 (剩余 {margin})", + "diskcheck.status.insufficient": "空间检查: 空间不足!\n 估算备份需要: {needed}\n 可用空间: {available}\n 缺少: {missing}", + "diskcheck.insufficient": "磁盘空间不足:需要 {needed},可用 {available},缺少 {missing}", + "cli.help.rotate": "根据保留策略轮转清理备份文件", + "cli.help.rotate.backup_dir": "包含备份文件的目录路径", + "cli.help.rotate.keep_count": "保留最近 N 个备份文件(0 = 不限制)", + "cli.help.rotate.keep_days": "保留最近 N 天的备份文件(0 = 不限制)", + "cli.help.rotate.keep_daily": "保留最近 N 天每天一份备份(0 = 不限制)", + "cli.help.rotate.dry_run": "仅预览将删除的文件,不实际删除", + "cli.help.rotate_list": "列出目录中的所有备份文件", + "cli.help.rotate_list.backup_dir": "要扫描的备份文件目录", + "cmd.rotate.header": "═══ 备份轮转 ═══", + "cmd.rotate.scan_found": "在 {path} 中找到 {count} 个备份文件:", + "cmd.rotate.scan_empty": "在 {path} 中未找到备份文件。", + "cmd.rotate.scan_item": " {name} {size} {time}", + "cmd.rotate.plan_keep": "将保留 {count} 个文件:", + "cmd.rotate.plan_delete": "将删除 {count} 个文件:", + "cmd.rotate.no_action": "没有需要轮转的文件。", + "cmd.rotate.dry_run_header": "预览模式(不会实际删除):", + "cmd.rotate.dry_run_item": " ~ {name} {size} {time}", + "cmd.rotate.delete_item": " - {name} {size}", + "cmd.rotate.done": "已轮转清理 {count} 个备份文件。", + "cmd.rotate.auto_title": "正在自动轮转 {path} 中的备份...", + "cmd.rotate.auto_done": "自动轮转完成: 删除了 {deleted} 个文件。", + "log.rotation.dry_run_skip": "[dry-run] 将删除: %s (%.2f MB)", + "log.rotation.deleted": "已删除: %s (%.2f MB)", + "log.rotation.delete_error": "删除失败 %s: %s", + "dryrun.title": "Dry-run 预览", + "dryrun.scanned_dir": "扫描目录", + "dryrun.total_files": "文件总数", + "dryrun.total_size": "总大小", + "dryrun.included_files": "包含文件", + "dryrun.excluded_files": "排除文件", + "dryrun.exclusion_reasons": "排除原因", + "dryrun.skip": "跳过", + "dryrun.unit_files": "个文件", + "dryrun.file_types": "文件类型", + "dryrun.and_others": "其他", + "dryrun.largest_files": "最大文件", + "dryrun.warnings": "警告", + "dryrun.included_files_list": "包含的文件列表", + "dryrun.excluded_files_list": "排除的文件列表", + "dryrun.more_files": "... 还有 {count} 个文件", + "dryrun.no_extension": "(无扩展名)", + "dryrun.reason.not_included": "未匹配包含规则", + "dryrun.reason.excluded": "匹配排除规则", + "dryrun.reason.too_large": "超过最大文件大小", + "dryrun.reason.too_small": "小于最小文件大小", + "dryrun.warning.not_dir": "路径不是有效目录: {path}", + "dryrun.warning.permission": "权限不足,无法访问: {path}", + "dryrun.warning.os_error": "系统错误: {error}", + "dryrun.warning.disk_space": "磁盘空间不足: 可用 {free}, 需要 {need}", + "cli.help.compress": "直接压缩指定文件夹(不使用策略)", + "cli.help.compress.source": "要压缩的源文件夹路径", + "cli.help.compress.dry_run": "仅预览将备份的文件,不实际执行压缩", + "cli.help.compress.format": "压缩格式: zip(默认) / tar / tar.gz / tar.bz2 / tar.xz / tar.zst / 7z", + "cli.help.compress.output": "输出文件路径(可选)", + "cli.help.compress.password": "加密密码(仅 7z 格式支持)", + "cli.help.audit": "查看备份审计日志", + "cli.help.audit.action": "审计操作: log(查询) / stats(统计) / cleanup(清理)", + "cli.help.audit.log": "查询审计日志条目", + "cli.help.audit.log.event": "按事件类型过滤", + "cli.help.audit.log.status": "按状态过滤: success / failed / partial", + "cli.help.audit.log.since": "显示此日期之后的条目(ISO 8601 格式)", + "cli.help.audit.log.limit": "最多显示条目数(默认: 50)", + "cli.help.audit.stats": "显示审计日志统计信息", + "cli.help.audit.cleanup": "清理旧的审计日志条目", + "cli.help.audit.cleanup.keep_days": "保留最近 N 天的条目(默认: 90)", + "cmd.audit.cleanup.done": "已清理 {count} 条旧的审计日志记录。", + "cmd.audit.cleanup.none": "没有需要清理的旧记录。", + "cli.help.profile": "管理配置 Profile", + "cli.help.profile.action": "操作: list(列出) / create(创建) / delete(删除) / use(使用) / export(导出) / import(导入)", + "cli.help.profile.create.name": "从当前配置创建 Profile 的名称", + "cli.help.profile.delete.name": "要删除的 Profile 名称", + "cli.help.profile.use.name": "要临时使用的 Profile 名称", + "cli.help.profile.export.name": "要导出的 Profile 名称", + "cli.help.profile.export.path": "导出文件路径", + "cli.help.profile.import.path": "导入文件路径", + "cli.help.profile.import.name": "Profile 名称(默认使用文件名)", + "cli.help.profile_name": "在所有命令中使用指定的 Profile", + "cmd.profile.list.header": "配置 Profile 列表:", + "cmd.profile.list.empty": "未配置任何 Profile。", + "cmd.profile.list.item": " {name}", + "cmd.profile.list.detail": " {fields}", + "cmd.profile.created": "Profile '{name}' 创建成功。", + "cmd.profile.deleted": "Profile '{name}' 已删除。", + "cmd.profile.not_found": "Profile '{name}' 不存在。", + "cmd.profile.activated": "Profile '{name}' 已激活。", + "cmd.profile.exported": "Profile '{name}' 已导出到: {path}", + "cmd.profile.imported": "Profile '{name}' 导入成功。", + "cmd.profile.import_failed": "从 {path} 导入 Profile 失败。", + "cli.help.restore.search": "在备份包内搜索文件名(不区分大小写)", + "cli.help.restore.stats": "显示备份包统计信息(文件数、大小、格式分布)", + "cmd.restore.search.header": "在 {path} 中搜索 '{keyword}' 的结果:", + "cmd.restore.search.found": "找到 {count} 个匹配文件:", + "cmd.restore.search.no_results": "在 {path} 中未找到匹配 '{keyword}' 的文件", + "cmd.restore.stats.header": "═══ 备份包统计信息: {path} ═══", + "cmd.restore.stats.files": "文件数: {count}", + "cmd.restore.stats.dirs": "目录数: {count}", + "cmd.restore.stats.size": "总大小: {size} 字节", + "cmd.restore.stats.formats": "文件类型分布:", + "cmd.restore.stats.format_item": " {ext}: {count} 个文件", + "cmd.restore.selective.header": "选择性恢复:提取匹配 {patterns} 的 {count} 个文件", + "cmd.restore.selective.success": "已提取 {count} 个文件到 {target}", + "cli.help.hooks": "管理并手动执行备份前置/后置钩子", + "cli.help.hooks.action": "钩子操作: run(执行)", + "cli.help.hooks.run": "手动执行备份前置或后置钩子", + "cli.help.hooks.run.type": "要执行的钩子类型: pre(前置) 或 post(后置)", + "cli.help.hooks.run.timeout": "覆盖钩子超时时间(秒)(默认: 使用配置值)", + "cmd.hooks.no_hooks": "未配置 {type} 钩子。", + "cmd.hooks.no_results": "没有钩子执行结果。", + "cmd.hooks.result_line": "[{status}] {command} (返回码={return_code}, 耗时{duration}秒)", + "cmd.hooks.summary": "共 {total} 个, 成功 {success} 个, 失败 {fail} 个", + "cli.help.save.multi_dest": "启用多目标同时备份模式", + "cli.help.multi_backup": "多目标同时备份:本地备份 + 并行上传到所有启用的远程目标", + "cli.help.multi_backup.source": "要备份的源文件夹路径", + "cli.help.multi_backup.local_target": "本地备份目标文件夹路径", + "cli.help.multi_backup.keep": "保留最近 N 个备份文件(0 表示不清理)", + "cli.help.multi_backup.keep_days": "保留最近 N 天的备份文件", + "cli.help.multi_backup.password": "加密密码(仅 7z 格式支持)", + "cli.help.multi_backup.dry_run": "仅预览将备份的文件,不实际执行", + "cli.help.multi_backup.verify": "备份后自动校验完整性", + "cli.help.multi_backup.name_template": "备份文件名模板", + "multi_dest.local.failed": "本地备份失败", + "multi_dest.started": "多目标备份开始: {source} -> {targets}", + "multi_dest.local.success": "本地备份完成: {path}", + "multi_dest.uploading": "并行上传到 {count} 个远程目标...", + "multi_dest.aborted": "多目标备份中止: 本地备份失败", + "multi_dest.complete": "多目标备份完成", + "cli.help.xsearch": "跨档案搜索:在多个备份文件中搜索文件名", + "cli.help.xsearch.dirs": "递归搜索备份文件的目录列表", + "cli.help.xsearch.keyword": "搜索关键字(不区分大小写,匹配文件名)", + "cli.help.xsearch.pattern": "Glob 模式匹配文件名(如 \"*.log\")", + "cli.help.xsearch.ext": "按扩展名搜索(如 .log, .py)", + "cli.help.xsearch.password": "解密密码(搜索加密备份时使用)", + "cli.help.xsearch.output_lang": "输出语言:zh_CN(中文) / en_US(英文)", + "cmd.xsearch.no_query": "请指定搜索条件:--keyword、--pattern 或 --ext", + "cli.help.benchmark": "对指定目录运行压缩基准测试,比较不同格式的性能", + "cli.help.benchmark.path": "要测试的源目录路径", + "cli.help.benchmark.levels": "要测试的压缩级别(逗号分隔,默认: 1,6,9)", + "cli.help.benchmark.quick": "快速模式:仅测试每个格式的默认级别", + "cli.help.benchmark.password": "加密密码(7z 格式使用)", + "benchmark.header": "═══ 压缩基准测试 ═══", + "benchmark.source_info": "源目录: {path} ({size}, {count} 个文件)", + "benchmark.recommended": "推荐: {format} 级别={level} ({size}, 最佳平衡)", + "benchmark.running": "正在测试 {format} 级别={level}...", + "benchmark.error": "基准测试失败 {format}: {error}", + "benchmark.empty_source": "源目录为空: {path}", + "cli.help.metadata_export": "导出备份元数据(历史/审计日志)到文件", + "cli.help.metadata_export.action": "导出操作: history(历史) / audit(审计) / all(综合)", + "cli.help.metadata_export.history": "导出备份历史到 CSV 或 JSON", + "cli.help.metadata_export.audit": "导出审计日志到 CSV 或 JSON", + "cli.help.metadata_export.all": "导出综合报告(历史 + 审计 + 统计)", + "cli.help.metadata_export.output": "输出文件路径", + "cli.help.metadata_export.strategy": "按策略名称过滤(匹配 source 路径)", + "cli.help.metadata_export.event": "按事件类型过滤", + "cli.help.metadata_export.format": "输出格式: csv(history/audit 默认)或 json", + "cmd.metadata_export.history_done": "已导出 {count} 条历史记录到 {path}", + "cmd.metadata_export.audit_done": "已导出 {count} 条审计日志到 {path}", + "cmd.metadata_export.combined_done": "已导出综合报告({count} 条记录)到 {path}" +} diff --git a/sbackup/lock.py b/sbackup/lock.py new file mode 100644 index 0000000..9584da2 --- /dev/null +++ b/sbackup/lock.py @@ -0,0 +1,132 @@ +"""备份锁模块:防止并发冲突 + +使用原子文件操作(tempfile + os.rename)实现跨平台进程锁, +确保同一时间只有一个 sbackup 实例在执行备份。 +""" + +import os +import sys +import tempfile +import atexit +import logging + +logger = logging.getLogger(__name__) + + +class BackupLockError(Exception): + """备份锁异常""" + + +class BackupLock: + """跨平台备份进程锁 + + 基于 tempfile.mkstemp() + os.rename() 的原子性实现。 + + Usage:: + + lock = BackupLock(lock_dir) + if lock.acquire(): + try: + # do backup + finally: + lock.release() + else: + print("Another backup is running") + """ + + def __init__(self, lock_dir: str): + self._lock_path = os.path.join(lock_dir, ".backup.lock") + self._staging_path: str | None = None + self._locked = False + + def acquire(self) -> bool: + """尝试获取锁,成功返回 True""" + if self._locked: + return True + + # 清理过期锁 + self._clean_stale_lock() + + try: + # 创建临时 staging 文件 + fd, self._staging_path = tempfile.mkstemp( + dir=os.path.dirname(self._lock_path) or ".", + prefix=".backup.lock.", + ) + with os.fdopen(fd, "w") as f: + f.write(str(os.getpid())) + f.flush() + os.fsync(f.fileno()) + + # 原子 rename(Windows 上目标存在时失败) + os.rename(self._staging_path, self._lock_path) + + self._locked = True + atexit.register(self.release) + return True + + except OSError: + self._cleanup_staging() + return False + + def release(self) -> None: + """释放锁""" + if not self._locked: + return + try: + if os.path.exists(self._lock_path): + os.unlink(self._lock_path) + except OSError as e: + logger.debug("Failed to remove lock file: %s", e) + self._locked = False + # 注销 atexit handler(Python 3.12+ 支持 unregister) + try: + atexit.unregister(self.release) + except Exception: + pass + + def _clean_stale_lock(self) -> None: + """检查并清理过期锁文件""" + try: + if not os.path.exists(self._lock_path): + return + with open(self._lock_path) as f: + content = f.read().strip() + pid = int(content) + if not _is_pid_alive(pid): + os.unlink(self._lock_path) + logger.info("Removed stale lock from dead PID %d", pid) + except (OSError, ValueError): + try: + os.unlink(self._lock_path) + except OSError: + pass + + def _cleanup_staging(self) -> None: + if self._staging_path and os.path.exists(self._staging_path): + try: + os.unlink(self._staging_path) + except OSError: + pass + self._staging_path = None + + +def _is_pid_alive(pid: int) -> bool: + """跨平台检查 PID 是否存活""" + if sys.platform == "win32": + try: + import ctypes + + handle = ctypes.windll.kernel32.OpenProcess(0x0400, False, pid) + if handle: + ctypes.windll.kernel32.CloseHandle(handle) + return True + except Exception: + pass + return False + # Unix: signal 0 仅检查进程是否存在 + try: + os.kill(pid, 0) + return True + except OSError: + return False diff --git a/sbackup/monitor.py b/sbackup/monitor.py new file mode 100644 index 0000000..b60b772 --- /dev/null +++ b/sbackup/monitor.py @@ -0,0 +1,160 @@ +""" +文件系统监控模块:基于 watchdog 的实时备份触发 +""" + +import logging +import os +import threading +from pathlib import Path + +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer + +from sbackup.i18n import t + +logger = logging.getLogger(__name__) + + +class _BackupEventHandler(FileSystemEventHandler): + """watchdog 事件处理器:目录变化时触发回调""" + + def __init__(self, on_change_callback): + super().__init__() + self._on_change = on_change_callback + + def on_any_event(self, event): + if event.is_directory: + return + self._on_change(event.src_path) + + +class FileSystemMonitor: + """文件系统监控器:监听策略源目录变化,防抖后触发备份 + + 使用方式:: + + monitor = FileSystemMonitor(source_dirs, debounce_seconds=30) + monitor.set_backup_callback(lambda: manager.execute_backups(...)) + monitor.start() + # ... 等待 ... + monitor.stop() + """ + + def __init__( + self, + source_dirs: dict[str, str], + debounce_seconds: float = 30, + ): + """ + :param source_dirs: {源目录绝对路径: 目标目录}(仅用于确定监控范围) + :param debounce_seconds: 防抖秒数,文件最后一次变化后等待此时间再触发备份 + """ + self._source_dirs = source_dirs + self._debounce = debounce_seconds + self._observer: Observer | None = None + self._dirty: set[str] = set() + self._lock = threading.Lock() + self._debounce_timer: threading.Timer | None = None + self._backup_callback = None + self._running = False + + def set_backup_callback(self, callback) -> None: + """设置备份回调函数(变化触发时调用)""" + self._backup_callback = callback + + def _on_file_change(self, path_str: str) -> None: + """文件变化时标记对应源目录为 dirty 并重置防抖计时器""" + path = Path(path_str) + matched_source = None + for source in self._source_dirs: + try: + path.relative_to(Path(source)) + matched_source = source + break + except ValueError: + continue + + if matched_source is None: + return + + with self._lock: + self._dirty.add(matched_source) + count = len(self._dirty) + + logger.debug(t("log.monitor.change", path=path_str, dirty_count=count)) + self._reset_debounce() + + def _reset_debounce(self) -> None: + """重置防抖计时器:取消旧计时,启动新计时""" + with self._lock: + if self._debounce_timer is not None: + self._debounce_timer.cancel() + self._debounce_timer = threading.Timer(self._debounce, self._trigger_backup) + self._debounce_timer.daemon = True + self._debounce_timer.start() + + def _trigger_backup(self) -> None: + """防抖超时后触发备份回调""" + with self._lock: + if not self._dirty: + return + dirty_count = len(self._dirty) + self._dirty.clear() + + logger.debug(t("log.monitor.trigger", dirty_count=dirty_count)) + if self._backup_callback: + try: + self._backup_callback() + except Exception: + logger.exception("FileSystemMonitor 回调执行异常") + + def get_dirty_sources(self) -> list[str]: + """获取当前标记为 dirty 的源目录列表(用于调试)""" + with self._lock: + return list(self._dirty) + + def start(self) -> None: + """启动文件系统监控""" + if self._observer is not None: + return + + self._observer = Observer() + scheduled = 0 + for source in self._source_dirs: + if not os.path.isdir(source): + logger.warning(t("log.monitor.source_missing", path=source)) + continue + handler = _BackupEventHandler(self._on_file_change) + self._observer.schedule(handler, source, recursive=True) + scheduled += 1 + logger.debug(t("log.monitor.watching", path=source)) + + if scheduled == 0: + logger.warning(t("log.monitor.no_sources")) + self._observer = None + return + + self._observer.start() + self._running = True + + def stop(self) -> None: + """停止文件系统监控""" + self._running = False + with self._lock: + if self._debounce_timer is not None: + self._debounce_timer.cancel() + self._debounce_timer = None + + if self._observer is not None: + self._observer.stop() + try: + self._observer.join(timeout=5) + except RuntimeError: + pass + self._observer = None + + def is_running(self) -> bool: + """检查监控是否在运行""" + return ( + self._running and self._observer is not None and self._observer.is_alive() + ) diff --git a/sbackup/multi_dest.py b/sbackup/multi_dest.py new file mode 100644 index 0000000..5f4451f --- /dev/null +++ b/sbackup/multi_dest.py @@ -0,0 +1,417 @@ +""" +多目标同时备份模块:支持并行备份到本地 + 多个远程目标 +""" + +import os +import logging +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from sbackup.config import Config +from sbackup.i18n import t + +logger = logging.getLogger(__name__) + + +@dataclass +class DestResult: + """单个目标的备份/上传结果""" + + name: str # 目标名称("local", "sftp", "webdav", "cloud") + success: bool + path: str = "" # 目标路径 + error: str = "" + size: int = 0 # 上传文件大小 + duration: float = 0 # 耗时 + + +class MultiDestBackup: + """多目标备份管理器:先本地备份,再并行上传到远程目标""" + + def __init__(self, config: Config): + self.config = config + self.results: list[DestResult] = [] + + def get_enabled_destinations(self) -> list[str]: + """返回所有启用的目标列表 + 始终包含 "local"(本地备份) + 如果 sftp_enabled → "sftp" + 如果 webdav_enabled → "webdav" + 如果 cloud_enabled → "cloud" + """ + destinations = ["local"] + if self.config.sftp_enabled: + destinations.append("sftp") + if self.config.webdav_enabled: + destinations.append("webdav") + if self.config.cloud_enabled: + destinations.append("cloud") + return destinations + + def backup_to_local(self, source_path: str, backup_path: str) -> DestResult: + """执行本地备份(调用 BackupManager) + :param source_path: 源文件夹路径 + :param backup_path: 本地备份目标文件夹路径 + """ + start = time.monotonic() + try: + from sbackup.compression import create_compressor + + abs_source = os.path.abspath(source_path) + abs_dest = os.path.abspath(backup_path) + + if not os.path.isdir(abs_source): + return DestResult( + name="local", + success=False, + error=t("err.folder.invalid", path=abs_source), + ) + + os.makedirs(abs_dest, exist_ok=True) + + # 构建 Config 用于压缩 + compress_config = Config( + folder_path=abs_source, + skip_patterns=self.config.skip_patterns, + compression_format=self.config.compression_format, + compression_algorithm=self.config.compression_algorithm, + compression_level=self.config.compression_level, + password=self.config.password, + follow_symlinks=self.config.follow_symlinks, + name_template=self.config.name_template, + ) + + compressor = create_compressor(compress_config) + result = compressor.compress() + + elapsed = time.monotonic() - start + + if result and result.get("success"): + backup_file = result.get("path", "") + file_size = 0 + if backup_file and os.path.isfile(backup_file): + file_size = os.path.getsize(backup_file) + return DestResult( + name="local", + success=True, + path=backup_file, + size=file_size, + duration=elapsed, + ) + else: + error_msg = "" + if result: + error_msg = result.get("error", "") or result.get("message", "") + return DestResult( + name="local", + success=False, + error=error_msg or t("multi_dest.local.failed"), + duration=elapsed, + ) + except Exception as e: + elapsed = time.monotonic() - start + logger.error("Local backup failed: %s", e) + return DestResult( + name="local", + success=False, + error=str(e), + duration=elapsed, + ) + + def upload_to_sftp(self, backup_path: str) -> DestResult: + """上传到 SFTP""" + start = time.monotonic() + try: + from sbackup.sftp import SFTPClient + + if not self.config.sftp_enabled or not self.config.sftp_host: + return DestResult( + name="sftp", + success=False, + error=t("err.sftp.not_configured"), + ) + + # 获取认证凭据 + key_file = self.config.sftp_key_file + key_passphrase = self.config.sftp_key_passphrase + password = self.config.sftp_password + + if not key_file and not password: + default_key = SFTPClient.try_default_key() + if default_key: + key_file = default_key + key_passphrase = SFTPClient.resolve_key_passphrase(default_key) + if key_passphrase is None: + key_file = "" + password = self.config.sftp_password + key_passphrase = "" + else: + return DestResult( + name="sftp", + success=False, + error=t("cmd.sftp.no_default_key"), + ) + elif key_file and not key_passphrase and not password: + key_passphrase = SFTPClient.resolve_key_passphrase(key_file) + if key_passphrase is None: + key_file = "" + password = self.config.sftp_password + key_passphrase = "" + + with SFTPClient( + self.config.sftp_host, + self.config.sftp_port, + self.config.sftp_user, + password, + key_file, + key_passphrase, + ) as client: + client.upload_file(backup_path, self.config.sftp_remote_path) + + elapsed = time.monotonic() - start + file_size = ( + os.path.getsize(backup_path) if os.path.isfile(backup_path) else 0 + ) + return DestResult( + name="sftp", + success=True, + path=backup_path, + size=file_size, + duration=elapsed, + ) + except Exception as e: + elapsed = time.monotonic() - start + logger.error("SFTP upload failed: %s", e) + return DestResult( + name="sftp", + success=False, + error=str(e), + duration=elapsed, + ) + + def upload_to_webdav(self, backup_path: str) -> DestResult: + """上传到 WebDAV""" + start = time.monotonic() + try: + from sbackup.webdav import WebDAVClient + + if not self.config.webdav_enabled or not self.config.webdav_url: + return DestResult( + name="webdav", + success=False, + error=t("err.webdav.not_configured"), + ) + + filename = os.path.basename(backup_path) + remote_path = self.config.webdav_remote_path.rstrip("/") + "/" + filename + + client = WebDAVClient( + self.config.webdav_url, + self.config.webdav_user, + self.config.webdav_password, + ) + client.connect() + client.upload_file(backup_path, remote_path) + + elapsed = time.monotonic() - start + file_size = ( + os.path.getsize(backup_path) if os.path.isfile(backup_path) else 0 + ) + return DestResult( + name="webdav", + success=True, + path=remote_path, + size=file_size, + duration=elapsed, + ) + except Exception as e: + elapsed = time.monotonic() - start + logger.error("WebDAV upload failed: %s", e) + return DestResult( + name="webdav", + success=False, + error=str(e), + duration=elapsed, + ) + + def upload_to_cloud(self, backup_path: str) -> DestResult: + """上传到 S3 云存储""" + start = time.monotonic() + try: + from sbackup.cloud_storage import CloudStorageClient + + if not self.config.cloud_enabled or not self.config.cloud_endpoint: + return DestResult( + name="cloud", + success=False, + error=t("err.cloud.not_configured"), + ) + + filename = os.path.basename(backup_path) + remote_path = ( + self.config.cloud_remote_path.rstrip("/") + "/" + filename + ).lstrip("/") + + with CloudStorageClient( + self.config.cloud_endpoint, + self.config.cloud_access_key, + self.config.cloud_secret_key, + self.config.cloud_bucket, + region=self.config.cloud_region or None, + secure=self.config.cloud_secure, + ) as client: + client.upload_file(backup_path, remote_path) + + elapsed = time.monotonic() - start + file_size = ( + os.path.getsize(backup_path) if os.path.isfile(backup_path) else 0 + ) + return DestResult( + name="cloud", + success=True, + path=remote_path, + size=file_size, + duration=elapsed, + ) + except Exception as e: + elapsed = time.monotonic() - start + logger.error("Cloud upload failed: %s", e) + return DestResult( + name="cloud", + success=False, + error=str(e), + duration=elapsed, + ) + + def execute_all(self, source_path: str, backup_path: str) -> list[DestResult]: + """并行执行所有目标的备份/上传 + 1. 先执行本地备份(必须成功) + 2. 如果本地成功,并行上传到所有启用的远程目标 + 3. 每个目标独立处理异常,一个失败不影响其他 + 4. 汇总所有结果 + """ + self.results = [] + destinations = self.get_enabled_destinations() + + # Step 1: 执行本地备份 + local_result = self.backup_to_local(source_path, backup_path) + self.results.append(local_result) + + if not local_result.success: + logger.error( + "Multi-dest backup aborted: local backup failed (%s)", + local_result.error, + ) + return self.results + + uploaded_file = local_result.path + if not uploaded_file or not os.path.isfile(uploaded_file): + logger.error("Multi-dest backup aborted: local backup file not found") + return self.results + + # Step 2: 并行上传到远程目标 + remote_destinations = [d for d in destinations if d != "local"] + if not remote_destinations: + return self.results + + # 构建远程上传任务映射 + upload_map: dict[str, callable] = {} + for dest in remote_destinations: + if dest == "sftp": + upload_map["sftp"] = self.upload_to_sftp + elif dest == "webdav": + upload_map["webdav"] = self.upload_to_webdav + elif dest == "cloud": + upload_map["cloud"] = self.upload_to_cloud + + if not upload_map: + return self.results + + logger.debug( + "Multi-dest: parallel upload to %d remote targets", len(upload_map) + ) + + with ThreadPoolExecutor(max_workers=len(upload_map)) as executor: + futures = { + executor.submit(fn, uploaded_file): name + for name, fn in upload_map.items() + } + for future in as_completed(futures): + dest_name = futures[future] + try: + result = future.result() + self.results.append(result) + except Exception as e: + logger.error("Upload to %s failed unexpectedly: %s", dest_name, e) + self.results.append( + DestResult( + name=dest_name, + success=False, + error=str(e), + ) + ) + + return self.results + + def format_results(self, results: list[DestResult], lang: str = "zh_CN") -> str: + """格式化所有目标的结果""" + lines: list[str] = [] + + # 表头 + if lang == "zh_CN": + lines.append("═══ 多目标备份结果 ═══") + else: + lines.append("=== Multi-Destination Backup Results ===") + + success_count = sum(1 for r in results if r.success) + fail_count = len(results) - success_count + + for r in results: + status_icon = "[OK]" if r.success else "[FAIL]" + + if lang == "zh_CN": + dest_label = { + "local": "本地", + "sftp": "SFTP", + "webdav": "WebDAV", + "cloud": "云存储", + }.get(r.name, r.name) + else: + dest_label = { + "local": "Local", + "sftp": "SFTP", + "webdav": "WebDAV", + "cloud": "Cloud", + }.get(r.name, r.name) + + if r.success: + size_mb = r.size / (1024 * 1024) if r.size > 0 else 0 + if lang == "zh_CN": + lines.append( + f" {status_icon} {dest_label}: {r.path}" + f" ({size_mb:.2f} MB, {r.duration:.1f}秒)" + ) + else: + lines.append( + f" {status_icon} {dest_label}: {r.path}" + f" ({size_mb:.2f} MB, {r.duration:.1f}s)" + ) + else: + if lang == "zh_CN": + lines.append(f" {status_icon} {dest_label}: {r.error}") + else: + lines.append(f" {status_icon} {dest_label}: {r.error}") + + # 汇总 + if lang == "zh_CN": + lines.append( + f" ── 共 {len(results)} 个目标, " + f"成功 {success_count} 个, 失败 {fail_count} 个 ──" + ) + else: + lines.append( + f" -- {len(results)} target(s), " + f"{success_count} succeeded, {fail_count} failed --" + ) + + return "\n".join(lines) diff --git a/sbackup/parity.py b/sbackup/parity.py new file mode 100644 index 0000000..61d03a6 --- /dev/null +++ b/sbackup/parity.py @@ -0,0 +1,89 @@ +"""Reed-Solomon 纠错码:备份自修复""" + +import os +import logging + +logger = logging.getLogger(__name__) + +# 默认纠错参数:每 128 字节数据生成 8 字节纠错码 +DEFAULT_DATA_SYMBOLS = 128 +DEFAULT_PARITY_SYMBOLS = 8 + + +def generate_parity( + file_path: str, + parity_path: str | None = None, + data_symbols: int = DEFAULT_DATA_SYMBOLS, + parity_symbols: int = DEFAULT_PARITY_SYMBOLS, +) -> str | None: + """为文件生成 Reed-Solomon 纠错码 + + :returns: 纠错码文件路径,失败返回 None + """ + try: + from reedsolo import RSCodec + except ImportError: + logger.warning("reedsolo not installed, skipping parity generation") + return None + + if parity_path is None: + parity_path = file_path + ".par2" + + try: + coder = RSCodec(parity_symbols) + with open(file_path, "rb") as f: + data = f.read() + encoded = coder.encode(data) + with open(parity_path, "wb") as f: + f.write(encoded) + return parity_path + except Exception: + logger.exception("Failed to generate parity for %s", file_path) + return None + + +def verify_and_repair( + file_path: str, + parity_path: str | None = None, + data_symbols: int = DEFAULT_DATA_SYMBOLS, + parity_symbols: int = DEFAULT_PARITY_SYMBOLS, +) -> tuple[bool, str]: + """验证并修复文件 + + :returns: (success, message) + """ + try: + from reedsolo import RSCodec + except ImportError: + return False, "reedsolo not installed" + + if parity_path is None: + parity_path = file_path + ".par2" + + if not os.path.exists(parity_path): + # 没有纠错码文件,做常规校验 + return _verify_integrity(file_path) + + try: + coder = RSCodec(parity_symbols) + with open(parity_path, "rb") as f: + encoded = f.read() + decoded, _, errata = coder.decode(encoded) + if errata: + # 修复成功,写回文件 + with open(file_path, "wb") as f: + f.write(bytes(decoded)) + return True, f"Repaired {errata} errors" + return True, "Integrity OK" + except Exception as e: + return False, f"Repair failed: {e}" + + +def _verify_integrity(file_path: str) -> tuple[bool, str]: + """没有纠错码时的简单完整性检查""" + try: + with open(file_path, "rb") as f: + f.read() + return True, "File readable" + except Exception as e: + return False, f"File corrupted: {e}" diff --git a/sbackup/profile.py b/sbackup/profile.py new file mode 100644 index 0000000..763f393 --- /dev/null +++ b/sbackup/profile.py @@ -0,0 +1,313 @@ +""" +配置 Profile 管理模块:支持多配置方案的保存、切换、导入导出 +""" + +import os +import json +import logging + +from sbackup.config import Config, load_config, _load_json_file, _save_json_file + +logger = logging.getLogger(__name__) + + +class ProfileManager: + """管理命名配置 Profile:保存、加载、切换、导入导出""" + + def __init__(self, config_file: str = "config.json"): + self.config_file = config_file + + def list_profiles(self) -> dict[str, dict]: + """列出所有 profile,返回 {name: config_dict}""" + data = _load_json_file(self.config_file) + return data.get("profiles", {}) + + def get_profile(self, name: str) -> dict | None: + """获取指定 profile 的配置,不存在返回 None""" + profiles = self.list_profiles() + return profiles.get(name) + + def save_profile(self, name: str, config: Config) -> bool: + """保存当前配置为命名 profile""" + if not name: + return False + + # 从 Config dataclass 中提取字段作为 profile dict + profile_dict = _config_to_profile_dict(config) + + data = _load_json_file(self.config_file) + profiles = data.get("profiles", {}) + profiles[name] = profile_dict + data["profiles"] = profiles + _save_json_file(data, self.config_file) + return True + + def delete_profile(self, name: str) -> bool: + """删除指定 profile,返回是否成功""" + data = _load_json_file(self.config_file) + profiles = data.get("profiles", {}) + if name not in profiles: + return False + del profiles[name] + data["profiles"] = profiles + _save_json_file(data, self.config_file) + return True + + def activate_profile(self, name: str) -> Config | None: + """激活 profile:将 profile 配置合并到主配置。 + 只覆盖 profile 中明确设置的字段,不修改 config.json。 + 返回合并后的 Config 对象,profile 不存在返回 None。 + """ + profile = self.get_profile(name) + if profile is None: + return None + + # 读取当前主配置 + main_config = load_config(self.config_file) + + # 用 profile 字段覆盖主配置 + merged = _merge_profile_to_config(main_config, profile) + return merged + + def export_profile(self, name: str, export_path: str) -> bool: + """导出 profile 到独立 JSON 文件""" + profile = self.get_profile(name) + if profile is None: + return False + + export_data = {"profile_name": name, "config": profile} + try: + _save_json_file(export_data, export_path) + return True + except Exception: + logger.exception("Failed to export profile %s", name) + return False + + def import_profile(self, import_path: str, name: str = "") -> bool: + """从 JSON 文件导入 profile。name 为空时使用文件名(不含扩展名)。 + 文件格式:{"profile_name": "xxx", "config": {...}} 或直接 {...} + """ + if not os.path.isfile(import_path): + return False + + try: + with open(import_path, "r", encoding="utf-8") as f: + import_data = json.load(f) + except (json.JSONDecodeError, OSError): + logger.exception("Failed to read import file: %s", import_path) + return False + + # 支持两种格式 + if isinstance(import_data, dict) and "config" in import_data: + profile_dict = import_data["config"] + if not name and "profile_name" in import_data: + name = import_data["profile_name"] + else: + profile_dict = import_data + + if not name: + # 使用文件名(不含扩展名)作为 profile 名 + name = os.path.splitext(os.path.basename(import_path))[0] + + if not name or not isinstance(profile_dict, dict): + return False + + data = _load_json_file(self.config_file) + profiles = data.get("profiles", {}) + profiles[name] = profile_dict + data["profiles"] = profiles + _save_json_file(data, self.config_file) + return True + + +# --- 内部辅助函数 --- + +# Config dataclass 字段名到 JSON 序列化名的映射 +_CONFIG_FIELD_MAP = { + "folder_path": "folder_path", + "zipfile_path": "zipfile_path", + "skip_patterns": "skip_patterns", + "compression_format": "compression_format", + "compression_algorithm": "compression_algorithm", + "compression_level": "compression_level", + "lang": "lang", + "data_file": None, # 不序列化 + "password": "password", + "name_template": "name_template", + "webhook_url": None, # 使用 webhook_urls + "webhook_urls": "webhook_urls", + "webhook_template": "webhook_template", + "webhook_retries": "webhook_retries", + "follow_symlinks": "follow_symlinks", + "max_size": "max_size", + "min_size": "min_size", + "include_patterns": "include_patterns", + "exclude_patterns": "exclude_patterns", + "threads": "threads", + "max_age_seconds": "max_age_seconds", + "file_metadata": None, # 内部状态,不序列化 + "sftp_host": None, # 使用嵌套结构 + "sftp_port": None, + "sftp_user": None, + "sftp_password": None, + "sftp_key_file": None, + "sftp_key_passphrase": None, + "sftp_remote_path": None, + "sftp_enabled": None, + "webdav_url": None, + "webdav_user": None, + "webdav_password": None, + "webdav_remote_path": None, + "webdav_enabled": None, + "cloud_endpoint": None, + "cloud_access_key": None, + "cloud_secret_key": None, + "cloud_bucket": None, + "cloud_region": None, + "cloud_secure": None, + "cloud_remote_path": None, + "cloud_enabled": None, + "smtp_host": None, + "smtp_port": None, + "smtp_user": None, + "smtp_password": None, + "smtp_from": None, + "smtp_to": None, + "smtp_tls": None, + "smtp_enabled": None, + "rotation_keep_count": "rotation_keep_count", + "rotation_keep_days": "rotation_keep_days", + "rotation_keep_daily": "rotation_keep_daily", + "_config_checksum": None, +} + + +def _config_to_profile_dict(config: Config) -> dict: + """将 Config dataclass 转换为可序列化的 profile dict(扁平化)""" + profile: dict = {} + + # 简单字段 + simple_fields = [ + "folder_path", + "compression_format", + "compression_algorithm", + "compression_level", + "password", + "name_template", + "skip_patterns", + "follow_symlinks", + "max_size", + "min_size", + "include_patterns", + "exclude_patterns", + "threads", + "max_age_seconds", + "webhook_urls", + "webhook_template", + "webhook_retries", + "rotation_keep_count", + "rotation_keep_days", + "rotation_keep_daily", + ] + for field_name in simple_fields: + value = getattr(config, field_name, None) + if value is not None: + profile[field_name] = value + + # SFTP 嵌套结构 + sftp = {} + for suffix in [ + "host", + "port", + "user", + "password", + "key_file", + "key_passphrase", + "remote_path", + "enabled", + ]: + val = getattr(config, f"sftp_{suffix}", None) + if val is not None and val != "" and val is not False: + sftp[suffix] = val + if sftp: + profile["sftp"] = sftp + + # WebDAV 嵌套结构 + webdav = {} + for suffix in ["url", "user", "password", "remote_path", "enabled"]: + val = getattr(config, f"webdav_{suffix}", None) + if val is not None and val != "" and val is not False: + webdav[suffix] = val + if webdav: + profile["webdav"] = webdav + + # Cloud 嵌套结构 + cloud = {} + for suffix in [ + "endpoint", + "access_key", + "secret_key", + "bucket", + "region", + "secure", + "remote_path", + "enabled", + ]: + val = getattr(config, f"cloud_{suffix}", None) + if val is not None and val != "" and val is not True and val is not False: + cloud[suffix] = val + elif val is True or val is False: + cloud[suffix] = val + if cloud: + profile["cloud"] = cloud + + # SMTP 嵌套结构 + smtp = {} + for suffix in ["host", "port", "user", "password", "from", "to", "tls", "enabled"]: + val = getattr(config, f"smtp_{suffix}", None) + if val is not None and val != "" and val is not False: + smtp[suffix] = val + if smtp: + profile["smtp"] = smtp + + return profile + + +def _merge_profile_to_config(config: Config, profile: dict) -> Config: + """将 profile dict 合并到 Config 对象,profile 中明确设置的字段覆盖主配置。 + 返回新的 Config 对象(不修改原 config)。 + """ + import dataclasses + + # 将 Config 转为 dict + config_dict = dataclasses.asdict(config) + + # 扁平化合并 + for key, value in profile.items(): + if key == "sftp" and isinstance(value, dict): + for sftp_key, sftp_val in value.items(): + field_name = f"sftp_{sftp_key}" + if field_name in config_dict: + config_dict[field_name] = sftp_val + elif key == "webdav" and isinstance(value, dict): + for wd_key, wd_val in value.items(): + field_name = f"webdav_{wd_key}" + if field_name in config_dict: + config_dict[field_name] = wd_val + elif key == "cloud" and isinstance(value, dict): + for c_key, c_val in value.items(): + field_name = f"cloud_{c_key}" + if field_name in config_dict: + config_dict[field_name] = c_val + elif key == "smtp" and isinstance(value, dict): + for s_key, s_val in value.items(): + field_name = f"smtp_{s_key}" + if field_name in config_dict: + config_dict[field_name] = s_val + elif key in config_dict: + config_dict[key] = value + + # 移除内部字段(不在 dataclass 构造函数中) + config_dict.pop("_config_checksum", None) + + return Config(**config_dict) diff --git a/sbackup/ratelimiter.py b/sbackup/ratelimiter.py new file mode 100644 index 0000000..bdf3aba --- /dev/null +++ b/sbackup/ratelimiter.py @@ -0,0 +1,40 @@ +"""带宽限速器:Token Bucket 算法""" + +import time +import threading + + +class RateLimiter: + """令牌桶速率限制器 + + 用法:: + limiter = RateLimiter(1024 * 1024) # 1 MB/s + while data := file.read(chunk_size): + limiter.wait(len(data)) + upload(data) + """ + + def __init__(self, bytes_per_second: float): + self._rate = bytes_per_second + self._tokens = bytes_per_second + self._last = time.monotonic() + self._lock = threading.Lock() + + def wait(self, bytes_consumed: int) -> None: + """等待直到有足够的令牌""" + if self._rate <= 0: + return + with self._lock: + now = time.monotonic() + elapsed = now - self._last + self._tokens += elapsed * self._rate + self._last = now + if self._tokens > self._rate: + self._tokens = self._rate + if self._tokens >= bytes_consumed: + self._tokens -= bytes_consumed + return + deficit = bytes_consumed - self._tokens + sleep_time = deficit / self._rate + self._tokens = 0 + time.sleep(sleep_time) diff --git a/sbackup/retry.py b/sbackup/retry.py new file mode 100644 index 0000000..8e474a5 --- /dev/null +++ b/sbackup/retry.py @@ -0,0 +1,72 @@ +"""重试工具:指数退避重试机制""" + +import random +import time +import logging + +logger = logging.getLogger(__name__) + + +def with_retry( + max_retries: int = 2, + base_delay: float = 0.5, + max_delay: float = 60.0, + exponential_base: float = 2.0, + jitter: bool = True, +): + """指数退避重试装饰器""" + + def decorator(func): + from functools import wraps + + @wraps(func) + def wrapper(*args, **kwargs): + last_exc = None + for attempt in range(max_retries + 1): + try: + return func(*args, **kwargs) + except Exception as e: + last_exc = e + if attempt < max_retries: + delay = min( + base_delay * (exponential_base**attempt), + max_delay, + ) + if jitter: + delay += random.uniform(0, delay * 0.5) + logger.warning( + "%s failed (attempt %d/%d): %s. Retrying in %.1fs...", + func.__name__, + attempt + 1, + max_retries + 1, + e, + delay, + ) + time.sleep(delay) + raise last_exc + + return wrapper + + return decorator + + +def retry_call(func, *args, max_retries=2, base_delay=0.5, **kwargs): + """直接调用带重试的函数""" + last_exc = None + for attempt in range(max_retries + 1): + try: + return func(*args, **kwargs) + except Exception as e: + last_exc = e + if attempt < max_retries: + delay = min(base_delay * (2**attempt), 60.0) + delay += random.uniform(0, delay * 0.5) + logger.warning( + "Retry %d/%d after %.1fs: %s", + attempt + 1, + max_retries + 1, + delay, + e, + ) + time.sleep(delay) + raise last_exc diff --git a/sbackup/rotation.py b/sbackup/rotation.py new file mode 100644 index 0000000..e3e7766 --- /dev/null +++ b/sbackup/rotation.py @@ -0,0 +1,209 @@ +""" +备份轮转/清理策略模块:根据策略自动清理旧备份文件 +""" + +import os +import time +import logging +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from sbackup.i18n import t + +logger = logging.getLogger(__name__) + +# 所有已知的备份文件后缀(按长度降序排列,便于正确匹配 .tar.gz 等复合后缀) +BACKUP_EXTENSIONS = [ + ".tar.gz", + ".tar.bz2", + ".tar.xz", + ".tar.zst", + ".zip", + ".tar", + ".7z", +] + + +@dataclass +class RotationPolicy: + """备份轮转策略配置""" + + keep_count: int = 0 # 保留最近 N 份(0=不限制) + keep_days: int = 0 # 保留 N 天内的(0=不限制) + keep_daily: int = 0 # 保留最近 N 天每天一份 + dry_run: bool = False # 只显示会删除什么,不实际删除 + + def has_any_rule(self) -> bool: + """是否有至少一条非零规则""" + return self.keep_count > 0 or self.keep_days > 0 or self.keep_daily > 0 + + +class BackupRotator: + """备份轮转清理器""" + + def __init__(self, backup_dir: str, policy: RotationPolicy): + self.backup_dir = backup_dir + self.policy = policy + + def scan_backups(self) -> list[dict]: + """扫描目录下所有备份文件,返回按修改时间排序的列表(最新在前) + + Returns: + [{"path": str, "name": str, "size": int, "mtime": float, "mtime_iso": str}] + 只识别已知后缀的文件 + """ + target = Path(self.backup_dir) + if not target.is_dir(): + return [] + + results = [] + for entry in target.iterdir(): + if not entry.is_file(): + continue + if _is_backup_file(entry.name): + try: + st = entry.stat() + except OSError: + continue + mtime = st.st_mtime + results.append( + { + "path": str(entry.resolve()), + "name": entry.name, + "size": st.st_size, + "mtime": mtime, + "mtime_iso": datetime.fromtimestamp(mtime).isoformat(), + } + ) + + # 按修改时间降序排序(最新的在前) + results.sort(key=lambda b: b["mtime"], reverse=True) + return results + + def plan(self) -> tuple[list[dict], list[dict]]: + """根据策略计算保留和删除列表 + + 多个策略取交集:一份文件必须同时满足所有非零策略才保留。 + 如果一个文件只满足 keep_count 但不满足 keep_days,仍然删除。 + + Returns: + (keep_list, delete_list) + """ + backups = self.scan_backups() + if not backups: + return [], [] + + if not self.policy.has_any_rule(): + # 无策略时全部保留 + return backups, [] + + # 收集每条规则保留的文件路径集合 + rule_keep_sets: list[set[str]] = [] + + if self.policy.keep_count > 0: + rule_keep_sets.append(self._apply_keep_count(backups)) + + if self.policy.keep_days > 0: + rule_keep_sets.append(self._apply_keep_days(backups)) + + if self.policy.keep_daily > 0: + rule_keep_sets.append(self._apply_keep_daily(backups)) + + if not rule_keep_sets: + return backups, [] + + # 取交集:所有规则都保留的才保留 + keep_paths = rule_keep_sets[0] + for s in rule_keep_sets[1:]: + keep_paths = keep_paths & s + + keep_list = [b for b in backups if b["path"] in keep_paths] + delete_list = [b for b in backups if b["path"] not in keep_paths] + return keep_list, delete_list + + def execute(self) -> tuple[int, list[str]]: + """执行清理 + + Returns: + (deleted_count, deleted_paths) + 如果 dry_run=True,只返回会删除的文件但不实际删除 + """ + _keep_list, delete_list = self.plan() + + if not delete_list: + return 0, [] + + deleted_paths: list[str] = [] + for item in delete_list: + path = item["path"] + if self.policy.dry_run: + deleted_paths.append(path) + logger.info( + t("log.rotation.dry_run_skip"), path, item["size"] / (1024 * 1024) + ) + else: + try: + os.remove(path) + deleted_paths.append(path) + logger.debug( + t("log.rotation.deleted"), + path, + item["size"] / (1024 * 1024), + ) + except OSError as e: + logger.error(t("log.rotation.delete_error"), path, e) + + return len(deleted_paths), deleted_paths + + def _apply_keep_days(self, backups: list[dict]) -> set[str]: + """保留 N 天内的文件""" + cutoff = time.time() - self.policy.keep_days * 86400 + keep: set[str] = set() + for b in backups: + if b["mtime"] >= cutoff: + keep.add(b["path"]) + return keep + + def _apply_keep_count(self, backups: list[dict]) -> set[str]: + """保留最新的 N 个文件""" + keep: set[str] = set() + for b in backups[: self.policy.keep_count]: + keep.add(b["path"]) + return keep + + def _apply_keep_daily(self, backups: list[dict]) -> set[str]: + """保留最近 N 天每天一份(每天保留最新的那个)""" + if not backups: + return set() + + # 按日期分组(忽略时间),每天只保留最新的一个文件 + daily_groups: dict[str, list[dict]] = {} + for b in backups: + dt = datetime.fromtimestamp(b["mtime"]) + day_key = dt.strftime("%Y-%m-%d") + if day_key not in daily_groups: + daily_groups[day_key] = [] + daily_groups[day_key].append(b) + + # 按日期降序排列,取最近 N 天 + sorted_days = sorted(daily_groups.keys(), reverse=True) + selected_days = sorted_days[: self.policy.keep_daily] + + keep: set[str] = set() + for day_key in selected_days: + group = daily_groups[day_key] + # 每天保留最新的一个(mtime 最大的) + group.sort(key=lambda b: b["mtime"], reverse=True) + keep.add(group[0]["path"]) + + return keep + + +def _is_backup_file(filename: str) -> bool: + """检查文件名是否是已知的备份文件格式""" + name_lower = filename.lower() + for ext in BACKUP_EXTENSIONS: + if name_lower.endswith(ext): + return True + return False diff --git a/sbackup/schema.py b/sbackup/schema.py new file mode 100644 index 0000000..831d267 --- /dev/null +++ b/sbackup/schema.py @@ -0,0 +1,251 @@ +""" +配置校验模块:校验配置参数合法性、计算配置校验和检测篡改 +""" + +import os +import re +import hashlib +from dataclasses import dataclass + +from sbackup.config import Config + +VALID_COMPRESSION_FORMATS = {"ZIP", "TAR", "GZ", "BZ2", "XZ", "ZST", "7Z"} + + +@dataclass +class ValidationError: + """校验错误/警告条目""" + + field: str + message: str + severity: str = "error" # "error" / "warning" + + +def _is_valid_url(url: str) -> bool: + """检查 URL 格式是否合法(仅允许 http/https)""" + return bool(re.match(r"^https?://\S+$", url)) + + +def _is_ip_address(host: str) -> bool: + """检查字符串是否为 IP 地址格式""" + return bool(re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", host)) + + +class ConfigValidator: + """配置校验器:对 Config 对象执行全面的合法性校验""" + + def __init__(self, config: Config): + self.config = config + self.errors: list[ValidationError] = [] + self.warnings: list[ValidationError] = [] + + def _add_error(self, field_name: str, message: str) -> None: + self.errors.append( + ValidationError(field=field_name, message=message, severity="error") + ) + + def _add_warning(self, field_name: str, message: str) -> None: + self.warnings.append( + ValidationError(field=field_name, message=message, severity="warning") + ) + + def validate_all(self) -> tuple[bool, list[ValidationError], list[ValidationError]]: + """运行所有校验,返回 (is_valid, errors, warnings)""" + self.errors.clear() + self.warnings.clear() + + self._validate_compression() + self._validate_sftp() + self._validate_webdav() + self._validate_cloud() + self._validate_smtp() + self._validate_webhook() + self._validate_password() + self._validate_paths() + self._validate_network() + self._validate_integrity() + + is_valid = len(self.errors) == 0 + return is_valid, list(self.errors), list(self.warnings) + + def _validate_compression(self) -> None: + """校验压缩格式是否合法""" + fmt = self.config.compression_format.upper() + if fmt not in VALID_COMPRESSION_FORMATS: + self._add_error( + "compression_format", + f"Invalid compression format: {self.config.compression_format}. " + f"Valid formats: {', '.join(sorted(VALID_COMPRESSION_FORMATS))}", + ) + + def _validate_sftp(self) -> None: + """校验 SFTP 配置""" + if not self.config.sftp_enabled: + return + if not self.config.sftp_host: + self._add_error( + "sftp_host", "SFTP host cannot be empty when SFTP is enabled" + ) + if not self.config.sftp_user: + self._add_error( + "sftp_user", "SFTP user cannot be empty when SFTP is enabled" + ) + if not (1 <= self.config.sftp_port <= 65535): + self._add_error( + "sftp_port", + f"SFTP port {self.config.sftp_port} is out of range (1-65535)", + ) + if self.config.sftp_key_file and not os.path.isfile(self.config.sftp_key_file): + self._add_warning( + "sftp_key_file", + f"SFTP key file does not exist: {self.config.sftp_key_file}", + ) + + def _validate_webdav(self) -> None: + """校验 WebDAV 配置""" + if not self.config.webdav_enabled: + return + if not self.config.webdav_url: + self._add_error( + "webdav_url", "WebDAV URL cannot be empty when WebDAV is enabled" + ) + elif not _is_valid_url(self.config.webdav_url): + self._add_error( + "webdav_url", + f"Invalid WebDAV URL format: {self.config.webdav_url}. Must start with http:// or https://", + ) + + def _validate_cloud(self) -> None: + """校验云存储配置""" + if not self.config.cloud_enabled: + return + if not self.config.cloud_endpoint: + self._add_error( + "cloud_endpoint", + "Cloud endpoint cannot be empty when cloud storage is enabled", + ) + if not self.config.cloud_bucket: + self._add_error( + "cloud_bucket", + "Cloud bucket cannot be empty when cloud storage is enabled", + ) + if not self.config.cloud_access_key: + self._add_error( + "cloud_access_key", + "Cloud access_key cannot be empty when cloud storage is enabled", + ) + if not self.config.cloud_secret_key: + self._add_error( + "cloud_secret_key", + "Cloud secret_key cannot be empty when cloud storage is enabled", + ) + + def _validate_smtp(self) -> None: + """校验 SMTP 配置""" + if not self.config.smtp_enabled: + return + if not self.config.smtp_host: + self._add_error( + "smtp_host", "SMTP host cannot be empty when SMTP is enabled" + ) + if not (1 <= self.config.smtp_port <= 65535): + self._add_error( + "smtp_port", + f"SMTP port {self.config.smtp_port} is out of range (1-65535)", + ) + + def _validate_webhook(self) -> None: + """校验 Webhook URL 列表""" + for i, url in enumerate(self.config.webhook_urls): + if not _is_valid_url(url): + self._add_error( + "webhook_urls", + f"Invalid webhook URL at index {i}: {url}", + ) + + def _validate_password(self) -> None: + """校验密码加密配置""" + if self.config.password and not self.config.password.startswith("enc:"): + self._add_warning( + "password", + "Password is stored as plaintext in config. Consider using 'sbackup config lock' to encrypt it", + ) + + def _validate_paths(self) -> None: + """校验路径配置""" + if self.config.folder_path and not os.path.isdir(self.config.folder_path): + self._add_warning( + "folder_path", + f"Source folder does not exist: {self.config.folder_path}", + ) + if self.config.zipfile_path: + parent = os.path.dirname(self.config.zipfile_path) + if parent and not os.path.isdir(parent): + self._add_warning( + "zipfile_path", + f"Parent directory of zipfile_path does not exist: {parent}", + ) + elif parent and not os.access(parent, os.W_OK): + self._add_warning( + "zipfile_path", + f"Parent directory of zipfile_path is not writable: {parent}", + ) + + def _validate_network(self) -> None: + """校验网络配置(SFTP/SMTP host 格式)""" + hosts_to_check: list[tuple[str, str, int]] = [] + if self.config.sftp_enabled and self.config.sftp_host: + hosts_to_check.append( + ("sftp_host", self.config.sftp_host, self.config.sftp_port) + ) + if self.config.smtp_enabled and self.config.smtp_host: + hosts_to_check.append( + ("smtp_host", self.config.smtp_host, self.config.smtp_port) + ) + + for field_name, host, port in hosts_to_check: + if _is_ip_address(host): + # IP 地址格式校验端口 + if not (1 <= port <= 65535): + self._add_error( + field_name, + f"Port {port} for {host} is out of range (1-65535)", + ) + else: + # 非 IP 格式的 host(域名),跳过端口检查,但检查基本格式 + if not re.match( + r"^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*$", + host, + ): + self._add_warning( + field_name, + f"Host format may be invalid: {host}", + ) + + def _validate_integrity(self) -> None: + """配置校验和:计算配置的校验和,检测意外修改""" + checksum = get_config_checksum(self.config) + self.config._config_checksum = checksum + + +def get_config_checksum(config: Config) -> str: + """计算配置的 SHA256 校验和(排除 _encrypted、_key_salt、_config_checksum 字段)""" + from dataclasses import fields as dc_fields + + exclude = {"_encrypted", "_key_salt", "_config_checksum"} + parts: list[str] = [] + for f in sorted(dc_fields(config), key=lambda x: x.name): + if f.name in exclude: + continue + value = getattr(config, f.name) + parts.append(f"{f.name}={value!r}") + raw = "\n".join(parts) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def validate_config_file( + config: Config, +) -> tuple[bool, list[ValidationError], list[ValidationError]]: + """快速校验配置对象,返回 (is_valid, errors, warnings)""" + validator = ConfigValidator(config) + return validator.validate_all() diff --git a/sbackup/selective.py b/sbackup/selective.py new file mode 100644 index 0000000..57418b9 --- /dev/null +++ b/sbackup/selective.py @@ -0,0 +1,313 @@ +""" +选择性恢复模块:支持按模式、关键字提取备份包中的指定文件 +""" + +import os +import tarfile +import tempfile +import zipfile +from fnmatch import fnmatch +from pathlib import Path + + +def _detect_format(archive_path: str) -> str: + """根据文件后缀自动检测压缩格式,返回格式标识符""" + name_lower = archive_path.lower() + if name_lower.endswith(".zip"): + return "zip" + elif name_lower.endswith(".7z"): + return "7z" + elif name_lower.endswith(".tar.zst"): + return "tar.zst" + elif name_lower.endswith(".tar.gz") or name_lower.endswith(".tgz"): + return "tar.gz" + elif name_lower.endswith(".tar.bz2") or name_lower.endswith(".tbz2"): + return "tar.bz2" + elif name_lower.endswith(".tar.xz") or name_lower.endswith(".txz"): + return "tar.xz" + elif name_lower.endswith(".tar"): + return "tar" + return "unknown" + + +def _get_tar_mode(fmt: str) -> str: + """将格式标识符转换为 tarfile 打开模式""" + return { + "tar": "r", + "tar.gz": "r:gz", + "tar.bz2": "r:bz2", + "tar.xz": "r:xz", + }.get(fmt, "r") + + +class SelectiveRestore: + """选择性恢复工具:支持按模式、关键字从备份包中提取指定文件""" + + def __init__(self, archive_path: str, password: str = ""): + self.archive_path = archive_path + self.password = password + + def list_files(self, pattern: str = "") -> list[dict]: + """列出备份包内文件,可选 glob 过滤 + + 返回 [{"name": str, "size": int, "is_dir": bool}] + """ + fmt = _detect_format(self.archive_path) + + if fmt == "zip": + return self._list_zip(pattern) + elif fmt == "7z": + return self._list_7z(pattern) + elif fmt.startswith("tar"): + return self._list_tar(pattern, fmt) + return [] + + def _list_zip(self, pattern: str = "") -> list[dict]: + """列出 ZIP 备份包内文件""" + results: list[dict] = [] + with zipfile.ZipFile(self.archive_path, "r") as zf: + for info in zf.infolist(): + name = info.filename + is_dir = name.endswith("/") + size = info.file_size if not is_dir else 0 + entry = {"name": name, "size": size, "is_dir": is_dir} + if pattern: + basename = os.path.basename(name.rstrip("/")) + if fnmatch(name, pattern) or fnmatch(basename, pattern): + results.append(entry) + else: + results.append(entry) + return results + + def _list_7z(self, pattern: str = "") -> list[dict]: + """列出 7z 备份包内文件""" + import py7zr + + results: list[dict] = [] + szf_kwargs: dict = {"file": self.archive_path, "mode": "r"} + if self.password: + szf_kwargs["password"] = self.password + with py7zr.SevenZipFile(**szf_kwargs) as szf: + all_names = szf.getnames() + # 获取文件大小信息 + contents = szf.list() + size_map: dict[str, int] = {} + for entry in contents: + size_map[entry.filename] = entry.uncompressed + + for name in all_names: + is_dir = name.endswith("/") + size = size_map.get(name, 0) if not is_dir else 0 + entry = {"name": name, "size": size, "is_dir": is_dir} + if pattern: + basename = os.path.basename(name.rstrip("/")) + if fnmatch(name, pattern) or fnmatch(basename, pattern): + results.append(entry) + else: + results.append(entry) + return results + + def _list_tar(self, pattern: str = "", fmt: str = "tar") -> list[dict]: + """列出 tar 系列备份包内文件""" + mode = _get_tar_mode(fmt) + results: list[dict] = [] + if fmt == "tar.zst": + results = self._list_tar_zst(pattern) + else: + with tarfile.open(self.archive_path, mode) as tarf: + for member in tarf.getmembers(): + name = member.name + is_dir = member.isdir() + size = member.size if not is_dir else 0 + entry = {"name": name, "size": size, "is_dir": is_dir} + if pattern: + basename = os.path.basename(name.rstrip("/")) + if fnmatch(name, pattern) or fnmatch(basename, pattern): + results.append(entry) + else: + results.append(entry) + return results + + def _list_tar_zst(self, pattern: str = "") -> list[dict]: + """列出 tar.zst 备份包内文件""" + import zstandard as zstd + + results: list[dict] = [] + dctx = zstd.ZstdDecompressor() + with tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024) as tmp: + with open(self.archive_path, "rb") as f_in: + reader = dctx.stream_reader(f_in) + while True: + chunk = reader.read(65536) + if not chunk: + break + tmp.write(chunk) + tmp.seek(0) + with tarfile.open(fileobj=tmp, mode="r") as tarf: + for member in tarf.getmembers(): + name = member.name + is_dir = member.isdir() + size = member.size if not is_dir else 0 + entry = {"name": name, "size": size, "is_dir": is_dir} + if pattern: + basename = os.path.basename(name.rstrip("/")) + if fnmatch(name, pattern) or fnmatch(basename, pattern): + results.append(entry) + else: + results.append(entry) + return results + + def search(self, keyword: str) -> list[dict]: + """按关键字搜索文件名(不区分大小写)""" + all_files = self.list_files() + keyword_lower = keyword.lower() + return [f for f in all_files if keyword_lower in f["name"].lower()] + + def extract_files( + self, patterns: list[str], target_dir: str + ) -> tuple[int, list[str]]: + """提取匹配 patterns 的文件到目标目录 + + 支持 glob 模式:*.py, src/**/*.txt, data/report.csv + 保留原始目录结构 + 返回 (extracted_count, extracted_paths) + """ + fmt = _detect_format(self.archive_path) + target = Path(target_dir) + target.mkdir(parents=True, exist_ok=True) + + # 收集所有匹配的文件名 + all_files = self.list_files() + matched_names: list[str] = [] + for f in all_files: + if f["is_dir"]: + continue + name = f["name"] + basename = os.path.basename(name.rstrip("/")) + for pat in patterns: + if fnmatch(name, pat) or fnmatch(basename, pat): + matched_names.append(name) + break + + if not matched_names: + return 0, [] + + if fmt == "zip": + return self._extract_zip(matched_names, target) + elif fmt == "7z": + return self._extract_7z(matched_names, target) + elif fmt.startswith("tar"): + return self._extract_tar(matched_names, target, fmt) + return 0, [] + + def _extract_zip(self, names: list[str], target: Path) -> tuple[int, list[str]]: + """从 ZIP 中提取指定文件""" + extracted: list[str] = [] + with zipfile.ZipFile(self.archive_path, "r") as zf: + for name in names: + try: + zf.extract(name, target) + extracted.append(str(target / name)) + except (KeyError, OSError): + continue + return len(extracted), extracted + + def _extract_7z(self, names: list[str], target: Path) -> tuple[int, list[str]]: + """从 7z 中提取指定文件""" + import py7zr + + extracted: list[str] = [] + szf_kwargs: dict = {"file": self.archive_path, "mode": "r"} + if self.password: + szf_kwargs["password"] = self.password + with py7zr.SevenZipFile(**szf_kwargs) as szf: + szf.extract(path=target, targets=names) + for name in names: + dest = target / name + if dest.exists(): + extracted.append(str(dest)) + return len(extracted), extracted + + def _extract_tar( + self, names: list[str], target: Path, fmt: str + ) -> tuple[int, list[str]]: + """从 tar 系列中提取指定文件""" + extracted: list[str] = [] + name_set = set(names) + + if fmt == "tar.zst": + return self._extract_tar_zst(names, target) + + mode = _get_tar_mode(fmt) + with tarfile.open(self.archive_path, mode) as tarf: + for member in tarf.getmembers(): + if member.name in name_set: + try: + tarf.extract(member, path=str(target), filter="data") + extracted.append(str(target / member.name)) + except OSError: + continue + return len(extracted), extracted + + def _extract_tar_zst(self, names: list[str], target: Path) -> tuple[int, list[str]]: + """从 tar.zst 中提取指定文件""" + import zstandard as zstd + + extracted: list[str] = [] + name_set = set(names) + + dctx = zstd.ZstdDecompressor() + with tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024) as tmp: + with open(self.archive_path, "rb") as f_in: + reader = dctx.stream_reader(f_in) + while True: + chunk = reader.read(65536) + if not chunk: + break + tmp.write(chunk) + tmp.seek(0) + with tarfile.open(fileobj=tmp, mode="r") as tarf: + for member in tarf.getmembers(): + if member.name in name_set: + try: + tarf.extract(member, path=str(target), filter="data") + extracted.append(str(target / member.name)) + except OSError: + continue + return len(extracted), extracted + + def extract_single(self, member_name: str, target_dir: str) -> str | None: + """提取单个文件,返回提取后的路径""" + result, paths = self.extract_files([member_name], target_dir) + if result > 0 and paths: + return paths[0] + return None + + def get_stats(self) -> dict: + """获取备份包统计信息 + + 返回 {"total_files": int, "total_dirs": int, "total_size": int, "formats": dict} + """ + all_files = self.list_files() + total_files = 0 + total_dirs = 0 + total_size = 0 + + ext_count: dict[str, int] = {} + for f in all_files: + if f["is_dir"]: + total_dirs += 1 + else: + total_files += 1 + total_size += f["size"] + # 统计扩展名 + basename = os.path.basename(f["name"]) + ext = os.path.splitext(basename)[1].lower() or "(no ext)" + ext_count[ext] = ext_count.get(ext, 0) + 1 + + return { + "total_files": total_files, + "total_dirs": total_dirs, + "total_size": total_size, + "formats": ext_count, + } diff --git a/sbackup/sftp.py b/sbackup/sftp.py new file mode 100644 index 0000000..c46821e --- /dev/null +++ b/sbackup/sftp.py @@ -0,0 +1,391 @@ +""" +SFTP 远程备份模块:连接管理、文件上传、连接测试 +""" + +import os +import logging +from sbackup.i18n import t +from sbackup.ratelimiter import RateLimiter + +logger = logging.getLogger(__name__) + +# 默认 SSH 私钥位置(按优先级排序) +DEFAULT_KEY_FILES = [ + "~/.ssh/id_ed25519", # Ed25519(现代推荐) + "~/.ssh/id_rsa", # RSA(最常用) + "~/.ssh/id_ecdsa", # ECDSA +] + + +class SFTPError(Exception): + """SFTP 操作异常""" + + pass + + +class SFTPClient: + """SFTP 客户端,封装 paramiko 连接和文件传输""" + + def __init__( + self, + host: str, + port: int, + user: str, + password: str = "", + key_file: str = "", + key_passphrase: str = "", + ): + self.host = host + self.port = port + self.user = user + self.password = password + self.key_file = key_file + self.key_passphrase = key_passphrase + self._transport = None + self._sftp = None + + def connect(self) -> None: + """建立 SFTP 连接(优先使用私钥认证,验证主机密钥)""" + import paramiko + + # 展开 ~ 为用户目录(跨平台兼容) + key_file_expanded = os.path.expanduser(self.key_file) if self.key_file else "" + + try: + self._transport = paramiko.Transport((self.host, self.port)) + self._transport.start_client() + + # 主机密钥验证:加载系统 known_hosts + host_keys = paramiko.HostKeys() + known_hosts_paths = [ + os.path.expanduser("~/.ssh/known_hosts"), + "/etc/ssh/ssh_known_hosts", + ] + for kh_path in known_hosts_paths: + if os.path.isfile(kh_path): + try: + host_keys.load(kh_path) + except OSError: + pass + + if host_keys: + # 有已知主机密钥,使用严格验证 + remote_key = self._transport.get_remote_server_key() + if remote_key is None: + self.disconnect() + raise SFTPError(t("err.sftp.no_host_key", host=self.host)) + # 非标准端口使用 [host]:port 格式查找 + host_lookup = ( + f"[{self.host}]:{self.port}" + if self.port != 22 + else self.host + ) + host_key_entry = host_keys.lookup(host_lookup) + if host_key_entry is None: + # 主机不在已知列表中,警告但允许连接(首次连接) + logger.warning( + "SFTP: 主机 %s 不在 known_hosts 中(首次连接)", self.host + ) + else: + try: + host_keys.check(host_lookup, remote_key) + except paramiko.SSHException: + self.disconnect() + raise SFTPError(t("err.sftp.host_key_mismatch", host=self.host)) + else: + # 没有 known_hosts 文件,跳过验证(向后兼容) + logger.debug("SFTP: 未找到 known_hosts 文件,跳过主机密钥验证") + import sys + + print( + t("warn.sftp.no_known_hosts", host=self.host), + file=sys.stderr, + ) + + # 记录是否为首次连接(用于后续保存主机密钥) + first_connection = ( + host_keys and host_keys.lookup(host_lookup) is None + ) if host_keys else False + + if key_file_expanded and os.path.isfile(key_file_expanded): + # 私钥认证 + pkey = self._load_private_key(key_file_expanded, self.key_passphrase) + self._transport.auth_publickey(self.user, pkey) + logger.debug( + "SFTP 私钥连接成功: %s@%s:%d (key=%s)", + self.user, + self.host, + self.port, + key_file_expanded, + ) + else: + # 密码认证 + self._transport.auth_password(self.user, self.password) + logger.debug( + "SFTP 密码连接成功: %s@%s:%d", self.user, self.host, self.port + ) + + # 首次连接成功后,保存主机密钥到 known_hosts + if first_connection: + try: + remote_key = self._transport.get_remote_server_key() + if remote_key: + host_keys.add(host_lookup, remote_key.get_name(), remote_key) + known_hosts_file = os.path.expanduser("~/.ssh/known_hosts") + os.makedirs(os.path.dirname(known_hosts_file), exist_ok=True) + host_keys.save(known_hosts_file) + logger.debug("SFTP: 已保存主机密钥到 %s", known_hosts_file) + except Exception as e: + logger.debug("SFTP: 保存主机密钥失败: %s", e) + + self._sftp = paramiko.SFTPClient.from_transport(self._transport) + except paramiko.AuthenticationException: + self.disconnect() + raise SFTPError(t("err.sftp.auth", host=self.host)) + except paramiko.SSHException as e: + self.disconnect() + raise SFTPError(t("err.sftp.ssh", error=str(e))) + except OSError as e: + self.disconnect() + raise SFTPError( + t("err.sftp.connect", host=self.host, port=self.port, error=str(e)) + ) + + @staticmethod + def _is_passphrase_error(error_msg: str) -> bool: + """判断 SSH 异常是否由缺少密码短语引起""" + keywords = [ + "password", + "passphrase", + "decrypt", + "encrypted", + "required", + "bad decrypt", + "no decrypt", + ] + return any(kw in error_msg.lower() for kw in keywords) + + @staticmethod + def _load_private_key(key_file: str, passphrase: str = ""): + """尝试加载各种格式的私钥""" + import paramiko + + key_pass = passphrase if passphrase else None + key_classes = [ + paramiko.RSAKey, + paramiko.Ed25519Key, + paramiko.ECDSAKey, + ] + last_error = None + for key_cls in key_classes: + try: + return key_cls.from_private_key_file(key_file, password=key_pass) + except paramiko.SSHException as e: + last_error = e + continue + # 所有格式都失败,检查是否因缺少密码短语 + if last_error and SFTPClient._is_passphrase_error(str(last_error)): + raise SFTPError(t("err.sftp.key_needs_passphrase", path=key_file)) + raise SFTPError(t("err.sftp.key_load", path=key_file)) + + @staticmethod + def try_default_key() -> str | None: + """ + 尝试查找默认 SSH 私钥文件 + :return: 找到的私钥文件路径,或 None + """ + for key_path in DEFAULT_KEY_FILES: + expanded = os.path.expanduser(key_path) + if os.path.isfile(expanded): + logger.debug("发现默认私钥: %s", expanded) + return expanded + return None + + def disconnect(self) -> None: + """断开 SFTP 连接(确保每个资源都被清理)""" + try: + if self._sftp is not None: + self._sftp.close() + except Exception: + pass + finally: + self._sftp = None + + try: + if self._transport is not None: + self._transport.close() + except Exception: + pass + finally: + self._transport = None + + # 清除敏感凭据,防止内存泄漏 + self.password = "" + self.key_passphrase = "" + + def __enter__(self): + self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.disconnect() + return False + + def upload_file( + self, + local_path: str, + remote_path: str, + progress_callback=None, + rate_limiter: RateLimiter | None = None, + ) -> None: + """ + 上传单个文件到远程服务器 + :param local_path: 本地文件路径 + :param remote_path: 远程目标路径 + :param progress_callback: 进度回调函数 callback(bytes_transferred, total_bytes) + :param rate_limiter: 带宽限速器,None 表示不限速 + """ + if self._sftp is None: + raise SFTPError(t("err.sftp.not_connected")) + + if not os.path.isfile(local_path): + raise SFTPError(t("err.sftp.local_not_found", path=local_path)) + + filename = os.path.basename(local_path) + remote_full = os.path.join(remote_path, filename).replace("\\", "/") + file_size = os.path.getsize(local_path) + + # 确保远程目录存在 + self._ensure_remote_dir(remote_path) + + if rate_limiter is not None: + # 有速率限制时,使用分块写入方式上传 + try: + with open(local_path, "rb") as local_f: + with self._sftp.open(remote_full, "wb") as remote_f: + sent = 0 + while True: + data = local_f.read(65536) # 64KB 块 + if not data: + break + rate_limiter.wait(len(data)) + remote_f.write(data) + sent += len(data) + if progress_callback: + progress_callback(sent, file_size) + logger.debug("SFTP 上传成功(限速): %s -> %s", local_path, remote_full) + except OSError as e: + raise SFTPError(t("err.sftp.upload", path=filename, error=str(e))) + else: + try: + self._sftp.put( + local_path, + remote_full, + callback=progress_callback, + confirm=True, + ) + logger.debug("SFTP 上传成功: %s -> %s", local_path, remote_full) + except OSError as e: + raise SFTPError(t("err.sftp.upload", path=filename, error=str(e))) + + def _ensure_remote_dir(self, remote_path: str, max_depth: int = 50) -> None: + """确保远程目录存在,不存在则逐级创建""" + if self._sftp is None: + return + remote_path = remote_path.replace("\\", "/").rstrip("/") + if remote_path == "/" or remote_path == "": + return + try: + self._sftp.stat(remote_path) + except FileNotFoundError: + # 递归创建父目录 + parent = os.path.dirname(remote_path).replace("\\", "/") + if parent and parent != remote_path: + if max_depth <= 0: + raise SFTPError(t("err.sftp.mkdir_max_depth", path=remote_path)) + self._ensure_remote_dir(parent, max_depth - 1) + try: + self._sftp.mkdir(remote_path) + except OSError as e: + raise SFTPError(t("err.sftp.mkdir", path=remote_path, error=str(e))) + + @staticmethod + def resolve_key_passphrase(key_file: str) -> str | None: + """ + 检测私钥是否需要密码短语,需要时交互式提示输入 + :return: 密码短语(空字符串表示不需要),None 表示用户放弃输入 + """ + import getpass + + try: + SFTPClient._load_private_key(key_file, "") + return "" + except SFTPError: + while True: + passphrase = getpass.getpass(t("cli.prompt.sftp.key_passphrase") + " ") + if not passphrase: + return None # 用户放弃输入,回退到密码认证 + # 验证密码短语是否正确 + try: + SFTPClient._load_private_key(key_file, passphrase) + return passphrase + except SFTPError: + print(t("err.sftp.wrong_passphrase")) + + def list_remote_files(self, remote_path: str = "/") -> list[dict]: + """ + 列出远程目录下的文件 + :return: 包含 name, size, mtime 的字典列表 + """ + if self._sftp is None: + raise SFTPError(t("err.sftp.not_connected")) + + remote_path = remote_path.replace("\\", "/").rstrip("/") + if not remote_path: + remote_path = "/" + + try: + entries = self._sftp.listdir_attr(remote_path) + except FileNotFoundError: + raise SFTPError(t("err.sftp.remote_not_found", path=remote_path)) + + files = [] + for entry in entries: + if entry.filename.startswith("."): + continue + # 跳过目录 + if entry.st_mode is not None and entry.st_mode & 0o170000 == 0o040000: + continue + files.append( + { + "name": entry.filename, + "size": entry.st_size or 0, + "mtime": entry.st_mtime or 0, + } + ) + return files + + def delete_remote_file(self, remote_path: str) -> None: + """删除远程文件""" + if self._sftp is None: + raise SFTPError(t("err.sftp.not_connected")) + + try: + self._sftp.remove(remote_path) + logger.debug("SFTP 删除文件: %s", remote_path) + except FileNotFoundError: + raise SFTPError(t("err.sftp.remote_not_found", path=remote_path)) + except OSError as e: + raise SFTPError(t("err.sftp.delete", path=remote_path, error=str(e))) + + def test_connection(self) -> bool: + """测试 SFTP 连接是否可用""" + try: + self.connect() + if self._sftp is not None: + self._sftp.listdir(".") + return True + except SFTPError: + return False + finally: + self.disconnect() diff --git a/sbackup/task_queue.py b/sbackup/task_queue.py new file mode 100644 index 0000000..b7dec81 --- /dev/null +++ b/sbackup/task_queue.py @@ -0,0 +1,313 @@ +"""任务队列系统:管理本地备份任务队列,支持添加、执行、取消任务""" + +import json +import os +import uuid +import logging +from dataclasses import dataclass, asdict +from datetime import datetime, timezone +from typing import Any + +from sbackup.config import load_config +from sbackup.auto_save import BackupManager +from sbackup.i18n import t + +logger = logging.getLogger(__name__) + + +@dataclass +class BackupTask: + """备份任务数据类""" + + id: str # UUID + name: str # 任务名称 + folder_path: str # 备份目录 + zipfile_path: str | None # 输出路径 + compression_format: str # 格式 + config_overrides: dict[str, Any] # 配置覆盖 + status: str = "pending" # pending/running/completed/failed + created_at: str = "" # ISO 时间 + started_at: str = "" + finished_at: str = "" + error: str = "" + result_path: str = "" # 备份文件路径 + + def to_dict(self) -> dict[str, Any]: + """转为 JSON 兼容字典""" + return asdict(self) + + @staticmethod + def from_dict(data: dict[str, Any]) -> "BackupTask": + """从字典创建 BackupTask""" + return BackupTask( + id=data.get("id", ""), + name=data.get("name", ""), + folder_path=data.get("folder_path", ""), + zipfile_path=data.get("zipfile_path"), + compression_format=data.get("compression_format", "ZIP"), + config_overrides=data.get("config_overrides", {}), + status=data.get("status", "pending"), + created_at=data.get("created_at", ""), + started_at=data.get("started_at", ""), + finished_at=data.get("finished_at", ""), + error=data.get("error", ""), + result_path=data.get("result_path", ""), + ) + + +class TaskQueue: + """本地备份任务队列管理器""" + + def __init__(self, queue_file: str = ""): + """ + 初始化任务队列 + :param queue_file: 队列数据存储的 JSON 文件路径 + """ + if not queue_file: + queue_file = os.path.join( + os.path.dirname(load_config().data_file), "task_queue.json" + ) + self.queue_file = queue_file + self.tasks: list[BackupTask] = [] + self._load() + + def _load(self) -> None: + """从 JSON 文件加载队列数据""" + if not os.path.exists(self.queue_file): + self.tasks = [] + return + try: + with open(self.queue_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.tasks = [BackupTask.from_dict(item) for item in data] + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to load task queue: %s", e) + self.tasks = [] + + def _save(self) -> None: + """将队列数据保存到 JSON 文件(原子写入)""" + data_dir = os.path.dirname(self.queue_file) + if data_dir: + os.makedirs(data_dir, exist_ok=True) + + tmp_path = self.queue_file + ".tmp" + try: + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump( + [task.to_dict() for task in self.tasks], + f, + ensure_ascii=False, + indent=4, + ) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, self.queue_file) + except OSError: + if os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except OSError: + pass + raise + + def add_task( + self, + name: str, + folder_path: str, + zipfile_path: str | None = None, + compression_format: str = "ZIP", + config_overrides: dict[str, Any] | None = None, + ) -> str: + """ + 添加一个新任务到队列 + :param name: 任务名称 + :param folder_path: 备份目录 + :param zipfile_path: 输出路径 + :param compression_format: 压缩格式 + :param config_overrides: 配置覆盖参数 + :return: 任务 ID + """ + task_id = str(uuid.uuid4()) + task = BackupTask( + id=task_id, + name=name, + folder_path=os.path.abspath(folder_path), + zipfile_path=zipfile_path, + compression_format=compression_format.upper(), + config_overrides=config_overrides or {}, + status="pending", + created_at=datetime.now(timezone.utc).isoformat(), + ) + self.tasks.append(task) + self._save() + return task_id + + def list_tasks(self, status: str | None = None) -> list[BackupTask]: + """ + 列出任务 + :param status: 按状态过滤,None 返回所有任务 + :return: 任务列表 + """ + if status is None: + return list(self.tasks) + return [t for t in self.tasks if t.status == status] + + def get_task(self, task_id: str) -> BackupTask | None: + """ + 获取指定 ID 的任务 + :param task_id: 任务 ID + :return: 任务对象,未找到返回 None + """ + for task in self.tasks: + if task.id == task_id: + return task + return None + + def cancel_task(self, task_id: str) -> bool: + """ + 取消一个 pending 状态的任务 + :param task_id: 任务 ID + :return: 是否取消成功 + """ + task = self.get_task(task_id) + if task is None: + return False + if task.status != "pending": + return False + task.status = "failed" + task.error = t("task_queue.cancelled") + task.finished_at = datetime.now(timezone.utc).isoformat() + self._save() + return True + + def run_next(self) -> BackupTask | None: + """ + 取下一个 pending 任务并执行 + :return: 执行的任务对象,无 pending 任务时返回 None + """ + task = None + for task_item in self.tasks: + if task_item.status == "pending": + task = task_item + break + + if task is None: + return None + + # 设为 running + task.status = "running" + task.started_at = datetime.now(timezone.utc).isoformat() + self._save() + + try: + config = load_config() + # 应用配置覆盖 + for key, value in task.config_overrides.items(): + if hasattr(config, key): + setattr(config, key, value) + + manager = BackupManager(data_file=config.data_file) + + # 如果设置了输出路径,临时创建备份策略 + source_path = task.folder_path + if task.zipfile_path: + dest_dir = os.path.dirname(task.zipfile_path) + else: + # 使用默认目标路径(与源同级的 _backups 目录) + dest_dir = os.path.join( + os.path.dirname(source_path), + "_backups", + ) + + os.makedirs(dest_dir, exist_ok=True) + + entry_fmt = task.compression_format + success = manager.add_folder( + source_path, dest_dir, compression_format=entry_fmt + ) + if not success: + # 可能已存在,继续执行 + pass + + manager.execute_backups() + + # 查找生成的备份文件 + result_path = "" + if task.zipfile_path: + result_path = task.zipfile_path + else: + # 查找最新生成的备份文件 + from pathlib import Path + + patterns = [ + "*.zip", + "*.tar", + "*.tar.gz", + "*.tar.bz2", + "*.tar.xz", + "*.tar.zst", + "*.7z", + ] + all_files = [] + dest_path = Path(dest_dir) + if dest_path.is_dir(): + for pat in patterns: + all_files.extend(dest_path.glob(pat)) + if all_files: + all_files.sort(key=lambda f: f.stat().st_mtime, reverse=True) + result_path = str(all_files[0]) + + task.status = "completed" + task.result_path = result_path + task.finished_at = datetime.now(timezone.utc).isoformat() + except Exception as e: + task.status = "failed" + task.error = str(e) + task.finished_at = datetime.now(timezone.utc).isoformat() + logger.error("Task %s failed: %s", task.id, e) + + self._save() + return task + + def run_all(self, max_concurrent: int = 1) -> list[BackupTask]: + """ + 顺序执行所有 pending 任务 + :param max_concurrent: 最大并发数(当前仅支持 1,顺序执行) + :return: 已执行的任务列表 + """ + executed: list[BackupTask] = [] + while True: + task = self.run_next() + if task is None: + break + executed.append(task) + return executed + + def clear_completed(self) -> int: + """ + 清除已完成和已失败的任务 + :return: 清除的任务数量 + """ + before = len(self.tasks) + self.tasks = [t for t in self.tasks if t.status not in ("completed", "failed")] + removed = before - len(self.tasks) + if removed > 0: + self._save() + return removed + + def get_stats(self) -> dict[str, int]: + """ + 获取各状态任务数统计 + :return: {"pending": N, "running": N, "completed": N, "failed": N, "total": N} + """ + stats: dict[str, int] = { + "pending": 0, + "running": 0, + "completed": 0, + "failed": 0, + "total": len(self.tasks), + } + for task in self.tasks: + if task.status in stats: + stats[task.status] += 1 + return stats diff --git a/sbackup/webdav.py b/sbackup/webdav.py new file mode 100644 index 0000000..99d343d --- /dev/null +++ b/sbackup/webdav.py @@ -0,0 +1,320 @@ +""" +WebDAV 远程备份模块:基于 HTTP 的文件上传(支持坚果云、NextCloud、群晖等) +使用 Python 标准库 urllib,零额外依赖。 +""" + +import os +import sys +import socket +import ipaddress +import logging +import urllib.request +import urllib.error +import urllib.parse +from base64 import b64encode +from io import BytesIO + +from sbackup.i18n import t +from sbackup.ratelimiter import RateLimiter + +logger = logging.getLogger(__name__) + + +class WebDAVError(Exception): + """WebDAV 操作异常""" + + pass + + +class WebDAVClient: + """WebDAV 客户端,基于 urllib 实现,零额外依赖""" + + def __init__(self, url: str, user: str, password: str): + # SSRF 防护:验证 URL scheme 和内部 IP + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in ("http", "https"): + raise WebDAVError( + t("err.webdav.invalid_scheme", scheme=parsed.scheme or "missing") + ) + + # HTTP 明文警告 + if parsed.scheme == "http": + print( + t("warn.webdav.http_plaintext", url=url), + file=sys.stderr, + ) + + # 阻止内网地址 + if parsed.hostname: + try: + host_ip = socket.getaddrinfo( + parsed.hostname, None, type=socket.SOCK_STREAM + )[0][4][0] + ip = ipaddress.ip_address(host_ip) + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + ): + raise WebDAVError(t("err.webdav.private_ip", host=parsed.hostname)) + except WebDAVError: + raise + except (OSError, ValueError): + pass + + self.url = url.rstrip("/") + # 保留原始 URL 的尾部斜杠(某些服务器要求) + if url.endswith("/") and not self.url.endswith("/"): + self.url += "/" + self.user = user + self.password = password + self._auth_header = self._make_auth() + # 支持 PROPFIND 等非标准方法的重定向 + redirect_handler = urllib.request.HTTPRedirectHandler() + redirect_handler.redirect_request = self._redirect_request + self._opener = urllib.request.build_opener(redirect_handler) + + def _make_auth(self) -> str: + """生成 Basic Auth 头""" + credentials = f"{self.user}:{self.password}" + encoded = b64encode(credentials.encode("utf-8")).decode("utf-8") + return f"Basic {encoded}" + + def _redirect_request(self, req, fp, code, msg, headers, newurl): + """处理重定向,保留原始请求方法和认证头""" + new_req = urllib.request.Request( + newurl, + method=req.get_method(), + data=req.data, + headers={ + k: v for k, v in req.header_items() + if k.lower() not in ("host", "content-length") + }, + ) + return new_req + + def _build_url(self, path: str = "") -> str: + """构建完整 URL""" + path = path.strip("/") + if path: + return f"{self.url.rstrip('/')}/{path}" + return self.url + + def _build_request( + self, + method: str, + path: str = "", + data: bytes | None = None, + content_type: str = "application/octet-stream", + ) -> urllib.request.Request: + """构建 HTTP 请求对象""" + url = self._build_url(path) + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", self._auth_header) + req.add_header("User-Agent", "sbackup/1.0") + if data is not None: + req.add_header("Content-Length", str(len(data))) + if content_type: + req.add_header("Content-Type", content_type) + return req + + def _ensure_remote_dir(self, path: str) -> None: + """递归创建远程目录(MKCOL)""" + if not path: + return + parts = [p for p in path.strip("/").split("/") if p] + current = "" + for part in parts: + current = f"{current}/{part}" if current else part + try: + req = self._build_request("MKCOL", current) + self._opener.open(req, timeout=30) + logger.debug("创建远程目录: %s", current) + except urllib.error.HTTPError as e: + if e.code == 405: + # 405 Method Not Allowed = 目录已存在 + pass + elif e.code == 401: + raise WebDAVError(t("err.webdav.auth", host=self.url)) + else: + raise WebDAVError(t("err.webdav.mkdir", path=current, error=str(e))) + except OSError as e: + raise WebDAVError(t("err.webdav.connect", url=self.url, error=str(e))) + + def connect(self) -> None: + """测试 WebDAV 连接(PROPFIND)""" + try: + req = self._build_request( + "PROPFIND", + data=b'' + b'', + content_type="application/xml", + ) + req.add_header("Depth", "0") + resp = self._opener.open(req, timeout=15) + + # XML 炸弹防护:限制响应大小 + MAX_CONNECT_XML_SIZE = 1 * 1024 * 1024 # 1MB + xml_data = resp.read(MAX_CONNECT_XML_SIZE + 1) + if len(xml_data) > MAX_CONNECT_XML_SIZE: + raise WebDAVError(t("err.webdav.xml_too_large", size=len(xml_data))) + + logger.debug("WebDAV 连接成功: %s", self.url) + except urllib.error.HTTPError as e: + if e.code == 401: + raise WebDAVError(t("err.webdav.auth", host=self.url)) + raise WebDAVError(t("err.webdav.connect", url=self.url, error=str(e))) + except OSError as e: + raise WebDAVError(t("err.webdav.connect", url=self.url, error=str(e))) + + def upload_file( + self, local_path: str, remote_path: str, rate_limiter: RateLimiter | None = None + ) -> int: + """ + 上传文件到 WebDAV 服务器 + :param local_path: 本地文件路径 + :param remote_path: 远程文件路径 + :param rate_limiter: 带宽限速器,None 表示不限速 + :return: 上传的字节数 + """ + if not os.path.isfile(local_path): + raise WebDAVError(t("err.webdav.local_not_found", path=local_path)) + + # 确保远程目录存在 + remote_dir = os.path.dirname(remote_path).replace("\\", "/") + self._ensure_remote_dir(remote_dir) + + file_size = os.path.getsize(local_path) + logger.debug( + "开始上传: %s -> %s (%d bytes)", local_path, remote_path, file_size + ) + + try: + url = self._build_url(remote_path) + if rate_limiter is not None: + # 有限速时:分块读入 BytesIO,每块限速 + buf = BytesIO() + with open(local_path, "rb") as f: + while True: + chunk = f.read(65536) # 64KB + if not chunk: + break + rate_limiter.wait(len(chunk)) + buf.write(chunk) + data = buf.getvalue() + req = urllib.request.Request(url, data=data, method="PUT") + req.add_header("Authorization", self._auth_header) + req.add_header("Content-Length", str(len(data))) + self._opener.open(req, timeout=300) + else: + # 不限速时保持流式上传(不读入内存) + with open(local_path, "rb") as f: + req = urllib.request.Request(url, data=f, method="PUT") + req.add_header("Authorization", self._auth_header) + req.add_header("Content-Length", str(file_size)) + self._opener.open(req, timeout=300) + logger.debug("上传成功: %s", remote_path) + return file_size + except urllib.error.HTTPError as e: + if e.code == 401: + raise WebDAVError(t("err.webdav.auth", host=self.url)) + raise WebDAVError(t("err.webdav.upload", path=remote_path, error=str(e))) + except OSError as e: + raise WebDAVError(t("err.webdav.upload", path=remote_path, error=str(e))) + + def list_remote_files(self, remote_path: str = "") -> list[dict]: + """ + 列出远程目录下的文件(PROPFIND) + :return: 包含 name, size, mtime 的字典列表 + """ + import xml.etree.ElementTree as ET + + try: + body = ( + '' + '' + "" + "" + ) + req = self._build_request( + "PROPFIND", + remote_path, + data=body.encode("utf-8"), + content_type="application/xml", + ) + req.add_header("Depth", "1") + resp = self._opener.open(req, timeout=30) + xml_data = resp.read() + except urllib.error.HTTPError as e: + if e.code == 401: + raise WebDAVError(t("err.webdav.auth", host=self.url)) + raise WebDAVError(t("err.webdav.list", path=remote_path, error=str(e))) + except OSError as e: + raise WebDAVError(t("err.webdav.list", path=remote_path, error=str(e))) + + # XML 炸弹防护:限制响应大小 + MAX_XML_SIZE = 10 * 1024 * 1024 # 10MB + if len(xml_data) > MAX_XML_SIZE: + raise WebDAVError(t("err.webdav.xml_too_large", size=len(xml_data))) + + files = [] + try: + root = ET.fromstring(xml_data) + ns = {"D": "DAV:"} + for resp_elem in root.findall(".//D:response", ns): + href = resp_elem.findtext("D:href", "", ns) + if not href: + continue + # 跳过目录(有 resourcetype collection) + rt = resp_elem.find(".//D:resourcetype/D:collection", ns) + if rt is not None: + continue + name = href.rstrip("/").split("/")[-1] + if not name: + continue + size_text = resp_elem.findtext(".//D:getcontentlength", "0", ns) + mtime_text = resp_elem.findtext(".//D:getlastmodified", "", ns) + size = int(size_text) if size_text else 0 + # 解析 HTTP 日期格式 + mtime = 0.0 + if mtime_text: + try: + from email.utils import parsedate_to_datetime + + mtime = parsedate_to_datetime(mtime_text).timestamp() + except (ValueError, TypeError): + mtime = 0.0 + files.append({"name": name, "size": size, "mtime": mtime}) + except ET.ParseError: + pass + return files + + def delete_remote_file(self, remote_path: str) -> None: + """删除远程文件(DELETE)""" + try: + req = self._build_request("DELETE", remote_path) + self._opener.open(req, timeout=30) + logger.debug("WebDAV 删除文件: %s", remote_path) + except urllib.error.HTTPError as e: + if e.code == 401: + raise WebDAVError(t("err.webdav.auth", host=self.url)) + if e.code == 404: + raise WebDAVError(t("err.webdav.not_found", path=remote_path)) + raise WebDAVError(t("err.webdav.delete", path=remote_path, error=str(e))) + except OSError as e: + raise WebDAVError(t("err.webdav.delete", path=remote_path, error=str(e))) + + def test_connection(self) -> bool: + """测试连接是否可用""" + try: + self.connect() + return True + except WebDAVError: + return False + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return False diff --git a/sbackup/wizard.py b/sbackup/wizard.py new file mode 100644 index 0000000..aed7f69 --- /dev/null +++ b/sbackup/wizard.py @@ -0,0 +1,200 @@ +"""交互式配置向导:引导用户完成首次配置""" + +import os +import logging +from sbackup.i18n import t +from sbackup.config import _load_json_file, _save_json_file + +logger = logging.getLogger(__name__) + + +def run_wizard(data_file: str) -> int: + """运行交互式配置向导""" + print("=" * 50) + print(t("cmd.wizard.welcome")) + print("=" * 50) + print() + + # Step 1: Language selection + print(t("cmd.wizard.step_lang")) + print(" 1. 中文 (zh_CN)") + print(" 2. English (en_US)") + choice = input(t("cmd.wizard.choice") + " ").strip() + lang = "zh_CN" if choice == "1" else "en_US" + from sbackup.config import save_lang + + save_lang(lang) + from sbackup.i18n import set_locale + + set_locale(lang) + print(t("cmd.wizard.lang_set", lang=lang)) + print() + + # Step 2: Add backup strategy + from sbackup.auto_save import BackupManager + + manager = BackupManager(data_file=data_file) + + strategies_added = 0 + while True: + print(t("cmd.wizard.step_strategy")) + source = input(t("cmd.wizard.source") + " ").strip() + if not source: + break + if not os.path.isdir(source): + print(t("err.folder.invalid", path=source)) + continue + + dest = input(t("cmd.wizard.dest") + " ").strip() + if not dest: + break + os.makedirs(dest, exist_ok=True) + + ignore = input(t("cmd.wizard.ignore") + " ").strip() or ".git,__pycache__" + + manager.add_folder(source, dest, ignore) + strategies_added += 1 + print(t("cmd.add.success", source=source, dest=dest)) + + more = input(t("cmd.wizard.more") + " ").strip().lower() + if more not in ("y", "yes", "是", "yes"): + break + + if strategies_added > 0: + manager.save() + + # Step 3: Remote storage + print() + print(t("cmd.wizard.step_remote")) + print(" 1. SFTP") + print(" 2. WebDAV") + print(" 3. S3 Cloud Storage") + print(" 4. " + t("cmd.wizard.skip")) + remote_choice = input(t("cmd.wizard.choice") + " ").strip() + + if remote_choice == "1": + _setup_sftp() + elif remote_choice == "2": + _setup_webdav() + elif remote_choice == "3": + _setup_cloud() + + # Step 4: Email notifications + print() + print(t("cmd.wizard.step_email")) + email = input(t("cmd.wizard.email_yn") + " ").strip().lower() + if email in ("y", "yes", "是"): + _setup_email() + + # Step 5: Schedule + print() + print(t("cmd.wizard.step_schedule")) + schedule = input(t("cmd.wizard.schedule_yn") + " ").strip().lower() + if schedule in ("y", "yes", "是"): + interval_str = input(t("cmd.wizard.schedule_interval") + " ").strip() + interval = int(interval_str) if interval_str.isdigit() else 60 + print(t("cmd.wizard.schedule_info", interval=interval)) + print(f" sbackup watch --interval {interval}") + + # Summary + print() + print("=" * 50) + print(t("cmd.wizard.complete")) + print("=" * 50) + print(t("cmd.wizard.summary", strategies=strategies_added)) + print() + + return 0 + + +def _setup_sftp(): + """交互式配置 SFTP""" + from sbackup.config import save_sftp_config + + host = input(" SFTP " + t("cmd.wizard.host") + " ").strip() + if not host: + return + port_str = input(" SFTP " + t("cmd.wizard.port") + " [22] ").strip() + port = int(port_str) if port_str.isdigit() else 22 + user = input(" SFTP " + t("cmd.wizard.user") + " ").strip() + if not user: + return + pwd = input(" SFTP " + t("cmd.wizard.password") + " (enter to skip) ").strip() + remote = input(" SFTP " + t("cmd.wizard.remote_path") + " [/] ").strip() or "/" + + save_sftp_config(host, port, user, pwd, remote, enabled=True) + print(t("cmd.wizard.sftp_done")) + + +def _setup_webdav(): + """交互式配置 WebDAV""" + from sbackup.config import save_webdav_config + + url = input(" WebDAV URL (e.g. https://dav.jianguoyun.com/dav/) ").strip() + if not url: + return + user = input(" WebDAV " + t("cmd.wizard.user") + " ").strip() + if not user: + return + pwd = input(" WebDAV " + t("cmd.wizard.password") + " (enter to skip) ").strip() + remote = input(" WebDAV " + t("cmd.wizard.remote_path") + " [/] ").strip() or "/" + + save_webdav_config(url, user, pwd, remote, enabled=True) + print(t("cmd.wizard.webdav_done")) + + +def _setup_cloud(): + """交互式配置 S3 云存储""" + print(t("cmd.wizard.cloud_info")) + endpoint = input(" S3 Endpoint (e.g. s3.amazonaws.com) ").strip() + if not endpoint: + return + bucket = input(" Bucket ").strip() + if not bucket: + return + access_key = input(" Access Key ").strip() + secret_key = input(" Secret Key ").strip() + region = input(" Region (enter to skip) ").strip() + + data = _load_json_file("config.json") + data["cloud"] = { + "endpoint": endpoint, + "access_key": access_key, + "secret_key": secret_key, + "bucket": bucket, + "region": region, + "secure": True, + "remote_path": "/", + "enabled": True, + } + _save_json_file(data, "config.json") + print(t("cmd.wizard.cloud_done")) + + +def _setup_email(): + """交互式配置 SMTP 邮件""" + host = input(" SMTP " + t("cmd.wizard.host") + " ").strip() + if not host: + return + port_str = input(" SMTP " + t("cmd.wizard.port") + " [587] ").strip() + port = int(port_str) if port_str.isdigit() else 587 + user = input(" SMTP " + t("cmd.wizard.user") + " ").strip() + if not user: + return + pwd = input(" SMTP " + t("cmd.wizard.password") + " (enter to skip) ").strip() + from_addr = input(" SMTP From (email) ").strip() + to_addr = input(" SMTP To (email) ").strip() + + data = _load_json_file("config.json") + data["smtp"] = { + "host": host, + "port": port, + "user": user, + "password": pwd, + "from": from_addr, + "to": to_addr, + "tls": True, + "enabled": bool(from_addr and to_addr), + } + _save_json_file(data, "config.json") + print(t("cmd.wizard.email_done")) diff --git a/tests/sbackup/test_audit.py b/tests/sbackup/test_audit.py new file mode 100644 index 0000000..f8296cc --- /dev/null +++ b/tests/sbackup/test_audit.py @@ -0,0 +1,379 @@ +""" +单元测试 for sbackup.audit 模块 +""" + +import unittest +import os +import tempfile +import shutil +from sbackup.audit import AuditEntry, AuditLogger + + +class TestAuditEntry(unittest.TestCase): + """AuditEntry 数据类测试""" + + def test_default_values(self): + """测试默认字段值""" + entry = AuditEntry() + self.assertEqual(entry.id, "") + self.assertEqual(entry.timestamp, "") + self.assertEqual(entry.event, "") + self.assertEqual(entry.status, "success") + self.assertEqual(entry.files_count, 0) + self.assertEqual(entry.total_size, 0) + self.assertEqual(entry.backup_size, 0) + self.assertEqual(entry.duration, 0) + self.assertEqual(entry.error_message, "") + self.assertEqual(entry.metadata, {}) + + def test_to_dict(self): + """测试 to_dict 序列化""" + entry = AuditEntry( + id="test-id", + timestamp="2026-01-01T00:00:00Z", + event="backup_complete", + source_path="/src", + target_path="/dst", + format="ZIP", + status="success", + files_count=10, + total_size=1024, + backup_size=512, + duration=1.5, + error_message="", + metadata={"key": "value"}, + ) + d = entry.to_dict() + self.assertEqual(d["id"], "test-id") + self.assertEqual(d["event"], "backup_complete") + self.assertEqual(d["files_count"], 10) + self.assertEqual(d["backup_size"], 512) + self.assertEqual(d["duration"], 1.5) + self.assertEqual(d["metadata"], {"key": "value"}) + + def test_from_dict(self): + """测试 from_dict 反序列化""" + data = { + "id": "abc-123", + "timestamp": "2026-01-01T00:00:00Z", + "event": "backup_failed", + "source_path": "/src", + "target_path": "/dst", + "format": "tar.gz", + "status": "failed", + "files_count": 0, + "total_size": 0, + "backup_size": 0, + "duration": 0.5, + "error_message": "disk full", + "metadata": {}, + } + entry = AuditEntry.from_dict(data) + self.assertEqual(entry.id, "abc-123") + self.assertEqual(entry.event, "backup_failed") + self.assertEqual(entry.status, "failed") + self.assertEqual(entry.error_message, "disk full") + + def test_from_dict_missing_fields(self): + """测试 from_dict 处理缺失字段""" + entry = AuditEntry.from_dict({"event": "test"}) + self.assertEqual(entry.event, "test") + self.assertEqual(entry.status, "success") + self.assertEqual(entry.files_count, 0) + + def test_roundtrip(self): + """测试 to_dict -> from_dict 往返""" + original = AuditEntry( + id="roundtrip", + timestamp="2026-06-06T12:00:00Z", + event="restore", + source_path="/a", + target_path="/b", + format="7z", + status="partial", + files_count=5, + total_size=2048, + backup_size=1024, + duration=3.0, + error_message="some error", + metadata={"extra": True}, + ) + restored = AuditEntry.from_dict(original.to_dict()) + self.assertEqual(original.id, restored.id) + self.assertEqual(original.event, restored.event) + self.assertEqual(original.status, restored.status) + self.assertEqual(original.files_count, restored.files_count) + self.assertEqual(original.metadata, restored.metadata) + + +class TestAuditLogger(unittest.TestCase): + """AuditLogger 功能测试""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.audit_file = os.path.join(self.test_dir, "audit.json") + + def tearDown(self): + if os.path.exists(self.test_dir): + shutil.rmtree(self.test_dir) + + def test_log_creates_entry(self): + """测试 log 记录事件""" + logger = AuditLogger(audit_file=self.audit_file) + entry = logger.log("backup_start", source_path="/src", format="ZIP") + self.assertEqual(entry.event, "backup_start") + self.assertEqual(entry.source_path, "/src") + self.assertEqual(entry.format, "ZIP") + self.assertNotEqual(entry.id, "") + self.assertNotEqual(entry.timestamp, "") + + def test_log_persists_to_file(self): + """测试日志持久化到 JSON 文件""" + logger = AuditLogger(audit_file=self.audit_file) + logger.log("backup_complete", files_count=10, backup_size=2048) + # 重新加载并验证 + logger2 = AuditLogger(audit_file=self.audit_file) + entries = logger2.query() + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0].event, "backup_complete") + self.assertEqual(entries[0].files_count, 10) + + def test_log_default_status(self): + """测试默认状态为 success""" + logger = AuditLogger(audit_file=self.audit_file) + entry = logger.log("verify") + self.assertEqual(entry.status, "success") + + def test_query_by_event(self): + """测试按事件类型过滤""" + logger = AuditLogger(audit_file=self.audit_file) + logger.log("backup_start", source_path="/a") + logger.log("backup_complete", source_path="/a") + logger.log("backup_failed", source_path="/b") + logger.log("backup_complete", source_path="/c") + + complete_entries = logger.query(event="backup_complete") + self.assertEqual(len(complete_entries), 2) + for e in complete_entries: + self.assertEqual(e.event, "backup_complete") + + def test_query_by_status(self): + """测试按状态过滤""" + logger = AuditLogger(audit_file=self.audit_file) + logger.log("backup_complete", status="success") + logger.log("backup_failed", status="failed") + logger.log("backup_complete", status="success") + + failed = logger.query(status="failed") + self.assertEqual(len(failed), 1) + self.assertEqual(failed[0].status, "failed") + + success = logger.query(status="success") + self.assertEqual(len(success), 2) + + def test_query_by_since(self): + """测试按时间过滤""" + logger = AuditLogger(audit_file=self.audit_file) + logger.log("backup_start", timestamp="2026-01-01T00:00:00Z") + logger.log("backup_complete", timestamp="2026-06-01T00:00:00Z") + logger.log("backup_failed", timestamp="2026-12-01T00:00:00Z") + + # 注意:query 使用 reversed,since 过滤的是 timestamp >= since + # 手动设置时间戳来测试 + entries_raw = logger._entries + entries_raw[0]["timestamp"] = "2026-01-01T00:00:00Z" + entries_raw[1]["timestamp"] = "2026-06-01T00:00:00Z" + entries_raw[2]["timestamp"] = "2026-12-01T00:00:00Z" + logger._save() + + # 重新加载确保持久化 + logger2 = AuditLogger(audit_file=self.audit_file) + since_june = logger2.query(since="2026-06-01T00:00:00Z") + self.assertTrue(len(since_june) >= 2) + + def test_query_limit(self): + """测试 limit 参数""" + logger = AuditLogger(audit_file=self.audit_file) + for i in range(10): + logger.log("backup_complete") + + limited = logger.query(limit=3) + self.assertEqual(len(limited), 3) + + def test_query_empty_log(self): + """测试空审计日志查询""" + logger = AuditLogger(audit_file=self.audit_file) + entries = logger.query() + self.assertEqual(len(entries), 0) + + def test_query_combined_filters(self): + """测试组合过滤条件""" + logger = AuditLogger(audit_file=self.audit_file) + logger.log("backup_complete", status="success") + logger.log("backup_complete", status="failed") + logger.log("backup_start", status="success") + + result = logger.query(event="backup_complete", status="success") + self.assertEqual(len(result), 1) + self.assertEqual(result[0].event, "backup_complete") + self.assertEqual(result[0].status, "success") + + def test_get_stats(self): + """测试统计信息""" + logger = AuditLogger(audit_file=self.audit_file) + logger.log("backup_complete", status="success", backup_size=1024) + logger.log("backup_complete", status="success", backup_size=2048) + logger.log("backup_failed", status="failed", backup_size=0) + logger.log("backup_start") + + stats = logger.get_stats() + self.assertEqual(stats["total"], 4) + self.assertEqual(stats["total_backup_size"], 3072) + self.assertEqual(stats["success_count"], 3) + self.assertEqual(stats["event_counts"]["backup_complete"], 2) + self.assertEqual(stats["event_counts"]["backup_failed"], 1) + self.assertEqual(stats["event_counts"]["backup_start"], 1) + + def test_get_stats_empty(self): + """测试空日志统计""" + logger = AuditLogger(audit_file=self.audit_file) + stats = logger.get_stats() + self.assertEqual(stats["total"], 0) + self.assertEqual(stats["total_backup_size"], 0) + self.assertEqual(stats["success_count"], 0) + self.assertEqual(stats["success_rate"], 0) + + def test_cleanup(self): + """测试清理旧日志""" + logger = AuditLogger(audit_file=self.audit_file) + logger.log("backup_complete") + + # 修改时间戳为很久以前 + logger._entries[0]["timestamp"] = "2020-01-01T00:00:00Z" + logger._save() + + logger.log("backup_complete") + + # 重新加载 + logger2 = AuditLogger(audit_file=self.audit_file) + removed = logger2.cleanup(keep_days=30) + self.assertEqual(removed, 1) + + remaining = logger2.query() + self.assertEqual(len(remaining), 1) + + def test_cleanup_nothing_to_remove(self): + """测试清理时无需删除""" + logger = AuditLogger(audit_file=self.audit_file) + logger.log("backup_complete") + removed = logger.cleanup(keep_days=90) + self.assertEqual(removed, 0) + + def test_format_entries_empty(self): + """测试格式化空日志""" + logger = AuditLogger(audit_file=self.audit_file) + result = logger.format_entries([]) + self.assertIn("没有审计日志记录", result) + + def test_format_entries_en(self): + """测试英文格式化""" + logger = AuditLogger(audit_file=self.audit_file) + result = logger.format_entries([], lang="en_US") + self.assertIn("No audit log entries", result) + + def test_format_entries_with_data(self): + """测试格式化有数据的日志""" + logger = AuditLogger(audit_file=self.audit_file) + logger.log( + "backup_complete", + source_path="/src", + target_path="/dst", + files_count=10, + backup_size=2048, + duration=1.5, + ) + entries = logger.query() + result = logger.format_entries(entries, lang="zh_CN") + self.assertIn("backup_complete", result) + self.assertIn("/src", result) + self.assertIn("10", result) + + def test_format_entries_with_error(self): + """测试格式化带错误信息的日志""" + logger = AuditLogger(audit_file=self.audit_file) + logger.log("backup_failed", error_message="disk full", status="failed") + entries = logger.query() + result = logger.format_entries(entries, lang="zh_CN") + self.assertIn("disk full", result) + + def test_format_stats(self): + """测试格式化统计信息""" + logger = AuditLogger(audit_file=self.audit_file) + logger.log("backup_complete", backup_size=1024 * 1024) + stats = logger.get_stats() + result = logger.format_stats(stats, lang="zh_CN") + self.assertIn("审计日志统计", result) + self.assertIn("1.00 MB", result) + + def test_format_stats_en(self): + """测试英文格式化统计""" + logger = AuditLogger(audit_file=self.audit_file) + logger.log("backup_complete", backup_size=1024 * 1024) + stats = logger.get_stats() + result = logger.format_stats(stats, lang="en_US") + self.assertIn("Audit Log Statistics", result) + + def test_persistence_roundtrip(self): + """测试完整持久化往返""" + # 写入 + logger1 = AuditLogger(audit_file=self.audit_file) + logger1.log("backup_start", source_path="/a") + logger1.log("backup_complete", source_path="/a", files_count=5) + logger1.log("backup_failed", source_path="/b", error_message="timeout") + + # 重新加载并验证 + logger2 = AuditLogger(audit_file=self.audit_file) + entries = logger2.query() + self.assertEqual(len(entries), 3) + self.assertEqual(entries[0].event, "backup_failed") + self.assertEqual(entries[1].event, "backup_complete") + self.assertEqual(entries[2].event, "backup_start") + + def test_json_file_created(self): + """测试 JSON 文件创建""" + logger = AuditLogger(audit_file=self.audit_file) + logger.log("backup_start") + self.assertTrue(os.path.exists(self.audit_file)) + + def test_default_audit_path(self): + """测试默认审计日志路径不为空""" + from sbackup.audit import _default_audit_path + + path = _default_audit_path() + self.assertTrue(path.endswith("audit.json")) + self.assertIn("sbackup", path) + + def test_log_with_metadata(self): + """测试记录带 metadata 的事件""" + logger = AuditLogger(audit_file=self.audit_file) + logger.log("upload", metadata={"remote": "sftp://host/path"}) + entries = logger.query() + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0].metadata["remote"], "sftp://host/path") + + def test_corrupted_file_handling(self): + """测试损坏的审计文件处理""" + with open(self.audit_file, "w", encoding="utf-8") as f: + f.write("not valid json {{{") + + logger = AuditLogger(audit_file=self.audit_file) + self.assertEqual(len(logger._entries), 0) + + def test_missing_file_handling(self): + """测试审计文件不存在时的处理""" + logger = AuditLogger(audit_file=os.path.join(self.test_dir, "nonexistent.json")) + self.assertEqual(len(logger._entries), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sbackup/test_auto_save.py b/tests/sbackup/test_auto_save.py index ba0d503..202419e 100644 --- a/tests/sbackup/test_auto_save.py +++ b/tests/sbackup/test_auto_save.py @@ -9,6 +9,7 @@ import shutil import time from pathlib import Path +from unittest.mock import patch, MagicMock from sbackup.auto_save import BackupManager from sbackup.i18n import t @@ -129,6 +130,109 @@ def test_rm_nonexistent_folder(self): result = self.manager.rm_folder("/nonexistent/path") self.assertFalse(result) + def test_edit_strategy_target(self): + """测试编辑策略的目标路径""" + self.manager.add_folder(self.source_folder, self.target_folder) + new_target = os.path.join(self.test_dir, "new_target") + os.makedirs(new_target) + result = self.manager.edit_strategy(self.source_folder, new_target=new_target) + self.assertTrue(result) + abs_source = os.path.abspath(self.source_folder) + entry = self.manager._get_entry(abs_source) + assert entry is not None + self.assertEqual(entry.target, os.path.abspath(new_target)) + + def test_edit_strategy_ignore(self): + """测试编辑策略的忽略模式""" + self.manager.add_folder(self.source_folder, self.target_folder, ".git") + result = self.manager.edit_strategy( + self.source_folder, new_ignore="node_modules,dist" + ) + self.assertTrue(result) + abs_source = os.path.abspath(self.source_folder) + entry = self.manager._get_entry(abs_source) + assert entry is not None + self.assertEqual(entry.skip_patterns, ["node_modules", "dist"]) + + def test_edit_strategy_format(self): + """测试编辑策略的打包格式""" + self.manager.add_folder(self.source_folder, self.target_folder) + result = self.manager.edit_strategy(self.source_folder, new_format="tar.gz") + self.assertTrue(result) + abs_source = os.path.abspath(self.source_folder) + entry = self.manager._get_entry(abs_source) + assert entry is not None + self.assertEqual(entry.compression_format, "TAR_GZ") + + def test_edit_strategy_name_template(self): + """测试编辑策略的文件名模板""" + self.manager.add_folder(self.source_folder, self.target_folder) + result = self.manager.edit_strategy( + self.source_folder, new_name_template="{name}_{date}" + ) + self.assertTrue(result) + abs_source = os.path.abspath(self.source_folder) + entry = self.manager._get_entry(abs_source) + assert entry is not None + self.assertEqual(entry.name_template, "{name}_{date}") + + def test_edit_strategy_multiple_fields(self): + """测试同时编辑多个字段""" + self.manager.add_folder(self.source_folder, self.target_folder) + new_target = os.path.join(self.test_dir, "new_target2") + os.makedirs(new_target) + result = self.manager.edit_strategy( + self.source_folder, + new_target=new_target, + new_ignore="*.log,tmp", + new_format="7z", + ) + self.assertTrue(result) + abs_source = os.path.abspath(self.source_folder) + entry = self.manager._get_entry(abs_source) + assert entry is not None + self.assertEqual(entry.target, os.path.abspath(new_target)) + self.assertEqual(entry.skip_patterns, ["*.log", "tmp"]) + self.assertEqual(entry.compression_format, "7Z") + + def test_edit_nonexistent_strategy(self): + """测试编辑不存在的策略应失败""" + result = self.manager.edit_strategy("/nonexistent/path", new_format="tar") + self.assertFalse(result) + + def test_edit_no_changes(self): + """测试未指定任何修改参数应失败""" + self.manager.add_folder(self.source_folder, self.target_folder) + result = self.manager.edit_strategy(self.source_folder) + self.assertFalse(result) + + def test_edit_invalid_target(self): + """测试编辑为无效目标路径应失败""" + self.manager.add_folder(self.source_folder, self.target_folder) + result = self.manager.edit_strategy( + self.source_folder, new_target="/nonexistent/path" + ) + self.assertFalse(result) + + def test_edit_same_target(self): + """测试编辑目标为源目录本身应失败""" + self.manager.add_folder(self.source_folder, self.target_folder) + result = self.manager.edit_strategy( + self.source_folder, new_target=self.source_folder + ) + self.assertFalse(result) + + def test_edit_persistence(self): + """测试编辑后数据持久化""" + self.manager.add_folder(self.source_folder, self.target_folder) + self.manager.edit_strategy(self.source_folder, new_format="tar.xz") + # 重新加载数据 + self.manager.load() + abs_source = os.path.abspath(self.source_folder) + entry = self.manager._get_entry(abs_source) + assert entry is not None + self.assertEqual(entry.compression_format, "TAR_XZ") + def test_add_invalid_source(self): """测试添加无效源目录应失败""" result = self.manager.add_folder("/nonexistent/path", self.target_folder) @@ -139,6 +243,11 @@ def test_add_invalid_dest(self): result = self.manager.add_folder(self.source_folder, "/nonexistent/path") self.assertFalse(result) + def test_add_source_equals_dest(self): + """测试源目录与目标目录相同时应失败""" + result = self.manager.add_folder(self.source_folder, self.source_folder) + self.assertFalse(result) + def test_save_missing_source(self): """测试源文件夹不存在时的 save 行为""" # 手动构造一条源文件夹不存在的记录 @@ -321,6 +430,28 @@ def test_cleanup_old_backups_invalid_dir(self): BackupManager._cleanup_old_backups("/nonexistent", keep=2) # 不应抛出异常 + def test_check_disk_space_valid_dir(self): + """测试磁盘空间检查不抛异常""" + BackupManager._check_disk_space(self.target_folder) + # 不应抛出异常 + + def test_check_disk_space_invalid_dir(self): + """测试磁盘空间检查无效目录不抛异常""" + BackupManager._check_disk_space("/nonexistent/dir") + # 不应抛出异常 + + def test_do_backup_returns_result(self): + """测试 _do_backup 返回正确结果""" + from sbackup.config import load_config + + self.manager.add_folder(self.source_folder, self.target_folder, "") + abs_source = os.path.abspath(self.source_folder) + entry = self.manager._get_entry(abs_source) + assert entry is not None + config = load_config() + result = BackupManager._do_backup(abs_source, entry, 0.0, config, "") + self.assertTrue(result["success"]) + def test_execute_backups_with_keep(self): """测试执行备份时保留 N 个文件""" self.manager.add_folder(self.source_folder, self.target_folder, "") @@ -407,7 +538,7 @@ def test_to_list_includes_format(self): entry = BackupEntry( mtime=1.0, target="/t", skip_patterns=[], compression_format="7Z" ) - self.assertEqual(entry.to_list(), [1.0, "/t", [], "7Z"]) + self.assertEqual(entry.to_list(), [1.0, "/t", [], "7Z", ""]) def test_execute_backups_entry_format_overrides_global(self): """测试条目级格式优先于全局格式""" @@ -455,6 +586,647 @@ def test_list_folder_table_shows_default_when_no_format(self): # 应包含"默认"文本(中文环境) self.assertIn(t("table.cell.default"), text) + def test_display_width_ascii(self): + """测试 ASCII 字符宽度计算""" + self.assertEqual(BackupManager._display_width("hello"), 5) + + def test_display_width_cjk(self): + """测试中文字符宽度计算(每个算2)""" + self.assertEqual(BackupManager._display_width("你好"), 4) + + def test_display_width_fullwidth(self): + """测试全角符号宽度计算(U+FF01-FF60 等)""" + # A 是全角大写 A,宽度应为2 + self.assertEqual(BackupManager._display_width("A"), 2) + # ! 是全角感叹号 + self.assertEqual(BackupManager._display_width("!"), 2) + + def test_display_width_mixed(self): + """测试中英混合字符串宽度""" + # "hi你好" = 1+1+2+2 = 6 + self.assertEqual(BackupManager._display_width("hi你好"), 6) + + def test_display_width_non_string(self): + """测试非字符串输入""" + self.assertEqual(BackupManager._display_width(123), 3) + + def test_display_width_empty(self): + """测试空字符串""" + self.assertEqual(BackupManager._display_width(""), 0) + + def test_list_folder_table_only_history(self): + """测试仅含 _history 数据时返回空提示""" + self.manager.data["_history"] = [ + {"time": "2026-01-01", "source": "/x", "size_mb": 1.0, "files_count": 1} + ] + text = self.manager.list_folder_table() + self.assertEqual(text, t("cmd.all.empty")) + + def test_format_history_table_empty(self): + """测试无历史记录时返回提示""" + text = self.manager.format_history_table() + self.assertEqual(text, t("cmd.list.empty")) + + def test_format_history_table_with_data(self): + """测试有历史记录时生成表格""" + self.manager.add_folder(self.source_folder, self.target_folder, "") + import time + + time.sleep(0.1) + (Path(self.source_folder) / "f.txt").write_text("x") + self.manager.execute_backups() + text = self.manager.format_history_table() + self.assertIn(self.source_folder, text) + self.assertIn(t("table.header.time"), text) + + def test_entry_from_list_invalid_data(self): + """测试 from_list 输入验证:非列表返回空条目""" + from sbackup.auto_save import BackupEntry + + entry = BackupEntry.from_list("not a list") + self.assertEqual(entry.mtime, 0.0) + self.assertEqual(entry.target, "") + + def test_entry_from_list_too_short(self): + """测试 from_list 输入验证:少于3元素返回空条目""" + from sbackup.auto_save import BackupEntry + + entry = BackupEntry.from_list([1.0, "/path"]) + self.assertEqual(entry.mtime, 0.0) + self.assertEqual(entry.target, "") + + def test_entry_from_list_empty(self): + """测试 from_list 输入验证:空列表""" + from sbackup.auto_save import BackupEntry + + entry = BackupEntry.from_list([]) + self.assertEqual(entry.mtime, 0.0) + + +class TestUploadSftp(unittest.TestCase): + """测试 _upload_to_sftp 静态方法""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.test_file = os.path.join(self.test_dir, "backup.zip") + with open(self.test_file, "w") as f: + f.write("fake backup") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + @patch("sbackup.sftp.SFTPClient") + def test_upload_not_configured(self, mock_sftp_cls): + """测试 SFTP 未配置时打印错误""" + from sbackup.config import Config + + config = Config(sftp_enabled=False) + with patch("builtins.print") as mock_print: + BackupManager._upload_to_sftp([self.test_file], config) + mock_print.assert_called() + + @patch("sbackup.sftp.SFTPClient") + def test_upload_no_credentials(self, mock_sftp_cls): + """测试无凭据时打印提示""" + from sbackup.config import Config + + mock_sftp_cls.try_default_key.return_value = None + config = Config(sftp_enabled=True, sftp_host="host", sftp_password="") + with patch("builtins.print") as mock_print: + BackupManager._upload_to_sftp([self.test_file], config) + printed = " ".join(str(c) for c in mock_print.call_args_list) + # 无默认私钥时应打印提示 + self.assertIn(t("cmd.sftp.no_default_key"), printed) + + @patch("sbackup.sftp.SFTPClient") + def test_upload_success(self, mock_sftp_cls): + """测试 SFTP 上传成功""" + from sbackup.config import Config + + mock_client = MagicMock() + mock_sftp_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_sftp_cls.return_value.__exit__ = MagicMock(return_value=False) + + config = Config( + sftp_enabled=True, + sftp_host="host", + sftp_user="user", + sftp_password="pass", + ) + with patch("builtins.print"): + BackupManager._upload_to_sftp([self.test_file], config) + mock_client.upload_file.assert_called_once() + + @patch("sbackup.sftp.SFTPClient") + def test_upload_sftp_error(self, mock_sftp_cls): + """测试 SFTP 上传失败""" + from sbackup.config import Config + from sbackup.sftp import SFTPError + + mock_client = MagicMock() + mock_client.upload_file.side_effect = SFTPError("upload failed") + mock_sftp_cls.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_sftp_cls.return_value.__exit__ = MagicMock(return_value=False) + + config = Config( + sftp_enabled=True, + sftp_host="host", + sftp_user="user", + sftp_password="pass", + ) + with patch("builtins.print") as mock_print: + BackupManager._upload_to_sftp([self.test_file], config) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("upload failed", printed) + + +class TestUploadWebdav(unittest.TestCase): + """测试 _upload_to_webdav 静态方法""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.test_file = os.path.join(self.test_dir, "backup.zip") + with open(self.test_file, "w") as f: + f.write("fake backup") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + @patch("sbackup.webdav.WebDAVClient") + def test_upload_not_configured(self, mock_wdav_cls): + """测试 WebDAV 未配置时打印错误""" + from sbackup.config import Config + + config = Config(webdav_enabled=False) + with patch("builtins.print") as mock_print: + BackupManager._upload_to_webdav([self.test_file], config) + mock_print.assert_called() + + @patch("sbackup.webdav.WebDAVClient") + def test_upload_success(self, mock_wdav_cls): + """测试 WebDAV 上传成功""" + from sbackup.config import Config + + mock_client = MagicMock() + mock_wdav_cls.return_value = mock_client + + config = Config( + webdav_enabled=True, + webdav_url="https://dav.example.com", + webdav_user="user", + webdav_password="pass", + ) + with patch("builtins.print"): + BackupManager._upload_to_webdav([self.test_file], config) + mock_client.connect.assert_called_once() + mock_client.upload_file.assert_called_once() + + @patch("sbackup.webdav.WebDAVClient") + def test_upload_webdav_error(self, mock_wdav_cls): + """测试 WebDAV 上传失败""" + from sbackup.config import Config + from sbackup.webdav import WebDAVError + + mock_client = MagicMock() + mock_client.upload_file.side_effect = WebDAVError("upload failed") + mock_wdav_cls.return_value = mock_client + + config = Config( + webdav_enabled=True, + webdav_url="https://dav.example.com", + webdav_user="user", + webdav_password="pass", + ) + with patch("builtins.print") as mock_print: + BackupManager._upload_to_webdav([self.test_file], config) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("upload failed", printed) + + @patch("sbackup.webdav.WebDAVClient") + def test_upload_connect_error(self, mock_wdav_cls): + """测试 WebDAV 连接失败""" + from sbackup.config import Config + from sbackup.webdav import WebDAVError + + mock_wdav_cls.return_value.connect.side_effect = WebDAVError("connect failed") + + config = Config( + webdav_enabled=True, + webdav_url="https://dav.example.com", + webdav_user="user", + webdav_password="pass", + ) + with patch("builtins.print") as mock_print: + BackupManager._upload_to_webdav([self.test_file], config) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("connect failed", printed) + + +class TestEditStrategy(unittest.TestCase): + """测试 edit_strategy 方法""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.original_cwd = os.getcwd() + os.chdir(self.test_dir) + self.data_file = os.path.join(self.test_dir, "test_sbackup.json") + self.manager = BackupManager(self.data_file) + self.source_folder = os.path.join(self.test_dir, "source") + self.target_folder = os.path.join(self.test_dir, "target") + os.makedirs(self.source_folder) + os.makedirs(self.target_folder) + self.manager.add_folder(self.source_folder, self.target_folder, ".git") + + def tearDown(self): + os.chdir(self.original_cwd) + shutil.rmtree(self.test_dir) + + def test_edit_target(self): + """测试修改目标路径""" + new_target = os.path.join(self.test_dir, "new_target") + os.makedirs(new_target) + result = self.manager.edit_strategy(self.source_folder, new_target=new_target) + self.assertTrue(result) + entry = self.manager._get_entry(os.path.abspath(self.source_folder)) + assert entry is not None + self.assertEqual(entry.target, os.path.abspath(new_target)) + + def test_edit_ignore(self): + """测试修改忽略模式""" + result = self.manager.edit_strategy( + self.source_folder, new_ignore="node_modules,dist" + ) + self.assertTrue(result) + entry = self.manager._get_entry(os.path.abspath(self.source_folder)) + assert entry is not None + self.assertEqual(entry.skip_patterns, ["node_modules", "dist"]) + + def test_edit_format(self): + """测试修改打包格式""" + result = self.manager.edit_strategy(self.source_folder, new_format="tar.gz") + self.assertTrue(result) + entry = self.manager._get_entry(os.path.abspath(self.source_folder)) + assert entry is not None + self.assertEqual(entry.compression_format, "TAR_GZ") + + def test_edit_name_template(self): + """测试修改文件名模板""" + result = self.manager.edit_strategy( + self.source_folder, new_name_template="{name}_{date}" + ) + self.assertTrue(result) + entry = self.manager._get_entry(os.path.abspath(self.source_folder)) + assert entry is not None + self.assertEqual(entry.name_template, "{name}_{date}") + + def test_edit_multiple_fields(self): + """测试同时修改多个字段""" + result = self.manager.edit_strategy( + self.source_folder, + new_ignore="*.log,tmp", + new_format="7z", + ) + self.assertTrue(result) + entry = self.manager._get_entry(os.path.abspath(self.source_folder)) + assert entry is not None + self.assertEqual(entry.skip_patterns, ["*.log", "tmp"]) + self.assertEqual(entry.compression_format, "7Z") + + def test_edit_no_changes(self): + """测试未指定修改参数""" + result = self.manager.edit_strategy(self.source_folder) + self.assertFalse(result) + + def test_edit_nonexistent_strategy(self): + """测试编辑不存在的策略""" + result = self.manager.edit_strategy("/nonexistent/path", new_format="tar") + self.assertFalse(result) + + def test_edit_invalid_target(self): + """测试修改为不存在的目标路径""" + result = self.manager.edit_strategy( + self.source_folder, new_target="/nonexistent/target" + ) + self.assertFalse(result) + + def test_edit_same_target(self): + """测试修改为与源相同的目标路径""" + result = self.manager.edit_strategy( + self.source_folder, new_target=self.source_folder + ) + self.assertFalse(result) + + +class TestWebhook(unittest.TestCase): + """测试 Webhook 增强功能""" + + @patch("urllib.request.OpenerDirector.open") + @patch("socket.getaddrinfo") + def test_send_webhook_single(self, mock_getaddrinfo, mock_open): + """测试发送单个 webhook""" + mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))] + mock_open.return_value = MagicMock() + BackupManager._send_webhook( + "https://example.com/hook", + status="success", + backed=2, + skipped=1, + elapsed=5.0, + ) + mock_open.assert_called_once() + + @patch("urllib.request.OpenerDirector.open") + @patch("socket.getaddrinfo") + def test_send_webhook_with_template(self, mock_getaddrinfo, mock_open): + """测试自定义模板 webhook""" + mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))] + mock_open.return_value = MagicMock() + BackupManager._send_webhook( + "https://example.com/hook", + status="success", + backed=2, + skipped=1, + elapsed=5.0, + template='{"event":"backup","status":"{status}"}', + ) + req = mock_open.call_args[0][0] + import json + + payload = json.loads(req.data.decode("utf-8")) + self.assertEqual(payload["status"], "success") + + @patch("urllib.request.OpenerDirector.open") + @patch("socket.getaddrinfo") + def test_send_webhook_retries(self, mock_getaddrinfo, mock_open): + """测试 webhook 重试机制""" + import urllib.error + + mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))] + mock_open.side_effect = urllib.error.URLError("timeout") + BackupManager._send_webhook( + "https://example.com/hook", + status="success", + backed=1, + skipped=0, + elapsed=1.0, + retries=3, + ) + self.assertEqual(mock_open.call_count, 3) + + @patch("urllib.request.OpenerDirector.open") + @patch("socket.getaddrinfo") + def test_send_webhooks_multiple(self, mock_getaddrinfo, mock_open): + """测试多个 webhook URL""" + mock_getaddrinfo.return_value = [(None, None, None, None, ("8.8.8.8", 0))] + mock_open.return_value = MagicMock() + BackupManager._send_webhooks( + ["https://hook1.example.com", "https://hook2.example.com"], + status="success", + backed=1, + skipped=0, + elapsed=1.0, + ) + self.assertEqual(mock_open.call_count, 2) + + +class TestDiffBackup(unittest.TestCase): + """测试 diff_backup 和 format_diff 方法""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.original_cwd = os.getcwd() + os.chdir(self.test_dir) + self.data_file = os.path.join(self.test_dir, "test_sbackup.json") + self.manager = BackupManager(self.data_file) + self.source_folder = os.path.join(self.test_dir, "source") + self.target_folder = os.path.join(self.test_dir, "target") + os.makedirs(self.source_folder) + os.makedirs(self.target_folder) + # 创建源文件 + (Path(self.source_folder) / "file1.txt").write_text("hello") + (Path(self.source_folder) / "file2.txt").write_text("world") + # 添加策略 + self.manager.add_folder(self.source_folder, self.target_folder) + # 强制 mtime 为 0 确保备份执行 + abs_source = os.path.abspath(self.source_folder) + entry = self.manager._get_entry(abs_source) + assert entry is not None + entry.mtime = 0.0 + self.manager._set_entry(abs_source, entry) + self.manager.save() + self.manager.execute_backups() + + def tearDown(self): + os.chdir(self.original_cwd) + shutil.rmtree(self.test_dir) + + def test_diff_no_changes(self): + """测试无差异时返回空列表""" + result = self.manager.diff_backup(self.source_folder) + self.assertTrue(result["success"]) + self.assertEqual(len(result["added"]), 0) + self.assertEqual(len(result["removed"]), 0) + + def test_diff_with_new_file(self): + """测试新增文件被检测到""" + (Path(self.source_folder) / "new_file.txt").write_text("new") + result = self.manager.diff_backup(self.source_folder) + self.assertTrue(result["success"]) + self.assertTrue(len(result["added"]) > 0) + + def test_diff_with_specified_backup(self): + """测试指定备份文件路径""" + import glob + + backups = glob.glob(os.path.join(self.target_folder, "*")) + self.assertTrue(len(backups) > 0) + result = self.manager.diff_backup(self.source_folder, backup_file=backups[0]) + self.assertTrue(result["success"]) + + def test_diff_nonexistent_backup_file(self): + """测试指定不存在的备份文件""" + result = self.manager.diff_backup( + self.source_folder, backup_file="/nonexistent/file.zip" + ) + self.assertFalse(result["success"]) + + def test_diff_no_strategy(self): + """测试没有策略时的行为""" + result = self.manager.diff_backup("/nonexistent/source") + self.assertFalse(result["success"]) + + def test_diff_no_backup_in_target(self): + """测试目标目录无备份文件""" + empty_target = os.path.join(self.test_dir, "empty_target") + os.makedirs(empty_target) + manager2 = BackupManager(os.path.join(self.test_dir, "data2.json")) + source2 = os.path.join(self.test_dir, "source2") + os.makedirs(source2) + (Path(source2) / "f.txt").write_text("x") + manager2.add_folder(source2, empty_target) + result = manager2.diff_backup(source2) + self.assertFalse(result["success"]) + + def test_format_diff_with_changes(self): + """测试 format_diff 输出包含差异信息""" + (Path(self.source_folder) / "added.txt").write_text("new") + result = self.manager.diff_backup(self.source_folder) + text = self.manager.format_diff(result) + self.assertIn("+", text) + self.assertIn("added.txt", text) + + def test_format_diff_no_changes(self): + """测试 format_diff 无差异输出(共同文件显示为可能修改)""" + result = self.manager.diff_backup(self.source_folder) + text = self.manager.format_diff(result) + # diff 总是将共同文件列为"可能修改" + self.assertIn("差异对比", text) + self.assertIn("差异摘要", text) + + def test_format_diff_failure(self): + """测试 format_diff 失败时返回空字符串""" + text = self.manager.format_diff({"success": False}) + self.assertEqual(text, "") + + +class TestExportReport(unittest.TestCase): + """测试 export_report 方法""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.original_cwd = os.getcwd() + os.chdir(self.test_dir) + self.data_file = os.path.join(self.test_dir, "test_sbackup.json") + self.manager = BackupManager(self.data_file) + + def tearDown(self): + os.chdir(self.original_cwd) + shutil.rmtree(self.test_dir) + + def test_report_empty(self): + """测试空策略时生成报告""" + report = self.manager.export_report() + self.assertIn("Sbackup", report) + self.assertIn("策略总数: 0", report) + + def test_report_with_strategies(self): + """测试有策略时生成报告""" + source = os.path.join(self.test_dir, "source") + target = os.path.join(self.test_dir, "target") + os.makedirs(source) + os.makedirs(target) + (Path(source) / "f.txt").write_text("data") + self.manager.add_folder(source, target) + self.manager.execute_backups() + report = self.manager.export_report() + self.assertIn("策略总数: 1", report) + self.assertIn("source", report) + + def test_report_save_to_file(self): + """测试保存报告到文件""" + output = os.path.join(self.test_dir, "report.md") + self.manager.export_report(output) + self.assertTrue(os.path.exists(output)) + with open(output, "r", encoding="utf-8") as f: + content = f.read() + self.assertIn("Sbackup", content) + + +class TestCleanupKeepDays(unittest.TestCase): + """测试 _cleanup_old_backups 的 keep_days 功能""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _create_backup_file(self, name: str, mtime: float = 0): + """创建一个备份文件并设置修改时间""" + path = Path(self.test_dir) / name + path.write_text("fake backup") + if mtime > 0: + os.utime(path, (mtime, mtime)) + return path + + def test_keep_days_removes_old(self): + """测试 keep_days 删除过期文件""" + import time as _time + + now = _time.time() + old_file = self._create_backup_file("old.zip", now - 40 * 86400) + new_file = self._create_backup_file("new.zip", now - 1 * 86400) + BackupManager._cleanup_old_backups(self.test_dir, keep_days=30) + self.assertFalse(old_file.exists()) + self.assertTrue(new_file.exists()) + + def test_keep_days_keeps_recent(self): + """测试 keep_days 保留近期文件""" + import time as _time + + now = _time.time() + f1 = self._create_backup_file("f1.zip", now - 5 * 86400) + f2 = self._create_backup_file("f2.tar.gz", now - 10 * 86400) + BackupManager._cleanup_old_backups(self.test_dir, keep_days=30) + self.assertTrue(f1.exists()) + self.assertTrue(f2.exists()) + + def test_keep_zero_no_cleanup(self): + """测试 keep=0 且 keep_days=0 时不清理""" + self._create_backup_file("old.zip") + BackupManager._cleanup_old_backups(self.test_dir, keep=0, keep_days=0) + self.assertTrue(os.path.exists(os.path.join(self.test_dir, "old.zip"))) + + def test_keep_combined(self): + """测试 keep 和 keep_days 组合使用""" + import time as _time + + now = _time.time() + for i in range(5): + self._create_backup_file(f"f{i}.zip", now - i * 86400) + # keep=2 保留最新2个,keep_days=2 删除2天前的 + BackupManager._cleanup_old_backups(self.test_dir, keep=2, keep_days=2) + remaining = list(Path(self.test_dir).glob("*.zip")) + # f0 和 f1 应该保留(最新2个且在2天内) + self.assertLessEqual(len(remaining), 3) + + +class TestAddHistoryChecksum(unittest.TestCase): + """测试 _add_history 的 SHA256 校验和功能""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.data_file = os.path.join(self.test_dir, "test.json") + self.manager = BackupManager(self.data_file) + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_checksum_stored(self): + """测试备份文件的 SHA256 被存储到历史记录""" + backup_file = os.path.join(self.test_dir, "backup.zip") + with open(backup_file, "wb") as f: + f.write(b"test backup content") + self.manager._add_history("/source", 1.0, 10, 2.0, backup_path=backup_file) + history = self.manager.get_history() + self.assertEqual(len(history), 1) + self.assertIn("sha256", history[0]) + self.assertEqual(len(history[0]["sha256"]), 64) + + def test_no_checksum_without_path(self): + """测试不传 backup_path 时不存储校验和""" + self.manager._add_history("/source", 1.0, 10) + history = self.manager.get_history() + self.assertNotIn("sha256", history[0]) + + def test_no_checksum_nonexistent_file(self): + """测试文件不存在时不存储校验和""" + self.manager._add_history( + "/source", 1.0, 10, backup_path="/nonexistent/file.zip" + ) + history = self.manager.get_history() + self.assertNotIn("sha256", history[0]) + if __name__ == "__main__": unittest.main() diff --git a/tests/sbackup/test_benchmark.py b/tests/sbackup/test_benchmark.py new file mode 100644 index 0000000..9602b13 --- /dev/null +++ b/tests/sbackup/test_benchmark.py @@ -0,0 +1,266 @@ +""" +单元测试 for sbackup.benchmark 模块 +""" + +import os +import shutil +import tempfile +import unittest + +from sbackup.benchmark import ( + BenchmarkResult, + BenchmarkRunner, + FORMATS, + _get_source_info, +) + + +class TestBenchmarkResult(unittest.TestCase): + """BenchmarkResult 数据类测试""" + + def test_creation(self): + """测试 BenchmarkResult 创建""" + r = BenchmarkResult( + format="ZIP", + level=6, + original_size=1024 * 1024, + compressed_size=512 * 1024, + compression_time=0.5, + decompression_time=0.2, + compression_ratio=0.5, + speed_mbps=2.0, + file_count=10, + ) + self.assertEqual(r.format, "ZIP") + self.assertEqual(r.level, 6) + self.assertEqual(r.original_size, 1024 * 1024) + self.assertEqual(r.compressed_size, 512 * 1024) + self.assertAlmostEqual(r.compression_time, 0.5) + self.assertAlmostEqual(r.decompression_time, 0.2) + self.assertAlmostEqual(r.compression_ratio, 0.5) + self.assertAlmostEqual(r.speed_mbps, 2.0) + self.assertEqual(r.file_count, 10) + + def test_formats_list(self): + """测试支持的格式列表""" + self.assertIn("ZIP", FORMATS) + self.assertIn("TAR", FORMATS) + self.assertIn("TAR_GZ", FORMATS) + self.assertIn("TAR_BZ2", FORMATS) + self.assertIn("TAR_XZ", FORMATS) + self.assertIn("TAR_ZST", FORMATS) + self.assertIn("7Z", FORMATS) + + +class TestGetSourceInfo(unittest.TestCase): + """_get_source_info 函数测试""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_bench_test_") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_empty_directory(self): + """测试空目录""" + size, count = _get_source_info(self.test_dir) + self.assertEqual(size, 0) + self.assertEqual(count, 0) + + def test_with_files(self): + """测试有文件的目录""" + content = b"hello world" + for i in range(5): + fpath = os.path.join(self.test_dir, f"file{i}.txt") + with open(fpath, "wb") as f: + f.write(content) + + size, count = _get_source_info(self.test_dir) + self.assertEqual(count, 5) + self.assertGreater(size, 0) + + def test_nested_directories(self): + """测试嵌套目录""" + content = b"nested content" + subdir = os.path.join(self.test_dir, "sub") + os.makedirs(subdir) + with open(os.path.join(self.test_dir, "root.txt"), "wb") as f: + f.write(content) + with open(os.path.join(subdir, "nested.txt"), "wb") as f: + f.write(content) + + size, count = _get_source_info(self.test_dir) + self.assertEqual(count, 2) + self.assertEqual(size, len(content) * 2) + + +class TestBenchmarkRunner(unittest.TestCase): + """BenchmarkRunner 测试""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_bench_test_") + # 创建测试文件 + content = b"x" * 1024 # 1KB 的重复数据(易于压缩) + for i in range(10): + fpath = os.path.join(self.test_dir, f"file{i}.txt") + with open(fpath, "wb") as f: + f.write(content) + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_run_single_zip(self): + """测试单个 ZIP 格式压缩""" + runner = BenchmarkRunner(self.test_dir) + result = runner.run_single("ZIP", level=6) + + self.assertIsInstance(result, BenchmarkResult) + self.assertEqual(result.format, "ZIP") + self.assertEqual(result.level, 6) + self.assertGreater(result.original_size, 0) + self.assertGreater(result.compressed_size, 0) + self.assertGreaterEqual(result.compression_time, 0) + self.assertGreaterEqual(result.decompression_time, 0) + self.assertGreater(result.compression_ratio, 0) + self.assertGreaterEqual(result.speed_mbps, 0) + self.assertEqual(result.file_count, 10) + + def test_run_single_tar_gz(self): + """测试单个 TAR_GZ 格式压缩""" + runner = BenchmarkRunner(self.test_dir) + result = runner.run_single("TAR_GZ", level=6) + + self.assertEqual(result.format, "TAR_GZ") + self.assertGreater(result.original_size, 0) + + def test_run_single_tar(self): + """测试 TAR 格式(不压缩)""" + runner = BenchmarkRunner(self.test_dir) + result = runner.run_single("TAR", level=6) + + self.assertEqual(result.format, "TAR") + self.assertEqual(result.level, 6) + # TAR 不压缩,compressed 应该 >= original(因为 tar 开销) + self.assertGreaterEqual(result.compressed_size, result.original_size) + + def test_run_all(self): + """测试 run_all 多格式测试""" + runner = BenchmarkRunner(self.test_dir) + results = runner.run_all(levels=[1, 6]) + + self.assertGreater(len(results), 0) + # 确保每个结果都是 BenchmarkResult + for r in results: + self.assertIsInstance(r, BenchmarkResult) + self.assertGreater(r.original_size, 0) + + # TAR 格式应该只有一个结果(level=6) + tar_results = [r for r in results if r.format == "TAR"] + self.assertEqual(len(tar_results), 1) + self.assertEqual(tar_results[0].level, 6) + + # 其他格式在 levels=[1,6] 下应各有两个结果 + zip_results = [r for r in results if r.format == "ZIP"] + self.assertEqual(len(zip_results), 2) + + def test_run_all_default_levels(self): + """测试 run_all 默认级别""" + runner = BenchmarkRunner(self.test_dir) + results = runner.run_all() + + self.assertGreater(len(results), 0) + # 默认级别 [1, 6, 9] + zip_levels = sorted(r.level for r in results if r.format == "ZIP") + self.assertEqual(zip_levels, [1, 6, 9]) + + def test_run_quick(self): + """测试 run_quick 快速测试""" + runner = BenchmarkRunner(self.test_dir) + results = runner.run_quick() + + self.assertEqual(len(results), len(FORMATS)) + for r in results: + self.assertIsInstance(r, BenchmarkResult) + self.assertGreater(r.original_size, 0) + + def test_get_recommended(self): + """测试推荐逻辑""" + runner = BenchmarkRunner(self.test_dir) + runner.run_quick() + + recommended = runner.get_recommended() + self.assertIsInstance(recommended, BenchmarkResult) + self.assertIn(recommended.format, FORMATS) + + def test_get_recommended_auto_run(self): + """测试未运行时自动运行快速测试""" + runner = BenchmarkRunner(self.test_dir) + recommended = runner.get_recommended() + self.assertIsInstance(recommended, BenchmarkResult) + self.assertGreater(len(runner.results), 0) + + def test_format_results(self): + """测试格式化输出""" + runner = BenchmarkRunner(self.test_dir) + results = runner.run_quick() + + output = runner.format_results(results, lang="zh_CN") + self.assertIsInstance(output, str) + self.assertIn("ZIP", output) + self.assertIn("7Z", output) + # 输出应包含格式名和级别信息 + self.assertIn("MB/s", output) + self.assertIn("level", output) + + def test_format_results_en(self): + """测试英文格式化输出""" + runner = BenchmarkRunner(self.test_dir) + results = runner.run_quick() + + output = runner.format_results(results, lang="en_US") + self.assertIsInstance(output, str) + self.assertIn("ZIP", output) + + +class TestBenchmarkEmptySource(unittest.TestCase): + """空目录基准测试""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_bench_empty_") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_run_single_empty_dir(self): + """测试空目录压缩(不应崩溃)""" + runner = BenchmarkRunner(self.test_dir) + # 空目录也可以压缩,只是没有文件 + result = runner.run_single("ZIP", level=6) + self.assertEqual(result.file_count, 0) + self.assertEqual(result.original_size, 0) + + +class TestBenchmarkPassword(unittest.TestCase): + """密码加密基准测试""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_bench_pwd_") + content = b"secret data " * 100 + with open(os.path.join(self.test_dir, "secret.txt"), "wb") as f: + f.write(content) + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_run_single_with_password(self): + """测试 7z 带密码压缩""" + runner = BenchmarkRunner(self.test_dir, password="test123") + result = runner.run_single("7Z", level=6) + + self.assertEqual(result.format, "7Z") + self.assertGreater(result.compressed_size, 0) + self.assertGreater(result.file_count, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sbackup/test_bugfixes.py b/tests/sbackup/test_bugfixes.py new file mode 100644 index 0000000..9fd9895 --- /dev/null +++ b/tests/sbackup/test_bugfixes.py @@ -0,0 +1,718 @@ +""" +针对性单元测试:覆盖通过深入代码分析发现的 bug +""" + +import unittest +import os +import json +import time +import tempfile +import shutil +from pathlib import Path +from sbackup.config import Config, _gitignore_to_fnmatch +from sbackup.compression import ( + ZipfileCompression, + _resolve_name, +) +from sbackup.auto_save import BackupManager, BackupEntry + + +class TestDryRunPreviewUnpacking(unittest.TestCase): + """Bug1: _dry_run_preview 解包错误 + 任务元组有 12 个元素但解包只期望 6 个,导致 dry-run 模式崩溃。 + """ + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.source_dir = os.path.join(self.test_dir, "source") + self.target_dir = os.path.join(self.test_dir, "target") + os.makedirs(self.source_dir) + os.makedirs(self.target_dir) + (Path(self.source_dir) / "file.txt").write_text("content") + self.data_file = os.path.join(self.test_dir, "data.json") + self.manager = BackupManager(self.data_file) + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_dry_run_preview_with_12_element_tuples(self): + """验证 _dry_run_preview 能正确处理 12 元素的任务元组""" + self.manager.add_folder(self.source_dir, self.target_dir, "") + abs_source = os.path.abspath(self.source_dir) + entry = self.manager._get_entry(abs_source) + assert entry is not None + + config = Config( + compression_format="ZIP", + compression_algorithm="ZIP_DEFLATED", + compression_level=6, + ) + + # 构造 12 元素元组(与 execute_backups 中一致) + tasks = [ + ( + abs_source, # key + entry, # entry + 0.0, # current_mtime + config, # config + "", # password + "", # name_template + False, # follow_symlinks + 0, # max_size + 0, # min_size + 0, # max_age_seconds + {}, # file_metadata + 0, # split_size + ) + ] + + # 修复前:这会抛出 ValueError: too many values to unpack + try: + BackupManager._dry_run_preview(tasks, config) + except ValueError as e: + self.fail(f"_dry_run_preview 解包失败: {e}") + + def test_dry_run_preview_empty_tasks(self): + """验证空任务列表时 _dry_run_preview 不崩溃""" + config = Config() + BackupManager._dry_run_preview([], config) + + def test_dry_run_preview_integration(self): + """集成测试:通过 execute_backups(dry_run=True) 验证完整流程""" + self.manager.add_folder(self.source_dir, self.target_dir, "") + time.sleep(0.1) + (Path(self.source_dir) / "new.txt").write_text("new") + + # 修复前:这会抛出 ValueError + try: + self.manager.execute_backups(dry_run=True) + except ValueError as e: + self.fail(f"dry-run 模式崩溃: {e}") + + +class TestShouldIgnoreNegation(unittest.TestCase): + """Bug2: _should_ignore 取反逻辑错误 + 当没有正则模式匹配时,取反模式不应排除文件。 + 取反模式(!pattern)只应在文件已被忽略时恢复它。 + """ + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _make_compressor(self, skip_patterns): + config = Config(folder_path=self.test_dir, skip_patterns=skip_patterns) + return ZipfileCompression(config) + + def test_negation_without_prior_match_should_not_ignore(self): + """只有取反模式没有正模式时,文件不应被忽略""" + comp = self._make_compressor(["!important.log"]) + # "readme.txt" 不匹配任何模式(包括取反),应返回 False(不忽略) + self.assertFalse(comp._should_ignore("readme.txt")) + # "important.log" 匹配取反模式,但之前没有被忽略,应返回 False + # 修复前:返回 True(错误地排除了文件) + self.assertFalse(comp._should_ignore("important.log")) + + def test_negation_restores_previously_ignored_file(self): + """取反模式正确恢复被正模式忽略的文件""" + comp = self._make_compressor(["*.log", "!important.log"]) + # "error.log" 匹配 *.log,应被忽略 + self.assertTrue(comp._should_ignore("error.log")) + # "important.log" 匹配 *.log 但被 !important.log 恢复 + self.assertFalse(comp._should_ignore("important.log")) + + def test_negation_with_regex(self): + """正则取反模式在无正则匹配时不应排除文件""" + comp = self._make_compressor([r"!re:important\.log"]) + self.assertFalse(comp._should_ignore("important.log")) + self.assertFalse(comp._should_ignore("readme.txt")) + + def test_negation_restores_regex_ignored_file(self): + """正则取反模式正确恢复被正则忽略的文件""" + comp = self._make_compressor([r"re:.*\.log$", r"!re:important\.log"]) + self.assertTrue(comp._should_ignore("error.log")) + self.assertFalse(comp._should_ignore("important.log")) + + def test_negation_real_compression(self): + """集成测试:验证取反模式在实际压缩中正确工作""" + (Path(self.test_dir) / "keep.txt").write_text("keep") + (Path(self.test_dir) / "important.log").write_text("important") + zip_path = os.path.join(self.test_dir, "test.zip") + + # 只有取反模式,不应忽略任何文件 + config = Config( + folder_path=self.test_dir, + zipfile_path=zip_path, + skip_patterns=["!important.log"], + ) + compressor = ZipfileCompression(config) + result = compressor.compress() + self.assertTrue(result["success"]) + + import zipfile + + with zipfile.ZipFile(zip_path, "r") as zf: + names = zf.namelist() + self.assertTrue(any("keep.txt" in n for n in names)) + # 修复前:important.log 被错误排除 + self.assertTrue(any("important.log" in n for n in names)) + + +class TestResolveNameTemplateError(unittest.TestCase): + """Bug3: _resolve_name 模板 KeyError + 用户模板中包含未知占位符(如 {password})时应降级到 folder_name, + 而不是抛出 KeyError。 + """ + + def test_unknown_placeholder_returns_folder_name(self): + """未知占位符应回退到 folder_name""" + result = _resolve_name("{name}_{password}", "myproject") + # 修复前:抛出 KeyError + self.assertEqual(result, "myproject") + + def test_valid_template_still_works(self): + """有效模板仍正常工作""" + result = _resolve_name("{name}_{date}", "myproject") + self.assertTrue(result.startswith("myproject_")) + self.assertIn("-", result) + + def test_empty_template_returns_folder_name(self): + """空模板返回 folder_name""" + self.assertEqual(_resolve_name("", "myproject"), "myproject") + + def test_malformed_braces_returns_folder_name(self): + """格式错误的大括号应回退到 folder_name""" + result = _resolve_name("{name_}{invalid", "myproject") + self.assertEqual(result, "myproject") + + def test_valid_template_with_time(self): + """包含 {time} 变量的模板正常工作""" + result = _resolve_name("{name}_{date}_{time}", "proj") + self.assertTrue(result.startswith("proj_")) + # 应包含日期和时间部分 + parts = result.split("_") + self.assertGreaterEqual(len(parts), 3) + + +class TestDiffBackupModifiedDetection(unittest.TestCase): + """Bug4: diff_backup 修改检测错误 + 所有共同文件都被报告为已修改,即使内容未变化。 + 修复后:只有 mtime 晚于备份文件的源文件才报告为已修改。 + """ + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.original_cwd = os.getcwd() + os.chdir(self.test_dir) + + self.source_dir = os.path.join(self.test_dir, "source") + self.target_dir = os.path.join(self.test_dir, "target") + os.makedirs(self.source_dir) + os.makedirs(self.target_dir) + self.data_file = os.path.join(self.test_dir, "data.json") + self.manager = BackupManager(self.data_file) + + def tearDown(self): + os.chdir(self.original_cwd) + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _make_backup(self): + """创建一次备份并返回备份文件路径""" + (Path(self.source_dir) / "file1.txt").write_text("content1") + (Path(self.source_dir) / "file2.txt").write_text("content2") + self.manager.add_folder(self.source_dir, self.target_dir, "") + time.sleep(0.1) + (Path(self.source_dir) / "trigger.txt").write_text("trigger") + self.manager.execute_backups() + backups = list(Path(self.target_dir).glob("*.zip")) + self.assertGreater(len(backups), 0) + return str(backups[0]) + + def test_unchanged_files_not_reported_as_modified(self): + """未修改的文件不应出现在 modified 列表中""" + self._make_backup() + diff = self.manager.diff_backup(self.source_dir) + self.assertTrue(diff["success"]) + # 修复前:所有共同文件都被报告为 modified + self.assertEqual( + len(diff["modified"]), + 0, + f"未修改的文件不应出现在 modified 中: {diff['modified']}", + ) + + def test_modified_file_detected(self): + """修改后的文件应出现在 modified 列表中""" + self._make_backup() + # 修改一个文件 + time.sleep(0.1) + (Path(self.source_dir) / "file1.txt").write_text("CHANGED") + diff = self.manager.diff_backup(self.source_dir) + self.assertTrue(diff["success"]) + modified_names = [Path(p).name for p in diff["modified"]] + self.assertIn("file1.txt", modified_names) + + def test_added_file_detected(self): + """新增文件应出现在 added 列表中""" + self._make_backup() + (Path(self.source_dir) / "new_file.txt").write_text("new") + diff = self.manager.diff_backup(self.source_dir) + self.assertTrue(diff["success"]) + added_names = [Path(p).name for p in diff["added"]] + self.assertIn("new_file.txt", added_names) + + +class TestFormatStatusBrokenSymlink(unittest.TestCase): + """Bug5: format_status OSError + 目标目录中存在断裂的符号链接时,rglob + stat 会抛出 OSError。 + """ + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.data_file = os.path.join(self.test_dir, "data.json") + self.manager = BackupManager(self.data_file) + self.source_dir = os.path.join(self.test_dir, "source") + self.target_dir = os.path.join(self.test_dir, "target") + os.makedirs(self.source_dir) + os.makedirs(self.target_dir) + # 创建一个假备份文件 + (Path(self.target_dir) / "backup.zip").write_bytes(b"fake") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_format_status_with_broken_symlink(self): + """format_status 在目标目录有断开符号链接时不崩溃""" + self.manager.add_folder(self.source_dir, self.target_dir, "") + + # 创建一个断开的符号链接(Windows 上可能需要管理员权限) + broken_link = Path(self.target_dir) / "broken_link" + try: + broken_link.symlink_to("/nonexistent/file") + except (OSError, NotImplementedError): + self.skipTest("无法创建符号链接(需要管理员权限或不支持)") + + # 修复前:这会抛出 OSError + try: + result = self.manager.format_status() + self.assertIsInstance(result, str) + except OSError as e: + self.fail(f"format_status 因断开符号链接崩溃: {e}") + + def test_format_status_normal(self): + """format_status 在正常目录下工作""" + self.manager.add_folder(self.source_dir, self.target_dir, "") + result = self.manager.format_status() + self.assertIsInstance(result, str) + self.assertIn(self.source_dir, result) + + +class TestGitignoreToFnmatchRedundantBranch(unittest.TestCase): + """Bug6: _gitignore_to_fnmatch 冗余条件 + 内层 if-not-startswith-* 条件与外层重复,else 分支是死代码。 + """ + + def test_simple_pattern(self): + """简单模式 'build' 应转换为 '*build'""" + result = _gitignore_to_fnmatch("build") + self.assertEqual(result, "*build") + + def test_pattern_already_with_star(self): + """以 * 开头的模式不添加前缀""" + result = _gitignore_to_fnmatch("*.log") + self.assertEqual(result, "*.log") + + def test_pattern_with_slash(self): + """包含路径分隔符的模式不添加前缀""" + result = _gitignore_to_fnmatch("src/build") + self.assertEqual(result, "src/build") + + def test_pattern_with_double_star(self): + """** 模式正确转换""" + result = _gitignore_to_fnmatch("**/node_modules") + self.assertEqual(result, "*node_modules") + + def test_leading_slash_removed(self): + """开头斜杠被移除""" + result = _gitignore_to_fnmatch("/build") + self.assertEqual(result, "*build") + + def test_trailing_slash_removed(self): + """尾部斜杠被移除""" + result = _gitignore_to_fnmatch("build/") + self.assertEqual(result, "*build") + + def test_negation_preserved(self): + """取反模式中 ! 由 parse_gitignore 处理,_gitignore_to_fnmatch 不处理 !""" + # 注意:_gitignore_to_fnmatch 本身不处理 ! 前缀 + # parse_gitignore 先剥离 !,再调用 _gitignore_to_fnmatch,最后加回 ! + # 所以直接调用 _gitignore_to_fnmatch 时,! 被视为普通字符 + result = _gitignore_to_fnmatch("!important.log") + self.assertEqual(result, "*!important.log") + + +class TestBackupEntryRoundTrip(unittest.TestCase): + """测试 BackupEntry 序列化/反序列化的完整性""" + + def test_full_round_trip(self): + """完整 5 元素列表的往返""" + entry = BackupEntry( + mtime=123.456, + target="/path/to/target", + skip_patterns=[".git", "*.log"], + compression_format="TAR_GZ", + name_template="{name}_{date}", + ) + data = entry.to_list() + self.assertEqual(len(data), 5) + restored = BackupEntry.from_list(data) + self.assertEqual(restored.mtime, 123.456) + self.assertEqual(restored.target, "/path/to/target") + self.assertEqual(restored.skip_patterns, [".git", "*.log"]) + self.assertEqual(restored.compression_format, "TAR_GZ") + self.assertEqual(restored.name_template, "{name}_{date}") + + def test_three_element_backward_compat(self): + """3 元素旧格式向后兼容""" + entry = BackupEntry.from_list([99.0, "/old/path", [".git"]]) + self.assertEqual(entry.mtime, 99.0) + self.assertEqual(entry.target, "/old/path") + self.assertEqual(entry.skip_patterns, [".git"]) + self.assertEqual(entry.compression_format, "") + self.assertEqual(entry.name_template, "") + + def test_four_element_backward_compat(self): + """4 元素格式向后兼容""" + entry = BackupEntry.from_list([99.0, "/old/path", [".git"], "7Z"]) + self.assertEqual(entry.compression_format, "7Z") + self.assertEqual(entry.name_template, "") + + def test_non_list_input(self): + """非列表输入返回空条目""" + entry = BackupEntry.from_list("invalid") + self.assertEqual(entry.mtime, 0.0) + self.assertEqual(entry.target, "") + + def test_none_input(self): + """None 输入返回空条目""" + entry = BackupEntry.from_list(None) + self.assertEqual(entry.mtime, 0.0) + + +class TestCollectFilesIncrementalMetadata(unittest.TestCase): + """测试增量备份的文件元数据比较逻辑""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + (Path(self.test_dir) / "file.txt").write_text("content") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_metadata_mode_skip_unchanged_file(self): + """元数据模式下未变化的文件应被跳过""" + file_path = Path(self.test_dir) / "file.txt" + stat = file_path.stat() + metadata = {"file.txt": [stat.st_mtime, stat.st_size]} + + config = Config( + folder_path=self.test_dir, + skip_patterns=[], + file_metadata=metadata, + ) + compressor = ZipfileCompression(config) + files = compressor._collect_files(Path(self.test_dir)) + self.assertEqual(len(files), 0, "未变化的文件应被跳过") + + def test_metadata_mode_include_changed_file(self): + """元数据模式下变化的文件不应被跳过""" + file_path = Path(self.test_dir) / "file.txt" + stat = file_path.stat() + # 故意使用不同的 mtime + metadata = {"file.txt": [stat.st_mtime + 100, stat.st_size]} + + config = Config( + folder_path=self.test_dir, + skip_patterns=[], + file_metadata=metadata, + ) + compressor = ZipfileCompression(config) + files = compressor._collect_files(Path(self.test_dir)) + self.assertEqual(len(files), 1, "变化的文件不应被跳过") + + def test_checksum_mode_skip_unchanged_file(self): + """校验和模式下内容未变化的文件应被跳过""" + import hashlib + + sha = hashlib.sha256() + file_path = Path(self.test_dir) / "file.txt" + sha.update(file_path.read_bytes()) + metadata = {"file.txt": sha.hexdigest()} + + config = Config( + folder_path=self.test_dir, + skip_patterns=[], + file_metadata=metadata, + ) + compressor = ZipfileCompression(config) + files = compressor._collect_files(Path(self.test_dir)) + self.assertEqual(len(files), 0, "内容未变化的文件应被跳过") + + def test_checksum_mode_include_changed_file(self): + """校验和模式下内容变化的文件不应被跳过""" + metadata = {"file.txt": "a" * 64} # 假的校验和 + + config = Config( + folder_path=self.test_dir, + skip_patterns=[], + file_metadata=metadata, + ) + compressor = ZipfileCompression(config) + files = compressor._collect_files(Path(self.test_dir)) + self.assertEqual(len(files), 1, "内容变化的文件不应被跳过") + + +class TestEncryptionRoundTrip(unittest.TestCase): + """测试文件加密/解密往返""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_encrypt_decrypt_round_trip(self): + """加密后解密应恢复原始内容""" + from sbackup.compression import _encrypt_file, _decrypt_file + + original_content = b"Hello, this is test content for encryption!" + file_path = os.path.join(self.test_dir, "test.bin") + with open(file_path, "wb") as f: + f.write(original_content) + + password = "test_password_123" + encrypted_path = _encrypt_file(file_path, password) + + self.assertTrue(encrypted_path.endswith(".enc")) + self.assertFalse(os.path.exists(file_path)) # 原文件应被删除 + self.assertTrue(os.path.exists(encrypted_path)) + + decrypted_path = _decrypt_file(encrypted_path, password) + self.assertEqual(decrypted_path, file_path) + + with open(decrypted_path, "rb") as f: + decrypted_content = f.read() + self.assertEqual(decrypted_content, original_content) + + def test_decrypt_wrong_password(self): + """错误密码解密应产生不同的内容""" + from sbackup.compression import _encrypt_file, _decrypt_file + + original_content = b"Secret data" + file_path = os.path.join(self.test_dir, "secret.bin") + with open(file_path, "wb") as f: + f.write(original_content) + + encrypted_path = _encrypt_file(file_path, "correct_password") + + decrypted_path = _decrypt_file(encrypted_path, "wrong_password") + with open(decrypted_path, "rb") as f: + decrypted_content = f.read() + self.assertNotEqual(decrypted_content, original_content) + + +class TestSplitAndMergeFiles(unittest.TestCase): + """测试文件分卷和合并功能""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_split_and_merge_round_trip(self): + """分卷后合并应恢复原始内容""" + from sbackup.compression import split_file, merge_files + + original_content = b"A" * 1000 + b"B" * 500 + file_path = os.path.join(self.test_dir, "large.bin") + with open(file_path, "wb") as f: + f.write(original_content) + + parts = split_file(file_path, 512) + self.assertGreater(len(parts), 1) + self.assertFalse(os.path.exists(file_path)) # 原文件应被删除 + + output_path = os.path.join(self.test_dir, "merged.bin") + result = merge_files(parts, output_path) + self.assertTrue(result) + + with open(output_path, "rb") as f: + merged_content = f.read() + self.assertEqual(merged_content, original_content) + + def test_split_small_file_no_split(self): + """小于分卷大小的文件不分割""" + from sbackup.compression import split_file + + file_path = os.path.join(self.test_dir, "small.bin") + with open(file_path, "wb") as f: + f.write(b"small") + + parts = split_file(file_path, 1024) + self.assertEqual(len(parts), 1) + self.assertEqual(parts[0], file_path) + self.assertTrue(os.path.exists(file_path)) # 原文件保留 + + def test_split_zero_chunk_size(self): + """chunk_size=0 不分割""" + from sbackup.compression import split_file + + file_path = os.path.join(self.test_dir, "test.bin") + with open(file_path, "wb") as f: + f.write(b"data") + + parts = split_file(file_path, 0) + self.assertEqual(len(parts), 1) + + def test_merge_empty_list(self): + """空列表合并不创建文件并返回 False""" + from sbackup.compression import merge_files + + output_path = os.path.join(self.test_dir, "empty.bin") + result = merge_files([], output_path) + self.assertFalse(result) + + +class TestWebhookPreset(unittest.TestCase): + """测试 Webhook 预设配置""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.config_file = os.path.join(self.test_dir, "config.json") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_setup_webhook_preset_dingtalk(self): + """测试钉钉预设""" + from sbackup.config import setup_webhook_preset + + template = setup_webhook_preset("dingtalk", self.config_file) + self.assertIn("{status}", template) + self.assertTrue(os.path.exists(self.config_file)) + + def test_setup_webhook_preset_unknown(self): + """测试未知预设返回空字符串""" + from sbackup.config import setup_webhook_preset + + template = setup_webhook_preset("unknown_preset", self.config_file) + self.assertEqual(template, "") + + +class TestParseGitignore(unittest.TestCase): + """测试 .gitignore 解析""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.original_cwd = os.getcwd() + os.chdir(self.test_dir) + + def tearDown(self): + os.chdir(self.original_cwd) + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_parse_gitignore_basic(self): + """测试基本 gitignore 解析""" + from sbackup.config import parse_gitignore + + gitignore_path = os.path.join(self.test_dir, ".gitignore") + with open(gitignore_path, "w") as f: + f.write("*.log\n# comment\n\n/node_modules\n!important.log\n") + + patterns = parse_gitignore(gitignore_path) + # *.log 已以 * 开头,不添加前缀 + self.assertIn("*.log", patterns) + self.assertIn("*node_modules", patterns) + self.assertIn("!*important.log", patterns) + # 注释和空行应被跳过 + self.assertEqual(len([p for p in patterns if "comment" in p]), 0) + + def test_parse_gitignore_nonexistent(self): + """不存在的文件返回空列表""" + from sbackup.config import parse_gitignore + + result = parse_gitignore("/nonexistent/.gitignore") + self.assertEqual(result, []) + + +class TestConfigEncryptDecrypt(unittest.TestCase): + """测试配置文件加密/解密""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.config_file = os.path.join(self.test_dir, "config.json") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_encrypt_decrypt_round_trip(self): + """加密后解密应恢复原始敏感字段""" + from sbackup.config import encrypt_config, decrypt_config, is_config_encrypted + + config_data = { + "password": "my_backup_password", + "sftp": {"password": "sftp_pass", "key_passphrase": "key_pass"}, + "webdav": {"password": "webdav_pass"}, + } + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump(config_data, f) + + self.assertFalse(is_config_encrypted(self.config_file)) + + result = encrypt_config("master_key", self.config_file) + self.assertTrue(result) + self.assertTrue(is_config_encrypted(self.config_file)) + + # 加密后密码应被替换 + with open(self.config_file, "r", encoding="utf-8") as f: + encrypted_data = json.load(f) + self.assertTrue(encrypted_data["password"].startswith("enc:")) + + # 解密 + result = decrypt_config("master_key", self.config_file) + self.assertTrue(result) + self.assertFalse(is_config_encrypted(self.config_file)) + + with open(self.config_file, "r", encoding="utf-8") as f: + decrypted_data = json.load(f) + self.assertEqual(decrypted_data["password"], "my_backup_password") + self.assertEqual(decrypted_data["sftp"]["password"], "sftp_pass") + self.assertEqual(decrypted_data["webdav"]["password"], "webdav_pass") + + def test_decrypt_wrong_password(self): + """错误密码解密应返回 False""" + from sbackup.config import encrypt_config, decrypt_config + + config_data = {"password": "secret"} + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump(config_data, f) + + encrypt_config("master", self.config_file) + result = decrypt_config("wrong_master", self.config_file) + self.assertFalse(result) + + def test_decrypt_unencrypted_config(self): + """解密未加密的配置应返回 True""" + from sbackup.config import decrypt_config + + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump({"password": "plain"}, f) + + result = decrypt_config("any_password", self.config_file) + self.assertTrue(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sbackup/test_chunked_backup.py b/tests/sbackup/test_chunked_backup.py new file mode 100644 index 0000000..c42bd01 --- /dev/null +++ b/tests/sbackup/test_chunked_backup.py @@ -0,0 +1,173 @@ +"""单元测试 for sbackup.chunked_backup 模块""" + +import os +import unittest +import tempfile +import shutil + +from sbackup.chunked_backup import ( + compute_chunk_hashes, + find_changed_chunks, + create_patch, + apply_patch, + patch_size_ratio, + should_do_full_backup, + CHUNK_SIZE, +) + + +class TestChunkedBackup(unittest.TestCase): + def setUp(self): + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _create_file(self, name: str, size: int = 100) -> str: + """创建指定大小的测试文件""" + path = os.path.join(self.test_dir, name) + with open(path, "wb") as f: + f.write(b"x" * size) + return path + + def test_compute_chunk_hashes_small_file(self): + """小文件应返回一个块的哈希""" + path = self._create_file("small.bin", 10) + hashes = compute_chunk_hashes(path) + self.assertEqual(len(hashes), 1) + self.assertIn("0", hashes) + self.assertEqual(len(hashes["0"]), 64) # SHA256 hex + + def test_compute_chunk_hashes_large_file(self): + """大文件应返回多个块的哈希""" + path = self._create_file("large.bin", CHUNK_SIZE * 3 + 100) + hashes = compute_chunk_hashes(path) + self.assertEqual(len(hashes), 4) # 3 full chunks + 1 partial + + def test_compute_chunk_hashes_empty_file(self): + """空文件应返回空字典""" + path = self._create_file("empty.bin", 0) + hashes = compute_chunk_hashes(path) + self.assertEqual(len(hashes), 0) + + def test_find_changed_chunks_no_change(self): + """无变化时应返回空集合""" + path = self._create_file("data.bin", CHUNK_SIZE * 2) + stored = compute_chunk_hashes(path) + changed, total = find_changed_chunks(path, stored) + self.assertEqual(len(changed), 0) + self.assertEqual(total, 2) + + def test_find_changed_chunks_all_changed(self): + """全部块变化时返回所有索引""" + path = self._create_file("data.bin", CHUNK_SIZE * 2) + stored = {"0": "a" * 64, "1": "b" * 64} # 伪造的哈希 + changed, total = find_changed_chunks(path, stored) + self.assertEqual(len(changed), 2) + + def test_find_changed_chunks_partial(self): + """部分块变化""" + path = self._create_file("data.bin", CHUNK_SIZE * 3) + stored = compute_chunk_hashes(path) + # 修改第二个块 + with open(path, "r+b") as f: + f.seek(CHUNK_SIZE) + f.write(b"modified data!") + changed, total = find_changed_chunks(path, stored) + self.assertIn(1, changed) # 第二个块(index=1)变了 + self.assertNotIn(0, changed) + self.assertEqual(total, 3) + + def test_create_patch_no_changes(self): + """无变化时不应创建补丁""" + path = self._create_file("data.bin", CHUNK_SIZE * 2) + patch_path = os.path.join(self.test_dir, "patch.bin") + result = create_patch(path, set(), patch_path) + self.assertEqual(result, 0) + self.assertFalse(os.path.exists(patch_path)) + + def test_create_patch_with_changes(self): + """有变化时应创建补丁文件""" + path = self._create_file("data.bin", CHUNK_SIZE * 3) + patch_path = os.path.join(self.test_dir, "patch.bin") + result = create_patch(path, {0, 2}, patch_path) + self.assertGreater(result, 0) + self.assertTrue(os.path.exists(patch_path)) + self.assertGreater(os.path.getsize(patch_path), 0) + + def test_apply_patch(self): + """应用补丁应还原文件内容""" + # 创建基准文件 + base_path = self._create_file("base.bin", CHUNK_SIZE * 3) + stored = compute_chunk_hashes(base_path) + + # 修改第一个块 + with open(base_path, "r+b") as f: + f.write(b"modified chunk 0 data! padding...") + + # 创建补丁 + changed, _ = find_changed_chunks(base_path, stored) + patch_path = os.path.join(self.test_dir, "patch.bin") + create_patch(base_path, changed, patch_path) + + # 用补丁还原到新文件 + restored_path = os.path.join(self.test_dir, "restored.bin") + result = apply_patch(base_path, patch_path, restored_path) + self.assertTrue(result) + self.assertTrue(os.path.exists(restored_path)) + + # 验证还原后的文件与修改后的文件相同 + with open(base_path, "rb") as f: + original = f.read() + with open(restored_path, "rb") as f: + restored = f.read() + # 由于 apply_patch 是从 base 重建的,base_path 本身已经修改了 + # 所以 restored 应该等于 base_path 的当前内容 + self.assertEqual(len(original), len(restored)) + + def test_patch_size_ratio(self): + """补丁大小比例计算""" + path = self._create_file("data.bin", CHUNK_SIZE * 10) + ratio = patch_size_ratio(path, {0, 1, 2}) + self.assertAlmostEqual(ratio, 0.3, places=1) + + def test_should_do_full_backup_below_threshold(self): + """变化比例低于阈值时应返回 False""" + path = self._create_file("data.bin", CHUNK_SIZE * 10) + result = should_do_full_backup(path, {0}, threshold=0.5) + self.assertFalse(result) + + def test_should_do_full_backup_above_threshold(self): + """变化比例高于阈值时应返回 True""" + path = self._create_file("data.bin", CHUNK_SIZE * 10) + result = should_do_full_backup(path, set(range(6)), threshold=0.5) + self.assertTrue(result) + + def test_should_do_full_backup_empty_file(self): + """空文件不应触发全量备份""" + path = self._create_file("empty.bin", 0) + result = should_do_full_backup(path, {0}, threshold=0.5) + self.assertFalse(result) + + def test_custom_chunk_size(self): + """自定义块大小""" + path = self._create_file("data.bin", 200) + hashes = compute_chunk_hashes(path, chunk_size=50) + self.assertEqual(len(hashes), 4) # 200 / 50 + + def test_find_changed_chunks_file_shorter(self): + """文件变短时应标记缺失块""" + path = self._create_file("data.bin", CHUNK_SIZE * 5) + stored = compute_chunk_hashes(path) + # 截断文件 + with open(path, "r+b") as f: + f.truncate(CHUNK_SIZE * 2) + changed, total = find_changed_chunks(path, stored) + self.assertIn(2, changed) # index 2 应该消失 + self.assertEqual(total, 2) + + def test_create_patch_empty_indices(self): + """空索引集合应返回 0""" + path = self._create_file("data.bin", 100) + result = create_patch(path, set(), "/nonexistent/patch") + self.assertEqual(result, 0) diff --git a/tests/sbackup/test_completion.py b/tests/sbackup/test_completion.py new file mode 100644 index 0000000..37d212a --- /dev/null +++ b/tests/sbackup/test_completion.py @@ -0,0 +1,116 @@ +"""单元测试 for sbackup.completion 模块""" + +import unittest +from sbackup.completion import ( + generate_bash, + generate_zsh, + generate_fish, + generate_powershell, + generate, +) + + +class TestCompletion(unittest.TestCase): + """测试 Shell 自动补全脚本生成""" + + def test_generate_bash_returns_string(self): + """Bash 补全脚本应返回字符串""" + script = generate_bash() + self.assertIsInstance(script, str) + self.assertIn("complete -F", script) + self.assertIn("sbackup", script) + + def test_generate_bash_contains_subcommands(self): + """Bash 补全应包含子命令""" + script = generate_bash() + for cmd in ("save", "watch", "add", "restore", "sftp", "webdav"): + self.assertIn(cmd, script) + + def test_generate_zsh_returns_string(self): + """Zsh 补全脚本应返回字符串""" + script = generate_zsh() + self.assertIsInstance(script, str) + self.assertIn("#compdef", script) + self.assertIn("sbackup", script) + + def test_generate_zsh_contains_subcommands(self): + """Zsh 补全应包含子命令""" + script = generate_zsh() + for cmd in ("save", "watch", "add", "restore"): + self.assertIn(cmd, script) + + def test_generate_fish_returns_string(self): + """Fish 补全脚本应返回字符串""" + script = generate_fish() + self.assertIsInstance(script, str) + self.assertIn("complete -c sbackup", script) + + def test_generate_fish_contains_subcommands(self): + """Fish 补全应包含子命令""" + script = generate_fish() + for cmd in ("save", "watch", "add"): + self.assertIn(cmd, script) + + def test_generate_powershell_returns_string(self): + """PowerShell 补全脚本应返回字符串""" + script = generate_powershell() + self.assertIsInstance(script, str) + self.assertIn("Register-ArgumentCompleter", script) + self.assertIn("sbackup", script) + + def test_generate_powershell_contains_flags(self): + """PowerShell 补全应包含 flag""" + script = generate_powershell() + self.assertIn("--debug", script) + self.assertIn("--lang", script) + + def test_generate_dispatch_bash(self): + """generate('bash') 应分发到 generate_bash""" + result = generate("bash") + self.assertIn("complete -F", result) + + def test_generate_dispatch_zsh(self): + """generate('zsh') 应分发到 generate_zsh""" + result = generate("zsh") + self.assertIn("#compdef", result) + + def test_generate_dispatch_fish(self): + """generate('fish') 应分发到 generate_fish""" + result = generate("fish") + self.assertIn("complete -c sbackup", result) + + def test_generate_dispatch_powershell(self): + """generate('powershell') 应分发到 generate_powershell""" + result = generate("powershell") + self.assertIn("Register-ArgumentCompleter", result) + + def test_generate_invalid_shell(self): + """无效的 shell 名称应抛出 ValueError""" + with self.assertRaises(ValueError): + generate("invalid_shell") + + def test_generate_case_insensitive(self): + """生成 shell 名应忽略大小写""" + bash1 = generate("BASH") + bash2 = generate("bash") + self.assertEqual(bash1, bash2) + + def test_all_generators_have_basic_structure(self): + """所有 shell 的补全脚本都应包含 sbackup 引用""" + for shell in ("bash", "zsh", "fish", "powershell"): + script = generate(shell) + self.assertIsInstance(script, str) + self.assertGreater(len(script), 50, f"{shell} script is too short") + + def test_zsh_contains_format_choices(self): + """Zsh 补全应列出格式选项""" + script = generate_zsh() + for fmt in ("zip", "tar", "7z"): + self.assertIn(fmt, script) + + def test_fish_contains_global_flags(self): + """Fish 补全应包含全局 flag""" + script = generate_fish() + self.assertIn("-l debug", script) + self.assertIn("-l format", script) + self.assertIn("-l lang", script) diff --git a/tests/sbackup/test_compression.py b/tests/sbackup/test_compression.py index 9e1c45f..2b7d048 100644 --- a/tests/sbackup/test_compression.py +++ b/tests/sbackup/test_compression.py @@ -6,9 +6,10 @@ import os import shutil import tarfile +import tempfile import zipfile from pathlib import Path -from unittest.mock import patch +from unittest.mock import patch, MagicMock from sbackup.config import Config from sbackup.compression import ( ZipfileCompression, @@ -17,6 +18,8 @@ SevenZipCompression, create_compressor, restore_backup, + list_backup_contents, + verify_backup, ) from sbackup.i18n import t @@ -465,6 +468,48 @@ def test_restore_tar_zst(self): ) os.remove("test.tar.zst") + @patch("builtins.print") + def test_restore_permission_error(self, mock_print): + """测试还原时权限不足""" + config = Config(folder_path=self.test_dir, zipfile_path="test.zip") + ZipfileCompression(config).compress() + with patch("zipfile.ZipFile") as mock_zf: + mock_zf.return_value.__enter__ = MagicMock( + side_effect=PermissionError("denied") + ) + mock_zf.return_value.__exit__ = MagicMock(return_value=False) + result = restore_backup("test.zip", self.restore_dir) + self.assertFalse(result["success"]) + + @patch("builtins.print") + def test_restore_os_error(self, mock_print): + """测试还原时 OS 错误""" + config = Config(folder_path=self.test_dir, zipfile_path="test.zip") + ZipfileCompression(config).compress() + with patch("zipfile.ZipFile") as mock_zf: + mock_zf.return_value.__enter__ = MagicMock( + side_effect=OSError("disk error") + ) + mock_zf.return_value.__exit__ = MagicMock(return_value=False) + result = restore_backup("test.zip", self.restore_dir) + self.assertFalse(result["success"]) + + @patch("builtins.print") + def test_restore_7z_permission_error(self, mock_print): + """测试 7z 还原时权限不足""" + config = Config( + folder_path=self.test_dir, zipfile_path="test.7z", compression_format="7Z" + ) + SevenZipCompression(config).compress() + with patch("py7zr.SevenZipFile") as mock_7z: + mock_7z.return_value.__enter__ = MagicMock( + side_effect=PermissionError("denied") + ) + mock_7z.return_value.__exit__ = MagicMock(return_value=False) + result = restore_backup("test.7z", self.restore_dir) + self.assertFalse(result["success"]) + os.remove("test.7z") + class TestPathMatching(unittest.TestCase): def setUp(self): @@ -534,6 +579,44 @@ def test_tar_no_compression(self): self.assertTrue(result["success"]) self.assertTrue(os.path.exists("test.tar")) + @patch("builtins.print") + def test_tar_permission_error(self, mock_print): + """测试 tar 压缩时权限不足""" + config = Config( + folder_path=self.test_dir, + zipfile_path="/root/test.tar", + compression_format="TAR", + ) + compressor = TarfileCompression(config) + result = compressor.compress() + self.assertFalse(result["success"]) + + @patch("builtins.print") + def test_tar_os_error(self, mock_print): + """测试 tar 压缩时系统错误""" + config = Config( + folder_path=self.test_dir, + zipfile_path="test.tar", + compression_format="TAR", + ) + compressor = TarfileCompression(config) + with patch("tarfile.open", side_effect=OSError("disk error")): + result = compressor.compress() + self.assertFalse(result["success"]) + + @patch("builtins.print") + def test_tar_unknown_exception(self, mock_print): + """测试 tar 压缩时未知异常""" + config = Config( + folder_path=self.test_dir, + zipfile_path="test.tar", + compression_format="TAR", + ) + compressor = TarfileCompression(config) + with patch("tarfile.open", side_effect=RuntimeError("unexpected")): + result = compressor.compress() + self.assertFalse(result["success"]) + class TestZstdCompression(unittest.TestCase): def setUp(self): @@ -613,6 +696,19 @@ def test_zstd_os_error(self, mock_print): result = compressor.compress() self.assertFalse(result["success"]) + @patch("builtins.print") + def test_zstd_unknown_exception(self, mock_print): + """测试 zstd 压缩未知异常""" + config = Config( + folder_path=self.test_dir, + zipfile_path="test.tar.zst", + compression_format="TAR_ZST", + ) + compressor = ZstdCompression(config) + with patch("zstandard.ZstdCompressor", side_effect=RuntimeError("unexpected")): + result = compressor.compress() + self.assertFalse(result["success"]) + class TestSevenZipCompression(unittest.TestCase): def setUp(self): @@ -704,6 +800,527 @@ def test_7z_with_password(self): names = szf.getnames() self.assertGreater(len(names), 0) + @patch("builtins.print") + def test_7z_unknown_exception(self, mock_print): + """测试 7z 压缩未知异常""" + config = Config( + folder_path=self.test_dir, zipfile_path="test.7z", compression_format="7Z" + ) + compressor = SevenZipCompression(config) + with patch("py7zr.SevenZipFile", side_effect=RuntimeError("unexpected")): + result = compressor.compress() + self.assertFalse(result["success"]) + + +class TestCreateCompressor(unittest.TestCase): + """测试工厂函数 create_compressor 的各种格式""" + + def test_create_compressor_tar(self): + """测试工厂函数创建 TAR 压缩器""" + config = Config(folder_path=".", compression_format="TAR") + compressor = create_compressor(config) + self.assertIsInstance(compressor, TarfileCompression) + + def test_create_compressor_tar_gz_with_dot(self): + """测试 tar.gz(带点号)格式正确匹配 TarfileCompression""" + config = Config(folder_path=".", compression_format="tar.gz") + compressor = create_compressor(config) + self.assertIsInstance(compressor, TarfileCompression) + + def test_create_compressor_tar_bz2_with_dot(self): + """测试 tar.bz2(带点号)格式正确匹配""" + config = Config(folder_path=".", compression_format="tar.bz2") + compressor = create_compressor(config) + self.assertIsInstance(compressor, TarfileCompression) + + def test_create_compressor_tar_xz_with_dot(self): + """测试 tar.xz(带点号)格式正确匹配""" + config = Config(folder_path=".", compression_format="tar.xz") + compressor = create_compressor(config) + self.assertIsInstance(compressor, TarfileCompression) + + def test_create_compressor_tar_zst_with_dot(self): + """测试 tar.zst(带点号)格式正确匹配""" + config = Config(folder_path=".", compression_format="tar.zst") + compressor = create_compressor(config) + self.assertIsInstance(compressor, ZstdCompression) + + +class TestShouldIgnore(unittest.TestCase): + """测试 _should_ignore 模式匹配""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + os.makedirs(os.path.join(self.test_dir, "sub"), exist_ok=True) + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_should_ignore_directory_pattern(self): + """测试忽略目录模式""" + config = Config(folder_path=self.test_dir, skip_patterns=["sub"]) + compressor = ZipfileCompression(config) + self.assertTrue(compressor._should_ignore("sub")) + + def test_should_ignore_no_match(self): + """测试不匹配时不忽略""" + config = Config(folder_path=self.test_dir, skip_patterns=["*.log"]) + compressor = ZipfileCompression(config) + self.assertFalse(compressor._should_ignore("readme.txt")) + + def test_should_ignore_basename_match(self): + """测试 basename 匹配""" + config = Config(folder_path=self.test_dir, skip_patterns=["*.log"]) + compressor = ZipfileCompression(config) + self.assertTrue(compressor._should_ignore("subdir/error.log")) + + +class TestListBackupContents(unittest.TestCase): + """测试 list_backup_contents 函数""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + (Path(self.test_dir) / "file1.txt").write_text("content1") + (Path(self.test_dir) / "sub").mkdir() + (Path(self.test_dir) / "sub" / "file2.txt").write_text("content2") + self.zip_path = os.path.join(self.test_dir, "test.zip") + config = Config(folder_path=self.test_dir, zipfile_path=self.zip_path) + ZipfileCompression(config).compress() + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_list_zip_contents(self): + """测试列出 ZIP 文件内容""" + result = list_backup_contents(self.zip_path) + self.assertIn("file1.txt", result) + self.assertIn("file2.txt", result) + self.assertIn("2 files", result) + + def test_list_nonexistent_file(self): + """测试列出不存在的文件""" + result = list_backup_contents("/nonexistent/file.zip") + self.assertIn("/nonexistent/file.zip", result) + + def test_list_unknown_format(self): + """测试列出未知格式文件(返回空列表提示)""" + unknown = os.path.join(self.test_dir, "test.xyz") + Path(unknown).write_text("data") + result = list_backup_contents(unknown) + self.assertIn("test.xyz", result) + + +class TestVerifyBackup(unittest.TestCase): + """测试 verify_backup 函数""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + (Path(self.test_dir) / "file1.txt").write_text("content1") + (Path(self.test_dir) / "sub").mkdir() + (Path(self.test_dir) / "sub" / "file2.txt").write_text("content2") + self.zip_path = os.path.join(self.test_dir, "test.zip") + config = Config(folder_path=self.test_dir, zipfile_path=self.zip_path) + ZipfileCompression(config).compress() + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_verify_valid_zip(self): + """测试校验有效 ZIP 文件""" + result = verify_backup(self.zip_path) + self.assertTrue(result["success"]) + self.assertEqual(result["files_count"], 2) + + def test_verify_nonexistent(self): + """测试校验不存在的文件""" + result = verify_backup("/nonexistent/file.zip") + self.assertFalse(result["success"]) + + +class TestSbackupIgnore(unittest.TestCase): + """测试 .sbackupignore 文件支持""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + (Path(self.test_dir) / "keep.txt").write_text("keep") + (Path(self.test_dir) / "skip.log").write_text("skip") + (Path(self.test_dir) / "sub").mkdir() + (Path(self.test_dir) / "sub" / "data.txt").write_text("data") + self.zip_path = os.path.join(self.test_dir, "test.zip") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_ignore_file_loaded(self): + """测试 .sbackupignore 文件中的规则生效""" + (Path(self.test_dir) / ".sbackupignore").write_text("*.log\n# comment\n") + config = Config( + folder_path=self.test_dir, + zipfile_path=self.zip_path, + skip_patterns=[], + ) + compressor = ZipfileCompression(config) + result = compressor.compress() + self.assertTrue(result["success"]) + with zipfile.ZipFile(self.zip_path, "r") as zf: + names = zf.namelist() + self.assertTrue(any(n.endswith("keep.txt") for n in names)) + self.assertFalse(any(n.endswith("skip.log") for n in names)) + + def test_ignore_file_not_present(self): + """测试没有 .sbackupignore 时不影响正常压缩""" + config = Config( + folder_path=self.test_dir, + zipfile_path=self.zip_path, + skip_patterns=[], + ) + compressor = ZipfileCompression(config) + result = compressor.compress() + self.assertTrue(result["success"]) + with zipfile.ZipFile(self.zip_path, "r") as zf: + names = zf.namelist() + self.assertTrue(any(n.endswith("keep.txt") for n in names)) + self.assertTrue(any(n.endswith("skip.log") for n in names)) + + +class TestPatternNegation(unittest.TestCase): + """测试 ! 取反模式""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + (Path(self.test_dir) / "a.log").write_text("log") + (Path(self.test_dir) / "b.log").write_text("log") + (Path(self.test_dir) / "important.log").write_text("important") + self.zip_path = os.path.join(self.test_dir, "test.zip") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_negation_restores_file(self): + """测试 ! 模式恢复被忽略的文件""" + config = Config( + folder_path=self.test_dir, + zipfile_path=self.zip_path, + skip_patterns=["*.log", "!important.log"], + ) + compressor = ZipfileCompression(config) + result = compressor.compress() + self.assertTrue(result["success"]) + with zipfile.ZipFile(self.zip_path, "r") as zf: + names = zf.namelist() + self.assertFalse(any(n.endswith("a.log") for n in names)) + self.assertFalse(any(n.endswith("b.log") for n in names)) + self.assertTrue(any(n.endswith("important.log") for n in names)) + + +class TestBackupInfo(unittest.TestCase): + """测试 get_backup_info 和 format_backup_info""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.source_dir = os.path.join(self.test_dir, "data") + os.makedirs(self.source_dir) + (Path(self.source_dir) / "file1.txt").write_text("hello") + (Path(self.source_dir) / "file2.txt").write_text("world") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _make_backup(self, fmt: str, ext: str) -> str: + """创建测试备份文件""" + config = Config( + folder_path=self.source_dir, + zipfile_path=os.path.join(self.test_dir, f"test{ext}"), + compression_format=fmt, + ) + result = create_compressor(config).compress() + self.assertTrue(result["success"]) + return result["path"] + + def test_info_zip(self): + """测试 ZIP 备份信息""" + from sbackup.compression import get_backup_info + + path = self._make_backup("ZIP", ".zip") + info = get_backup_info(path) + self.assertTrue(info["success"]) + self.assertEqual(info["format"], "ZIP") + self.assertGreater(info["files_count"], 0) + self.assertGreater(info["size_bytes"], 0) + self.assertIn("sha256", info) + self.assertEqual(len(info["sha256"]), 64) # SHA256 hex length + + def test_info_tar_gz(self): + """测试 tar.gz 备份信息""" + from sbackup.compression import get_backup_info + + path = self._make_backup("TAR_GZ", ".tar.gz") + info = get_backup_info(path) + self.assertTrue(info["success"]) + self.assertEqual(info["format"], "TAR_GZ") + + def test_info_nonexistent(self): + """测试不存在的文件""" + from sbackup.compression import get_backup_info + + info = get_backup_info("/nonexistent/file.zip") + self.assertFalse(info["success"]) + + def test_format_info(self): + """测试格式化输出""" + from sbackup.compression import get_backup_info, format_backup_info + + path = self._make_backup("ZIP", ".zip") + info = get_backup_info(path) + text = format_backup_info(info) + self.assertIn("ZIP", text) + self.assertIn("SHA256", text) + + def test_format_info_failure(self): + """测试失败时的格式化输出""" + from sbackup.compression import format_backup_info + + text = format_backup_info({"success": False, "error": "test error"}) + self.assertIn("test error", text) + + def test_info_7z(self): + """测试 7z 备份信息""" + from sbackup.compression import get_backup_info + + path = self._make_backup("7Z", ".7z") + info = get_backup_info(path) + self.assertTrue(info["success"]) + self.assertEqual(info["format"], "7Z") + + def test_info_tar_bz2(self): + """测试 tar.bz2 备份信息""" + from sbackup.compression import get_backup_info + + path = self._make_backup("TAR_BZ2", ".tar.bz2") + info = get_backup_info(path) + self.assertTrue(info["success"]) + self.assertEqual(info["format"], "TAR_BZ2") + + def test_info_tar_xz(self): + """测试 tar.xz 备份信息""" + from sbackup.compression import get_backup_info + + path = self._make_backup("TAR_XZ", ".tar.xz") + info = get_backup_info(path) + self.assertTrue(info["success"]) + self.assertEqual(info["format"], "TAR_XZ") + + def test_info_tar_zst(self): + """测试 tar.zst 备份信息""" + from sbackup.compression import get_backup_info + + path = self._make_backup("TAR_ZST", ".tar.zst") + info = get_backup_info(path) + self.assertTrue(info["success"]) + self.assertEqual(info["format"], "TAR_ZST") + + def test_info_tar(self): + """测试 tar 备份信息""" + from sbackup.compression import get_backup_info + + path = self._make_backup("TAR", ".tar") + info = get_backup_info(path) + self.assertTrue(info["success"]) + self.assertEqual(info["format"], "TAR") + + def test_info_unknown_format(self): + """测试未知格式""" + from sbackup.compression import get_backup_info + + path = os.path.join(self.test_dir, "test.xyz") + Path(path).write_bytes(b"unknown") + info = get_backup_info(path) + self.assertTrue(info["success"]) + self.assertEqual(info["format"], "UNKNOWN") + + def test_info_encrypted_format(self): + """测试 .enc 加密格式""" + from sbackup.compression import get_backup_info + + path = os.path.join(self.test_dir, "test.enc") + Path(path).write_bytes(b"encrypted") + info = get_backup_info(path) + self.assertTrue(info["success"]) + self.assertEqual(info["format"], "ENCRYPTED") + + +class TestArchiveMemberSet(unittest.TestCase): + """测试 get_archive_member_set""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.source_dir = os.path.join(self.test_dir, "data") + os.makedirs(self.source_dir) + (Path(self.source_dir) / "a.txt").write_text("a") + (Path(self.source_dir) / "sub").mkdir() + (Path(self.source_dir) / "sub" / "b.txt").write_text("b") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_member_set_zip(self): + """测试 ZIP 成员集合""" + from sbackup.compression import get_archive_member_set, create_compressor + + zip_path = os.path.join(self.test_dir, "test.zip") + config = Config( + folder_path=self.source_dir, + zipfile_path=zip_path, + skip_patterns=[], + ) + create_compressor(config).compress() + members = get_archive_member_set(zip_path) + self.assertIsInstance(members, set) + self.assertTrue(len(members) > 0) + # 不应包含目录条目 + for m in members: + self.assertFalse(m.endswith("/")) + + def test_member_set_nonexistent(self): + """测试不存在的文件返回空集合""" + from sbackup.compression import get_archive_member_set + + members = get_archive_member_set("/nonexistent/file.zip") + self.assertEqual(len(members), 0) + + def test_member_set_tar_gz(self): + """测试 tar.gz 成员集合""" + from sbackup.compression import get_archive_member_set, create_compressor + + path = os.path.join(self.test_dir, "test.tar.gz") + config = Config( + folder_path=self.source_dir, + zipfile_path=path, + compression_format="TAR_GZ", + skip_patterns=[], + ) + create_compressor(config).compress() + members = get_archive_member_set(path) + self.assertTrue(len(members) > 0) + + +class TestSearchInBackup(unittest.TestCase): + """测试 search_in_backup""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.source_dir = os.path.join(self.test_dir, "data") + os.makedirs(self.source_dir) + (Path(self.source_dir) / "readme.txt").write_text("readme") + (Path(self.source_dir) / "data.csv").write_text("csv") + (Path(self.source_dir) / "image.png").write_text("png") + (Path(self.source_dir) / "sub").mkdir() + (Path(self.source_dir) / "sub" / "nested.txt").write_text("nested") + self.zip_path = os.path.join(self.test_dir, "test.zip") + config = Config( + folder_path=self.source_dir, + zipfile_path=self.zip_path, + skip_patterns=[], + ) + create_compressor(config).compress() + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_search_glob_pattern(self): + """测试 glob 通配符搜索""" + from sbackup.compression import search_in_backup + + results = search_in_backup(self.zip_path, "*.txt") + self.assertTrue(len(results) > 0) + for r in results: + self.assertTrue(r.endswith(".txt")) + + def test_search_specific_file(self): + """测试搜索特定文件名""" + from sbackup.compression import search_in_backup + + results = search_in_backup(self.zip_path, "data.csv") + self.assertEqual(len(results), 1) + + def test_search_no_match(self): + """测试无匹配结果""" + from sbackup.compression import search_in_backup + + results = search_in_backup(self.zip_path, "*.xyz") + self.assertEqual(len(results), 0) + + def test_search_nonexistent_file(self): + """测试不存在的备份文件""" + from sbackup.compression import search_in_backup + + results = search_in_backup("/nonexistent/file.zip", "*.txt") + self.assertEqual(len(results), 0) + + def test_search_nested_files(self): + """测试搜索嵌套目录中的文件""" + from sbackup.compression import search_in_backup + + results = search_in_backup(self.zip_path, "*.txt") + has_nested = any("nested" in r for r in results) + self.assertTrue(has_nested) + + +class TestRegexIgnore(unittest.TestCase): + """测试正则表达式忽略规则""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + (Path(self.test_dir) / "file.txt").write_text("txt") + (Path(self.test_dir) / "file.log").write_text("log") + (Path(self.test_dir) / "data.csv").write_text("csv") + (Path(self.test_dir) / "backup.bak").write_text("bak") + self.zip_path = os.path.join(self.test_dir, "test.zip") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_regex_ignore_log_files(self): + """测试正则忽略 .log 文件""" + config = Config( + folder_path=self.test_dir, + zipfile_path=self.zip_path, + skip_patterns=[r"re:.*\.log$"], + ) + compressor = ZipfileCompression(config) + result = compressor.compress() + self.assertTrue(result["success"]) + with zipfile.ZipFile(self.zip_path, "r") as zf: + names = zf.namelist() + self.assertFalse(any(n.endswith(".log") for n in names)) + self.assertTrue(any(n.endswith(".txt") for n in names)) + + def test_regex_negation(self): + """测试正则取反模式""" + config = Config( + folder_path=self.test_dir, + zipfile_path=self.zip_path, + skip_patterns=[r"re:.*\.(log|bak)$", r"!re:.*backup\.bak$"], + ) + compressor = ZipfileCompression(config) + result = compressor.compress() + self.assertTrue(result["success"]) + with zipfile.ZipFile(self.zip_path, "r") as zf: + names = zf.namelist() + self.assertFalse(any("file.log" in n for n in names)) + self.assertTrue(any("backup.bak" in n for n in names)) + + def test_invalid_regex_ignored(self): + """测试无效正则表达式被安全忽略""" + config = Config( + folder_path=self.test_dir, + zipfile_path=self.zip_path, + skip_patterns=[r"re:[invalid"], + ) + compressor = ZipfileCompression(config) + result = compressor.compress() + self.assertTrue(result["success"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/sbackup/test_config.py b/tests/sbackup/test_config.py index e5fb9b6..7a696a5 100644 --- a/tests/sbackup/test_config.py +++ b/tests/sbackup/test_config.py @@ -5,9 +5,10 @@ import unittest import os import tempfile +import shutil import json from unittest.mock import patch -from sbackup.config import load_config, save_lang, save_format +from sbackup.config import load_config, save_lang, save_format, save_sftp_config class TestConfig(unittest.TestCase): @@ -224,5 +225,422 @@ def test_save_format_write_error(self, mock_open): save_format("tar.gz", self.config_file) +class TestSaveSftpConfig(unittest.TestCase): + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.config_file = os.path.join(self.test_dir, "config.json") + + def tearDown(self): + if os.path.exists(self.test_dir): + import shutil + + shutil.rmtree(self.test_dir) + + def test_save_sftp_config_creates_new_file(self): + """测试 save_sftp_config 创建新配置文件""" + save_sftp_config( + "host.com", + 2222, + "admin", + "secret", + "/backups", + key_file="/path/to/key", + key_passphrase="keypass", + config_file=self.config_file, + ) + self.assertTrue(os.path.exists(self.config_file)) + with open(self.config_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["sftp"]["host"], "host.com") + self.assertEqual(data["sftp"]["port"], 2222) + self.assertEqual(data["sftp"]["user"], "admin") + self.assertEqual(data["sftp"]["password"], "secret") + self.assertEqual(data["sftp"]["key_file"], "/path/to/key") + self.assertEqual(data["sftp"]["key_passphrase"], "keypass") + self.assertEqual(data["sftp"]["remote_path"], "/backups") + self.assertTrue(data["sftp"]["enabled"]) + + def test_save_sftp_config_updates_existing(self): + """测试 save_sftp_config 更新已有配置""" + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump({"lang": "zh_CN", "compression_format": "zip"}, f) + save_sftp_config( + "newhost", + 22, + "user", + "pass", + "/", + key_file="", + key_passphrase="", + config_file=self.config_file, + ) + with open(self.config_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["sftp"]["host"], "newhost") + self.assertEqual(data["lang"], "zh_CN") + self.assertEqual(data["compression_format"], "zip") + + def test_save_sftp_config_disabled(self): + """测试 save_sftp_config 禁用 SFTP""" + save_sftp_config( + "host", + 22, + "user", + "pass", + "/", + enabled=False, + key_file="", + key_passphrase="", + config_file=self.config_file, + ) + with open(self.config_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertFalse(data["sftp"]["enabled"]) + + def test_save_sftp_config_malformed_json(self): + """测试配置文件损坏时 save_sftp_config 重置""" + with open(self.config_file, "w") as f: + f.write("{bad") + save_sftp_config( + "host", + 22, + "user", + "pass", + "/", + key_file="", + key_passphrase="", + config_file=self.config_file, + ) + with open(self.config_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["sftp"]["host"], "host") + + @patch("os.makedirs") + def test_save_sftp_config_makedirs_error(self, mock_makedirs): + """测试创建目录失败""" + mock_makedirs.side_effect = OSError("denied") + save_sftp_config( + "host", + 22, + "user", + "pass", + "/", + key_file="", + key_passphrase="", + config_file=self.config_file, + ) + + @patch("builtins.open") + def test_save_sftp_config_write_error(self, mock_open): + """测试写入失败""" + mock_open.side_effect = OSError("disk full") + save_sftp_config("host", 22, "user", "pass", "/", config_file=self.config_file) + + def test_load_config_with_sftp(self): + """测试 load_config 读取 SFTP 配置""" + config_data = { + "lang": "en_US", + "sftp": { + "host": "sftp.example.com", + "port": 2222, + "user": "backup", + "password": "s3cret", + "remote_path": "/data/backups", + "enabled": True, + }, + } + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump(config_data, f) + config = load_config(self.config_file) + self.assertEqual(config.sftp_host, "sftp.example.com") + self.assertEqual(config.sftp_port, 2222) + self.assertEqual(config.sftp_user, "backup") + self.assertEqual(config.sftp_password, "s3cret") + self.assertEqual(config.sftp_remote_path, "/data/backups") + self.assertTrue(config.sftp_enabled) + + def test_load_config_sftp_defaults(self): + """测试 load_config 无 SFTP 配置时使用默认值""" + config_data = {"lang": "en_US"} + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump(config_data, f) + config = load_config(self.config_file) + self.assertEqual(config.sftp_host, "") + self.assertEqual(config.sftp_port, 22) + self.assertEqual(config.sftp_user, "") + self.assertEqual(config.sftp_password, "") + self.assertEqual(config.sftp_remote_path, "/") + self.assertFalse(config.sftp_enabled) + + def test_load_config_with_sftp_key(self): + """测试加载包含私钥的 SFTP 配置""" + config_data = { + "sftp": { + "host": "keyhost", + "port": 2222, + "user": "keyuser", + "password": "", + "key_file": "/home/user/.ssh/id_rsa", + "key_passphrase": "keypass", + "remote_path": "/backups", + "enabled": True, + } + } + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump(config_data, f) + config = load_config(self.config_file) + self.assertEqual(config.sftp_host, "keyhost") + self.assertEqual(config.sftp_port, 2222) + self.assertEqual(config.sftp_user, "keyuser") + self.assertEqual(config.sftp_key_file, "/home/user/.ssh/id_rsa") + self.assertEqual(config.sftp_key_passphrase, "keypass") + self.assertTrue(config.sftp_enabled) + + def test_load_config_default_lang_zh_cn(self): + """测试 config.json 缺少 lang 字段时默认为 zh_CN""" + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump({"compression_format": "ZIP"}, f) + config = load_config(self.config_file) + self.assertEqual(config.lang, "zh_CN") + + def test_load_config_with_webdav(self): + """测试加载包含 WebDAV 配置""" + config_data = { + "webdav": { + "url": "https://dav.jianguoyun.com/dav/", + "user": "user@example.com", + "password": "secret", + "remote_path": "/backups", + "enabled": True, + } + } + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump(config_data, f) + config = load_config(self.config_file) + self.assertEqual(config.webdav_url, "https://dav.jianguoyun.com/dav/") + self.assertEqual(config.webdav_user, "user@example.com") + self.assertEqual(config.webdav_password, "secret") + self.assertEqual(config.webdav_remote_path, "/backups") + self.assertTrue(config.webdav_enabled) + + def test_load_config_webdav_defaults(self): + """测试 WebDAV 配置默认值""" + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump({}, f) + config = load_config(self.config_file) + self.assertEqual(config.webdav_url, "") + self.assertEqual(config.webdav_user, "") + self.assertEqual(config.webdav_password, "") + self.assertEqual(config.webdav_remote_path, "/") + self.assertFalse(config.webdav_enabled) + + def test_save_webdav_config_creates_new_file(self): + """测试 save_webdav_config 创建新配置文件""" + from sbackup.config import save_webdav_config + + save_webdav_config( + "https://dav.example.com/dav/", + "user@test.com", + "pass123", + "/backups", + config_file=self.config_file, + ) + self.assertTrue(os.path.exists(self.config_file)) + with open(self.config_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["webdav"]["url"], "https://dav.example.com/dav/") + self.assertEqual(data["webdav"]["user"], "user@test.com") + self.assertEqual(data["webdav"]["password"], "pass123") + self.assertEqual(data["webdav"]["remote_path"], "/backups") + self.assertTrue(data["webdav"]["enabled"]) + + def test_save_webdav_config_updates_existing(self): + """测试 save_webdav_config 更新已有配置""" + from sbackup.config import save_webdav_config + + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump({"lang": "zh_CN"}, f) + save_webdav_config( + "https://dav.example.com/", + "user", + "pass", + "/", + config_file=self.config_file, + ) + with open(self.config_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["webdav"]["url"], "https://dav.example.com/") + self.assertEqual(data["lang"], "zh_CN") + + +class TestJsonHelpers(unittest.TestCase): + """测试 _load_json_file 和 _save_json_file 辅助函数""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.config_file = os.path.join(self.test_dir, "config.json") + + def tearDown(self): + import shutil + + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_load_json_file_nonexistent(self): + """测试加载不存在的文件返回空字典""" + from sbackup.config import _load_json_file + + result = _load_json_file("/nonexistent/file.json") + self.assertEqual(result, {}) + + def test_load_json_file_valid(self): + """测试加载有效的 JSON 文件""" + from sbackup.config import _load_json_file + + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump({"key": "value"}, f) + result = _load_json_file(self.config_file) + self.assertEqual(result, {"key": "value"}) + + def test_load_json_file_malformed(self): + """测试加载损坏的 JSON 文件返回空字典""" + from sbackup.config import _load_json_file + + with open(self.config_file, "w") as f: + f.write("{bad json") + result = _load_json_file(self.config_file) + self.assertEqual(result, {}) + + def test_save_json_file_creates_dir(self): + """测试自动创建目录""" + from sbackup.config import _save_json_file + + nested_file = os.path.join(self.test_dir, "sub", "config.json") + _save_json_file({"test": True}, nested_file) + self.assertTrue(os.path.exists(nested_file)) + with open(nested_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data, {"test": True}) + + @patch("os.makedirs") + def test_save_json_file_makedirs_error(self, mock_makedirs): + """测试目录创建失败时静默返回""" + from sbackup.config import _save_json_file + + mock_makedirs.side_effect = OSError("denied") + nested_file = os.path.join(self.test_dir, "sub", "config.json") + _save_json_file({"test": True}, nested_file) + # 不应抛出异常 + + @patch("builtins.open") + def test_save_json_file_write_error(self, mock_open): + """测试写入失败时静默处理""" + from sbackup.config import _save_json_file + + mock_open.side_effect = OSError("disk full") + _save_json_file({"test": True}, self.config_file) + # 不应抛出异常 + + +class TestGenerateConfigTemplate(unittest.TestCase): + """测试 generate_config_template""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_generate_template(self): + """测试生成完整配置模板""" + from sbackup.config import generate_config_template + + config_file = os.path.join(self.test_dir, "config.json") + generate_config_template(config_file) + self.assertTrue(os.path.exists(config_file)) + with open(config_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertIn("lang", data) + self.assertIn("compression_format", data) + self.assertIn("compression", data) + self.assertIn("sftp", data) + self.assertIn("webdav", data) + self.assertIn("webhook", data) + self.assertIn("skip_patterns", data) + self.assertEqual(data["lang"], "zh_CN") + + def test_generate_template_webhook_structure(self): + """测试 webhook 配置结构""" + from sbackup.config import generate_config_template + + config_file = os.path.join(self.test_dir, "config.json") + generate_config_template(config_file) + with open(config_file, "r", encoding="utf-8") as f: + data = json.load(f) + webhook = data["webhook"] + self.assertIn("urls", webhook) + self.assertIn("template", webhook) + self.assertIn("retries", webhook) + self.assertIsInstance(webhook["urls"], list) + + +class TestWebhookConfigLoading(unittest.TestCase): + """测试 webhook 配置加载的向后兼容性""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_load_legacy_webhook_url(self): + """测试加载旧版单个 url 字段""" + from sbackup.config import load_config + + config_file = os.path.join(self.test_dir, "config.json") + with open(config_file, "w", encoding="utf-8") as f: + json.dump({"webhook": {"url": "https://example.com/hook"}}, f) + config = load_config(config_file) + self.assertEqual(config.webhook_url, "https://example.com/hook") + self.assertIn("https://example.com/hook", config.webhook_urls) + + def test_load_webhook_urls_list(self): + """测试加载 urls 列表""" + from sbackup.config import load_config + + config_file = os.path.join(self.test_dir, "config.json") + with open(config_file, "w", encoding="utf-8") as f: + json.dump( + {"webhook": {"urls": ["https://a.com", "https://b.com"], "retries": 3}}, + f, + ) + config = load_config(config_file) + self.assertEqual(len(config.webhook_urls), 2) + self.assertEqual(config.webhook_retries, 3) + + def test_load_webhook_template(self): + """测试加载自定义模板""" + from sbackup.config import load_config + + config_file = os.path.join(self.test_dir, "config.json") + with open(config_file, "w", encoding="utf-8") as f: + json.dump( + {"webhook": {"template": '{"event":"{status}"}'}}, + f, + ) + config = load_config(config_file) + self.assertEqual(config.webhook_template, '{"event":"{status}"}') + + def test_load_empty_webhook(self): + """测试加载空 webhook 配置""" + from sbackup.config import load_config + + config_file = os.path.join(self.test_dir, "config.json") + with open(config_file, "w", encoding="utf-8") as f: + json.dump({"webhook": {}}, f) + config = load_config(config_file) + self.assertEqual(config.webhook_urls, []) + self.assertEqual(config.webhook_retries, 2) + + if __name__ == "__main__": unittest.main() diff --git a/tests/sbackup/test_cross_search.py b/tests/sbackup/test_cross_search.py new file mode 100644 index 0000000..03d4568 --- /dev/null +++ b/tests/sbackup/test_cross_search.py @@ -0,0 +1,485 @@ +"""单元测试 for sbackup.cross_search 模块""" + +import os +import shutil +import tarfile +import tempfile +import time +import unittest +import zipfile +from pathlib import Path + + +class TestCrossSearcherScanArchives(unittest.TestCase): + """测试 scan_archives 方法""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_xsearch_test_") + self.scan_dir = os.path.join(self.test_dir, "archives") + os.makedirs(self.scan_dir) + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _create_zip(self, name: str, files: dict[str, str]) -> str: + path = os.path.join(self.scan_dir, name) + with zipfile.ZipFile(path, "w") as zf: + for arcname, content in files.items(): + zf.writestr(arcname, content) + return path + + def _create_tar(self, name: str, files: dict[str, str]) -> str: + path = os.path.join(self.scan_dir, name) + with tarfile.open(path, "w") as tarf: + for arcname, content in files.items(): + import io + + data = content.encode("utf-8") + info = tarfile.TarInfo(name=arcname) + info.size = len(data) + tarf.addfile(info, io.BytesIO(data)) + return path + + def _create_tar_gz(self, name: str, files: dict[str, str]) -> str: + path = os.path.join(self.scan_dir, name) + with tarfile.open(path, "w:gz") as tarf: + for arcname, content in files.items(): + import io + + data = content.encode("utf-8") + info = tarfile.TarInfo(name=arcname) + info.size = len(data) + tarf.addfile(info, io.BytesIO(data)) + return path + + def test_scan_finds_zip(self): + self._create_zip("backup1.zip", {"a.txt": "hello", "b.py": "code"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + archives = searcher.scan_archives() + self.assertEqual(len(archives), 1) + self.assertEqual(archives[0]["format"], "ZIP") + self.assertTrue(archives[0]["path"].endswith("backup1.zip")) + + def test_scan_finds_tar(self): + self._create_tar("backup2.tar", {"x.log": "log"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + archives = searcher.scan_archives() + self.assertEqual(len(archives), 1) + self.assertEqual(archives[0]["format"], "TAR") + + def test_scan_finds_tar_gz(self): + self._create_tar_gz("backup3.tar.gz", {"data.csv": "a,b"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + archives = searcher.scan_archives() + self.assertEqual(len(archives), 1) + self.assertEqual(archives[0]["format"], "TAR_GZ") + + def test_scan_finds_multiple_formats(self): + self._create_zip("a.zip", {"f1.txt": "1"}) + self._create_tar("b.tar", {"f2.txt": "2"}) + self._create_tar_gz("c.tar.gz", {"f3.txt": "3"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + archives = searcher.scan_archives() + self.assertEqual(len(archives), 3) + formats = {a["format"] for a in archives} + self.assertEqual(formats, {"ZIP", "TAR", "TAR_GZ"}) + + def test_scan_ignores_non_archive(self): + self._create_zip("good.zip", {"a.txt": "1"}) + Path(os.path.join(self.scan_dir, "readme.txt")).write_text("nope") + Path(os.path.join(self.scan_dir, "data.csv")).write_text("nope") + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + archives = searcher.scan_archives() + self.assertEqual(len(archives), 1) + + def test_scan_sorted_by_mtime_desc(self): + path1 = self._create_zip("old.zip", {"a.txt": "1"}) + # ensure different mtime + old_time = time.time() - 100 + os.utime(path1, (old_time, old_time)) + self._create_zip("new.zip", {"b.txt": "2"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + archives = searcher.scan_archives() + self.assertEqual(len(archives), 2) + self.assertTrue(archives[0]["path"].endswith("new.zip")) + self.assertTrue(archives[1]["path"].endswith("old.zip")) + + def test_scan_nonexistent_dir(self): + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher(["/nonexistent/path/xyz"]) + archives = searcher.scan_archives() + self.assertEqual(archives, []) + + +class TestCrossSearcherSearch(unittest.TestCase): + """测试 search 关键字搜索""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_xsearch_test_") + self.scan_dir = os.path.join(self.test_dir, "archives") + os.makedirs(self.scan_dir) + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _create_zip(self, name: str, files: dict[str, str]) -> str: + path = os.path.join(self.scan_dir, name) + with zipfile.ZipFile(path, "w") as zf: + for arcname, content in files.items(): + zf.writestr(arcname, content) + return path + + def test_keyword_search_basic(self): + self._create_zip( + "backup.zip", + { + "src/main.py": "code", + "src/utils.py": "utils", + "docs/readme.txt": "readme", + }, + ) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search("main") + self.assertEqual(len(results), 1) + self.assertEqual(len(results[0].members), 1) + self.assertEqual(results[0].members[0]["name"], "src/main.py") + + def test_keyword_search_case_insensitive(self): + self._create_zip("backup.zip", {"LOG/Error.log": "err"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search("error") + self.assertEqual(len(results), 1) + self.assertEqual(results[0].members[0]["name"], "LOG/Error.log") + + def test_keyword_search_no_match(self): + self._create_zip("backup.zip", {"a.txt": "1"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search("zzz_nonexistent_zzz") + self.assertEqual(results, []) + + def test_keyword_search_across_archives(self): + self._create_zip("a.zip", {"file_main.py": "1"}) + self._create_zip("b.zip", {"file_main.txt": "2"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search("main") + self.assertEqual(len(results), 2) + + def test_keyword_search_result_metadata(self): + self._create_zip("backup.zip", {"test.py": "code"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search("test") + self.assertEqual(len(results), 1) + r = results[0] + self.assertTrue(r.archive.endswith("backup.zip")) + self.assertGreater(r.archive_size, 0) + self.assertNotEqual(r.archive_mtime, "") + + +class TestCrossSearcherSearchByPattern(unittest.TestCase): + """测试 search_by_pattern glob 搜索""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_xsearch_test_") + self.scan_dir = os.path.join(self.test_dir, "archives") + os.makedirs(self.scan_dir) + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _create_zip(self, name: str, files: dict[str, str]) -> str: + path = os.path.join(self.scan_dir, name) + with zipfile.ZipFile(path, "w") as zf: + for arcname, content in files.items(): + zf.writestr(arcname, content) + return path + + def test_pattern_match_glob(self): + self._create_zip( + "backup.zip", + {"src/main.py": "code", "src/utils.py": "u", "docs/readme.txt": "r"}, + ) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search_by_pattern("*.py") + self.assertEqual(len(results), 1) + names = {m["name"] for m in results[0].members} + self.assertEqual(names, {"src/main.py", "src/utils.py"}) + + def test_pattern_match_with_path(self): + self._create_zip( + "backup.zip", + {"logs/app.log": "log1", "logs/error.log": "log2", "src/main.py": "code"}, + ) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search_by_pattern("*.log") + self.assertEqual(len(results), 1) + self.assertEqual(len(results[0].members), 2) + + def test_pattern_no_match(self): + self._create_zip("backup.zip", {"a.txt": "1"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search_by_pattern("*.xyz") + self.assertEqual(results, []) + + +class TestCrossSearcherSearchByExtension(unittest.TestCase): + """测试 search_by_extension 扩展名搜索""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_xsearch_test_") + self.scan_dir = os.path.join(self.test_dir, "archives") + os.makedirs(self.scan_dir) + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _create_zip(self, name: str, files: dict[str, str]) -> str: + path = os.path.join(self.scan_dir, name) + with zipfile.ZipFile(path, "w") as zf: + for arcname, content in files.items(): + zf.writestr(arcname, content) + return path + + def test_ext_search(self): + self._create_zip( + "backup.zip", + {"src/main.py": "code", "docs/readme.txt": "r", "src/utils.py": "u"}, + ) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search_by_extension(".py") + self.assertEqual(len(results), 1) + names = {m["name"] for m in results[0].members} + self.assertEqual(names, {"src/main.py", "src/utils.py"}) + + def test_ext_search_case_insensitive(self): + self._create_zip("backup.zip", {"File.LOG": "log"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search_by_extension(".log") + self.assertEqual(len(results), 1) + + def test_ext_search_without_dot(self): + self._create_zip("backup.zip", {"main.py": "code"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search_by_extension("py") + self.assertEqual(len(results), 1) + self.assertEqual(results[0].members[0]["name"], "main.py") + + def test_ext_no_match(self): + self._create_zip("backup.zip", {"a.txt": "1"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search_by_extension(".xyz") + self.assertEqual(results, []) + + +class TestCrossSearcherGetSummary(unittest.TestCase): + """测试 get_summary 统计""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_xsearch_test_") + self.scan_dir = os.path.join(self.test_dir, "archives") + os.makedirs(self.scan_dir) + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _create_zip(self, name: str, files: dict[str, str]) -> str: + path = os.path.join(self.scan_dir, name) + with zipfile.ZipFile(path, "w") as zf: + for arcname, content in files.items(): + zf.writestr(arcname, content) + return path + + def test_summary_with_results(self): + self._create_zip("a.zip", {"a.py": "1", "b.py": "2", "c.txt": "3"}) + self._create_zip("b.zip", {"d.py": "4", "e.txt": "5"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search_by_extension(".py") + summary = searcher.get_summary(results) + self.assertEqual(summary["total_archives"], 2) + self.assertEqual(summary["matched_archives"], 2) + self.assertEqual(summary["total_matches"], 3) + + def test_summary_empty_results(self): + self._create_zip("a.zip", {"a.txt": "1"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search_by_extension(".py") + summary = searcher.get_summary(results) + self.assertEqual(summary["total_archives"], 1) + self.assertEqual(summary["matched_archives"], 0) + self.assertEqual(summary["total_matches"], 0) + + def test_summary_no_archives(self): + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search("anything") + summary = searcher.get_summary(results) + self.assertEqual(summary["total_archives"], 0) + self.assertEqual(summary["matched_archives"], 0) + self.assertEqual(summary["total_matches"], 0) + + +class TestCrossSearcherFormatResults(unittest.TestCase): + """测试 format_results 输出""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_xsearch_test_") + self.scan_dir = os.path.join(self.test_dir, "archives") + os.makedirs(self.scan_dir) + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _create_zip(self, name: str, files: dict[str, str]) -> str: + path = os.path.join(self.scan_dir, name) + with zipfile.ZipFile(path, "w") as zf: + for arcname, content in files.items(): + zf.writestr(arcname, content) + return path + + def test_format_results_zh(self): + self._create_zip("backup.zip", {"main.py": "code"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search_by_extension(".py") + output = searcher.format_results(results, lang="zh_CN") + self.assertIn("搜索结果", output) + self.assertIn("main.py", output) + self.assertIn("1/1", output) + + def test_format_results_en(self): + self._create_zip("backup.zip", {"main.py": "code"}) + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search_by_extension(".py") + output = searcher.format_results(results, lang="en_US") + self.assertIn("Search results", output) + self.assertIn("main.py", output) + + def test_format_results_empty_zh(self): + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + output = searcher.format_results([], lang="zh_CN") + self.assertIn("未找到", output) + + def test_format_results_empty_en(self): + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + output = searcher.format_results([], lang="en_US") + self.assertIn("No matching", output) + + +class TestCrossSearcherEdgeCases(unittest.TestCase): + """测试边界情况""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_xsearch_test_") + self.scan_dir = os.path.join(self.test_dir, "archives") + os.makedirs(self.scan_dir) + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_empty_directory(self): + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search("anything") + self.assertEqual(results, []) + + def test_nonexistent_directory(self): + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher(["/nonexistent/path"]) + results = searcher.search("anything") + self.assertEqual(results, []) + + def test_multiple_directories(self): + dir1 = os.path.join(self.test_dir, "d1") + dir2 = os.path.join(self.test_dir, "d2") + os.makedirs(dir1) + os.makedirs(dir2) + path1 = os.path.join(dir1, "a.zip") + with zipfile.ZipFile(path1, "w") as zf: + zf.writestr("file1.py", "code") + path2 = os.path.join(dir2, "b.zip") + with zipfile.ZipFile(path2, "w") as zf: + zf.writestr("file2.py", "code") + + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([dir1, dir2]) + results = searcher.search("file") + self.assertEqual(len(results), 2) + + def test_search_result_dataclass(self): + from sbackup.cross_search import SearchResult + + r = SearchResult( + archive="/path/to/backup.zip", + members=[{"name": "a.txt", "size": 100}], + archive_size=1024, + archive_mtime="2026-01-01 00:00:00", + ) + self.assertEqual(r.archive, "/path/to/backup.zip") + self.assertEqual(len(r.members), 1) + self.assertEqual(r.archive_size, 1024) + self.assertEqual(r.archive_mtime, "2026-01-01 00:00:00") + + def test_empty_zip(self): + path = os.path.join(self.scan_dir, "empty.zip") + with zipfile.ZipFile(path, "w"): + pass + from sbackup.cross_search import CrossSearcher + + searcher = CrossSearcher([self.scan_dir]) + results = searcher.search("anything") + self.assertEqual(results, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sbackup/test_diskcheck.py b/tests/sbackup/test_diskcheck.py new file mode 100644 index 0000000..7609323 --- /dev/null +++ b/tests/sbackup/test_diskcheck.py @@ -0,0 +1,330 @@ +"""磁盘空间预估模块测试""" + +import os + +import pytest +from pathlib import Path + +from sbackup.diskcheck import ( + DiskChecker, + DiskSpaceInfo, + _format_size, + _calc_ratio_text, + _get_extension_ratio, +) + + +class TestFormatSize: + """测试 _format_size 函数""" + + def test_bytes(self) -> None: + assert _format_size(0) == "0 B" + assert _format_size(512) == "512 B" + + def test_kilobytes(self) -> None: + assert _format_size(1024) == "1.0 KB" + assert _format_size(1536) == "1.5 KB" + + def test_megabytes(self) -> None: + assert _format_size(1024**2) == "1.0 MB" + assert _format_size(5 * 1024**2) == "5.0 MB" + + def test_gigabytes(self) -> None: + assert _format_size(1024**3) == "1.00 GB" + assert _format_size(2.5 * 1024**3) == "2.50 GB" + + def test_negative(self) -> None: + result = _format_size(-1024) + assert result.startswith("-") or result.startswith("-1.0 KB") + + +class TestCalcRatioText: + """测试 _calc_ratio_text 函数""" + + def test_basic_ratio(self) -> None: + assert _calc_ratio_text(1000, 500) == "50.0%" + + def test_zero_original(self) -> None: + assert _calc_ratio_text(0, 0) == "0%" + + def test_high_compression(self) -> None: + assert _calc_ratio_text(1000, 300) == "30.0%" + + +class TestGetExtensionRatio: + """测试 _get_extension_ratio 函数""" + + def test_text_extensions(self) -> None: + assert _get_extension_ratio(".py") == 0.3 + assert _get_extension_ratio(".js") == 0.3 + assert _get_extension_ratio(".txt") == 0.3 + assert _get_extension_ratio(".json") == 0.3 + assert _get_extension_ratio(".html") == 0.3 + assert _get_extension_ratio(".md") == 0.3 + assert _get_extension_ratio(".css") == 0.3 + + def test_text_extensions_case_insensitive(self) -> None: + assert _get_extension_ratio(".PY") == 0.3 + assert _get_extension_ratio(".JS") == 0.3 + + def test_database_extensions(self) -> None: + assert _get_extension_ratio(".db") == 0.5 + assert _get_extension_ratio(".sqlite") == 0.5 + assert _get_extension_ratio(".sqlite3") == 0.5 + + def test_image_extensions(self) -> None: + assert _get_extension_ratio(".jpg") == 0.95 + assert _get_extension_ratio(".png") == 0.95 + assert _get_extension_ratio(".gif") == 0.95 + + def test_media_extensions(self) -> None: + assert _get_extension_ratio(".mp4") == 0.98 + assert _get_extension_ratio(".mp3") == 0.98 + assert _get_extension_ratio(".wav") == 0.98 + + def test_archive_extensions(self) -> None: + assert _get_extension_ratio(".zip") == 0.99 + assert _get_extension_ratio(".gz") == 0.99 + assert _get_extension_ratio(".7z") == 0.99 + + def test_unknown_extension(self) -> None: + assert _get_extension_ratio(".xyz") == 0.6 + assert _get_extension_ratio("") == 0.6 + + +class TestDiskSpaceInfo: + """测试 DiskSpaceInfo 数据类""" + + def test_default_values(self) -> None: + info = DiskSpaceInfo() + assert info.total == 0 + assert info.used == 0 + assert info.free == 0 + assert info.source_total == 0 + assert info.estimated_backup == 0 + assert info.margin == 0 + assert info.enough is True + assert info.warnings == [] + + def test_custom_values(self) -> None: + info = DiskSpaceInfo( + total=1000, + used=500, + free=500, + source_total=100, + estimated_backup=50, + margin=450, + enough=True, + ) + assert info.total == 1000 + assert info.enough is True + + +class TestDiskChecker: + """测试 DiskChecker 类""" + + @pytest.fixture + def temp_dirs(self, tmp_path: Path) -> tuple[Path, Path]: + """创建临时源目录和目标目录""" + source = tmp_path / "source" + source.mkdir() + target = tmp_path / "target" + target.mkdir() + return source, target + + def test_get_disk_space(self, temp_dirs: tuple[Path, Path]) -> None: + """测试 get_disk_space 返回有效数据""" + source, target = temp_dirs + checker = DiskChecker(str(source), str(target)) + info = checker.get_disk_space(str(target)) + + assert info.total > 0 + assert info.free > 0 + assert info.used >= 0 + assert info.total == info.used + info.free + + def test_get_disk_space_nonexistent(self, temp_dirs: tuple[Path, Path]) -> None: + """测试 get_disk_space 对不存在的路径回退到父目录""" + source, target = temp_dirs + fake_path = str(target / "nonexistent") + checker = DiskChecker(str(source), str(target)) + # 应该不会崩溃,回退到父目录 + info = checker.get_disk_space(fake_path) + assert info.total > 0 + + def test_scan_source(self, temp_dirs: tuple[Path, Path]) -> None: + """测试 scan_source 正确计算文件大小""" + source, target = temp_dirs + + # 创建测试文件 + (source / "file1.txt").write_text("hello world") # 11 bytes + (source / "file2.py").write_text("print('hello')") # 14 bytes + + checker = DiskChecker(str(source), str(target)) + total = checker.scan_source() + + assert total == 11 + 14 + + def test_scan_source_with_subdirs(self, temp_dirs: tuple[Path, Path]) -> None: + """测试 scan_source 包含子目录中的文件""" + source, target = temp_dirs + subdir = source / "subdir" + subdir.mkdir() + + (source / "root.txt").write_text("root") # 4 bytes + (subdir / "nested.txt").write_text("nested") # 6 bytes + + checker = DiskChecker(str(source), str(target)) + total = checker.scan_source() + + assert total == 4 + 6 + + def test_scan_source_empty_directory(self, temp_dirs: tuple[Path, Path]) -> None: + """测试扫描空目录""" + source, target = temp_dirs + checker = DiskChecker(str(source), str(target)) + total = checker.scan_source() + assert total == 0 + + def test_scan_source_nonexistent(self, temp_dirs: tuple[Path, Path]) -> None: + """测试扫描不存在的目录""" + source, target = temp_dirs + checker = DiskChecker(str(source) + "_nonexistent", str(target)) + total = checker.scan_source() + assert total == 0 + + def test_estimate_backup_size_zip(self, temp_dirs: tuple[Path, Path]) -> None: + """测试 ZIP 格式的估算""" + source, target = temp_dirs + + # 创建纯文本文件(压缩比 0.3) + (source / "code.py").write_text("x = 1\n" * 1000) # ~6KB + (source / "readme.txt").write_text("hello\n" * 500) # ~3KB + + checker = DiskChecker(str(source), str(target)) + source_total = checker.scan_source() + estimated = checker.estimate_backup_size("ZIP", 6, source_total) + + # ZIP 对文本文件的压缩比大约 0.3 * level_factor + assert estimated > 0 + assert estimated < source_total # 压缩后应该更小 + + def test_estimate_backup_size_tar(self, temp_dirs: tuple[Path, Path]) -> None: + """测试 TAR 格式的估算(无压缩)""" + source, target = temp_dirs + (source / "file.txt").write_text("hello world " * 100) + + checker = DiskChecker(str(source), str(target)) + source_total = checker.scan_source() + estimated = checker.estimate_backup_size("TAR", 6, source_total) + + # TAR 不压缩,估算大小应该等于源大小 + assert estimated == source_total + + def test_estimate_backup_size_7z(self, temp_dirs: tuple[Path, Path]) -> None: + """测试 7Z 格式的估算""" + source, target = temp_dirs + (source / "code.py").write_text("x = 1\n" * 1000) + + checker = DiskChecker(str(source), str(target)) + source_total = checker.scan_source() + estimated = checker.estimate_backup_size("7z", 6, source_total) + + # 7z 应该比 ZIP 压缩率更高 + zip_est = checker.estimate_backup_size("ZIP", 6, source_total) + assert estimated <= zip_est + + def test_estimate_backup_size_empty(self, temp_dirs: tuple[Path, Path]) -> None: + """测试空目录的估算""" + source, target = temp_dirs + checker = DiskChecker(str(source), str(target)) + estimated = checker.estimate_backup_size("ZIP", 6, 0) + assert estimated == 0 + + def test_estimate_backup_size_mixed_files( + self, temp_dirs: tuple[Path, Path] + ) -> None: + """测试混合文件类型""" + source, target = temp_dirs + # 文本文件 + (source / "code.py").write_text("x = 1\n" * 1000) + # 二进制模拟(图片扩展名,压缩比 0.95) + (source / "image.png").write_bytes(os.urandom(1000)) + + checker = DiskChecker(str(source), str(target)) + source_total = checker.scan_source() + estimated = checker.estimate_backup_size("ZIP", 6, source_total) + + # 混合文件的估算应该在两个极端之间 + assert estimated > 0 + assert estimated < source_total + + def test_check_integration(self, temp_dirs: tuple[Path, Path]) -> None: + """测试 check 完整流程""" + source, target = temp_dirs + (source / "test.py").write_text("print('hello')\n" * 100) + + checker = DiskChecker(str(source), str(target)) + info = checker.check(fmt="ZIP", compression_level=6) + + assert info.source_total > 0 + assert info.estimated_backup > 0 + assert info.total > 0 + assert info.free > 0 + assert info.margin == info.free - info.estimated_backup + assert info.enough is True # 正常磁盘应该空间充足 + + def test_format_report(self, temp_dirs: tuple[Path, Path]) -> None: + """测试 format_report 输出包含关键信息""" + source, target = temp_dirs + (source / "test.py").write_text("print('hello')\n" * 100) + + checker = DiskChecker(str(source), str(target)) + info = checker.check(fmt="ZIP", compression_level=6) + report = checker.format_report(info, lang="zh_CN") + + assert str(source) in report + assert str(target) in report + assert "MB" in report or "KB" in report or "B" in report or "GB" in report + + def test_format_report_en(self, temp_dirs: tuple[Path, Path]) -> None: + """测试英文格式化报告""" + source, target = temp_dirs + (source / "test.py").write_text("hello\n" * 50) + + checker = DiskChecker(str(source), str(target)) + info = checker.check(fmt="ZIP", compression_level=6) + report = checker.format_report(info, lang="en_US") + + assert str(source) in report + assert str(target) in report + + def test_check_insufficient_space(self, tmp_path: Path) -> None: + """测试空间不足的场景""" + # 创建一个小目标目录(通过 mock 间接测试) + source = tmp_path / "source" + source.mkdir() + target = tmp_path / "target" + target.mkdir() + (source / "big.py").write_text("x\n" * 10000) + + checker = DiskChecker(str(source), str(target)) + info = checker.check(fmt="ZIP", compression_level=6) + + # 在正常系统上空间应该充足,但我们验证数据结构正确 + assert isinstance(info.enough, bool) + assert isinstance(info.warnings, list) + + def test_compression_level_effect(self, temp_dirs: tuple[Path, Path]) -> None: + """测试不同压缩级别对估算的影响""" + source, target = temp_dirs + (source / "data.txt").write_text("hello world " * 1000) + + checker = DiskChecker(str(source), str(target)) + source_total = checker.scan_source() + + low_level = checker.estimate_backup_size("ZIP", 1, source_total) + high_level = checker.estimate_backup_size("ZIP", 9, source_total) + + # 更高压缩级别应该产生更小的估算 + assert high_level <= low_level diff --git a/tests/sbackup/test_dryrun.py b/tests/sbackup/test_dryrun.py new file mode 100644 index 0000000..18d70b3 --- /dev/null +++ b/tests/sbackup/test_dryrun.py @@ -0,0 +1,332 @@ +"""单元测试 for sbackup.dryrun 模块""" + +import os +import shutil +import tempfile +import unittest +from sbackup.config import Config +from sbackup.dryrun import DryRunResult, DryRunScanner + + +class TestDryRunResult(unittest.TestCase): + """DryRunResult 数据类测试""" + + def test_default_values(self): + result = DryRunResult() + self.assertEqual(result.total_files, 0) + self.assertEqual(result.included_files, 0) + self.assertEqual(result.excluded_files, 0) + self.assertEqual(result.total_size, 0) + self.assertEqual(result.included_size, 0) + self.assertEqual(result.excluded_size, 0) + self.assertEqual(result.skip_patterns_matched, {}) + self.assertEqual(result.large_files, []) + self.assertEqual(result.file_types, {}) + self.assertEqual(result.warnings, []) + + def test_accumulation(self): + result = DryRunResult() + result.total_files = 10 + result.included_files = 7 + result.excluded_files = 3 + result.total_size = 1000 + result.included_size = 700 + result.excluded_size = 300 + # included + excluded should equal total + self.assertEqual( + result.included_files + result.excluded_files, + result.total_files, + ) + + +class TestDryRunScanner(unittest.TestCase): + """DryRunScanner 扫描逻辑测试""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_dryrun_test_") + self.config = Config() + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _create_file(self, rel_path: str, size: int = 100) -> str: + """在测试目录中创建指定大小的文件""" + file_path = os.path.join(self.test_dir, rel_path) + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "wb") as f: + f.write(b"x" * size) + return file_path + + def test_scan_empty_directory(self): + """空目录应返回零统计""" + scanner = DryRunScanner(self.test_dir, self.config) + result = scanner.scan() + self.assertEqual(result.total_files, 0) + self.assertEqual(result.included_files, 0) + self.assertEqual(result.excluded_files, 0) + + def test_scan_includes_all_files(self): + """没有过滤规则时,所有文件应被包含""" + self._create_file("a.txt", 100) + self._create_file("b.py", 200) + self._create_file("sub/c.json", 300) + + scanner = DryRunScanner(self.test_dir, self.config) + result = scanner.scan() + + self.assertEqual(result.total_files, 3) + self.assertEqual(result.included_files, 3) + self.assertEqual(result.excluded_files, 0) + self.assertEqual(result.total_size, 600) + self.assertEqual(result.included_size, 600) + + def test_skip_patterns_filter(self): + """skip_patterns 应排除匹配的目录(目录被剪枝,内部文件不遍历)""" + self._create_file("code.py", 100) + self._create_file(".git/config", 50) + self._create_file("__pycache__/module.pyc", 200) + + config = Config(skip_patterns=[".git", "__pycache__"]) + scanner = DryRunScanner(self.test_dir, config) + result = scanner.scan() + + # skip_patterns 剪枝目录,.git 和 __pycache__ 下的文件不被遍历 + self.assertEqual(result.total_files, 1) + self.assertEqual(result.included_files, 1) + self.assertEqual(result.excluded_files, 0) + + def test_include_patterns_filter(self): + """include_patterns 应只保留匹配的文件""" + self._create_file("code.py", 100) + self._create_file("data.json", 200) + self._create_file("readme.txt", 300) + + config = Config(include_patterns=["*.py"]) + scanner = DryRunScanner(self.test_dir, config) + result = scanner.scan() + + self.assertEqual(result.total_files, 3) + self.assertEqual(result.included_files, 1) + self.assertEqual(result.excluded_files, 2) + + def test_exclude_patterns_filter(self): + """exclude_patterns 应排除匹配的文件""" + self._create_file("code.py", 100) + self._create_file("debug.log", 200) + self._create_file("error.log", 300) + + config = Config(exclude_patterns=["*.log"]) + scanner = DryRunScanner(self.test_dir, config) + result = scanner.scan() + + self.assertEqual(result.total_files, 3) + self.assertEqual(result.included_files, 1) + self.assertEqual(result.excluded_files, 2) + + def test_max_size_filter(self): + """max_size 应排除超过大小限制的文件""" + self._create_file("small.txt", 100) + self._create_file("medium.txt", 500) + self._create_file("large.txt", 1000) + + config = Config(max_size=600) + scanner = DryRunScanner(self.test_dir, config) + result = scanner.scan() + + self.assertEqual(result.total_files, 3) + self.assertEqual(result.included_files, 2) + self.assertEqual(result.excluded_files, 1) + # 验证被排除的是最大的文件 + excluded_paths = [f for f, _, _ in result.excluded_list] + self.assertTrue(any("large.txt" in p for p in excluded_paths)) + + def test_min_size_filter(self): + """min_size 应排除小于大小限制的文件""" + self._create_file("tiny.txt", 10) + self._create_file("small.txt", 100) + self._create_file("big.txt", 1000) + + config = Config(min_size=50) + scanner = DryRunScanner(self.test_dir, config) + result = scanner.scan() + + self.assertEqual(result.total_files, 3) + self.assertEqual(result.included_files, 2) + self.assertEqual(result.excluded_files, 1) + excluded_paths = [f for f, _, _ in result.excluded_list] + self.assertTrue(any("tiny.txt" in p for p in excluded_paths)) + + def test_file_types统计(self): + """应正确统计文件类型分布""" + self._create_file("a.py", 100) + self._create_file("b.py", 200) + self._create_file("c.json", 300) + self._create_file("d.txt", 400) + + scanner = DryRunScanner(self.test_dir, self.config) + result = scanner.scan() + + self.assertEqual(result.file_types[".py"], 2) + self.assertEqual(result.file_types[".json"], 1) + self.assertEqual(result.file_types[".txt"], 1) + + def test_large_files_sorted(self): + """最大文件应按大小降序排列""" + self._create_file("tiny.txt", 10) + self._create_file("medium.txt", 500) + self._create_file("big.txt", 1000) + self._create_file("huge.bin", 5000) + + scanner = DryRunScanner(self.test_dir, self.config) + result = scanner.scan() + + self.assertEqual(len(result.large_files), 4) + # 验证降序排列 + sizes = [size for _, size in result.large_files] + self.assertEqual(sizes, sorted(sizes, reverse=True)) + # 第一个应该是最大的文件 + self.assertTrue(result.large_files[0][0].endswith("huge.bin")) + + def test_large_files_limited_to_10(self): + """最大文件列表应限制为 10 个""" + for i in range(15): + self._create_file(f"file_{i:02d}.txt", 100 + i * 10) + + scanner = DryRunScanner(self.test_dir, self.config) + result = scanner.scan() + + self.assertEqual(len(result.large_files), 10) + + def test_invalid_directory(self): + """无效目录应返回带警告的结果""" + scanner = DryRunScanner("/nonexistent/path", self.config) + result = scanner.scan() + + self.assertEqual(result.total_files, 0) + self.assertTrue(len(result.warnings) > 0) + + def test_format_summary_contains_key_info(self): + """format_summary 应包含关键信息""" + self._create_file("code.py", 100) + self._create_file(".git/config", 50) + + config = Config(skip_patterns=[".git"]) + scanner = DryRunScanner(self.test_dir, config) + result = scanner.scan() + summary = scanner.format_summary(result) + + self.assertIn(self.test_dir, summary) + self.assertIn( + "1", summary + ) # total_files (.git dir is pruned, only code.py counted) + + def test_format_file_list_included(self): + """format_file_list 应列出包含的文件""" + self._create_file("a.py", 100) + self._create_file("b.txt", 200) + + scanner = DryRunScanner(self.test_dir, self.config) + result = scanner.scan() + file_list = scanner.format_file_list(result, show_excluded=False) + + self.assertIn("a.py", file_list) + self.assertIn("b.txt", file_list) + + def test_format_file_list_excluded(self): + """format_file_list 应列出排除的文件""" + self._create_file("code.py", 100) + self._create_file("debug.log", 200) + + config = Config(exclude_patterns=["*.log"]) + scanner = DryRunScanner(self.test_dir, config) + result = scanner.scan() + file_list = scanner.format_file_list(result, show_excluded=True) + + self.assertIn("debug.log", file_list) + self.assertNotIn("code.py", file_list) + + def test_format_file_list_limit(self): + """format_file_list 应遵守 limit 参数""" + for i in range(60): + self._create_file(f"file_{i:02d}.txt", 100) + + scanner = DryRunScanner(self.test_dir, self.config) + result = scanner.scan() + file_list = scanner.format_file_list(result, limit=10) + + self.assertIn("...", file_list) + + def test_dry_run_creates_no_files(self): + """DryRunScanner 不应创建或修改任何文件""" + self._create_file("existing.txt", 100) + before = set(os.listdir(self.test_dir)) + + scanner = DryRunScanner(self.test_dir, self.config) + scanner.scan() + + after = set(os.listdir(self.test_dir)) + self.assertEqual(before, after) + + def test_combined_include_exclude(self): + """同时设置 include_patterns 和 exclude_patterns""" + self._create_file("main.py", 100) + self._create_file("test.py", 200) + self._create_file("data.json", 300) + + config = Config(include_patterns=["*.py"], exclude_patterns=["test.py"]) + scanner = DryRunScanner(self.test_dir, config) + result = scanner.scan() + + self.assertEqual(result.included_files, 1) + self.assertEqual(result.excluded_files, 2) + + def test_size_accounting(self): + """包含和排除的大小之和应等于总大小""" + self._create_file("a.txt", 100) + self._create_file("b.log", 200) + + config = Config(exclude_patterns=["*.log"]) + scanner = DryRunScanner(self.test_dir, config) + result = scanner.scan() + + self.assertEqual(result.total_size, result.included_size + result.excluded_size) + + def test_subdirectory_traversal(self): + """应递归遍历子目录""" + self._create_file("root.txt", 10) + self._create_file("sub1/a.txt", 20) + self._create_file("sub1/sub2/b.txt", 30) + + scanner = DryRunScanner(self.test_dir, self.config) + result = scanner.scan() + + self.assertEqual(result.total_files, 3) + + def test_skip_pattern_no_negation(self): + """当 skip_patterns 中有 ! 取反模式时,取反应生效""" + self._create_file("code.py", 100) + self._create_file(".gitignore", 50) + self._create_file("__pycache__/cache.pyc", 200) + + # skip .git, __pycache__ 但排除 .gitignore + config = Config(skip_patterns=[".git", "__pycache__", "!.gitignore"]) + scanner = DryRunScanner(self.test_dir, config) + result = scanner.scan() + + # __pycache__ 目录被剪枝不遍历,.gitignore 取反保留 + self.assertEqual(result.total_files, 2) + # .gitignore 应被保留(取反),code.py 正常 + self.assertEqual(result.included_files, 2) + self.assertEqual(result.excluded_files, 0) + + def test_format_size_helper(self): + """_format_size 应正确格式化各种大小""" + self.assertEqual(DryRunScanner._format_size(0), "0 B") + self.assertEqual(DryRunScanner._format_size(512), "512 B") + self.assertIn("KB", DryRunScanner._format_size(2048)) + self.assertIn("MB", DryRunScanner._format_size(5 * 1024 * 1024)) + self.assertIn("GB", DryRunScanner._format_size(3 * 1024 * 1024 * 1024)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sbackup/test_export.py b/tests/sbackup/test_export.py new file mode 100644 index 0000000..9755bd5 --- /dev/null +++ b/tests/sbackup/test_export.py @@ -0,0 +1,422 @@ +""" +单元测试 for sbackup.export 模块 +""" + +import csv +import json +import os +import tempfile +import shutil +import unittest + +from sbackup.export import MetadataExporter + + +class TestMetadataExporter(unittest.TestCase): + """MetadataExporter 类测试""" + + def setUp(self): + """创建临时目录和测试数据文件""" + self.tmpdir = tempfile.mkdtemp() + # 创建模拟数据文件(包含历史记录) + self.data_file = os.path.join(self.tmpdir, "sbackup.json") + self.data = { + "_history": [ + { + "time": "2026-06-07T10:30:00", + "source": "/home/user/docs", + "size_mb": 25.6, + "files_count": 150, + "sha256": "abc123def456", + }, + { + "time": "2026-06-08T11:00:00", + "source": "/home/user/photos", + "size_mb": 1024.0, + "files_count": 500, + "sha256": "789abc012def", + }, + ], + } + with open(self.data_file, "w", encoding="utf-8") as f: + json.dump(self.data, f, ensure_ascii=False, indent=4) + + # 创建模拟审计日志文件 + # 计算审计文件路径(使用与 AuditLogger 相同的逻辑) + if os.name == "nt": + base = os.environ.get("APPDATA", os.path.expanduser("~")) + elif __import__("sys").platform == "darwin": + base = os.path.expanduser("~/Library/Application Support") + else: + base = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) + self.audit_dir = os.path.join(base, "sbackup_test_export") + os.makedirs(self.audit_dir, exist_ok=True) + self.audit_file = os.path.join(self.audit_dir, "audit.json") + self.audit_data = [ + { + "id": "uuid-001", + "timestamp": "2026-06-07T10:30:00Z", + "event": "backup_complete", + "source_path": "/home/user/docs", + "target_path": "/backup/docs", + "format": "ZIP", + "status": "success", + "files_count": 150, + "total_size": 26843545, + "backup_size": 25600000, + "duration": 5.3, + "error_message": "", + "metadata": {}, + }, + { + "id": "uuid-002", + "timestamp": "2026-06-08T11:00:00Z", + "event": "backup_failed", + "source_path": "/home/user/photos", + "target_path": "/backup/photos", + "format": "tar.gz", + "status": "failed", + "files_count": 0, + "total_size": 0, + "backup_size": 0, + "duration": 1.2, + "error_message": "disk full", + "metadata": {}, + }, + ] + with open(self.audit_file, "w", encoding="utf-8") as f: + json.dump(self.audit_data, f, ensure_ascii=False, indent=4) + + # 创建导出器,使用测试数据文件,并 mock 审计日志路径 + self.exporter = MetadataExporter(data_file=self.data_file) + # monkey-patch 审计日志加载以使用测试文件 + self.exporter._load_audit_entries = self._mock_load_audit + + def _mock_load_audit(self) -> list[dict]: + """模拟加载审计日志""" + if not os.path.exists(self.audit_file): + return [] + try: + with open(self.audit_file, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, list) else [] + except (json.JSONDecodeError, OSError): + return [] + + def tearDown(self): + """清理临时目录""" + shutil.rmtree(self.tmpdir, ignore_errors=True) + shutil.rmtree(self.audit_dir, ignore_errors=True) + + # ── 历史记录 CSV 导出 ──────────────────────────────────── + + def test_export_history_csv(self): + """测试 CSV 导出备份历史""" + output = os.path.join(self.tmpdir, "history.csv") + count = self.exporter.export_history_csv(output) + self.assertEqual(count, 2) + self.assertTrue(os.path.exists(output)) + + def test_export_history_csv_content(self): + """测试 CSV 文件格式正确性(header + data rows)""" + output = os.path.join(self.tmpdir, "history.csv") + self.exporter.export_history_csv(output) + with open(output, "r", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + # header + 2 data rows + self.assertEqual(len(rows), 3) + # 验证 header + self.assertEqual( + rows[0], + ["time", "strategy", "source", "files_count", "size", "checksum"], + ) + # 验证数据行 + self.assertEqual(rows[1][0], "2026-06-07T10:30:00") + self.assertEqual(rows[1][2], "/home/user/docs") + self.assertEqual(rows[1][3], "150") + self.assertEqual(rows[1][5], "abc123def456") + + def test_export_history_csv_strategy_basename(self): + """测试 CSV 中 strategy 列为 source 路径的 basename""" + output = os.path.join(self.tmpdir, "history.csv") + self.exporter.export_history_csv(output) + with open(output, "r", encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + self.assertEqual(rows[0]["strategy"], "docs") + self.assertEqual(rows[1]["strategy"], "photos") + + # ── 历史记录 JSON 导出 ─────────────────────────────────── + + def test_export_history_json(self): + """测试 JSON 导出备份历史""" + output = os.path.join(self.tmpdir, "history.json") + count = self.exporter.export_history_json(output) + self.assertEqual(count, 2) + self.assertTrue(os.path.exists(output)) + + def test_export_history_json_content(self): + """测试 JSON 文件内容正确""" + output = os.path.join(self.tmpdir, "history.json") + self.exporter.export_history_json(output) + with open(output, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(len(data), 2) + self.assertEqual(data[0]["source"], "/home/user/docs") + self.assertEqual(data[1]["source"], "/home/user/photos") + + # ── 审计日志 CSV 导出 ──────────────────────────────────── + + def test_export_audit_csv(self): + """测试 CSV 导出审计日志""" + output = os.path.join(self.tmpdir, "audit.csv") + count = self.exporter.export_audit_csv(output) + self.assertEqual(count, 2) + self.assertTrue(os.path.exists(output)) + + def test_export_audit_csv_content(self): + """测试审计 CSV 文件格式正确性""" + output = os.path.join(self.tmpdir, "audit.csv") + self.exporter.export_audit_csv(output) + with open(output, "r", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + # header + 2 data rows + self.assertEqual(len(rows), 3) + # 验证 header + self.assertEqual( + rows[0], + [ + "id", + "timestamp", + "event", + "status", + "source_path", + "target_path", + "format", + "files_count", + "total_size", + "backup_size", + "duration", + "error_message", + ], + ) + # 验证数据行 + self.assertEqual(rows[1][0], "uuid-001") + self.assertEqual(rows[1][2], "backup_complete") + self.assertEqual(rows[1][3], "success") + + # ── 审计日志 JSON 导出 ─────────────────────────────────── + + def test_export_audit_json(self): + """测试 JSON 导出审计日志""" + output = os.path.join(self.tmpdir, "audit.json") + count = self.exporter.export_audit_json(output) + self.assertEqual(count, 2) + self.assertTrue(os.path.exists(output)) + + def test_export_audit_json_content(self): + """测试审计 JSON 文件内容正确""" + output = os.path.join(self.tmpdir, "audit.json") + self.exporter.export_audit_json(output) + with open(output, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(len(data), 2) + self.assertEqual(data[0]["event"], "backup_complete") + self.assertEqual(data[1]["event"], "backup_failed") + + # ── 综合导出 ───────────────────────────────────────────── + + def test_export_combined_json(self): + """测试 JSON 综合导出""" + output = os.path.join(self.tmpdir, "combined.json") + count = self.exporter.export_combined(output, fmt="json") + self.assertEqual(count, 4) # 2 history + 2 audit + self.assertTrue(os.path.exists(output)) + with open(output, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertIn("export_time", data) + self.assertIn("history", data) + self.assertIn("audit", data) + self.assertIn("stats", data) + self.assertEqual(len(data["history"]), 2) + self.assertEqual(len(data["audit"]), 2) + self.assertEqual(data["stats"]["history_count"], 2) + self.assertEqual(data["stats"]["audit_count"], 2) + + def test_export_combined_csv(self): + """测试 CSV 综合导出(生成多个文件)""" + output = os.path.join(self.tmpdir, "combined.csv") + count = self.exporter.export_combined(output, fmt="csv") + self.assertEqual(count, 4) # 2 history + 2 audit + # 应该生成 history CSV、audit CSV 和 stats JSON + self.assertTrue( + os.path.exists(os.path.join(self.tmpdir, "combined_history.csv")) + ) + self.assertTrue(os.path.exists(os.path.join(self.tmpdir, "combined_audit.csv"))) + self.assertTrue( + os.path.exists(os.path.join(self.tmpdir, "combined_stats.json")) + ) + + # ── 策略名称过滤 ───────────────────────────────────────── + + def test_strategy_name_filter(self): + """测试按策略名称过滤历史记录""" + output = os.path.join(self.tmpdir, "filtered.csv") + count = self.exporter.export_history_csv(output, strategy_name="docs") + self.assertEqual(count, 1) + with open(output, "r", encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["source"], "/home/user/docs") + + def test_strategy_name_filter_no_match(self): + """测试策略名称过滤无匹配""" + output = os.path.join(self.tmpdir, "filtered.csv") + count = self.exporter.export_history_csv(output, strategy_name="nonexistent") + self.assertEqual(count, 0) + + def test_strategy_name_filter_json(self): + """测试 JSON 格式策略名称过滤""" + output = os.path.join(self.tmpdir, "filtered.json") + count = self.exporter.export_history_json(output, strategy_name="photos") + self.assertEqual(count, 1) + with open(output, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["source"], "/home/user/photos") + + # ── 事件类型过滤 ───────────────────────────────────────── + + def test_event_filter(self): + """测试按事件类型过滤审计日志""" + output = os.path.join(self.tmpdir, "filtered.csv") + count = self.exporter.export_audit_csv(output, event="backup_complete") + self.assertEqual(count, 1) + with open(output, "r", encoding="utf-8") as f: + reader = csv.DictReader(f) + rows = list(reader) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["event"], "backup_complete") + + def test_event_filter_no_match(self): + """测试事件类型过滤无匹配""" + output = os.path.join(self.tmpdir, "filtered.csv") + count = self.exporter.export_audit_csv(output, event="restore") + self.assertEqual(count, 0) + + def test_event_filter_json(self): + """测试 JSON 格式事件类型过滤""" + output = os.path.join(self.tmpdir, "filtered.json") + count = self.exporter.export_audit_json(output, event="backup_failed") + self.assertEqual(count, 1) + with open(output, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["event"], "backup_failed") + + # ── 空历史记录 ─────────────────────────────────────────── + + def test_empty_history_csv(self): + """测试空历史记录的 CSV 导出""" + empty_data_file = os.path.join(self.tmpdir, "empty.json") + with open(empty_data_file, "w", encoding="utf-8") as f: + json.dump({}, f) + exporter = MetadataExporter(data_file=empty_data_file) + output = os.path.join(self.tmpdir, "empty.csv") + count = exporter.export_history_csv(output) + self.assertEqual(count, 0) + with open(output, "r", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + # 仅 header 行 + self.assertEqual(len(rows), 1) + + def test_empty_history_json(self): + """测试空历史记录的 JSON 导出""" + empty_data_file = os.path.join(self.tmpdir, "empty.json") + with open(empty_data_file, "w", encoding="utf-8") as f: + json.dump({}, f) + exporter = MetadataExporter(data_file=empty_data_file) + output = os.path.join(self.tmpdir, "empty.json") + count = exporter.export_history_json(output) + self.assertEqual(count, 0) + with open(output, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data, []) + + def test_empty_audit_csv(self): + """测试空审计日志的 CSV 导出""" + exporter = MetadataExporter(data_file=self.data_file) + exporter._load_audit_entries = lambda: [] + output = os.path.join(self.tmpdir, "empty_audit.csv") + count = exporter.export_audit_csv(output) + self.assertEqual(count, 0) + with open(output, "r", encoding="utf-8") as f: + reader = csv.reader(f) + rows = list(reader) + # 仅 header 行 + self.assertEqual(len(rows), 1) + + # ── get_history / get_audit_entries ─────────────────────── + + def test_get_history_all(self): + """测试获取全部历史记录""" + history = self.exporter.get_history() + self.assertEqual(len(history), 2) + + def test_get_history_filtered(self): + """测试过滤获取历史记录""" + history = self.exporter.get_history(strategy_name="docs") + self.assertEqual(len(history), 1) + self.assertEqual(history[0]["source"], "/home/user/docs") + + def test_get_audit_entries_all(self): + """测试获取全部审计日志""" + entries = self.exporter.get_audit_entries() + self.assertEqual(len(entries), 2) + + def test_get_audit_entries_filtered(self): + """测试过滤获取审计日志""" + entries = self.exporter.get_audit_entries(event="backup_complete") + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["event"], "backup_complete") + + # ── 创建输出目录 ───────────────────────────────────────── + + def test_export_creates_output_directory(self): + """测试导出时自动创建输出目录""" + output = os.path.join(self.tmpdir, "subdir", "nested", "history.csv") + count = self.exporter.export_history_csv(output) + self.assertEqual(count, 2) + self.assertTrue(os.path.exists(output)) + + # ── 不存在的数据文件 ───────────────────────────────────── + + def test_nonexistent_data_file(self): + """测试数据文件不存在时的处理""" + exporter = MetadataExporter(data_file="/nonexistent/path/data.json") + history = exporter.get_history() + self.assertEqual(history, []) + + # ── get_history 返回 dict 列表 ─────────────────────────── + + def test_get_history_returns_list_of_dicts(self): + """测试 get_history 返回 list[dict]""" + history = self.exporter.get_history() + self.assertIsInstance(history, list) + for entry in history: + self.assertIsInstance(entry, dict) + + def test_get_audit_entries_returns_list_of_dicts(self): + """测试 get_audit_entries 返回 list[dict]""" + entries = self.exporter.get_audit_entries() + self.assertIsInstance(entries, list) + for entry in entries: + self.assertIsInstance(entry, dict) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sbackup/test_hooks.py b/tests/sbackup/test_hooks.py new file mode 100644 index 0000000..0dc3194 --- /dev/null +++ b/tests/sbackup/test_hooks.py @@ -0,0 +1,350 @@ +""" +单元测试 for sbackup.hooks 模块(HookResult / HookRunner) +""" + +import os +import sys +import unittest + +from sbackup.hooks import HookResult, HookRunner + + +def _make_python_cmd(script: str) -> str: + """创建跨平台的 Python 命令 + + 将可执行文件路径用双引号包裹,确保 shlex.split 在 posix 模式下 + 不会将 Windows 路径中的反斜杠解释为转义字符。 + """ + return f'"{sys.executable}" -c {script}' + + +# 命令中不包含空格或分号,确保 shlex.split 正确解析 +_echo_cmd = _make_python_cmd("print(42)") +_fail_cmd = _make_python_cmd("exit(1)") +# 超时命令使用 ping(无引号/分号问题): +# Windows: ping -n 11 127.0.0.1 (~10s) +# Unix: ping -c 11 127.0.0.1 (~10s) +_sleep_cmd = "ping -n 11 127.0.0.1" if sys.platform == "win32" else "sleep 10" + + +class TestHookResult(unittest.TestCase): + """测试 HookResult 数据类""" + + def test_basic_creation(self): + """测试基本创建""" + r = HookResult( + hook_type="pre", + command="echo hello", + return_code=0, + stdout="hello\n", + stderr="", + success=True, + duration=0.1, + ) + self.assertEqual(r.hook_type, "pre") + self.assertEqual(r.command, "echo hello") + self.assertEqual(r.return_code, 0) + self.assertTrue(r.success) + self.assertFalse(r.timed_out) + + def test_timeout_result(self): + """测试超时结果""" + r = HookResult( + hook_type="post", + command="sleep 999", + return_code=None, + stdout="", + stderr="timed out", + success=False, + duration=5.0, + timed_out=True, + ) + self.assertIsNone(r.return_code) + self.assertFalse(r.success) + self.assertTrue(r.timed_out) + + +class TestHookRunnerRunSingle(unittest.TestCase): + """测试 HookRunner.run_single 方法""" + + def test_success_command(self): + """测试正常执行成功的命令""" + runner = HookRunner(timeout=10) + result = runner.run_single(_echo_cmd, "pre") + + self.assertTrue(result.success) + self.assertEqual(result.return_code, 0) + self.assertEqual(result.hook_type, "pre") + self.assertIn("42", result.stdout) + self.assertFalse(result.timed_out) + self.assertGreaterEqual(result.duration, 0) + + def test_failure_command(self): + """测试执行失败的命令(return_code != 0)""" + runner = HookRunner(timeout=10) + result = runner.run_single(_fail_cmd, "post") + + self.assertFalse(result.success) + self.assertNotEqual(result.return_code, 0) + self.assertEqual(result.hook_type, "post") + self.assertFalse(result.timed_out) + + def test_timeout_command(self): + """测试命令超时""" + runner = HookRunner(timeout=1) + result = runner.run_single(_sleep_cmd, "pre") + + self.assertFalse(result.success) + self.assertTrue(result.timed_out) + self.assertIsNone(result.return_code) + self.assertLess(result.duration, 5.0) # Should timeout quickly + + def test_nonexistent_command(self): + """测试执行不存在的命令""" + runner = HookRunner(timeout=10) + result = runner.run_single("this_command_does_not_exist_xyz123", "pre") + + self.assertFalse(result.success) + self.assertNotEqual(result.return_code, 0) + self.assertFalse(result.timed_out) + self.assertTrue(len(result.stderr) > 0 or result.return_code is not None) + + def test_hook_type_preserved(self): + """测试 hook_type 正确保留""" + runner = HookRunner(timeout=10) + result = runner.run_single(_echo_cmd, "post") + self.assertEqual(result.hook_type, "post") + + +class TestHookRunnerRunHooks(unittest.TestCase): + """测试 HookRunner.run_hooks 方法""" + + def test_multiple_hooks(self): + """测试执行多个 hooks""" + runner = HookRunner( + pre_hooks=[_echo_cmd, _echo_cmd], + timeout=10, + ) + results = runner.run_hooks("pre") + self.assertEqual(len(results), 2) + self.assertTrue(all(r.success for r in results)) + self.assertEqual(results[0].hook_type, "pre") + self.assertEqual(results[1].hook_type, "pre") + + def test_empty_hooks_list(self): + """测试空 hooks 列表""" + runner = HookRunner(pre_hooks=[], timeout=10) + results = runner.run_hooks("pre") + self.assertEqual(len(results), 0) + + def test_pre_hooks_only(self): + """测试 pre_hooks 单独执行""" + runner = HookRunner( + pre_hooks=[_echo_cmd], + post_hooks=["never runs"], + timeout=10, + ) + pre_results = runner.run_pre_hooks() + self.assertEqual(len(pre_results), 1) + self.assertIn("42", pre_results[0].stdout) + + def test_post_hooks_only(self): + """测试 post_hooks 单独执行""" + runner = HookRunner( + pre_hooks=["never runs"], + post_hooks=[_echo_cmd], + timeout=10, + ) + post_results = runner.run_post_hooks() + self.assertEqual(len(post_results), 1) + self.assertIn("42", post_results[0].stdout) + + def test_mixed_success_and_failure(self): + """测试混合成功和失败的 hooks""" + runner = HookRunner( + pre_hooks=[_echo_cmd, _fail_cmd], + timeout=10, + ) + results = runner.run_hooks("pre") + self.assertEqual(len(results), 2) + self.assertTrue(results[0].success) + self.assertFalse(results[1].success) + + def test_empty_string_hook_skipped(self): + """测试空字符串 hook 被跳过""" + runner = HookRunner( + pre_hooks=["", _echo_cmd, ""], + timeout=10, + ) + results = runner.run_hooks("pre") + self.assertEqual(len(results), 1) + self.assertTrue(results[0].success) + + def test_none_pre_hooks(self): + """测试 pre_hooks 为 None""" + runner = HookRunner(pre_hooks=None, timeout=10) + results = runner.run_pre_hooks() + self.assertEqual(len(results), 0) + + def test_none_post_hooks(self): + """测试 post_hooks 为 None""" + runner = HookRunner(post_hooks=None, timeout=10) + results = runner.run_post_hooks() + self.assertEqual(len(results), 0) + + +class TestHookRunnerFormatResults(unittest.TestCase): + """测试 HookRunner.format_results 方法""" + + def test_format_empty_results(self): + """测试格式化空结果""" + runner = HookRunner() + output = runner.format_results([]) + self.assertIn("钩子", output) + + def test_format_with_results(self): + """测试格式化有结果的情况""" + runner = HookRunner() + results = [ + HookResult( + hook_type="pre", + command="echo ok", + return_code=0, + stdout="ok\n", + stderr="", + success=True, + duration=0.1, + ), + ] + output = runner.format_results(results) + self.assertIn("echo ok", output) + + def test_format_with_failure(self): + """测试格式化包含失败结果""" + runner = HookRunner() + results = [ + HookResult( + hook_type="pre", + command="bad_cmd", + return_code=1, + stdout="", + stderr="error msg", + success=False, + duration=0.5, + ), + ] + output = runner.format_results(results) + self.assertIn("bad_cmd", output) + self.assertIn("stderr: error msg", output) + + def test_format_summary(self): + """测试格式化摘要行""" + runner = HookRunner() + results = [ + HookResult( + hook_type="pre", + command="a", + return_code=0, + stdout="", + stderr="", + success=True, + duration=0.1, + ), + HookResult( + hook_type="pre", + command="b", + return_code=1, + stdout="", + stderr="", + success=False, + duration=0.1, + ), + ] + output = runner.format_results(results) + self.assertIn("2", output) # total + self.assertIn("1", output) # success/fail + + def test_format_timeout_status(self): + """测试格式化超时状态""" + runner = HookRunner() + results = [ + HookResult( + hook_type="pre", + command="sleep 999", + return_code=None, + stdout="", + stderr="", + success=False, + duration=1.0, + timed_out=True, + ), + ] + output = runner.format_results(results) + self.assertIn("TIMEOUT", output) + self.assertIn("N/A", output) + + +class TestHookRunnerWithConfig(unittest.TestCase): + """测试 HookRunner 与 Config 的集成""" + + def test_config_hooks_fields(self): + """测试 Config 包含 hooks 字段""" + from sbackup.config import Config + + cfg = Config() + self.assertEqual(cfg.pre_hooks, []) + self.assertEqual(cfg.post_hooks, []) + self.assertEqual(cfg.hook_timeout, 300) + + def test_config_load_hooks(self): + """测试从配置文件加载 hooks""" + import json + import tempfile + import shutil + + from sbackup.config import load_config + + test_dir = tempfile.mkdtemp() + try: + config_file = os.path.join(test_dir, "config.json") + config_data = { + "hooks": { + "pre": ["echo Starting", "python -c pass"], + "post": ["echo Done"], + "timeout": 60, + } + } + with open(config_file, "w", encoding="utf-8") as f: + json.dump(config_data, f) + + cfg = load_config(config_file) + self.assertEqual(cfg.pre_hooks, ["echo Starting", "python -c pass"]) + self.assertEqual(cfg.post_hooks, ["echo Done"]) + self.assertEqual(cfg.hook_timeout, 60) + finally: + shutil.rmtree(test_dir) + + def test_config_load_hooks_defaults(self): + """测试配置中无 hooks 段时使用默认值""" + import json + import tempfile + import shutil + + from sbackup.config import load_config + + test_dir = tempfile.mkdtemp() + try: + config_file = os.path.join(test_dir, "config.json") + with open(config_file, "w", encoding="utf-8") as f: + json.dump({"lang": "zh_CN"}, f) + + cfg = load_config(config_file) + self.assertEqual(cfg.pre_hooks, []) + self.assertEqual(cfg.post_hooks, []) + self.assertEqual(cfg.hook_timeout, 300) + finally: + shutil.rmtree(test_dir) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sbackup/test_integrity.py b/tests/sbackup/test_integrity.py new file mode 100644 index 0000000..00744f1 --- /dev/null +++ b/tests/sbackup/test_integrity.py @@ -0,0 +1,296 @@ +""" +单元测试 for sbackup.integrity 模块 +""" + +import json +import os +import shutil +import tempfile +import unittest + +from sbackup.integrity import ( + compute_checksum, + generate_checksum_file, + verify_checksum_file, + generate_backup_integrity, + verify_backup_integrity, +) + + +class TestComputeChecksum(unittest.TestCase): + def setUp(self): + self.tmp_dir = tempfile.mkdtemp() + self.test_file = os.path.join(self.tmp_dir, "test.txt") + + def tearDown(self): + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_checksum_correctness(self): + """验证 SHA256 校验和的正确性""" + content = b"Hello, sbackup integrity check!" + with open(self.test_file, "wb") as f: + f.write(content) + + checksum = compute_checksum(self.test_file) + self.assertEqual(len(checksum), 64, "SHA256 hex digest should be 64 chars") + self.assertEqual( + checksum, + "a7678d4a50336602c62bd7d23a9968cbe54a82a3d219f14f65c9328e6f421234", + ) + + def test_checksum_known_value(self): + """用已知内容验证 SHA256 值""" + content = b"abc" + with open(self.test_file, "wb") as f: + f.write(content) + + checksum = compute_checksum(self.test_file) + # SHA256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad + self.assertEqual( + checksum, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ) + + def test_checksum_empty_file(self): + """空文件的校验和""" + with open(self.test_file, "wb"): + pass + + checksum = compute_checksum(self.test_file) + self.assertEqual(len(checksum), 64) + + def test_checksum_file_not_found(self): + """文件不存在时抛出 FileNotFoundError""" + with self.assertRaises(FileNotFoundError): + compute_checksum(os.path.join(self.tmp_dir, "nonexistent.txt")) + + def test_checksum_deterministic(self): + """同一文件多次计算结果一致""" + with open(self.test_file, "wb") as f: + f.write(b"consistent") + + c1 = compute_checksum(self.test_file) + c2 = compute_checksum(self.test_file) + self.assertEqual(c1, c2) + + def test_checksum_different_files(self): + """不同内容产生不同校验和""" + with open(self.test_file, "wb") as f: + f.write(b"content_a") + + other_file = os.path.join(self.tmp_dir, "other.txt") + with open(other_file, "wb") as f: + f.write(b"content_b") + + self.assertNotEqual( + compute_checksum(self.test_file), compute_checksum(other_file) + ) + + +class TestChecksumFile(unittest.TestCase): + def setUp(self): + self.tmp_dir = tempfile.mkdtemp() + self.test_file = os.path.join(self.tmp_dir, "archive.zip") + + def tearDown(self): + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_generate_and_verify(self): + """生成 .sha256 文件后验证通过""" + with open(self.test_file, "wb") as f: + f.write(b"backup data here") + + checksum_path = generate_checksum_file(self.test_file) + self.assertTrue(os.path.isfile(checksum_path)) + self.assertEqual(checksum_path, self.test_file + ".sha256") + + # 验证文件内容格式 + with open(checksum_path, "r", encoding="utf-8") as f: + line = f.readline().strip() + parts = line.split(" ", 1) + self.assertEqual(len(parts), 2) + self.assertEqual(len(parts[0]), 64, "Hash should be 64 hex chars") + self.assertEqual(parts[1], "archive.zip") + + # 验证 + ok, msg = verify_checksum_file(self.test_file) + self.assertTrue(ok) + self.assertIn("verified", msg.lower()) + + def test_verify_tampered_file(self): + """文件被篡改后验证失败""" + with open(self.test_file, "wb") as f: + f.write(b"original content") + + generate_checksum_file(self.test_file) + + # 篡改文件 + with open(self.test_file, "wb") as f: + f.write(b"tampered content") + + ok, msg = verify_checksum_file(self.test_file) + self.assertFalse(ok) + self.assertIn("mismatch", msg.lower()) + + def test_verify_no_checksum_file(self): + """缺少 .sha256 文件时返回失败""" + with open(self.test_file, "wb") as f: + f.write(b"data") + + ok, msg = verify_checksum_file(self.test_file) + self.assertFalse(ok) + self.assertIn("not found", msg.lower()) + + def test_verify_missing_source_file(self): + """源文件缺失时返回失败""" + # 创建一个 .sha256 文件但不创建源文件 + checksum_path = self.test_file + ".sha256" + with open(checksum_path, "w", encoding="utf-8") as f: + f.write( + "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 archive.zip\n" + ) + + ok, msg = verify_checksum_file(self.test_file) + self.assertFalse(ok) + self.assertIn("not found", msg.lower()) + + def test_generate_file_not_found(self): + """为不存在的文件生成校验文件时抛出异常""" + with self.assertRaises(FileNotFoundError): + generate_checksum_file(os.path.join(self.tmp_dir, "nope.zip")) + + +class TestBackupIntegrity(unittest.TestCase): + def setUp(self): + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def _create_backup_files(self, names: dict[str, bytes]) -> None: + """创建模拟备份文件""" + for name, content in names.items(): + with open(os.path.join(self.tmp_dir, name), "wb") as f: + f.write(content) + + def test_generate_integrity(self): + """生成 integrity.json 包含所有备份文件""" + self._create_backup_files( + { + "backup1.zip": b"zip content", + "backup2.tar.gz": b"tar.gz content", + "readme.txt": b"this should be ignored", + } + ) + + integrity_path = generate_backup_integrity(self.tmp_dir) + self.assertTrue(os.path.isfile(integrity_path)) + self.assertEqual(integrity_path, os.path.join(self.tmp_dir, "integrity.json")) + + with open(integrity_path, "r", encoding="utf-8") as f: + data = json.load(f) + + self.assertIn("created", data) + self.assertIn("files", data) + self.assertIn("backup1.zip", data["files"]) + self.assertIn("backup2.tar.gz", data["files"]) + self.assertNotIn("readme.txt", data["files"]) + self.assertEqual(len(data["files"]), 2) + + def test_verify_integrity_ok(self): + """文件未被修改时验证通过""" + self._create_backup_files( + { + "backup1.zip": b"content1", + "backup2.tar.gz": b"content2", + } + ) + generate_backup_integrity(self.tmp_dir) + + ok, messages = verify_backup_integrity(self.tmp_dir) + self.assertTrue(ok) + self.assertTrue(all(m.startswith("OK:") for m in messages)) + + def test_verify_integrity_tampered(self): + """文件被篡改后验证失败""" + self._create_backup_files({"backup1.zip": b"original"}) + generate_backup_integrity(self.tmp_dir) + + # 篡改文件 + with open(os.path.join(self.tmp_dir, "backup1.zip"), "wb") as f: + f.write(b"tampered") + + ok, messages = verify_backup_integrity(self.tmp_dir) + self.assertFalse(ok) + failed_msgs = [m for m in messages if m.startswith("FAILED:")] + self.assertEqual(len(failed_msgs), 1) + + def test_verify_integrity_missing_file(self): + """文件被删除后验证失败""" + self._create_backup_files( + { + "backup1.zip": b"data", + "backup2.tar.gz": b"data", + } + ) + generate_backup_integrity(self.tmp_dir) + + # 删除一个文件 + os.remove(os.path.join(self.tmp_dir, "backup1.zip")) + + ok, messages = verify_backup_integrity(self.tmp_dir) + self.assertFalse(ok) + missing_msgs = [m for m in messages if m.startswith("MISSING:")] + self.assertEqual(len(missing_msgs), 1) + self.assertIn("backup1.zip", missing_msgs[0]) + + def test_verify_integrity_no_manifest(self): + """缺少 integrity.json 时验证失败""" + ok, messages = verify_backup_integrity(self.tmp_dir) + self.assertFalse(ok) + self.assertTrue(any("not found" in m.lower() for m in messages)) + + def test_verify_integrity_empty_manifest(self): + """空的 integrity.json 通过验证""" + with open(os.path.join(self.tmp_dir, "integrity.json"), "w") as f: + json.dump({"created": "2026-01-01T00:00:00", "files": {}}, f) + + ok, messages = verify_backup_integrity(self.tmp_dir) + self.assertTrue(ok) + self.assertTrue(any("No files" in m for m in messages)) + + def test_generate_integrity_not_a_directory(self): + """路径不是目录时抛出异常""" + file_path = os.path.join(self.tmp_dir, "not_a_dir") + with open(file_path, "w") as f: + f.write("data") + with self.assertRaises(NotADirectoryError): + generate_backup_integrity(file_path) + + def test_integrity_preserves_checksums(self): + """生成的 integrity.json 中的校验和与 compute_checksum 一致""" + self._create_backup_files({"test.zip": b"verify me"}) + generate_backup_integrity(self.tmp_dir) + + with open(os.path.join(self.tmp_dir, "integrity.json"), "r") as f: + data = json.load(f) + + actual = compute_checksum(os.path.join(self.tmp_dir, "test.zip")) + self.assertEqual(data["files"]["test.zip"], actual) + + def test_generate_idempotent(self): + """多次生成 integrity.json 结果一致""" + self._create_backup_files({"backup.zip": b"stable content"}) + generate_backup_integrity(self.tmp_dir) + with open(os.path.join(self.tmp_dir, "integrity.json"), "r") as f: + data1 = json.load(f) + + generate_backup_integrity(self.tmp_dir) + with open(os.path.join(self.tmp_dir, "integrity.json"), "r") as f: + data2 = json.load(f) + + self.assertEqual(data1["files"], data2["files"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sbackup/test_keychain.py b/tests/sbackup/test_keychain.py new file mode 100644 index 0000000..0deb0fe --- /dev/null +++ b/tests/sbackup/test_keychain.py @@ -0,0 +1,153 @@ +"""单元测试 for sbackup.keychain 模块""" + +import sys +import unittest +from unittest.mock import patch, MagicMock + + +class TestKeychainFunctions(unittest.TestCase): + """测试 keychain 模块的基础功能(mock 平台 API)""" + + @patch("sbackup.keychain._win32_get_password") + def test_get_password_windows(self, mock_get): + """Windows 平台 get_password 应调用 win32 实现""" + mock_get.return_value = "secret123" + from sbackup.keychain import get_password + + with patch.object(sys, "platform", "win32"): + result = get_password("sbackup", "test_user") + self.assertEqual(result, "secret123") + mock_get.assert_called_once_with("sbackup", "test_user") + + @patch("sbackup.keychain._darwin_set_password") + def test_set_password_macos(self, mock_set): + """macOS 平台 set_password 应调用 darwin 实现""" + mock_set.return_value = True + from sbackup.keychain import set_password + + with patch.object(sys, "platform", "darwin"): + result = set_password("sbackup", "test_user", "mypass") + self.assertTrue(result) + mock_set.assert_called_once_with("sbackup", "test_user", "mypass") + + @patch("sbackup.keychain._linux_delete_password") + def test_delete_password_linux(self, mock_del): + """Linux 平台 delete_password 应调用 linux 实现""" + mock_del.return_value = True + from sbackup.keychain import delete_password + + with patch.object(sys, "platform", "linux"): + result = delete_password("sbackup", "test_user") + self.assertTrue(result) + mock_del.assert_called_once_with("sbackup", "test_user") + + def test_is_available_windows(self): + """Windows 平台 is_available 应返回 True""" + from sbackup.keychain import is_available + + with patch.object(sys, "platform", "win32"): + with patch("sbackup.keychain._win32_get_password") as mock_g: + mock_g.return_value = None + self.assertTrue(is_available()) + + def test_get_password_exception_returns_none(self): + """异常时应返回 None 而非抛出""" + from sbackup.keychain import get_password + + with patch.object(sys, "platform", "win32"): + with patch("sbackup.keychain._win32_get_password") as mock_g: + mock_g.side_effect = RuntimeError("API failed") + result = get_password("sbackup", "test") + self.assertIsNone(result) + + def test_set_password_exception_returns_false(self): + """异常时应返回 False 而非抛出""" + from sbackup.keychain import set_password + + with patch.object(sys, "platform", "darwin"): + with patch("sbackup.keychain._darwin_set_password") as mock_s: + mock_s.side_effect = RuntimeError("API failed") + result = set_password("sbackup", "test", "pass") + self.assertFalse(result) + + def test_delete_password_exception_returns_false(self): + """异常时应返回 False 而非抛出""" + from sbackup.keychain import delete_password + + with patch.object(sys, "platform", "linux"): + with patch("sbackup.keychain._linux_delete_password") as mock_d: + mock_d.side_effect = RuntimeError("API failed") + result = delete_password("sbackup", "test") + self.assertFalse(result) + + @patch("subprocess.run") + def test_darwin_get_password(self, mock_run): + """测试 macOS security CLI 调用""" + mock_run.return_value = MagicMock(returncode=0, stdout="secret\n") + from sbackup.keychain import _darwin_get_password + + result = _darwin_get_password("sbackup", "user") + self.assertEqual(result, "secret") + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + self.assertIn("security", args) + self.assertIn("find-generic-password", args) + + @patch("subprocess.run") + def test_darwin_get_password_not_found(self, mock_run): + """密钥中找不到密码时返回 None""" + mock_run.return_value = MagicMock(returncode=1, stdout="") + from sbackup.keychain import _darwin_get_password + + result = _darwin_get_password("sbackup", "user") + self.assertIsNone(result) + + @patch("subprocess.run") + def test_darwin_set_password(self, mock_run): + """测试 macOS security CLI 存储密码""" + mock_run.return_value = MagicMock(returncode=0) + from sbackup.keychain import _darwin_set_password + + result = _darwin_set_password("sbackup", "user", "pass") + self.assertTrue(result) + + @patch("subprocess.run") + def test_linux_get_password(self, mock_run): + """测试 Linux secret-tool CLI 调用""" + mock_run.return_value = MagicMock(returncode=0, stdout="secret\n") + from sbackup.keychain import _linux_get_password + + result = _linux_get_password("sbackup", "user") + self.assertEqual(result, "secret") + args = mock_run.call_args[0][0] + self.assertIn("secret-tool", args) + + @patch("subprocess.run") + def test_linux_set_password(self, mock_run): + """测试 Linux secret-tool 存储密码""" + mock_run.return_value = MagicMock(returncode=0) + from sbackup.keychain import _linux_set_password + + result = _linux_set_password("sbackup", "user", "pass") + self.assertTrue(result) + self.assertIn("secret-tool", mock_run.call_args[0][0]) + + @patch("subprocess.run") + def test_is_available_darwin(self, mock_run): + """macOS is_available 应调用 security help""" + mock_run.return_value = MagicMock(returncode=0) + from sbackup.keychain import is_available + + with patch.object(sys, "platform", "darwin"): + result = is_available() + self.assertTrue(result) + + @patch("subprocess.run") + def test_is_available_darwin_not_installed(self, mock_run): + """security 未安装时 is_available 返回 False""" + mock_run.side_effect = FileNotFoundError() + from sbackup.keychain import is_available + + with patch.object(sys, "platform", "darwin"): + result = is_available() + self.assertFalse(result) diff --git a/tests/sbackup/test_lock.py b/tests/sbackup/test_lock.py new file mode 100644 index 0000000..ba06b3d --- /dev/null +++ b/tests/sbackup/test_lock.py @@ -0,0 +1,133 @@ +"""单元测试 for sbackup.lock 模块""" + +import os +import unittest +import tempfile +import shutil + +from sbackup.lock import BackupLock + + +class TestBackupLock(unittest.TestCase): + def setUp(self): + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_acquire_and_release(self): + """测试获取和释放锁""" + lock = BackupLock(self.test_dir) + self.assertTrue(lock.acquire()) + self.assertTrue(lock._locked) + lock.release() + self.assertFalse(lock._locked) + self.assertFalse(os.path.exists(lock._lock_path)) + + def test_acquire_exclusive(self): + """测试锁的排他性:第二个获取应该失败""" + lock1 = BackupLock(self.test_dir) + lock2 = BackupLock(self.test_dir) + + self.assertTrue(lock1.acquire()) + self.assertFalse(lock2.acquire()) + + lock1.release() + + # 释放后第二个可以获取 + self.assertTrue(lock2.acquire()) + lock2.release() + + def test_double_acquire(self): + """测试重复获取:同一个实例多次获取应返回 True""" + lock = BackupLock(self.test_dir) + self.assertTrue(lock.acquire()) + self.assertTrue(lock.acquire()) # 已锁定,直接返回 True + lock.release() + + def test_clean_stale_lock(self): + """测试清理过期锁(死进程留下的锁文件)""" + lock = BackupLock(self.test_dir) + lock_path = os.path.join(self.test_dir, ".backup.lock") + + # 模拟一个死进程的锁文件 + dead_pid = 999999999 # 不可能存在的 PID + with open(lock_path, "w") as f: + f.write(str(dead_pid)) + + # 尝试获取,应该清理并成功 + self.assertTrue(lock.acquire()) + self.assertTrue(os.path.exists(lock_path)) + # 锁文件内容应该是当前进程的 PID + with open(lock_path) as f: + self.assertEqual(f.read().strip(), str(os.getpid())) + lock.release() + + def test_clean_corrupted_lock(self): + """测试清理损坏的锁文件""" + lock = BackupLock(self.test_dir) + lock_path = os.path.join(self.test_dir, ".backup.lock") + + # 写入无效内容 + with open(lock_path, "w") as f: + f.write("not_a_pid") + + self.assertTrue(lock.acquire()) + lock.release() + + def test_release_no_lock(self): + """测试未获取锁时释放不报错""" + lock = BackupLock(self.test_dir) + lock.release() # 不应抛异常 + + def test_context_manager_usage(self): + """测试手动 try/finally 用法(测试 release 防护)""" + lock = BackupLock(self.test_dir) + self.assertTrue(lock.acquire()) + # 模拟异常情况下的 release + lock.release() + self.assertFalse(lock._locked) + + def test_lock_file_location(self): + """测试锁文件路径正确""" + lock = BackupLock(self.test_dir) + expected = os.path.join(self.test_dir, ".backup.lock") + self.assertEqual(lock._lock_path, expected) + + def test_successive_acquire_after_release(self): + """测试释放后可以再次获取""" + lock = BackupLock(self.test_dir) + self.assertTrue(lock.acquire()) + lock.release() + self.assertTrue(lock.acquire()) + lock.release() + + def test_stale_lock_with_alive_pid(self): + """测试活进程的锁文件不会被清理""" + lock = BackupLock(self.test_dir) + + # 先获取锁 + lock.acquire() + + # 创建另一个锁实例,锁文件被活进程持有 + lock2 = BackupLock(self.test_dir) + self.assertFalse(lock2.acquire()) + + lock.release() + + +class TestIsPidAlive(unittest.TestCase): + """测试 _is_pid_alive 函数""" + + def test_own_pid_is_alive(self): + """当前进程的 PID 应该存活""" + from sbackup.lock import _is_pid_alive + + self.assertTrue(_is_pid_alive(os.getpid())) + + def test_nonexistent_pid_is_dead(self): + """不存在的 PID 应该返回 False""" + from sbackup.lock import _is_pid_alive + + # 使用一个极不可能存在的 PID + self.assertFalse(_is_pid_alive(999999999)) diff --git a/tests/sbackup/test_main.py b/tests/sbackup/test_main.py index de5a3e9..521db4e 100644 --- a/tests/sbackup/test_main.py +++ b/tests/sbackup/test_main.py @@ -9,8 +9,9 @@ import logging import tempfile import shutil -from unittest.mock import patch -from sbackup.i18n import t +from pathlib import Path +from unittest.mock import patch, MagicMock +from sbackup.i18n import t, _current_locale, _translations class TestMain(unittest.TestCase): @@ -19,16 +20,26 @@ def setUp(self): self.original_argv = sys.argv.copy() self._root_handlers = logging.root.handlers[:] self._root_level = logging.root.level + # 保存 i18n 状态 + self._orig_locale = _current_locale + self._orig_trans = _translations.copy() # 创建 config.json 指向测试目录的数据文件 self.data_path = os.path.join(self.test_dir, "sbackup.json") with open(os.path.join(self.test_dir, "config.json"), "w") as f: json.dump({"data_file": self.data_path}, f) + # 创建独立的目标目录(不能与源目录相同) + self.dest_dir = os.path.join(self.test_dir, "backup_dest") + os.makedirs(self.dest_dir, exist_ok=True) def tearDown(self): sys.argv = self.original_argv shutil.rmtree(self.test_dir, ignore_errors=True) logging.root.handlers = self._root_handlers logging.root.setLevel(self._root_level) + # 恢复 i18n 状态 + import sbackup.i18n as _i18n_mod + _i18n_mod._current_locale = self._orig_locale + _i18n_mod._translations = self._orig_trans @patch("builtins.print") def test_version_command(self, mock_print): @@ -117,7 +128,7 @@ def test_no_debug_keeps_default_logging(self, mock_print): def test_rm_command_success(self, mock_print): """测试 rm 命令成功删除策略""" os.chdir(self.test_dir) - sys.argv = ["sbackup", "--lang", "en_US", "add", self.test_dir, self.test_dir] + sys.argv = ["sbackup", "--lang", "en_US", "add", self.test_dir, self.dest_dir] from sbackup import run result1 = run() @@ -163,19 +174,19 @@ def test_watch_command(self, mock_sleep, mock_print): os.chdir(self.test_dir) sys.argv = ["sbackup", "--lang", "en_US", "watch", "--interval", "1"] from sbackup import run - import sbackup.__init__ as init_module + from sbackup.auto_save import BackupManager - original_execute = init_module.BackupManager.execute_backups + original_execute = BackupManager.execute_backups def mock_execute(self, *args, **kwargs): raise KeyboardInterrupt() - init_module.BackupManager.execute_backups = mock_execute + BackupManager.execute_backups = mock_execute try: result = run() self.assertEqual(result, 0) finally: - init_module.BackupManager.execute_backups = original_execute + BackupManager.execute_backups = original_execute @patch("builtins.print") def test_add_with_format(self, mock_print): @@ -187,7 +198,7 @@ def test_add_with_format(self, mock_print): "en_US", "add", self.test_dir, - self.test_dir, + self.dest_dir, "--format", "tar.gz", ] @@ -217,6 +228,27 @@ def test_interval_accepts_float(self): args = parser.parse_args(["watch", "--interval", "30"]) self.assertEqual(args.interval, 30) + def test_restore_password_argument(self): + """测试 restore --password 参数""" + from sbackup import get_parser + + parser = get_parser() + args = parser.parse_args( + ["restore", "backup.7z", "/tmp", "--password", "secret"] + ) + self.assertEqual(args.command, "restore") + self.assertEqual(args.backup_file, "backup.7z") + self.assertEqual(args.target_dir, "/tmp") + self.assertEqual(args.password, "secret") + + def test_restore_password_default_empty(self): + """测试 restore --password 默认为空""" + from sbackup import get_parser + + parser = get_parser() + args = parser.parse_args(["restore", "backup.zip", "/tmp"]) + self.assertEqual(args.password, "") + def test_argparse_invalid_choice_localized(self): """测试 argparse 无效选择错误被本地化""" from sbackup import get_parser @@ -259,6 +291,785 @@ def test_argparse_invalid_float_localized(self): finally: sys.stderr = old_stderr + @patch("builtins.print") + def test_save_with_sftp_flag(self, mock_print): + """测试 save --sftp 参数传递""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "save", "--sftp"] + from sbackup import run + from sbackup.auto_save import BackupManager + + original_execute = BackupManager.execute_backups + + def mock_execute( + self, keep=0, password="", sftp_upload=False, webdav_upload=False, **kwargs + ): + self._sftp_called = sftp_upload + + BackupManager.execute_backups = mock_execute + try: + result = run() + self.assertEqual(result, 0) + finally: + BackupManager.execute_backups = original_execute + + @patch("builtins.print") + def test_sftp_test_not_configured(self, mock_print): + """测试 sftp test 未配置时报错""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "sftp", "test"] + from sbackup import run + + result = run() + self.assertEqual(result, 1) + printed = " ".join(str(call) for call in mock_print.call_args_list) + self.assertIn("not configured", printed) + + @patch("builtins.print") + def test_sftp_no_action(self, mock_print): + """测试 sftp 无子命令时提示并返回 1""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "sftp"] + from sbackup import run + + result = run() + self.assertEqual(result, 1) + mock_print.assert_called() + + @patch("builtins.print") + def test_webdav_no_action(self, mock_print): + """测试 webdav 无子命令时提示并返回 1""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "webdav"] + from sbackup import run + + result = run() + self.assertEqual(result, 1) + mock_print.assert_called() + + @patch("builtins.print") + def test_init_command_creates_config(self, mock_print): + """测试 init 命令生成配置文件""" + import tempfile + + with tempfile.TemporaryDirectory() as tmpdir: + config_path = os.path.join(tmpdir, "config.json") + self.assertFalse(os.path.exists(config_path)) + old_cwd = os.getcwd() + os.chdir(tmpdir) + try: + self.assertEqual(os.getcwd(), tmpdir) + self.assertFalse(os.path.exists("config.json")) + sys.argv = ["sbackup", "--lang", "en_US", "init"] + from sbackup import run + + result = run() + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertEqual( + result, 0, f"run() returned {result}, printed: {printed}" + ) + self.assertTrue(os.path.exists(config_path)) + finally: + os.chdir(old_cwd) + + @patch("builtins.print") + def test_init_command_existing_config(self, mock_print): + """测试 init 命令配置文件已存在时跳过""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "init"] + from sbackup import run + + # config.json 已在 setUp 中创建 + result = run() + self.assertEqual(result, 1) + + @patch("builtins.print") + @patch("builtins.input", return_value="y") + def test_rm_all(self, mock_input, mock_print): + """测试 rm --all 删除所有策略""" + os.chdir(self.test_dir) + # 先添加一个策略 + sys.argv = ["sbackup", "--lang", "en_US", "add", self.test_dir, self.dest_dir] + from sbackup import run + + run() + # 再删除所有 + sys.argv = ["sbackup", "--lang", "en_US", "rm", "--all"] + result = run() + self.assertEqual(result, 0) + + @patch("builtins.print") + @patch("builtins.input") + @patch("getpass.getpass") + @patch("sbackup.sftp.SFTPClient.try_default_key", return_value=None) + def test_sftp_config_interactive( + self, mock_try_key, mock_getpass, mock_input, mock_print + ): + """测试 sftp config 交互式配置""" + mock_input.side_effect = [ + "testhost", + "2222", + "admin", + "", # key_file (空,使用密码) + "/backups", + ] + mock_getpass.side_effect = [ + "secret", # password + ] + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "sftp", "config"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(call) for call in mock_print.call_args_list) + self.assertIn("saved", printed) + + @patch("builtins.print") + def test_sftp_config_with_args(self, mock_print): + """测试 sftp config 通过命令行参数配置""" + os.chdir(self.test_dir) + sys.argv = [ + "sbackup", + "--lang", + "en_US", + "sftp", + "config", + "--host", + "myhost", + "--port", + "2222", + "--user", + "admin", + "--password", + "secret", + "--key-file", + "/path/to/id_rsa", + "--key-passphrase", + "keypass", + "--remote-path", + "/backups", + ] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(call) for call in mock_print.call_args_list) + self.assertIn("saved", printed) + + @patch("builtins.print") + @patch("sbackup.sftp.SFTPClient._load_private_key", return_value=MagicMock()) + @patch("sbackup.sftp.SFTPClient.try_default_key", return_value=None) + def test_sftp_config_key_no_passphrase_needed( + self, mock_try_key, mock_load_key, mock_print + ): + """测试 sftp config --key-file 私钥不需要密码短语时不提示输入""" + os.chdir(self.test_dir) + sys.argv = [ + "sbackup", + "--lang", + "en_US", + "sftp", + "config", + "--host", + "myhost", + "--port", + "22", + "--user", + "admin", + "--key-file", + "/path/to/id_rsa", + "--remote-path", + "/backups", + ] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + mock_load_key.assert_called_once() + + @patch("builtins.print") + def test_sftp_test_with_config(self, mock_print): + """测试 sftp test 连接成功""" + os.chdir(self.test_dir) + # 先配置 SFTP + from sbackup.config import save_sftp_config + + save_sftp_config( + "testhost", + 22, + "user", + "pass", + "/", + key_file="/path/to/key", + key_passphrase="keypass", + config_file=os.path.join(self.test_dir, "config.json"), + ) + + sys.argv = ["sbackup", "--lang", "en_US", "sftp", "test"] + from sbackup import run + from sbackup.sftp import SFTPClient + + original_connect = SFTPClient.connect + + def mock_connect(self): + pass + + SFTPClient.connect = mock_connect + try: + result = run() + self.assertEqual(result, 0) + finally: + SFTPClient.connect = original_connect + + @patch("builtins.print") + @patch("getpass.getpass") + def test_sftp_test_with_key_no_passphrase(self, mock_getpass, mock_print): + """测试 sftp test 已配置私钥但无密码短语""" + os.chdir(self.test_dir) + from sbackup.config import save_sftp_config + + save_sftp_config( + "testhost", + 22, + "user", + "", + "/", + key_file="/path/to/key", + key_passphrase="", + config_file=os.path.join(self.test_dir, "config.json"), + ) + + sys.argv = ["sbackup", "--lang", "en_US", "sftp", "test"] + from sbackup import run + from sbackup.sftp import SFTPClient, SFTPError + + # 模拟私钥需要密码短语,用户直接回车放弃 + mock_getpass.side_effect = [""] + + original_load = SFTPClient._load_private_key + + def mock_load(key_file, passphrase): + if passphrase == "": + raise SFTPError("needs passphrase") + return MagicMock() + + SFTPClient._load_private_key = staticmethod(mock_load) + try: + result = run() + self.assertEqual(result, 1) + finally: + SFTPClient._load_private_key = staticmethod(original_load) + + @patch("builtins.print") + @patch("builtins.input") + @patch("getpass.getpass") + @patch("sbackup.sftp.SFTPClient.try_default_key") + def test_sftp_config_auto_key_needs_passphrase( + self, mock_try_key, mock_getpass, mock_input, mock_print + ): + """测试 sftp config 自动检测私钥需密码短语""" + mock_try_key.return_value = "/home/user/.ssh/id_ed25519" + mock_input.side_effect = [ + "testhost", + "2222", + "admin", + "", # key_file 为空,触发自动检测 + "/backups", # remote_path + ] + # 自动检测到私钥后进入 if 分支,getpass 被调用一次后返回 None + # 然后回退到密码认证,getpass 再被调用一次 + mock_getpass.side_effect = ["", "correct_passphrase"] + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "sftp", "config"] + from sbackup import run + from sbackup.sftp import SFTPClient, SFTPError + + original_load = SFTPClient._load_private_key + + def mock_load(key_file, passphrase): + if passphrase == "": + raise SFTPError("needs passphrase") + return MagicMock() + + SFTPClient._load_private_key = staticmethod(mock_load) + try: + result = run() + self.assertEqual(result, 0) + finally: + SFTPClient._load_private_key = staticmethod(original_load) + + +class TestReportCommand(unittest.TestCase): + """测试 report 命令""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.original_argv = sys.argv.copy() + self._root_handlers = logging.root.handlers[:] + self.data_path = os.path.join(self.test_dir, "sbackup.json") + with open(os.path.join(self.test_dir, "config.json"), "w") as f: + json.dump({"data_file": self.data_path}, f) + + def tearDown(self): + sys.argv = self.original_argv + shutil.rmtree(self.test_dir, ignore_errors=True) + logging.root.handlers = self._root_handlers + + @patch("builtins.print") + def test_report_to_stdout(self, mock_print): + """测试 report 命令输出到终端""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "report"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("Sbackup", printed) + + def test_report_to_file(self): + """测试 report 命令保存到文件""" + output = os.path.join(self.test_dir, "report.md") + os.chdir(self.test_dir) + sys.argv = ["sbackup", "report", "-o", output] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + self.assertTrue(os.path.exists(output)) + + +class TestSearchCommand(unittest.TestCase): + """测试 search 命令""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.original_argv = sys.argv.copy() + self._root_handlers = logging.root.handlers[:] + self.data_path = os.path.join(self.test_dir, "sbackup.json") + with open(os.path.join(self.test_dir, "config.json"), "w") as f: + json.dump({"data_file": self.data_path}, f) + # 创建备份文件 + from sbackup.config import Config + from sbackup.compression import create_compressor + + self.source_dir = os.path.join(self.test_dir, "source") + os.makedirs(self.source_dir) + (Path(self.source_dir) / "test.txt").write_text("hello") + (Path(self.source_dir) / "data.csv").write_text("csv") + self.dest_dir = os.path.join(self.test_dir, "dest") + os.makedirs(self.dest_dir) + config = Config( + folder_path=self.source_dir, + zipfile_path=self.dest_dir, + skip_patterns=[], + ) + result = create_compressor(config).compress() + self.assertTrue(result["success"]) + self.backup_path = result["path"] + + def tearDown(self): + sys.argv = self.original_argv + shutil.rmtree(self.test_dir, ignore_errors=True) + logging.root.handlers = self._root_handlers + + @patch("builtins.print") + def test_search_in_specific_backup(self, mock_print): + """测试 search 命令在指定备份中搜索""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "search", "*.txt", "--in", self.backup_path] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("test.txt", printed) + + @patch("builtins.print") + def test_search_no_match(self, mock_print): + """测试 search 命令无匹配结果""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "search", "*.xyz", "--in", self.backup_path] + from sbackup import run + + result = run() + self.assertEqual(result, 1) + + +class TestDiffCommand(unittest.TestCase): + """测试 diff 命令""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.original_argv = sys.argv.copy() + self._root_handlers = logging.root.handlers[:] + self.data_path = os.path.join(self.test_dir, "sbackup.json") + with open(os.path.join(self.test_dir, "config.json"), "w") as f: + json.dump({"data_file": self.data_path}, f) + self.dest_dir = os.path.join(self.test_dir, "dest") + os.makedirs(self.dest_dir) + self.source_dir = os.path.join(self.test_dir, "source") + os.makedirs(self.source_dir) + (Path(self.source_dir) / "file.txt").write_text("hello") + + def tearDown(self): + sys.argv = self.original_argv + shutil.rmtree(self.test_dir, ignore_errors=True) + logging.root.handlers = self._root_handlers + + @patch("builtins.print") + def test_diff_no_strategy(self, mock_print): + """测试 diff 命令无策略""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "diff", self.source_dir] + from sbackup import run + + result = run() + self.assertEqual(result, 1) + + @patch("builtins.print") + def test_diff_nonexistent_source(self, mock_print): + """测试 diff 命令源目录不存在""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "diff", "/nonexistent/path"] + from sbackup import run + + result = run() + self.assertEqual(result, 1) + + +class TestInitCommand(unittest.TestCase): + """测试 init 命令生成完整模板""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.original_argv = sys.argv.copy() + self._root_handlers = logging.root.handlers[:] + + def tearDown(self): + sys.argv = self.original_argv + shutil.rmtree(self.test_dir, ignore_errors=True) + logging.root.handlers = self._root_handlers + + def test_init_generates_full_config(self): + """测试 init 生成包含所有字段的 config.json""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "init"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + config_path = os.path.join(self.test_dir, "config.json") + self.assertTrue(os.path.exists(config_path)) + with open(config_path, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertIn("webhook", data) + self.assertIn("sftp", data) + self.assertIn("webdav", data) + self.assertIn("compression", data) + + +class TestCompletionCommand(unittest.TestCase): + """测试 completion 命令""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.original_argv = sys.argv.copy() + self._root_handlers = logging.root.handlers[:] + + def tearDown(self): + sys.argv = self.original_argv + shutil.rmtree(self.test_dir, ignore_errors=True) + logging.root.handlers = self._root_handlers + + @patch("builtins.print") + def test_completion_bash(self, mock_print): + """测试 completion bash 输出补全脚本""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "completion", "bash"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("complete -F", printed) + + @patch("builtins.print") + def test_completion_zsh(self, mock_print): + """测试 completion zsh 输出补全脚本""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "completion", "zsh"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("#compdef", printed) + + @patch("builtins.print") + def test_completion_default_shell(self, mock_print): + """测试 completion 无参数默认 bash""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "completion"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("complete -F", printed) + + @patch("builtins.print") + def test_completion_fish(self, mock_print): + """测试 completion fish 输出补全脚本""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "completion", "fish"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("complete -c sbackup", printed) + + @patch("builtins.print") + def test_completion_powershell(self, mock_print): + """测试 completion powershell 输出补全脚本""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "completion", "powershell"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("Register-ArgumentCompleter", printed) + + +class TestLockIntegration(unittest.TestCase): + """测试锁与 CLI 集成""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.original_argv = sys.argv.copy() + self._root_handlers = logging.root.handlers[:] + self._root_level = logging.root.level + self.data_path = os.path.join(self.test_dir, "sbackup.json") + with open(os.path.join(self.test_dir, "config.json"), "w") as f: + json.dump({"data_file": self.data_path}, f) + self.dest_dir = os.path.join(self.test_dir, "backup_dest") + os.makedirs(self.dest_dir, exist_ok=True) + + def tearDown(self): + sys.argv = self.original_argv + shutil.rmtree(self.test_dir, ignore_errors=True) + logging.root.handlers = self._root_handlers + logging.root.setLevel(self._root_level) + + @patch("sbackup.cli.BackupManager.execute_backups") + @patch("builtins.print") + def test_save_acquires_lock(self, mock_print, mock_execute): + """测试 save 命令获取锁""" + os.chdir(self.test_dir) + from sbackup import run + + sys.argv = ["sbackup", "--lang", "en_US", "save"] + result = run() + self.assertEqual(result, 0) + mock_execute.assert_called_once() + + @patch("sbackup.cli.BackupManager.execute_backups") + @patch("builtins.print") + def test_save_lock_conflict(self, mock_print, mock_execute): + """测试 save 锁冲突""" + os.chdir(self.test_dir) + from sbackup import run + + # 手动创建锁文件,模拟另一个运行的进程 + from sbackup.lock import BackupLock + + lock = BackupLock(self.test_dir) + lock.acquire() + + sys.argv = ["sbackup", "--lang", "en_US", "save"] + result = run() + # save 尝试获取锁应该失败(锁被我们持有) + self.assertNotEqual(result, 0) + mock_execute.assert_not_called() + lock.release() + + +class TestNewCliCommands(unittest.TestCase): + """测试新增的 CLI 功能:restore --tag, list --tags, schedule install""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.original_argv = sys.argv.copy() + self._root_handlers = logging.root.handlers[:] + self._root_level = logging.root.level + self.config_path = os.path.join(self.test_dir, "config.json") + self.data_path = os.path.join(self.test_dir, "sbackup.json") + with open(self.config_path, "w") as f: + json.dump({"data_file": self.data_path}, f) + # 创建带标签的历史记录 + self.history = [ + { + "time": "2026-01-01T12:00:00", + "source": "/test/src", + "size_mb": 1.0, + "files_count": 10, + "tag": "daily", + "path": "/test/backup_daily.zip", + }, + { + "time": "2026-01-02T12:00:00", + "source": "/test/src", + "size_mb": 2.0, + "files_count": 20, + "tag": "weekly", + "path": "/test/backup_weekly.zip", + }, + { + "time": "2026-01-03T12:00:00", + "source": "/test/src", + "size_mb": 3.0, + "files_count": 30, + "tag": "", + "path": "/test/backup_untagged.zip", + }, + ] + self._init_data_file() + + def _init_data_file(self): + with open(self.data_path, "w") as f: + json.dump({"_history": self.history}, f) + + def tearDown(self): + sys.argv = self.original_argv + shutil.rmtree(self.test_dir, ignore_errors=True) + logging.root.handlers = self._root_handlers + logging.root.setLevel(self._root_level) + + @patch("builtins.print") + def test_list_tags(self, mock_print): + """测试 list --tags 列出所有标签""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "list", "--tags"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("daily", printed) + self.assertIn("weekly", printed) + + @patch("builtins.print") + def test_list_tags_empty(self, mock_print): + """测试无标签时 list --tags 显示空提示""" + os.chdir(self.test_dir) + # 移除历史中的标签 + for h in self.history: + h["tag"] = "" + self._init_data_file() + sys.argv = ["sbackup", "--lang", "en_US", "list", "--tags"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + + @patch("builtins.print") + def test_list_no_tags_flag(self, mock_print): + """测试 list 不加 --tags 显示历史表格""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "list"] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + + @patch("builtins.print") + def test_restore_tag_not_found(self, mock_print): + """测试 restore --tag 标签不存在""" + os.chdir(self.test_dir) + sys.argv = ["sbackup", "--lang", "en_US", "restore", "--tag", "nonexistent"] + from sbackup import run + + result = run() + self.assertEqual(result, 1) + + @patch("builtins.print") + def test_versions_with_tag(self, mock_print): + """测试 versions --tag 按标签筛选""" + os.chdir(self.test_dir) + sys.argv = [ + "sbackup", + "--lang", + "en_US", + "versions", + "--tag", + "daily", + ] + from sbackup import run + + result = run() + self.assertEqual(result, 0) + + @patch("builtins.print") + def test_schedule_install_systemd(self, mock_print): + """测试 schedule install --type systemd 输出安装命令""" + os.chdir(self.test_dir) + from sbackup import run + + sys.argv = [ + "sbackup", + "--lang", + "en_US", + "schedule", + "install", + "--type", + "systemd", + "--interval", + "120", + ] + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("systemd", printed) + + @patch("builtins.print") + def test_schedule_install_schtasks(self, mock_print): + """测试 schedule install --type schtasks 输出命令""" + os.chdir(self.test_dir) + from sbackup import run + + sys.argv = [ + "sbackup", + "--lang", + "en_US", + "schedule", + "install", + "--type", + "schtasks", + ] + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("Administrator", printed) + + @patch("builtins.print") + def test_schedule_install_launchd(self, mock_print): + """测试 schedule install --type launchd 输出 plist""" + os.chdir(self.test_dir) + from sbackup import run -if __name__ == "__main__": - unittest.main() + sys.argv = [ + "sbackup", + "--lang", + "en_US", + "schedule", + "install", + "--type", + "launchd", + ] + result = run() + self.assertEqual(result, 0) + printed = " ".join(str(c) for c in mock_print.call_args_list) + self.assertIn("plist", printed) diff --git a/tests/sbackup/test_monitor.py b/tests/sbackup/test_monitor.py new file mode 100644 index 0000000..b9ae798 --- /dev/null +++ b/tests/sbackup/test_monitor.py @@ -0,0 +1,251 @@ +""" +单元测试 for sbackup.monitor 模块 +""" + +import tempfile +import threading +import time as _time +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +from sbackup.monitor import FileSystemMonitor, _BackupEventHandler + + +class TestBackupEventHandler(unittest.TestCase): + """测试 _BackupEventHandler 内部类""" + + def test_on_any_event_ignores_directories(self): + """测试目录事件被忽略""" + callback = MagicMock() + handler = _BackupEventHandler(callback) + + mock_event = MagicMock() + mock_event.is_directory = True + mock_event.src_path = "/some/dir" + + handler.on_any_event(mock_event) + callback.assert_not_called() + + def test_on_any_event_triggers_for_files(self): + """测试文件事件触发回调""" + callback = MagicMock() + handler = _BackupEventHandler(callback) + + mock_event = MagicMock() + mock_event.is_directory = False + mock_event.src_path = "/some/file.txt" + + handler.on_any_event(mock_event) + callback.assert_called_once_with("/some/file.txt") + + +class TestFileSystemMonitor(unittest.TestCase): + """测试 FileSystemMonitor 类""" + + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmpdir.cleanup) + self.source = Path(self.tmpdir.name) / "src" + self.source.mkdir() + self.source_dirs = {str(self.source): "/backup"} + + def test_init_stores_dirs_and_debounce(self): + """测试初始化存储源目录和防抖参数""" + monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=10) + assert monitor._source_dirs == self.source_dirs + assert monitor._debounce == 10 + assert monitor._observer is None + assert monitor._running is False + assert monitor._backup_callback is None + + def test_set_backup_callback(self): + """测试设置备份回调""" + monitor = FileSystemMonitor(self.source_dirs) + + def noop(): + pass + + monitor.set_backup_callback(noop) + assert monitor._backup_callback is noop + + def test_start_stop_lifecycle(self): + """测试启动/停止生命周期""" + monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=1) + try: + monitor.start() + # 等待 observer 线程启动 + _time.sleep(0.2) + assert monitor.is_running() + finally: + monitor.stop() + assert not monitor.is_running() + + def test_double_start_noop(self): + """测试重复启动不创建多个 observer""" + monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=1) + try: + monitor.start() + _time.sleep(0.2) + first_observer = monitor._observer + monitor.start() + assert monitor._observer is first_observer + finally: + monitor.stop() + + def test_on_file_change_marks_dirty(self): + """测试文件变化标记源目录为 dirty""" + monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=60) + test_file = self.source / "test.txt" + test_file.write_text("hello") + + monitor._on_file_change(str(test_file)) + dirty = monitor.get_dirty_sources() + assert str(self.source) in dirty + + def test_on_file_change_outside_source_ignored(self): + """测试外部文件变化不标记 dirty""" + monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=60) + outside_file = Path(self.tmpdir.name) / "outside.txt" + outside_file.write_text("hello") + + monitor._on_file_change(str(outside_file)) + dirty = monitor.get_dirty_sources() + assert len(dirty) == 0 + + def test_on_file_change_multiple_dirs(self): + """测试多个源目录分别标记""" + src2 = Path(self.tmpdir.name) / "src2" + src2.mkdir() + dirs = {str(self.source): "/backup1", str(src2): "/backup2"} + + monitor = FileSystemMonitor(dirs, debounce_seconds=60) + (self.source / "a.txt").write_text("a") + (src2 / "b.txt").write_text("b") + + monitor._on_file_change(str(self.source / "a.txt")) + monitor._on_file_change(str(src2 / "b.txt")) + + dirty = monitor.get_dirty_sources() + assert str(self.source) in dirty + assert str(src2) in dirty + assert len(dirty) == 2 + + def test_debounce_reset_on_multiple_changes(self): + """测试多次变化时防抖计时器重置""" + monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=10) + cb = MagicMock() + monitor.set_backup_callback(cb) + + f1 = self.source / "a.txt" + f2 = self.source / "b.txt" + f1.write_text("a") + f2.write_text("b") + + # 第一次变化启动计时 + monitor._on_file_change(str(f1)) + timer1 = monitor._debounce_timer + assert timer1 is not None + + # 第二次变化应取消旧计时并启动新计时 + monitor._on_file_change(str(f2)) + timer2 = monitor._debounce_timer + assert timer2 is not None + assert timer2 is not timer1 # 不是同一个 timer 对象 + + def test_trigger_backup_calls_callback(self): + """测试防抖超时后触发回调""" + monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1) + cb = MagicMock() + monitor.set_backup_callback(cb) + + (self.source / "test.txt").write_text("data") + monitor._on_file_change(str(self.source / "test.txt")) + + # 等待防抖触发 + _time.sleep(0.3) + cb.assert_called_once() + + def test_trigger_backup_no_dirty_noop(self): + """测试没有 dirty 目录时不触发回调""" + monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1) + cb = MagicMock() + monitor.set_backup_callback(cb) + + monitor._trigger_backup() + cb.assert_not_called() + + def test_trigger_backup_clears_dirty(self): + """测试触发备份后清除 dirty 标记""" + monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1) + cb = MagicMock() + monitor.set_backup_callback(cb) + + (self.source / "test.txt").write_text("data") + monitor._on_file_change(str(self.source / "test.txt")) + _time.sleep(0.3) + + assert len(monitor.get_dirty_sources()) == 0 + + def test_callback_exception_caught(self): + """测试回调异常不影响监控器""" + monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1) + + def failing_cb(): + raise RuntimeError("test error") + + monitor.set_backup_callback(failing_cb) + + (self.source / "test.txt").write_text("data") + monitor._on_file_change(str(self.source / "test.txt")) + + # 不应抛出异常 + _time.sleep(0.3) + assert monitor._dirty == set() + + def test_start_from_missing_source_dir(self): + """测试源目录不存在时优雅处理""" + missing = str(Path(self.tmpdir.name) / "nonexistent") + monitor = FileSystemMonitor({missing: "/backup"}, debounce_seconds=1) + + monitor.start() + assert not monitor.is_running() + assert monitor._observer is None + + def test_stop_cancels_pending_timer(self): + """测试 stop 取消待处理的防抖计时器""" + monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=30) + cb = MagicMock() + monitor.set_backup_callback(cb) + + (self.source / "test.txt").write_text("data") + monitor._on_file_change(str(self.source / "test.txt")) + assert monitor._debounce_timer is not None + + monitor.stop() + assert monitor._debounce_timer is None + cb.assert_not_called() + + def test_concurrent_file_changes_thread_safety(self): + """测试多线程并发文件变化的线程安全性""" + monitor = FileSystemMonitor(self.source_dirs, debounce_seconds=0.1) + cb = MagicMock() + monitor.set_backup_callback(cb) + + def write_file(i): + f = self.source / f"file_{i}.txt" + f.write_text(str(i)) + monitor._on_file_change(str(f)) + + threads = [] + for i in range(10): + t = threading.Thread(target=write_file, args=(i,)) + threads.append(t) + t.start() + + for t in threads: + t.join() + + _time.sleep(0.3) + # 回调应至少被调用一次 + assert cb.call_count >= 1 diff --git a/tests/sbackup/test_multi_dest.py b/tests/sbackup/test_multi_dest.py new file mode 100644 index 0000000..10ac383 --- /dev/null +++ b/tests/sbackup/test_multi_dest.py @@ -0,0 +1,434 @@ +""" +单元测试 for sbackup.multi_dest 模块 +""" + +import os +import shutil +import tempfile +import unittest +from unittest.mock import patch, MagicMock +from sbackup.config import Config +from sbackup.multi_dest import DestResult, MultiDestBackup + + +class TestDestResult(unittest.TestCase): + """测试 DestResult 数据类""" + + def test_create_minimal(self): + """最小化创建 DestResult""" + r = DestResult(name="local", success=True) + self.assertEqual(r.name, "local") + self.assertTrue(r.success) + self.assertEqual(r.path, "") + self.assertEqual(r.error, "") + self.assertEqual(r.size, 0) + self.assertEqual(r.duration, 0.0) + + def test_create_full(self): + """完整参数创建 DestResult""" + r = DestResult( + name="sftp", + success=True, + path="/remote/backup.zip", + size=1024000, + duration=2.5, + ) + self.assertEqual(r.name, "sftp") + self.assertTrue(r.success) + self.assertEqual(r.path, "/remote/backup.zip") + self.assertEqual(r.size, 1024000) + self.assertAlmostEqual(r.duration, 2.5) + + def test_create_with_error(self): + """带错误信息创建 DestResult""" + r = DestResult( + name="webdav", + success=False, + error="Connection timeout", + ) + self.assertEqual(r.name, "webdav") + self.assertFalse(r.success) + self.assertEqual(r.error, "Connection timeout") + + +class TestGetEnabledDestinations(unittest.TestCase): + """测试 get_enabled_destinations 方法""" + + def test_only_local(self): + """默认配置只有本地""" + config = Config() + multi = MultiDestBackup(config) + destinations = multi.get_enabled_destinations() + self.assertEqual(destinations, ["local"]) + + def test_local_and_sftp(self): + """启用 SFTP""" + config = Config(sftp_enabled=True) + multi = MultiDestBackup(config) + destinations = multi.get_enabled_destinations() + self.assertEqual(destinations, ["local", "sftp"]) + + def test_local_and_webdav(self): + """启用 WebDAV""" + config = Config(webdav_enabled=True) + multi = MultiDestBackup(config) + destinations = multi.get_enabled_destinations() + self.assertEqual(destinations, ["local", "webdav"]) + + def test_local_and_cloud(self): + """启用云存储""" + config = Config(cloud_enabled=True) + multi = MultiDestBackup(config) + destinations = multi.get_enabled_destinations() + self.assertEqual(destinations, ["local", "cloud"]) + + def test_all_enabled(self): + """所有目标都启用""" + config = Config( + sftp_enabled=True, + webdav_enabled=True, + cloud_enabled=True, + ) + multi = MultiDestBackup(config) + destinations = multi.get_enabled_destinations() + self.assertEqual(destinations, ["local", "sftp", "webdav", "cloud"]) + + def test_sftp_and_webdav(self): + """SFTP 和 WebDAV 同时启用""" + config = Config(sftp_enabled=True, webdav_enabled=True) + multi = MultiDestBackup(config) + destinations = multi.get_enabled_destinations() + self.assertEqual(destinations, ["local", "sftp", "webdav"]) + + def test_sftp_and_cloud(self): + """SFTP 和云存储同时启用""" + config = Config(sftp_enabled=True, cloud_enabled=True) + multi = MultiDestBackup(config) + destinations = multi.get_enabled_destinations() + self.assertEqual(destinations, ["local", "sftp", "cloud"]) + + +class TestBackupToLocal(unittest.TestCase): + """测试 backup_to_local 方法""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.source_dir = os.path.join(self.test_dir, "source") + self.target_dir = os.path.join(self.test_dir, "target") + os.makedirs(self.source_dir) + os.makedirs(self.target_dir) + # 创建测试文件 + with open(os.path.join(self.source_dir, "test.txt"), "w") as f: + f.write("hello world") + + def tearDown(self): + shutil.rmtree(self.test_dir) + + def test_local_backup_invalid_source(self): + """源目录不存在时返回失败""" + config = Config() + multi = MultiDestBackup(config) + result = multi.backup_to_local("/nonexistent/path", self.target_dir) + self.assertFalse(result.success) + self.assertEqual(result.name, "local") + self.assertTrue(len(result.error) > 0) + + @patch("sbackup.compression.create_compressor") + def test_local_backup_success(self, mock_create): + """本地备份成功""" + mock_compressor = MagicMock() + mock_compressor.compress.return_value = { + "success": True, + "path": os.path.join(self.target_dir, "backup.zip"), + "size_mb": 1.0, + } + mock_create.return_value = mock_compressor + + config = Config() + multi = MultiDestBackup(config) + result = multi.backup_to_local(self.source_dir, self.target_dir) + + self.assertTrue(result.success) + self.assertEqual(result.name, "local") + self.assertGreaterEqual(result.duration, 0) + + @patch("sbackup.compression.create_compressor") + def test_local_backup_failure(self, mock_create): + """本地备份失败""" + mock_compressor = MagicMock() + mock_compressor.compress.return_value = { + "success": False, + "error": "Disk full", + } + mock_create.return_value = mock_compressor + + config = Config() + multi = MultiDestBackup(config) + result = multi.backup_to_local(self.source_dir, self.target_dir) + + self.assertFalse(result.success) + self.assertIn("Disk full", result.error) + + @patch("sbackup.compression.create_compressor") + def test_local_backup_exception(self, mock_create): + """本地备份异常""" + mock_create.side_effect = RuntimeError("Unexpected error") + + config = Config() + multi = MultiDestBackup(config) + result = multi.backup_to_local(self.source_dir, self.target_dir) + + self.assertFalse(result.success) + self.assertIn("Unexpected error", result.error) + + +class TestExecuteAll(unittest.TestCase): + """测试 execute_all 并行执行""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.source_dir = os.path.join(self.test_dir, "source") + self.target_dir = os.path.join(self.test_dir, "target") + os.makedirs(self.source_dir) + os.makedirs(self.target_dir) + with open(os.path.join(self.source_dir, "test.txt"), "w") as f: + f.write("hello world") + + def tearDown(self): + shutil.rmtree(self.test_dir) + + @patch("sbackup.compression.create_compressor") + def test_only_local(self, mock_create): + """只有本地目标""" + mock_compressor = MagicMock() + backup_file = os.path.join(self.target_dir, "backup.zip") + mock_compressor.compress.return_value = { + "success": True, + "path": backup_file, + "size_mb": 0.01, + } + mock_create.return_value = mock_compressor + + config = Config() + multi = MultiDestBackup(config) + results = multi.execute_all(self.source_dir, self.target_dir) + + self.assertEqual(len(results), 1) + self.assertEqual(results[0].name, "local") + self.assertTrue(results[0].success) + + @patch("sbackup.compression.create_compressor") + @patch.object(MultiDestBackup, "upload_to_sftp") + def test_local_and_sftp(self, mock_sftp, mock_create): + """本地 + SFTP 并行""" + mock_compressor = MagicMock() + backup_file = os.path.join(self.target_dir, "backup.zip") + # 创建一个真实文件使 os.path.getsize 工作 + with open(backup_file, "wb") as f: + f.write(b"fake zip data") + mock_compressor.compress.return_value = { + "success": True, + "path": backup_file, + "size_mb": 0.01, + } + mock_create.return_value = mock_compressor + + mock_sftp.return_value = DestResult( + name="sftp", + success=True, + path=backup_file, + size=1024, + duration=1.0, + ) + + config = Config(sftp_enabled=True) + multi = MultiDestBackup(config) + results = multi.execute_all(self.source_dir, self.target_dir) + + self.assertEqual(len(results), 2) + names = {r.name for r in results} + self.assertEqual(names, {"local", "sftp"}) + self.assertTrue(all(r.success for r in results)) + + @patch("sbackup.compression.create_compressor") + @patch.object(MultiDestBackup, "upload_to_sftp") + def test_one_target_failure_does_not_affect_others(self, mock_sftp, mock_create): + """一个目标失败不影响其他""" + mock_compressor = MagicMock() + backup_file = os.path.join(self.target_dir, "backup.zip") + with open(backup_file, "wb") as f: + f.write(b"fake zip data") + mock_compressor.compress.return_value = { + "success": True, + "path": backup_file, + "size_mb": 0.01, + } + mock_create.return_value = mock_compressor + + # SFTP 上传失败 + mock_sftp.return_value = DestResult( + name="sftp", + success=False, + error="Connection refused", + ) + + config = Config(sftp_enabled=True) + multi = MultiDestBackup(config) + results = multi.execute_all(self.source_dir, self.target_dir) + + self.assertEqual(len(results), 2) + # 本地成功 + local_result = next(r for r in results if r.name == "local") + self.assertTrue(local_result.success) + # SFTP 失败 + sftp_result = next(r for r in results if r.name == "sftp") + self.assertFalse(sftp_result.success) + self.assertIn("Connection refused", sftp_result.error) + + @patch("sbackup.compression.create_compressor") + def test_local_failure_aborts(self, mock_create): + """本地备份失败则不上传""" + mock_compressor = MagicMock() + mock_compressor.compress.return_value = { + "success": False, + "error": "Permission denied", + } + mock_create.return_value = mock_compressor + + config = Config(sftp_enabled=True) + multi = MultiDestBackup(config) + results = multi.execute_all(self.source_dir, self.target_dir) + + # 只有本地结果,没有远程上传 + self.assertEqual(len(results), 1) + self.assertEqual(results[0].name, "local") + self.assertFalse(results[0].success) + + @patch("sbackup.compression.create_compressor") + @patch.object(MultiDestBackup, "upload_to_sftp") + @patch.object(MultiDestBackup, "upload_to_webdav") + def test_multiple_remote_targets(self, mock_webdav, mock_sftp, mock_create): + """多个远程目标并行上传""" + mock_compressor = MagicMock() + backup_file = os.path.join(self.target_dir, "backup.zip") + with open(backup_file, "wb") as f: + f.write(b"fake zip data") + mock_compressor.compress.return_value = { + "success": True, + "path": backup_file, + "size_mb": 0.01, + } + mock_create.return_value = mock_compressor + + mock_sftp.return_value = DestResult( + name="sftp", success=True, path=backup_file, size=512, duration=0.5 + ) + mock_webdav.return_value = DestResult( + name="webdav", success=True, path="/remote/b.zip", size=512, duration=0.3 + ) + + config = Config(sftp_enabled=True, webdav_enabled=True) + multi = MultiDestBackup(config) + results = multi.execute_all(self.source_dir, self.target_dir) + + self.assertEqual(len(results), 3) + names = {r.name for r in results} + self.assertEqual(names, {"local", "sftp", "webdav"}) + self.assertTrue(all(r.success for r in results)) + + +class TestFormatResults(unittest.TestCase): + """测试 format_results 输出""" + + def test_all_success_zh(self): + """中文格式 - 全部成功""" + results = [ + DestResult( + name="local", + success=True, + path="/tmp/b.zip", + size=1048576, + duration=1.0, + ), + DestResult( + name="sftp", + success=True, + path="/remote/b.zip", + size=1048576, + duration=2.0, + ), + ] + config = Config() + multi = MultiDestBackup(config) + output = multi.format_results(results, lang="zh_CN") + + self.assertIn("多目标备份结果", output) + self.assertIn("[OK]", output) + self.assertIn("本地", output) + self.assertIn("SFTP", output) + self.assertIn("1.00 MB", output) + self.assertIn("成功 2 个", output) + self.assertIn("失败 0 个", output) + + def test_all_success_en(self): + """英文格式 - 全部成功""" + results = [ + DestResult( + name="local", + success=True, + path="/tmp/b.zip", + size=2097152, + duration=1.5, + ), + ] + config = Config() + multi = MultiDestBackup(config) + output = multi.format_results(results, lang="en_US") + + self.assertIn("Multi-Destination Backup Results", output) + self.assertIn("[OK]", output) + self.assertIn("Local", output) + self.assertIn("2.00 MB", output) + self.assertIn("1 succeeded", output) + self.assertIn("0 failed", output) + + def test_mixed_results(self): + """混合结果""" + results = [ + DestResult( + name="local", + success=True, + path="/tmp/b.zip", + size=1048576, + duration=1.0, + ), + DestResult(name="sftp", success=False, error="Timeout"), + ] + config = Config() + multi = MultiDestBackup(config) + output = multi.format_results(results, lang="en_US") + + self.assertIn("[OK]", output) + self.assertIn("[FAIL]", output) + self.assertIn("Timeout", output) + self.assertIn("1 succeeded", output) + self.assertIn("1 failed", output) + + def test_only_local(self): + """只有本地目标""" + results = [ + DestResult( + name="local", success=True, path="/tmp/b.zip", size=512000, duration=0.5 + ), + ] + config = Config() + multi = MultiDestBackup(config) + output = multi.format_results(results, lang="zh_CN") + + self.assertIn("本地", output) + self.assertIn("成功 1 个", output) + self.assertIn("失败 0 个", output) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sbackup/test_new_features.py b/tests/sbackup/test_new_features.py new file mode 100644 index 0000000..af06f1b --- /dev/null +++ b/tests/sbackup/test_new_features.py @@ -0,0 +1,341 @@ +""" +针对性单元测试:覆盖 4 个新功能 +""" + +import os +import sys +import time +import tempfile +import shutil +import unittest +from pathlib import Path +from sbackup.config import Config +from sbackup.compression import ( + create_compressor, + restore_backup, + split_file, + _match_select_pattern, +) +from sbackup.auto_save import BackupManager + + +class TestSelectiveRestore(unittest.TestCase): + """功能1: 选择性还原 restore --select""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.source_dir = os.path.join(self.test_dir, "source") + self.restore_dir = os.path.join(self.test_dir, "restored") + os.makedirs(self.source_dir) + (Path(self.source_dir) / "main.py").write_text("print('hello')") + (Path(self.source_dir) / "utils.py").write_text("def helper(): pass") + (Path(self.source_dir) / "readme.txt").write_text("readme") + (Path(self.source_dir) / "sub").mkdir() + (Path(self.source_dir) / "sub" / "data.csv").write_text("a,b,c") + self.zip_path = os.path.join(self.test_dir, "backup.zip") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _make_backup(self, fmt="ZIP", ext=".zip"): + path = os.path.join(self.test_dir, f"backup{ext}") + config = Config( + folder_path=self.source_dir, + zipfile_path=path, + compression_format=fmt, + skip_patterns=[], + ) + result = create_compressor(config).compress() + self.assertTrue(result["success"]) + return result["path"] + + def test_select_glob_pattern_zip(self): + """ZIP 选择性还原: *.py 只还原 Python 文件""" + self._make_backup("ZIP", ".zip") + result = restore_backup(self.zip_path, self.restore_dir, select_pattern="*.py") + self.assertTrue(result["success"]) + self.assertEqual(result["files_count"], 2) + # 检查只有 .py 文件被还原 + restored = Path(self.restore_dir) + py_files = list(restored.rglob("*.py")) + txt_files = list(restored.rglob("*.txt")) + self.assertEqual(len(py_files), 2) + self.assertEqual(len(txt_files), 0) + + def test_select_specific_file(self): + """选择性还原特定文件""" + self._make_backup("ZIP", ".zip") + result = restore_backup( + self.zip_path, self.restore_dir, select_pattern="*readme.txt" + ) + self.assertTrue(result["success"]) + self.assertEqual(result["files_count"], 1) + + def test_select_no_match(self): + """无匹配文件时返回 0""" + self._make_backup("ZIP", ".zip") + result = restore_backup(self.zip_path, self.restore_dir, select_pattern="*.xyz") + self.assertTrue(result["success"]) + self.assertEqual(result["files_count"], 0) + + def test_select_empty_pattern_restores_all(self): + """空模式还原全部文件""" + self._make_backup("ZIP", ".zip") + result = restore_backup(self.zip_path, self.restore_dir, select_pattern="") + self.assertTrue(result["success"]) + self.assertGreater(result["files_count"], 2) + + def test_select_tar_gz(self): + """tar.gz 选择性还原""" + self._make_backup("TAR_GZ", ".tar.gz") + tar_path = os.path.join(self.test_dir, "backup.tar.gz") + result = restore_backup(tar_path, self.restore_dir, select_pattern="*.py") + self.assertTrue(result["success"]) + self.assertEqual(result["files_count"], 2) + + def test_select_7z(self): + """7z 选择性还原""" + self._make_backup("7Z", ".7z") + sz_path = os.path.join(self.test_dir, "backup.7z") + result = restore_backup(sz_path, self.restore_dir, select_pattern="*.py") + self.assertTrue(result["success"]) + self.assertEqual(result["files_count"], 2) + + def test_match_select_pattern(self): + """测试 _match_select_pattern 辅助函数""" + self.assertTrue(_match_select_pattern("src/main.py", "*.py")) + self.assertTrue(_match_select_pattern("src/main.py", "src/main.py")) + self.assertFalse(_match_select_pattern("src/main.py", "*.txt")) + self.assertTrue(_match_select_pattern("any/path", "")) + + +class TestWindowsSchtasks(unittest.TestCase): + """功能2: Windows 计划任务 schtasks 导出""" + + def test_generate_schtasks(self): + """测试 schtasks 生成""" + from sbackup.handlers import _generate_schtasks + + content = _generate_schtasks(60) + self.assertIn("schtasks /create", content) + self.assertIn("SbackupBackup", content) + self.assertIn("/sc MINUTE /mo 60", content) + self.assertIn(" None: + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + + def test_activate_profile_merges_fields(self): + """测试 profile 字段覆盖主配置对应字段""" + self._write_config( + { + "lang": "zh_CN", + "compression_format": "ZIP", + "profiles": { + "work": { + "folder_path": "/home/user/work", + "compression_format": "7Z", + } + }, + } + ) + + pm = ProfileManager(self.config_file) + merged = pm.activate_profile("work") + self.assertIsNotNone(merged) + self.assertEqual(merged.folder_path, "/home/user/work") + self.assertEqual(merged.compression_format, "7Z") + # lang 不在 profile 中,应保持主配置值 + self.assertEqual(merged.lang, "zh_CN") + + def test_activate_profile_partial_override(self): + """测试 profile 只覆盖明确设置的字段""" + self._write_config( + { + "lang": "en_US", + "compression_format": "ZIP", + "password": "main_pass", + "profiles": { + "work": { + "compression_format": "7Z", + } + }, + } + ) + + pm = ProfileManager(self.config_file) + merged = pm.activate_profile("work") + self.assertIsNotNone(merged) + self.assertEqual(merged.compression_format, "7Z") + # password 不在 profile 中,保持主配置 + self.assertEqual(merged.password, "main_pass") + # lang 保持主配置 + self.assertEqual(merged.lang, "en_US") + + def test_activate_profile_not_exists_returns_none(self): + """测试激活不存在的 profile 返回 None""" + self._write_config( + {"lang": "zh_CN", "profiles": {"work": {"folder_path": "/work"}}} + ) + + pm = ProfileManager(self.config_file) + merged = pm.activate_profile("nonexistent") + self.assertIsNone(merged) + + def test_activate_profile_with_sftp(self): + """测试 profile 覆盖 SFTP 嵌套配置""" + self._write_config( + { + "lang": "zh_CN", + "sftp": { + "host": "old-host", + "port": 22, + "user": "old-user", + "remote_path": "/old", + "enabled": False, + }, + "profiles": { + "work": { + "sftp": { + "host": "new-host", + "user": "new-user", + } + } + }, + } + ) + + pm = ProfileManager(self.config_file) + merged = pm.activate_profile("work") + self.assertIsNotNone(merged) + self.assertEqual(merged.sftp_host, "new-host") + self.assertEqual(merged.sftp_user, "new-user") + # port 和 remote_path 未在 profile 中覆盖,应保持主配置值 + self.assertEqual(merged.sftp_port, 22) + + def test_activate_profile_does_not_modify_config_file(self): + """测试 activate_profile 不修改 config.json""" + self._write_config( + { + "lang": "zh_CN", + "compression_format": "ZIP", + "profiles": { + "work": { + "compression_format": "7Z", + } + }, + } + ) + + pm = ProfileManager(self.config_file) + pm.activate_profile("work") + + # 验证 config.json 未被修改 + with open(self.config_file, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["compression_format"], "ZIP") + + def test_activate_profile_with_password(self): + """测试 profile 覆盖密码""" + self._write_config( + { + "password": "old_pass", + "profiles": {"work": {"password": "work_pass"}}, + } + ) + + pm = ProfileManager(self.config_file) + merged = pm.activate_profile("work") + self.assertIsNotNone(merged) + self.assertEqual(merged.password, "work_pass") + + +class TestExportImportProfile(unittest.TestCase): + """测试 export_profile 和 import_profile""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.config_file = os.path.join(self.test_dir, "config.json") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _write_config(self, data: dict) -> None: + with open(self.config_file, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + + def test_export_profile_success(self): + """测试导出 profile""" + self._write_config( + { + "profiles": { + "work": { + "folder_path": "/work", + "compression_format": "7Z", + } + } + } + ) + + pm = ProfileManager(self.config_file) + export_path = os.path.join(self.test_dir, "exported.json") + result = pm.export_profile("work", export_path) + self.assertTrue(result) + self.assertTrue(os.path.exists(export_path)) + + with open(export_path, "r", encoding="utf-8") as f: + data = json.load(f) + self.assertEqual(data["profile_name"], "work") + self.assertEqual(data["config"]["folder_path"], "/work") + + def test_export_profile_not_exists_returns_false(self): + """测试导出不存在的 profile 返回 False""" + self._write_config({"profiles": {}}) + pm = ProfileManager(self.config_file) + export_path = os.path.join(self.test_dir, "exported.json") + result = pm.export_profile("nonexistent", export_path) + self.assertFalse(result) + self.assertFalse(os.path.exists(export_path)) + + def test_import_profile_wrapped_format(self): + """测试导入包裹格式的 profile 文件""" + self._write_config({"lang": "zh_CN"}) + + import_file = os.path.join(self.test_dir, "import.json") + import_data = { + "profile_name": "imported", + "config": {"folder_path": "/imported", "compression_format": "tar.gz"}, + } + with open(import_file, "w", encoding="utf-8") as f: + json.dump(import_data, f, ensure_ascii=False) + + pm = ProfileManager(self.config_file) + result = pm.import_profile(import_file, "imported") + self.assertTrue(result) + + profile = pm.get_profile("imported") + self.assertIsNotNone(profile) + self.assertEqual(profile["folder_path"], "/imported") + + def test_import_profile_direct_format(self): + """测试直接导入 profile dict 格式的文件""" + self._write_config({"lang": "zh_CN"}) + + import_file = os.path.join(self.test_dir, "direct.json") + import_data = {"folder_path": "/direct", "compression_format": "7Z"} + with open(import_file, "w", encoding="utf-8") as f: + json.dump(import_data, f, ensure_ascii=False) + + pm = ProfileManager(self.config_file) + # 不指定 name,应使用文件名 "direct" + result = pm.import_profile(import_file) + self.assertTrue(result) + + profile = pm.get_profile("direct") + self.assertIsNotNone(profile) + self.assertEqual(profile["folder_path"], "/direct") + + def test_import_profile_custom_name(self): + """测试导入时指定自定义名称""" + self._write_config({"lang": "zh_CN"}) + + import_file = os.path.join(self.test_dir, "somefile.json") + import_data = {"folder_path": "/custom"} + with open(import_file, "w", encoding="utf-8") as f: + json.dump(import_data, f, ensure_ascii=False) + + pm = ProfileManager(self.config_file) + result = pm.import_profile(import_file, "custom_name") + self.assertTrue(result) + + profile = pm.get_profile("custom_name") + self.assertIsNotNone(profile) + + def test_import_profile_nonexistent_file_returns_false(self): + """测试导入不存在的文件返回 False""" + pm = ProfileManager(self.config_file) + result = pm.import_profile("/nonexistent/file.json") + self.assertFalse(result) + + def test_import_profile_invalid_json_returns_false(self): + """测试导入无效 JSON 文件返回 False""" + self._write_config({"lang": "zh_CN"}) + bad_file = os.path.join(self.test_dir, "bad.json") + with open(bad_file, "w") as f: + f.write("{invalid json!!!") + + pm = ProfileManager(self.config_file) + result = pm.import_profile(bad_file) + self.assertFalse(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sbackup/test_rotation.py b/tests/sbackup/test_rotation.py new file mode 100644 index 0000000..eeeedbe --- /dev/null +++ b/tests/sbackup/test_rotation.py @@ -0,0 +1,536 @@ +"""备份轮转/清理策略测试""" + +import os +import time +import pytest +from pathlib import Path + +from sbackup.rotation import BackupRotator, RotationPolicy, _is_backup_file + + +@pytest.fixture +def backup_dir(tmp_path): + """创建临时备份目录并返回路径""" + d = tmp_path / "backups" + d.mkdir() + return str(d) + + +def _create_backup(d: str, name: str, mtime_offset_days: int = 0) -> str: + """在目录中创建一个假的备份文件,可设置修改时间偏移(天数)""" + path = os.path.join(d, name) + Path(path).touch() + if mtime_offset_days != 0: + old_time = time.time() - mtime_offset_days * 86400 + os.utime(path, (old_time, old_time)) + return path + + +# ============================================================ +# _is_backup_file 测试 +# ============================================================ + + +class TestIsBackupFile: + def test_zip(self): + assert _is_backup_file("backup.zip") is True + + def test_tar(self): + assert _is_backup_file("backup.tar") is True + + def test_tar_gz(self): + assert _is_backup_file("backup.tar.gz") is True + + def test_tar_bz2(self): + assert _is_backup_file("backup.tar.bz2") is True + + def test_tar_xz(self): + assert _is_backup_file("backup.tar.xz") is True + + def test_tar_zst(self): + assert _is_backup_file("backup.tar.zst") is True + + def test_7z(self): + assert _is_backup_file("backup.7z") is True + + def test_unknown_extension(self): + assert _is_backup_file("backup.txt") is False + + def test_no_extension(self): + assert _is_backup_file("backupfile") is False + + def test_partial_name(self): + assert _is_backup_file("my_archive_notazip") is False + + +# ============================================================ +# scan_backups 测试 +# ============================================================ + + +class TestScanBackups: + def test_scan_recognizes_known_suffixes(self, backup_dir): + """扫描只识别已知后缀的文件""" + _create_backup(backup_dir, "b1.zip", 0) + _create_backup(backup_dir, "b2.tar", 0) + _create_backup(backup_dir, "b3.tar.gz", 0) + _create_backup(backup_dir, "b4.tar.bz2", 0) + _create_backup(backup_dir, "b5.tar.xz", 0) + _create_backup(backup_dir, "b6.tar.zst", 0) + _create_backup(backup_dir, "b7.7z", 0) + + rotator = BackupRotator(backup_dir, RotationPolicy()) + backups = rotator.scan_backups() + assert len(backups) == 7 + + def test_scan_ignores_non_backup_files(self, backup_dir): + """非备份文件不会被扫描到""" + _create_backup(backup_dir, "backup.zip", 0) + _create_backup(backup_dir, "readme.txt", 0) + _create_backup(backup_dir, "data.csv", 0) + _create_backup(backup_dir, "image.png", 0) + + rotator = BackupRotator(backup_dir, RotationPolicy()) + backups = rotator.scan_backups() + assert len(backups) == 1 + assert backups[0]["name"] == "backup.zip" + + def test_scan_sorted_by_mtime_descending(self, backup_dir): + """结果按修改时间降序排列""" + _create_backup(backup_dir, "old.zip", 10) + _create_backup(backup_dir, "mid.zip", 5) + _create_backup(backup_dir, "new.zip", 0) + + rotator = BackupRotator(backup_dir, RotationPolicy()) + backups = rotator.scan_backups() + names = [b["name"] for b in backups] + assert names == ["new.zip", "mid.zip", "old.zip"] + + def test_scan_empty_dir(self, backup_dir): + """空目录返回空列表""" + rotator = BackupRotator(backup_dir, RotationPolicy()) + backups = rotator.scan_backups() + assert backups == [] + + def test_scan_nonexistent_dir(self): + """不存在的目录返回空列表""" + rotator = BackupRotator("/nonexistent/path", RotationPolicy()) + backups = rotator.scan_backups() + assert backups == [] + + def test_scan_skips_subdirectories(self, backup_dir): + """不扫描子目录""" + _create_backup(backup_dir, "backup.zip", 0) + sub = os.path.join(backup_dir, "subdir") + os.makedirs(sub) + _create_backup(sub, "nested.zip", 0) + + rotator = BackupRotator(backup_dir, RotationPolicy()) + backups = rotator.scan_backups() + assert len(backups) == 1 + assert backups[0]["name"] == "backup.zip" + + def test_scan_metadata_fields(self, backup_dir): + """扫描结果包含正确的字段""" + _create_backup(backup_dir, "test.zip", 0) + + rotator = BackupRotator(backup_dir, RotationPolicy()) + backups = rotator.scan_backups() + assert len(backups) == 1 + b = backups[0] + assert "path" in b + assert "name" in b + assert "size" in b + assert "mtime" in b + assert "mtime_iso" in b + assert b["name"] == "test.zip" + assert b["size"] == 0 + + +# ============================================================ +# keep_count 策略测试 +# ============================================================ + + +class TestKeepCount: + def test_keep_count_basic(self, backup_dir): + """保留最新的 N 个,删除其余""" + _create_backup(backup_dir, "b1.zip", 10) + _create_backup(backup_dir, "b2.zip", 8) + _create_backup(backup_dir, "b3.zip", 5) + _create_backup(backup_dir, "b4.zip", 2) + _create_backup(backup_dir, "b5.zip", 0) + + policy = RotationPolicy(keep_count=2) + rotator = BackupRotator(backup_dir, policy) + keep, delete = rotator.plan() + + assert len(keep) == 2 + assert len(delete) == 3 + keep_names = {b["name"] for b in keep} + assert "b5.zip" in keep_names + assert "b4.zip" in keep_names + + def test_keep_count_zero(self, backup_dir): + """keep_count=0 不限制""" + _create_backup(backup_dir, "b1.zip", 0) + _create_backup(backup_dir, "b2.zip", 0) + + policy = RotationPolicy(keep_count=0) + rotator = BackupRotator(backup_dir, policy) + keep, delete = rotator.plan() + assert len(keep) == 2 + assert len(delete) == 0 + + def test_keep_count_exceeds_total(self, backup_dir): + """keep_count 大于总数时全部保留""" + _create_backup(backup_dir, "b1.zip", 5) + _create_backup(backup_dir, "b2.zip", 0) + + policy = RotationPolicy(keep_count=100) + rotator = BackupRotator(backup_dir, policy) + keep, delete = rotator.plan() + assert len(keep) == 2 + assert len(delete) == 0 + + def test_keep_count_one(self, backup_dir): + """keep_count=1 只保留最新的一份""" + _create_backup(backup_dir, "old.zip", 30) + _create_backup(backup_dir, "new.zip", 0) + + policy = RotationPolicy(keep_count=1) + rotator = BackupRotator(backup_dir, policy) + keep, delete = rotator.plan() + assert len(keep) == 1 + assert keep[0]["name"] == "new.zip" + assert len(delete) == 1 + assert delete[0]["name"] == "old.zip" + + +# ============================================================ +# keep_days 策略测试 +# ============================================================ + + +class TestKeepDays: + def test_keep_days_basic(self, backup_dir): + """保留 N 天内的文件""" + _create_backup(backup_dir, "recent.zip", 1) + _create_backup(backup_dir, "old.zip", 10) + + policy = RotationPolicy(keep_days=3) + rotator = BackupRotator(backup_dir, policy) + keep, delete = rotator.plan() + + assert len(keep) == 1 + assert keep[0]["name"] == "recent.zip" + assert len(delete) == 1 + assert delete[0]["name"] == "old.zip" + + def test_keep_days_all_recent(self, backup_dir): + """所有文件都在 N 天内则全部保留""" + _create_backup(backup_dir, "b1.zip", 0) + _create_backup(backup_dir, "b2.zip", 1) + + policy = RotationPolicy(keep_days=3) + rotator = BackupRotator(backup_dir, policy) + keep, delete = rotator.plan() + assert len(keep) == 2 + assert len(delete) == 0 + + def test_keep_days_all_old(self, backup_dir): + """所有文件都超过 N 天则全部删除""" + _create_backup(backup_dir, "b1.zip", 30) + _create_backup(backup_dir, "b2.zip", 20) + + policy = RotationPolicy(keep_days=7) + rotator = BackupRotator(backup_dir, policy) + keep, delete = rotator.plan() + assert len(keep) == 0 + assert len(delete) == 2 + + def test_keep_days_boundary(self, backup_dir): + """恰好在边界内的文件应该保留""" + _create_backup(backup_dir, "exact.zip", 6) + + policy = RotationPolicy(keep_days=7) + rotator = BackupRotator(backup_dir, policy) + keep, delete = rotator.plan() + assert len(keep) == 1 + assert len(delete) == 0 + + +# ============================================================ +# keep_daily 策略测试 +# ============================================================ + + +class TestKeepDaily: + def test_keep_daily_basic(self, backup_dir): + """保留最近 N 天每天一份""" + _create_backup(backup_dir, "day1.zip", 5) + _create_backup(backup_dir, "day2.zip", 4) + _create_backup(backup_dir, "day3.zip", 3) + _create_backup(backup_dir, "day4.zip", 2) + _create_backup(backup_dir, "day5.zip", 1) + + policy = RotationPolicy(keep_daily=3) + rotator = BackupRotator(backup_dir, policy) + keep, delete = rotator.plan() + + assert len(keep) == 3 + assert len(delete) == 2 + + def test_keep_daily_same_day_multiple(self, backup_dir): + """同一天有多份备份时只保留最新的一个""" + # 使用固定时间创建文件,确保同一天 + base_time = time.time() - 86400 # 昨天 + p1 = os.path.join(backup_dir, "morning.zip") + p2 = os.path.join(backup_dir, "evening.zip") + Path(p1).touch() + Path(p2).touch() + # morning 比 evening 早 3 小时 + os.utime(p1, (base_time, base_time)) + os.utime(p2, (base_time + 10800, base_time + 10800)) + + # 3 天前的旧备份 + _create_backup(backup_dir, "old.zip", 5) + + policy = RotationPolicy(keep_daily=2) + rotator = BackupRotator(backup_dir, policy) + keep, delete = rotator.plan() + + # 应该保留昨天的 evening(最新)和 3 天前的 old + assert len(keep) == 2 + keep_names = {b["name"] for b in keep} + assert "evening.zip" in keep_names + assert "old.zip" in keep_names + # morning 不应该被保留(同天已被 evening 替代) + assert "morning.zip" not in keep_names + + def test_keep_daily_one_day(self, backup_dir): + """keep_daily=1 只保留今天的一份""" + _create_backup(backup_dir, "today.zip", 0) + _create_backup(backup_dir, "yesterday.zip", 1) + _create_backup(backup_dir, "twodays.zip", 2) + + policy = RotationPolicy(keep_daily=1) + rotator = BackupRotator(backup_dir, policy) + keep, delete = rotator.plan() + + assert len(keep) == 1 + assert keep[0]["name"] == "today.zip" + + +# ============================================================ +# 多策略交集测试 +# ============================================================ + + +class TestPolicyIntersection: + def test_keep_count_and_keep_days_intersection(self, backup_dir): + """keep_count 和 keep_days 的交集""" + # Files sorted by mtime (newest first): b1(2d), b2(4d), b3(8d), b4(15d) + _create_backup(backup_dir, "b1.zip", 2) + _create_backup(backup_dir, "b2.zip", 4) + _create_backup(backup_dir, "b3.zip", 8) + _create_backup(backup_dir, "b4.zip", 15) + + # keep_count=2 keeps: b1, b2 (two newest) + # keep_days=5 keeps: b1, b2 (within 5 days) + # intersection = b1, b2 + # But b3 is only 8 days old, outside keep_days, even though it would be in keep_count=4 + # Let's use keep_count=4 to make the intersection more interesting: + # keep_count=4 keeps: b1, b2, b3, b4 (all four) + # keep_days=5 keeps: b1, b2 (within 5 days) + # intersection = b1, b2 + policy = RotationPolicy(keep_count=4, keep_days=5) + rotator = BackupRotator(backup_dir, policy) + keep, delete = rotator.plan() + + keep_names = {b["name"] for b in keep} + delete_names = {b["name"] for b in delete} + # b1 and b2 satisfy both policies -> kept + assert "b1.zip" in keep_names + assert "b2.zip" in keep_names + # b3 satisfies keep_count=4 but not keep_days=5 -> deleted + assert "b3.zip" in delete_names + # b4 satisfies neither -> deleted + assert "b4.zip" in delete_names + + def test_all_three_policies(self, backup_dir): + """三个策略的交集""" + # 创建覆盖多天的备份 + _create_backup(backup_dir, "a.zip", 0) + _create_backup(backup_dir, "b.zip", 1) + _create_backup(backup_dir, "c.zip", 3) + _create_backup(backup_dir, "d.zip", 7) + _create_backup(backup_dir, "e.zip", 10) + + # keep_count=4: 保留 a,b,c,d + # keep_days=5: 保留 a,b,c + # keep_daily=2: 保留 a,c(或 a,b 取决于具体日期,但至少保留今天和昨天各一个) + # 交集 = 至少保留 a(今天最新的) + policy = RotationPolicy(keep_count=4, keep_days=5, keep_daily=2) + rotator = BackupRotator(backup_dir, policy) + keep, delete = rotator.plan() + + # a.zip 一定在交集内 + keep_names = {b["name"] for b in keep} + assert "a.zip" in keep_names + # e.zip 一定被删除 + delete_names = {b["name"] for b in delete} + assert "e.zip" in delete_names + + +# ============================================================ +# dry_run 测试 +# ============================================================ + + +class TestDryRun: + def test_dry_run_no_deletion(self, backup_dir): + """dry_run=True 时不实际删除文件""" + p1 = _create_backup(backup_dir, "old.zip", 30) + p2 = _create_backup(backup_dir, "new.zip", 0) + + policy = RotationPolicy(keep_count=1, dry_run=True) + rotator = BackupRotator(backup_dir, policy) + deleted_count, deleted_paths = rotator.execute() + + # 应该报告要删除但不实际删除 + assert deleted_count == 1 + assert len(deleted_paths) == 1 + assert os.path.exists(p1) # 文件仍然存在 + assert os.path.exists(p2) + + def test_dry_run_false_actually_deletes(self, backup_dir): + """dry_run=False 时实际删除文件""" + _create_backup(backup_dir, "old.zip", 30) + _create_backup(backup_dir, "new.zip", 0) + + policy = RotationPolicy(keep_count=1, dry_run=False) + rotator = BackupRotator(backup_dir, policy) + deleted_count, deleted_paths = rotator.execute() + + assert deleted_count == 1 + # old.zip 应该被删除了 + assert not os.path.exists(os.path.join(backup_dir, "old.zip")) + assert os.path.exists(os.path.join(backup_dir, "new.zip")) + + +# ============================================================ +# 空目录测试 +# ============================================================ + + +class TestEmptyDir: + def test_empty_dir_plan(self, backup_dir): + """空目录的 plan 返回空列表""" + policy = RotationPolicy(keep_count=5) + rotator = BackupRotator(backup_dir, policy) + keep, delete = rotator.plan() + assert keep == [] + assert delete == [] + + def test_empty_dir_execute(self, backup_dir): + """空目录的 execute 返回 (0, [])""" + policy = RotationPolicy(keep_count=5) + rotator = BackupRotator(backup_dir, policy) + deleted_count, deleted_paths = rotator.execute() + assert deleted_count == 0 + assert deleted_paths == [] + + +# ============================================================ +# 非备份文件不被删除测试 +# ============================================================ + + +class TestNonBackupFilesNotDeleted: + def test_non_backup_files_preserved(self, backup_dir): + """非备份文件在轮转后仍然存在""" + _create_backup(backup_dir, "old.zip", 30) + _create_backup(backup_dir, "new.zip", 0) + + # 创建一些非备份文件 + readme = os.path.join(backup_dir, "readme.txt") + data = os.path.join(backup_dir, "data.csv") + sub = os.path.join(backup_dir, "subdir.zip") # 看起来像备份但不是后缀 + + Path(readme).write_text("hello") + Path(data).write_text("data") + Path(sub).mkdir() # 这是个目录,不是文件 + + policy = RotationPolicy(keep_count=1) + rotator = BackupRotator(backup_dir, policy) + deleted_count, _ = rotator.execute() + + assert deleted_count == 1 # 只删除 old.zip + assert os.path.exists(readme) + assert os.path.exists(data) + assert os.path.isdir(sub) + + +# ============================================================ +# execute 功能测试 +# ============================================================ + + +class TestExecute: + def test_execute_deletes_correct_files(self, backup_dir): + """execute 正确删除文件并返回路径""" + p1 = _create_backup(backup_dir, "a.zip", 10) + p2 = _create_backup(backup_dir, "b.zip", 5) + p3 = _create_backup(backup_dir, "c.zip", 0) + + policy = RotationPolicy(keep_count=1) + rotator = BackupRotator(backup_dir, policy) + deleted_count, deleted_paths = rotator.execute() + + assert deleted_count == 2 + assert len(deleted_paths) == 2 + assert os.path.exists(p3) # 最新的保留 + assert not os.path.exists(p1) + assert not os.path.exists(p2) + + def test_execute_no_deletion_when_all_kept(self, backup_dir): + """全部保留时不删除任何文件""" + _create_backup(backup_dir, "b1.zip", 0) + _create_backup(backup_dir, "b2.zip", 0) + + policy = RotationPolicy(keep_count=10) + rotator = BackupRotator(backup_dir, policy) + deleted_count, deleted_paths = rotator.execute() + assert deleted_count == 0 + assert deleted_paths == [] + + +# ============================================================ +# RotationPolicy 测试 +# ============================================================ + + +class TestRotationPolicy: + def test_has_any_rule_none(self): + policy = RotationPolicy() + assert policy.has_any_rule() is False + + def test_has_any_rule_count(self): + policy = RotationPolicy(keep_count=5) + assert policy.has_any_rule() is True + + def test_has_any_rule_days(self): + policy = RotationPolicy(keep_days=7) + assert policy.has_any_rule() is True + + def test_has_any_rule_daily(self): + policy = RotationPolicy(keep_daily=3) + assert policy.has_any_rule() is True + + def test_defaults(self): + policy = RotationPolicy() + assert policy.keep_count == 0 + assert policy.keep_days == 0 + assert policy.keep_daily == 0 + assert policy.dry_run is False diff --git a/tests/sbackup/test_schema.py b/tests/sbackup/test_schema.py new file mode 100644 index 0000000..7bf7160 --- /dev/null +++ b/tests/sbackup/test_schema.py @@ -0,0 +1,523 @@ +""" +单元测试 for sbackup.schema 模块(配置校验/校验和) +""" + +import os +import tempfile +import shutil +import unittest + +from sbackup.config import Config +from sbackup.schema import ( + ConfigValidator, + ValidationError, + get_config_checksum, + validate_config_file, +) + + +class TestValidationError(unittest.TestCase): + """测试 ValidationError 数据类""" + + def test_default_severity(self): + err = ValidationError(field="test", message="msg") + self.assertEqual(err.severity, "error") + + def test_custom_severity(self): + err = ValidationError(field="test", message="msg", severity="warning") + self.assertEqual(err.severity, "warning") + + +class TestCompressionValidation(unittest.TestCase): + """测试压缩格式校验""" + + def test_valid_format_zip(self): + config = Config(compression_format="ZIP") + is_valid, errors, warnings = validate_config_file(config) + self.assertTrue(is_valid) + self.assertFalse(any(e.field == "compression_format" for e in errors)) + + def test_valid_format_tar(self): + config = Config(compression_format="TAR") + is_valid, errors, _ = validate_config_file(config) + self.assertTrue(is_valid) + self.assertFalse(any(e.field == "compression_format" for e in errors)) + + def test_valid_all_formats(self): + for fmt in ["ZIP", "TAR", "GZ", "BZ2", "XZ", "ZST", "7Z"]: + config = Config(compression_format=fmt) + is_valid, errors, _ = validate_config_file(config) + self.assertTrue(is_valid, f"Format {fmt} should be valid") + self.assertFalse(any(e.field == "compression_format" for e in errors)) + + def test_invalid_format(self): + config = Config(compression_format="INVALID") + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "compression_format" for e in errors)) + + def test_invalid_format_case_insensitive(self): + config = Config(compression_format="zip") + is_valid, errors, _ = validate_config_file(config) + # Should pass because validate_all uppercases the format + self.assertTrue(is_valid) + + +class TestSFTPValidation(unittest.TestCase): + """测试 SFTP 配置校验""" + + def test_sftp_disabled_skips_validation(self): + config = Config(sftp_enabled=False, sftp_host="", sftp_user="") + is_valid, errors, _ = validate_config_file(config) + self.assertTrue(is_valid) + + def test_sftp_enabled_empty_host(self): + config = Config(sftp_enabled=True, sftp_host="", sftp_user="test") + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "sftp_host" for e in errors)) + + def test_sftp_enabled_empty_user(self): + config = Config(sftp_enabled=True, sftp_host="example.com", sftp_user="") + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "sftp_user" for e in errors)) + + def test_sftp_port_out_of_range(self): + config = Config( + sftp_enabled=True, sftp_host="example.com", sftp_user="u", sftp_port=0 + ) + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "sftp_port" for e in errors)) + + def test_sftp_port_too_high(self): + config = Config( + sftp_enabled=True, sftp_host="example.com", sftp_user="u", sftp_port=70000 + ) + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "sftp_port" for e in errors)) + + def test_sftp_valid_port(self): + config = Config( + sftp_enabled=True, sftp_host="example.com", sftp_user="u", sftp_port=22 + ) + is_valid, errors, _ = validate_config_file(config) + # No sftp-related errors for valid config + sftp_errors = [e for e in errors if e.field.startswith("sftp")] + self.assertFalse(sftp_errors) + + def test_sftp_key_file_not_found(self): + config = Config( + sftp_enabled=True, + sftp_host="example.com", + sftp_user="u", + sftp_key_file="/nonexistent/path/key.pem", + ) + _, _, warnings = validate_config_file(config) + self.assertTrue(any(w.field == "sftp_key_file" for w in warnings)) + + +class TestWebDAVValidation(unittest.TestCase): + """测试 WebDAV 配置校验""" + + def test_webdav_disabled_skips_validation(self): + config = Config(webdav_enabled=False, webdav_url="") + is_valid, errors, _ = validate_config_file(config) + self.assertTrue(is_valid) + + def test_webdav_enabled_empty_url(self): + config = Config(webdav_enabled=True, webdav_url="") + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "webdav_url" for e in errors)) + + def test_webdav_invalid_url_no_scheme(self): + config = Config(webdav_enabled=True, webdav_url="example.com/dav") + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "webdav_url" for e in errors)) + + def test_webdav_invalid_url_ftp(self): + config = Config(webdav_enabled=True, webdav_url="ftp://example.com/dav") + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "webdav_url" for e in errors)) + + def test_webdav_valid_http(self): + config = Config(webdav_enabled=True, webdav_url="http://example.com/dav") + is_valid, errors, _ = validate_config_file(config) + webdav_errors = [e for e in errors if e.field == "webdav_url"] + self.assertFalse(webdav_errors) + + def test_webdav_valid_https(self): + config = Config( + webdav_enabled=True, webdav_url="https://dav.jianguoyun.com/dav/" + ) + is_valid, errors, _ = validate_config_file(config) + webdav_errors = [e for e in errors if e.field == "webdav_url"] + self.assertFalse(webdav_errors) + + def test_webdav_malformed_url_with_spaces(self): + config = Config(webdav_enabled=True, webdav_url="http://exam ple.com/dav") + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "webdav_url" for e in errors)) + + +class TestCloudValidation(unittest.TestCase): + """测试云存储配置校验""" + + def test_cloud_disabled_skips_validation(self): + config = Config(cloud_enabled=False) + is_valid, errors, _ = validate_config_file(config) + self.assertTrue(is_valid) + + def test_cloud_enabled_empty_endpoint(self): + config = Config( + cloud_enabled=True, + cloud_bucket="b", + cloud_access_key="a", + cloud_secret_key="s", + ) + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "cloud_endpoint" for e in errors)) + + def test_cloud_enabled_empty_bucket(self): + config = Config( + cloud_enabled=True, + cloud_endpoint="e", + cloud_access_key="a", + cloud_secret_key="s", + ) + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "cloud_bucket" for e in errors)) + + def test_cloud_enabled_empty_access_key(self): + config = Config( + cloud_enabled=True, + cloud_endpoint="e", + cloud_bucket="b", + cloud_secret_key="s", + ) + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "cloud_access_key" for e in errors)) + + def test_cloud_enabled_empty_secret_key(self): + config = Config( + cloud_enabled=True, + cloud_endpoint="e", + cloud_bucket="b", + cloud_access_key="a", + ) + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "cloud_secret_key" for e in errors)) + + def test_cloud_all_fields_filled(self): + config = Config( + cloud_enabled=True, + cloud_endpoint="https://s3.amazonaws.com", + cloud_bucket="my-bucket", + cloud_access_key="AKIA...", + cloud_secret_key="secret", + ) + is_valid, errors, _ = validate_config_file(config) + cloud_errors = [e for e in errors if e.field.startswith("cloud")] + self.assertFalse(cloud_errors) + + +class TestSMTPValidation(unittest.TestCase): + """测试 SMTP 配置校验""" + + def test_smtp_disabled_skips_validation(self): + config = Config(smtp_enabled=False) + is_valid, errors, _ = validate_config_file(config) + self.assertTrue(is_valid) + + def test_smtp_enabled_empty_host(self): + config = Config(smtp_enabled=True, smtp_host="") + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "smtp_host" for e in errors)) + + def test_smtp_port_out_of_range(self): + config = Config(smtp_enabled=True, smtp_host="smtp.example.com", smtp_port=0) + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "smtp_port" for e in errors)) + + def test_smtp_port_too_high(self): + config = Config( + smtp_enabled=True, smtp_host="smtp.example.com", smtp_port=99999 + ) + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "smtp_port" for e in errors)) + + def test_smtp_valid_config(self): + config = Config(smtp_enabled=True, smtp_host="smtp.example.com", smtp_port=587) + is_valid, errors, _ = validate_config_file(config) + smtp_errors = [e for e in errors if e.field.startswith("smtp")] + self.assertFalse(smtp_errors) + + +class TestWebhookValidation(unittest.TestCase): + """测试 Webhook URL 校验""" + + def test_empty_webhook_list(self): + config = Config(webhook_urls=[]) + is_valid, errors, _ = validate_config_file(config) + self.assertTrue(is_valid) + + def test_valid_webhook_urls(self): + config = Config( + webhook_urls=["https://example.com/hook", "http://localhost:8080/notify"] + ) + is_valid, errors, _ = validate_config_file(config) + webhook_errors = [e for e in errors if e.field == "webhook_urls"] + self.assertFalse(webhook_errors) + + def test_invalid_webhook_url(self): + config = Config(webhook_urls=["not-a-url"]) + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "webhook_urls" for e in errors)) + + def test_ftp_webhook_url_rejected(self): + config = Config(webhook_urls=["ftp://example.com/hook"]) + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "webhook_urls" for e in errors)) + + +class TestPasswordValidation(unittest.TestCase): + """测试密码配置校验""" + + def test_empty_password_no_warning(self): + config = Config(password="") + _, _, warnings = validate_config_file(config) + self.assertFalse(any(w.field == "password" for w in warnings)) + + def test_plaintext_password_warning(self): + config = Config(password="mysecret") + _, _, warnings = validate_config_file(config) + self.assertTrue(any(w.field == "password" for w in warnings)) + + def test_encrypted_password_no_warning(self): + config = Config(password="enc:someencryptedvalue") + _, _, warnings = validate_config_file(config) + self.assertFalse(any(w.field == "password" for w in warnings)) + + +class TestPathsValidation(unittest.TestCase): + """测试路径配置校验""" + + def test_existing_folder_no_warning(self): + config = Config(folder_path=tempfile.gettempdir()) + _, _, warnings = validate_config_file(config) + self.assertFalse(any(w.field == "folder_path" for w in warnings)) + + def test_nonexistent_folder_warning(self): + config = Config(folder_path="/nonexistent/folder/path") + _, _, warnings = validate_config_file(config) + self.assertTrue(any(w.field == "folder_path" for w in warnings)) + + def test_zipfile_path_nonexistent_parent_warning(self): + config = Config(zipfile_path="/nonexistent/dir/backup.zip") + _, _, warnings = validate_config_file(config) + self.assertTrue(any(w.field == "zipfile_path" for w in warnings)) + + def test_zipfile_path_valid_parent_no_warning(self): + tmpdir = tempfile.mkdtemp() + try: + zipfile_path = os.path.join(tmpdir, "backup.zip") + config = Config(zipfile_path=zipfile_path) + _, _, warnings = validate_config_file(config) + self.assertFalse(any(w.field == "zipfile_path" for w in warnings)) + finally: + shutil.rmtree(tmpdir) + + +class TestNetworkValidation(unittest.TestCase): + """测试网络配置校验""" + + def test_valid_ip_sftp(self): + config = Config( + sftp_enabled=True, sftp_host="192.168.1.1", sftp_user="u", sftp_port=22 + ) + _, errors, _ = validate_config_file(config) + network_errors = [e for e in errors if e.field == "sftp_host"] + self.assertFalse(network_errors) + + def test_invalid_port_for_ip(self): + config = Config( + sftp_enabled=True, sftp_host="192.168.1.1", sftp_user="u", sftp_port=0 + ) + _, errors, _ = validate_config_file(config) + self.assertTrue(any(e.field == "sftp_host" for e in errors)) + + def test_domain_host_skips_port_check(self): + # Non-IP host should not trigger port range error via network validation + config = Config(smtp_enabled=True, smtp_host="smtp.example.com", smtp_port=25) + _, errors, _ = validate_config_file(config) + network_errors = [e for e in errors if e.field == "smtp_host"] + self.assertFalse(network_errors) + + def test_malformed_host_warning(self): + config = Config( + sftp_enabled=True, sftp_host="invalid host!", sftp_user="u", sftp_port=22 + ) + _, _, warnings = validate_config_file(config) + self.assertTrue(any(w.field == "sftp_host" for w in warnings)) + + +class TestIntegrityChecksum(unittest.TestCase): + """测试配置校验和""" + + def test_checksum_consistency(self): + config = Config( + folder_path="/test", + compression_format="ZIP", + sftp_host="example.com", + ) + checksum1 = get_config_checksum(config) + checksum2 = get_config_checksum(config) + self.assertEqual(checksum1, checksum2) + + def test_checksum_changes_with_field(self): + config1 = Config(folder_path="/test") + config2 = Config(folder_path="/different") + self.assertNotEqual(get_config_checksum(config1), get_config_checksum(config2)) + + def test_checksum_excludes_internal_fields(self): + """校验和不应受 _encrypted/_key_salt/_config_checksum 影响""" + config = Config(folder_path="/test") + config._encrypted = True + config._key_salt = "some_salt" + config._config_checksum = "old_checksum" + checksum_with = get_config_checksum(config) + # Reset internal fields + config._encrypted = False + config._key_salt = "" + config._config_checksum = "" + checksum_without = get_config_checksum(config) + self.assertEqual(checksum_with, checksum_without) + + def test_validate_all_updates_checksum(self): + config = Config(compression_format="ZIP") + validator = ConfigValidator(config) + old_checksum = getattr(config, "_config_checksum", None) + validator.validate_all() + # After validate_all, the config's _config_checksum should be updated + self.assertIsNotNone(config._config_checksum) + self.assertNotEqual(old_checksum, config._config_checksum) + # Running again should produce same checksum + validator2 = ConfigValidator(config) + validator2.validate_all() + self.assertEqual(config._config_checksum, get_config_checksum(config)) + + +class TestValidateConfigFile(unittest.TestCase): + """测试 validate_config_file 完整校验""" + + def test_all_valid_config(self): + tmpdir = tempfile.mkdtemp() + try: + config = Config( + folder_path=tmpdir, + compression_format="ZIP", + zipfile_path=os.path.join(tmpdir, "backup.zip"), + ) + is_valid, errors, warnings = validate_config_file(config) + self.assertTrue(is_valid) + self.assertEqual(len(errors), 0) + finally: + shutil.rmtree(tmpdir) + + def test_multiple_errors(self): + config = Config( + sftp_enabled=True, + sftp_host="", + sftp_user="", + sftp_port=99999, + compression_format="BAD", + ) + is_valid, errors, warnings = validate_config_file(config) + self.assertFalse(is_valid) + self.assertGreaterEqual(len(errors), 4) + + def test_errors_and_warnings_coexist(self): + config = Config( + folder_path="/nonexistent/path", + password="plaintext_pass", + compression_format="ZIP", + ) + is_valid, errors, warnings = validate_config_file(config) + # folder_path warning + password warning, no errors + self.assertTrue(is_valid) + self.assertGreater(len(warnings), 0) + + +class TestSecurityScenarios(unittest.TestCase): + """安全场景测试""" + + def test_malformed_url(self): + """畸形 URL 应被拒绝""" + config = Config(webdav_enabled=True, webdav_url="javascript:alert(1)") + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertTrue(any(e.field == "webdav_url" for e in errors)) + + def test_url_with_newline_injection(self): + """包含换行符的 URL 应被拒绝""" + config = Config( + webdav_enabled=True, webdav_url="http://example.com/path\nEvil-Header: true" + ) + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + + def test_empty_required_fields(self): + """空的必填字段应产生错误""" + config = Config( + sftp_enabled=True, + webdav_enabled=True, + cloud_enabled=True, + smtp_enabled=True, + ) + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid) + self.assertGreater(len(errors), 0) + + def test_extreme_port_values(self): + """极端端口值应被拒绝""" + for port in [0, -1, 65536, 99999]: + config = Config( + sftp_enabled=True, + sftp_host="192.168.1.1", + sftp_user="u", + sftp_port=port, + ) + is_valid, errors, _ = validate_config_file(config) + self.assertFalse(is_valid, f"Port {port} should be invalid") + + def test_port_boundary_values(self): + """边界端口值 1 和 65535 应该合法""" + for port in [1, 65535]: + config = Config( + sftp_enabled=True, + sftp_host="192.168.1.1", + sftp_user="u", + sftp_port=port, + ) + _, errors, _ = validate_config_file(config) + port_errors = [e for e in errors if e.field == "sftp_port"] + self.assertFalse(port_errors, f"Port {port} should be valid") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sbackup/test_selective.py b/tests/sbackup/test_selective.py new file mode 100644 index 0000000..d73a285 --- /dev/null +++ b/tests/sbackup/test_selective.py @@ -0,0 +1,522 @@ +""" +单元测试 for sbackup.selective 模块 +""" + +import os +import shutil +import tarfile +import tempfile +import unittest +import zipfile +from pathlib import Path + + +class TestSelectiveRestoreZip(unittest.TestCase): + """ZIP 格式的 SelectiveRestore 测试""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_test_") + self.archive_path = os.path.join(self.test_dir, "test_backup.zip") + self.extract_dir = os.path.join(self.test_dir, "extract") + + # 创建测试文件结构 + self.src_dir = os.path.join(self.test_dir, "src") + os.makedirs(os.path.join(self.src_dir, "pkg")) + Path(os.path.join(self.src_dir, "main.py")).write_text("print('hello')") + Path(os.path.join(self.src_dir, "utils.py")).write_text("# utils") + Path(os.path.join(self.src_dir, "pkg", "__init__.py")).write_text("") + Path(os.path.join(self.src_dir, "pkg", "core.py")).write_text("# core") + Path(os.path.join(self.src_dir, "data.csv")).write_text("a,b,c\n1,2,3") + Path(os.path.join(self.src_dir, "readme.txt")).write_text("Readme") + + # 创建 ZIP + with zipfile.ZipFile(self.archive_path, "w") as zf: + for root, dirs, files in os.walk(self.src_dir): + for fn in files: + full = os.path.join(root, fn) + arcname = os.path.relpath(full, self.test_dir).replace("\\", "/") + zf.write(full, arcname) + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_list_files_all(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + files = sr.list_files() + names = [f["name"] for f in files] + self.assertIn("src/main.py", names) + self.assertIn("src/utils.py", names) + self.assertIn("src/data.csv", names) + self.assertIn("src/pkg/core.py", names) + + def test_list_files_with_pattern(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + files = sr.list_files("*.py") + names = [f["name"] for f in files] + self.assertIn("src/main.py", names) + self.assertIn("src/utils.py", names) + self.assertIn("src/pkg/core.py", names) + self.assertNotIn("src/data.csv", names) + + def test_list_files_returns_size(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + files = sr.list_files() + for f in files: + if not f["is_dir"]: + self.assertGreaterEqual(f["size"], 0) + + def test_search_keyword(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + results = sr.search("main") + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["name"], "src/main.py") + + def test_search_case_insensitive(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + results = sr.search("MAIN") + self.assertEqual(len(results), 1) + + def test_search_no_results(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + results = sr.search("nonexistent") + self.assertEqual(len(results), 0) + + def test_extract_files(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + count, paths = sr.extract_files(["src/main.py"], self.extract_dir) + self.assertEqual(count, 1) + self.assertTrue( + os.path.exists(os.path.join(self.extract_dir, "src", "main.py")) + ) + + def test_extract_files_glob(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + count, paths = sr.extract_files(["src/*.py"], self.extract_dir) + # fnmatch * matches / so src/*.py also matches src/pkg/__init__.py, src/pkg/core.py + self.assertEqual(count, 4) # main.py, utils.py, pkg/__init__.py, pkg/core.py + self.assertTrue( + os.path.exists(os.path.join(self.extract_dir, "src", "main.py")) + ) + self.assertTrue( + os.path.exists(os.path.join(self.extract_dir, "src", "utils.py")) + ) + + def test_extract_files_multiple_patterns(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + count, paths = sr.extract_files(["src/*.py", "src/data.csv"], self.extract_dir) + self.assertEqual(count, 5) # 4 py files + data.csv + + def test_extract_single(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + result = sr.extract_single("src/main.py", self.extract_dir) + self.assertIsNotNone(result) + self.assertTrue(os.path.exists(result)) + + def test_extract_single_not_found(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + result = sr.extract_single("nonexistent.txt", self.extract_dir) + self.assertIsNone(result) + + def test_extract_preserves_directory_structure(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + count, paths = sr.extract_files(["src/pkg/core.py"], self.extract_dir) + self.assertEqual(count, 1) + expected = os.path.join(self.extract_dir, "src", "pkg", "core.py") + self.assertTrue(os.path.exists(expected)) + + def test_get_stats(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + stats = sr.get_stats() + self.assertGreater(stats["total_files"], 0) + self.assertEqual(stats["total_dirs"], 0) + self.assertGreater(stats["total_size"], 0) + self.assertIn(".py", stats["formats"]) + + def test_extract_files_no_match(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + count, paths = sr.extract_files(["*.xyz"], self.extract_dir) + self.assertEqual(count, 0) + self.assertEqual(paths, []) + + +class TestSelectiveRestoreTar(unittest.TestCase): + """tar.gz 格式的 SelectiveRestore 测试""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_test_") + self.archive_path = os.path.join(self.test_dir, "test_backup.tar.gz") + self.extract_dir = os.path.join(self.test_dir, "extract") + + # 创建测试文件 + src_dir = os.path.join(self.test_dir, "src") + os.makedirs(src_dir) + Path(os.path.join(src_dir, "file_a.txt")).write_text("content a") + Path(os.path.join(src_dir, "file_b.py")).write_text("content b") + + with tarfile.open(self.archive_path, "w:gz") as tarf: + tarf.add(os.path.join(src_dir, "file_a.txt"), arcname="src/file_a.txt") + tarf.add(os.path.join(src_dir, "file_b.py"), arcname="src/file_b.py") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_list_files_tar(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + files = sr.list_files() + names = [f["name"] for f in files] + self.assertIn("src/file_a.txt", names) + self.assertIn("src/file_b.py", names) + + def test_list_files_tar_with_pattern(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + files = sr.list_files("*.py") + names = [f["name"] for f in files] + self.assertIn("src/file_b.py", names) + self.assertNotIn("src/file_a.txt", names) + + def test_search_tar(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + results = sr.search("file_a") + self.assertEqual(len(results), 1) + + def test_extract_files_tar(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + count, paths = sr.extract_files(["src/file_b.py"], self.extract_dir) + self.assertEqual(count, 1) + self.assertTrue( + os.path.exists(os.path.join(self.extract_dir, "src", "file_b.py")) + ) + + def test_get_stats_tar(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + stats = sr.get_stats() + self.assertEqual(stats["total_files"], 2) + self.assertGreater(stats["total_size"], 0) + + +class TestSelectiveRestoreTarBz2(unittest.TestCase): + """tar.bz2 格式的 SelectiveRestore 测试""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_test_") + self.archive_path = os.path.join(self.test_dir, "test_backup.tar.bz2") + self.extract_dir = os.path.join(self.test_dir, "extract") + + src_dir = os.path.join(self.test_dir, "data") + os.makedirs(src_dir) + Path(os.path.join(src_dir, "report.csv")).write_text("x,y\n1,2") + + with tarfile.open(self.archive_path, "w:bz2") as tarf: + tarf.add(os.path.join(src_dir, "report.csv"), arcname="data/report.csv") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_list_files_tar_bz2(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + files = sr.list_files() + names = [f["name"] for f in files] + self.assertIn("data/report.csv", names) + + def test_extract_files_tar_bz2(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + count, _ = sr.extract_files(["data/report.csv"], self.extract_dir) + self.assertEqual(count, 1) + self.assertTrue( + os.path.exists(os.path.join(self.extract_dir, "data", "report.csv")) + ) + + +class TestSelectiveRestoreTarXz(unittest.TestCase): + """tar.xz 格式的 SelectiveRestore 测试""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_test_") + self.archive_path = os.path.join(self.test_dir, "test_backup.tar.xz") + self.extract_dir = os.path.join(self.test_dir, "extract") + + src_dir = os.path.join(self.test_dir, "docs") + os.makedirs(src_dir) + Path(os.path.join(src_dir, "guide.txt")).write_text("guide content") + + with tarfile.open(self.archive_path, "w:xz") as tarf: + tarf.add(os.path.join(src_dir, "guide.txt"), arcname="docs/guide.txt") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_list_files_tar_xz(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + files = sr.list_files() + names = [f["name"] for f in files] + self.assertIn("docs/guide.txt", names) + + def test_extract_files_tar_xz(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + count, _ = sr.extract_files(["docs/guide.txt"], self.extract_dir) + self.assertEqual(count, 1) + + +class TestSelectiveRestoreTarZst(unittest.TestCase): + """tar.zst 格式的 SelectiveRestore 测试""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_test_") + self.archive_path = os.path.join(self.test_dir, "test_backup.tar.zst") + self.extract_dir = os.path.join(self.test_dir, "extract") + + src_dir = os.path.join(self.test_dir, "logs") + os.makedirs(src_dir) + Path(os.path.join(src_dir, "app.log")).write_text("log line 1\nlog line 2") + + try: + import zstandard as zstd + + with open(self.archive_path, "wb") as f_out: + cctx = zstd.ZstdCompressor(level=3) + compressor = cctx.stream_writer(f_out) + with tarfile.open(fileobj=compressor, mode="w") as tarf: + tarf.add(os.path.join(src_dir, "app.log"), arcname="logs/app.log") + compressor.close() + except ImportError: + self.skipTest("zstandard not installed") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_list_files_tar_zst(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + files = sr.list_files() + names = [f["name"] for f in files] + self.assertIn("logs/app.log", names) + + def test_extract_files_tar_zst(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + count, _ = sr.extract_files(["logs/app.log"], self.extract_dir) + self.assertEqual(count, 1) + self.assertTrue( + os.path.exists(os.path.join(self.extract_dir, "logs", "app.log")) + ) + + +class TestSelectiveRestore7z(unittest.TestCase): + """7z 格式的 SelectiveRestore 测试""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_test_") + self.archive_path = os.path.join(self.test_dir, "test_backup.7z") + self.extract_dir = os.path.join(self.test_dir, "extract") + + src_dir = os.path.join(self.test_dir, "project") + os.makedirs(src_dir) + Path(os.path.join(src_dir, "main.py")).write_text("x = 1") + Path(os.path.join(src_dir, "data.txt")).write_text("data") + + try: + import py7zr + + with py7zr.SevenZipFile(self.archive_path, mode="w") as szf: + szf.write(os.path.join(src_dir, "main.py"), arcname="project/main.py") + szf.write(os.path.join(src_dir, "data.txt"), arcname="project/data.txt") + except ImportError: + self.skipTest("py7zr not installed") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_list_files_7z(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + files = sr.list_files() + names = [f["name"] for f in files] + self.assertIn("project/main.py", names) + self.assertIn("project/data.txt", names) + + def test_list_files_7z_with_pattern(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + files = sr.list_files("*.py") + names = [f["name"] for f in files] + self.assertIn("project/main.py", names) + self.assertNotIn("project/data.txt", names) + + def test_search_7z(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + results = sr.search("main") + self.assertEqual(len(results), 1) + + def test_extract_files_7z(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + count, paths = sr.extract_files(["project/main.py"], self.extract_dir) + self.assertEqual(count, 1) + self.assertTrue( + os.path.exists(os.path.join(self.extract_dir, "project", "main.py")) + ) + + def test_get_stats_7z(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + stats = sr.get_stats() + self.assertEqual(stats["total_files"], 2) + + +class TestSelectiveRestoreNonexistent(unittest.TestCase): + """处理不存在的文件""" + + def test_list_files_nonexistent(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore("/nonexistent/path/archive.zip") + with self.assertRaises(Exception): + sr.list_files() + + def test_search_nonexistent(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore("/nonexistent/path/archive.zip") + with self.assertRaises(Exception): + sr.search("test") + + def test_extract_nonexistent(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore("/nonexistent/path/archive.zip") + with self.assertRaises(Exception): + sr.extract_files(["*.py"], "/tmp/output") + + def test_get_stats_nonexistent(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore("/nonexistent/path/archive.zip") + with self.assertRaises(Exception): + sr.get_stats() + + +class TestSelectiveRestoreTarPlain(unittest.TestCase): + """纯 tar 格式(无压缩)测试""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_test_") + self.archive_path = os.path.join(self.test_dir, "test_backup.tar") + self.extract_dir = os.path.join(self.test_dir, "extract") + + src_dir = os.path.join(self.test_dir, "assets") + os.makedirs(src_dir) + Path(os.path.join(src_dir, "style.css")).write_text("body { color: red; }") + + with tarfile.open(self.archive_path, "w") as tarf: + tarf.add(os.path.join(src_dir, "style.css"), arcname="assets/style.css") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_list_files_tar_plain(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + files = sr.list_files() + names = [f["name"] for f in files] + self.assertIn("assets/style.css", names) + + def test_extract_files_tar_plain(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + count, _ = sr.extract_files(["assets/style.css"], self.extract_dir) + self.assertEqual(count, 1) + + +class TestSelectiveRestoreDirectoryEntries(unittest.TestCase): + """测试目录条目处理""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp(prefix="sbackup_test_") + self.archive_path = os.path.join(self.test_dir, "test_dirs.zip") + self.extract_dir = os.path.join(self.test_dir, "extract") + + # 创建含目录结构的 ZIP + with zipfile.ZipFile(self.archive_path, "w") as zf: + zf.writestr("root_dir/", "") + zf.writestr("root_dir/sub_dir/", "") + zf.writestr("root_dir/sub_dir/file.txt", "content") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_list_files_marks_dirs(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + files = sr.list_files() + dirs = [f for f in files if f["is_dir"]] + regular_files = [f for f in files if not f["is_dir"]] + self.assertGreater(len(dirs), 0) + self.assertGreater(len(regular_files), 0) + + def test_extract_files_skips_dirs(self): + from sbackup.selective import SelectiveRestore + + sr = SelectiveRestore(self.archive_path) + count, _ = sr.extract_files(["root_dir/sub_dir/file.txt"], self.extract_dir) + self.assertEqual(count, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sbackup/test_sftp.py b/tests/sbackup/test_sftp.py new file mode 100644 index 0000000..af4ad53 --- /dev/null +++ b/tests/sbackup/test_sftp.py @@ -0,0 +1,858 @@ +""" +单元测试 for sbackup.sftp 模块 +""" + +import unittest +import os +import tempfile +from unittest.mock import patch, MagicMock +from sbackup.sftp import SFTPClient, SFTPError + + +class TestSFTPClient(unittest.TestCase): + def setUp(self): + self.host = "test.example.com" + self.port = 22 + self.user = "testuser" + self.password = "testpass" + self.key_file = "/path/to/id_rsa" + self.key_passphrase = "keypass" + # 使用临时目录避免污染真实的 known_hosts 文件 + self._tmp_dir = tempfile.mkdtemp() + self._orig_expanduser = os.path.expanduser + + def _mock_expanduser(path): + if path == "~/.ssh/known_hosts": + return os.path.join(self._tmp_dir, "known_hosts") + return self._orig_expanduser(path) + + self._expanduser_patcher = patch("os.path.expanduser", side_effect=_mock_expanduser) + self._expanduser_patcher.start() + + def tearDown(self): + self._expanduser_patcher.stop() + import shutil + shutil.rmtree(self._tmp_dir, ignore_errors=True) + + def test_init_stores_credentials(self): + """测试初始化存储凭据""" + client = SFTPClient(self.host, self.port, self.user, self.password) + self.assertEqual(client.host, self.host) + self.assertEqual(client.port, self.port) + self.assertEqual(client.user, self.user) + self.assertEqual(client.password, self.password) + self.assertIsNone(client._transport) + self.assertIsNone(client._sftp) + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_connect_success(self, mock_sftp_cls, mock_transport_cls): + """测试成功连接""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + + mock_transport_cls.assert_called_once_with((self.host, self.port)) + mock_transport.auth_password.assert_called_once_with( + self.user, self.password + ) + mock_sftp_cls.from_transport.assert_called_once_with(mock_transport) + + @patch("paramiko.Transport") + def test_connect_auth_failure(self, mock_transport_cls): + """测试认证失败""" + import paramiko + + mock_transport = MagicMock() + mock_transport.auth_password.side_effect = paramiko.AuthenticationException() + mock_transport_cls.return_value = mock_transport + + client = SFTPClient(self.host, self.port, self.user, self.password) + with self.assertRaises(SFTPError) as cm: + client.connect() + self.assertIn("SFTP", str(cm.exception)) + + @patch("paramiko.Transport") + def test_connect_ssh_error(self, mock_transport_cls): + """测试 SSH 连接错误""" + import paramiko + + mock_transport = MagicMock() + mock_transport.auth_password.side_effect = paramiko.SSHException("Connection refused") + mock_transport_cls.return_value = mock_transport + + client = SFTPClient(self.host, self.port, self.user, self.password) + with self.assertRaises(SFTPError): + client.connect() + + @patch("paramiko.Transport") + def test_connect_os_error(self, mock_transport_cls): + """测试网络不可达""" + mock_transport_cls.side_effect = OSError("Network unreachable") + + client = SFTPClient(self.host, self.port, self.user, self.password) + with self.assertRaises(SFTPError): + client.connect() + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_disconnect(self, mock_sftp_cls, mock_transport_cls): + """测试断开连接""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + client.disconnect() + + mock_sftp.close.assert_called_once() + mock_transport.close.assert_called_once() + self.assertIsNone(client._sftp) + self.assertIsNone(client._transport) + + def test_disconnect_without_connect(self): + """测试未连接时断开不报错""" + client = SFTPClient(self.host, self.port, self.user, self.password) + client.disconnect() # 不应抛出异常 + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_context_manager(self, mock_sftp_cls, mock_transport_cls): + """测试上下文管理器""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + + with SFTPClient(self.host, self.port, self.user, self.password) as client: + self.assertIsNotNone(client._transport) + self.assertIsNotNone(client._sftp) + + # 退出上下文后应断开 + self.assertIsNone(client._sftp) + self.assertIsNone(client._transport) + + def test_upload_not_connected(self): + """测试未连接时上传报错""" + client = SFTPClient(self.host, self.port, self.user, self.password) + with self.assertRaises(SFTPError): + client.upload_file("/local/file.zip", "/remote/") + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_upload_local_not_found(self, mock_sftp_cls, mock_transport_cls): + """测试本地文件不存在""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + with self.assertRaises(SFTPError): + client.upload_file("/nonexistent/file.zip", "/remote/") + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_upload_success(self, mock_sftp_cls, mock_transport_cls): + """测试成功上传""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + + # 创建临时文件 + with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as f: + f.write(b"test data") + temp_path = f.name + + try: + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + client.upload_file(temp_path, "/remote/backups/") + + # 验证 sftp.put 被调用 + mock_sftp.put.assert_called_once() + call_args = mock_sftp.put.call_args + self.assertEqual(call_args[0][0], temp_path) + self.assertIn(os.path.basename(temp_path), call_args[0][1]) + self.assertTrue(call_args[1]["confirm"]) + finally: + os.unlink(temp_path) + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_upload_with_progress(self, mock_sftp_cls, mock_transport_cls): + """测试带进度回调的上传""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + + progress_values = [] + + def progress_cb(sent, total): + progress_values.append((sent, total)) + + with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as f: + f.write(b"x" * 1000) + temp_path = f.name + + try: + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + client.upload_file(temp_path, "/remote/", progress_callback=progress_cb) + + mock_sftp.put.assert_called_once() + # 验证 callback 参数被传递 + self.assertEqual(mock_sftp.put.call_args[1]["callback"], progress_cb) + finally: + os.unlink(temp_path) + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_upload_creates_remote_dir(self, mock_sftp_cls, mock_transport_cls): + """测试上传时自动创建远程目录""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + # 第一次 stat 抛出 FileNotFoundError(目录不存在) + mock_sftp.stat.side_effect = FileNotFoundError() + mock_sftp_cls.from_transport.return_value = mock_sftp + + with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as f: + f.write(b"test") + temp_path = f.name + + try: + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + client.upload_file(temp_path, "/remote/newdir/") + + # 验证 mkdir 被调用 + mock_sftp.mkdir.assert_called_with("/remote/newdir") + finally: + os.unlink(temp_path) + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_upload_os_error(self, mock_sftp_cls, mock_transport_cls): + """测试上传时 OS 错误""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp.put.side_effect = OSError("Disk full") + mock_sftp_cls.from_transport.return_value = mock_sftp + + with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as f: + f.write(b"test") + temp_path = f.name + + try: + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + with self.assertRaises(SFTPError): + client.upload_file(temp_path, "/remote/") + finally: + os.unlink(temp_path) + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_test_connection_success(self, mock_sftp_cls, mock_transport_cls): + """测试连接测试成功""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + + client = SFTPClient(self.host, self.port, self.user, self.password) + result = client.test_connection() + self.assertTrue(result) + mock_sftp.listdir.assert_called_once_with(".") + + @patch("paramiko.Transport") + def test_test_connection_failure(self, mock_transport_cls): + """测试连接测试失败""" + import paramiko + + mock_transport = MagicMock() + mock_transport.auth_password.side_effect = paramiko.AuthenticationException() + mock_transport_cls.return_value = mock_transport + + client = SFTPClient(self.host, self.port, self.user, self.password) + result = client.test_connection() + self.assertFalse(result) + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_ensure_remote_dir_root(self, mock_sftp_cls, mock_transport_cls): + """测试远程根目录不需要创建""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + client._ensure_remote_dir("/") + # 根目录不应调用 stat 或 mkdir + mock_sftp.stat.assert_not_called() + mock_sftp.mkdir.assert_not_called() + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_ensure_remote_dir_exists(self, mock_sftp_cls, mock_transport_cls): + """测试远程目录已存在""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + client._ensure_remote_dir("/existing/dir") + # 目录存在,不应调用 mkdir + mock_sftp.stat.assert_called_once_with("/existing/dir") + mock_sftp.mkdir.assert_not_called() + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_ensure_remote_dir_nested_create(self, mock_sftp_cls, mock_transport_cls): + """测试远程嵌套目录逐级创建""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + # 所有目录都不存在 + mock_sftp.stat.side_effect = FileNotFoundError() + mock_sftp_cls.from_transport.return_value = mock_sftp + + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + client._ensure_remote_dir("/a/b/c") + # 应逐级创建 /a, /a/b, /a/b/c + expected_calls = ["/a/b/c", "/a/b", "/a"] + actual_calls = [c[0][0] for c in mock_sftp.stat.call_args_list] + self.assertEqual(actual_calls, expected_calls) + self.assertEqual(mock_sftp.mkdir.call_count, 3) + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_ensure_remote_dir_empty(self, mock_sftp_cls, mock_transport_cls): + """测试空路径不操作""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + client._ensure_remote_dir("") + mock_sftp.stat.assert_not_called() + mock_sftp.mkdir.assert_not_called() + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_ensure_remote_dir_mkdir_error(self, mock_sftp_cls, mock_transport_cls): + """测试创建远程目录失败""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp.stat.side_effect = FileNotFoundError() + mock_sftp.mkdir.side_effect = OSError("permission denied") + mock_sftp_cls.from_transport.return_value = mock_sftp + + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + with self.assertRaises(SFTPError): + client._ensure_remote_dir("/forbidden") + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_ensure_remote_dir_not_connected(self, mock_sftp_cls, mock_transport_cls): + """测试未连接时 _ensure_remote_dir 不报错""" + client = SFTPClient(self.host, self.port, self.user, self.password) + client._ensure_remote_dir("/some/path") + # 不应抛出异常 + + # ========== 私钥认证测试 ========== + + def test_init_with_key_file(self): + """测试初始化存储私钥路径""" + client = SFTPClient( + self.host, + self.port, + self.user, + key_file=self.key_file, + key_passphrase=self.key_passphrase, + ) + self.assertEqual(client.key_file, self.key_file) + self.assertEqual(client.key_passphrase, self.key_passphrase) + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + @patch("os.path.isfile", return_value=True) + @patch("os.path.expanduser", return_value="/home/user/.ssh/id_rsa") + @patch("paramiko.RSAKey") + def test_connect_with_key_auth( + self, mock_rsa, mock_expanduser, mock_isfile, mock_sftp_cls, mock_transport_cls + ): + """测试私钥认证连接""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + mock_pkey = MagicMock() + mock_rsa.from_private_key_file.return_value = mock_pkey + + client = SFTPClient( + self.host, + self.port, + self.user, + key_file=self.key_file, + key_passphrase=self.key_passphrase, + ) + client.connect() + + # 验证 expanduser 被调用(包括 key_file 和 known_hosts 路径) + expanduser_calls = [str(c) for c in mock_expanduser.call_args_list] + self.assertTrue( + any(self.key_file in c for c in expanduser_calls), + f"expanduser should be called with key_file, got: {expanduser_calls}", + ) + mock_transport.auth_publickey.assert_called_once_with( + self.user, mock_pkey + ) + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + @patch("os.path.isfile", return_value=True) + @patch("os.path.expanduser", return_value="/home/user/.ssh/id_rsa") + @patch("paramiko.RSAKey") + def test_connect_key_auth_no_passphrase( + self, mock_rsa, mock_expanduser, mock_isfile, mock_sftp_cls, mock_transport_cls + ): + """测试无密码短语的私钥认证""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + mock_pkey = MagicMock() + mock_rsa.from_private_key_file.return_value = mock_pkey + + client = SFTPClient( + self.host, + self.port, + self.user, + key_file=self.key_file, + key_passphrase="", + ) + client.connect() + + mock_rsa.from_private_key_file.assert_called_once_with( + "/home/user/.ssh/id_rsa", password=None + ) + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + @patch("os.path.isfile", return_value=False) + @patch("os.path.expanduser", return_value="/nonexistent/key") + def test_connect_falls_back_to_password( + self, mock_expanduser, mock_isfile, mock_sftp_cls, mock_transport_cls + ): + """测试私钥文件不存在时回退到密码认证""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + + client = SFTPClient( + self.host, + self.port, + self.user, + self.password, + key_file="/nonexistent/key", + ) + client.connect() + + mock_transport.auth_password.assert_called_once_with( + self.user, self.password + ) + + @patch("paramiko.RSAKey") + @patch("paramiko.Ed25519Key") + @patch("paramiko.ECDSAKey") + def test_load_private_key_tries_all_formats( + self, mock_ecdsa, mock_ed25519, mock_rsa + ): + """测试 _load_private_key 依次尝试所有密钥格式""" + import paramiko + + mock_rsa.from_private_key_file.side_effect = paramiko.SSHException("bad") + mock_ed25519.from_private_key_file.side_effect = paramiko.SSHException("bad") + mock_pkey = MagicMock() + mock_ecdsa.from_private_key_file.return_value = mock_pkey + + result = SFTPClient._load_private_key("/path/to/key") + self.assertEqual(result, mock_pkey) + + @patch("paramiko.RSAKey") + @patch("paramiko.Ed25519Key") + @patch("paramiko.ECDSAKey") + def test_load_private_key_all_fail(self, mock_ecdsa, mock_ed25519, mock_rsa): + """测试所有密钥格式都失败时抛出异常""" + import paramiko + + mock_rsa.from_private_key_file.side_effect = paramiko.SSHException("bad") + mock_ed25519.from_private_key_file.side_effect = paramiko.SSHException("bad") + mock_ecdsa.from_private_key_file.side_effect = paramiko.SSHException("bad") + + with self.assertRaises(SFTPError): + SFTPClient._load_private_key("/path/to/bad_key") + + # ========== 默认私钥检测测试 ========== + + @patch("os.path.expanduser") + @patch("os.path.isfile") + def test_try_default_key_found_ed25519(self, mock_isfile, mock_expanduser): + """测试检测到默认 Ed25519 私钥""" + mock_expanduser.side_effect = lambda p: p.replace("~", "/home/user") + mock_isfile.side_effect = lambda p: p == "/home/user/.ssh/id_ed25519" + + result = SFTPClient.try_default_key() + self.assertEqual(result, "/home/user/.ssh/id_ed25519") + + @patch("os.path.expanduser") + @patch("os.path.isfile") + def test_try_default_key_found_rsa(self, mock_isfile, mock_expanduser): + """测试检测到默认 RSA 私钥(Ed25519 不存在时)""" + mock_expanduser.side_effect = lambda p: p.replace("~", "/home/user") + mock_isfile.side_effect = lambda p: p == "/home/user/.ssh/id_rsa" + + result = SFTPClient.try_default_key() + self.assertEqual(result, "/home/user/.ssh/id_rsa") + + @patch("os.path.expanduser") + @patch("os.path.isfile") + def test_try_default_key_not_found(self, mock_isfile, mock_expanduser): + """测试没有找到默认私钥""" + mock_expanduser.side_effect = lambda p: p.replace("~", "/home/user") + mock_isfile.return_value = False + + result = SFTPClient.try_default_key() + self.assertIsNone(result) + + @patch("os.path.expanduser") + @patch("os.path.isfile") + def test_try_default_key_priority(self, mock_isfile, mock_expanduser): + """测试默认私钥优先级(Ed25519 > RSA > ECDSA)""" + mock_expanduser.side_effect = lambda p: p.replace("~", "/home/user") + # Ed25519 和 RSA 都存在,应该返回 Ed25519 + mock_isfile.side_effect = lambda p: ( + p + in [ + "/home/user/.ssh/id_ed25519", + "/home/user/.ssh/id_rsa", + ] + ) + + result = SFTPClient.try_default_key() + self.assertEqual(result, "/home/user/.ssh/id_ed25519") + + # ========== 密码短语检测测试 ========== + + def test_is_passphrase_error_true(self): + """测试检测到密码短语相关错误""" + self.assertTrue(SFTPClient._is_passphrase_error("password is required")) + self.assertTrue(SFTPClient._is_passphrase_error("bad decrypt")) + self.assertTrue(SFTPClient._is_passphrase_error("no decrypt")) + self.assertTrue(SFTPClient._is_passphrase_error("passphrase")) + self.assertTrue(SFTPClient._is_passphrase_error("encrypted key")) + + def test_is_passphrase_error_false(self): + """测试非密码短语相关错误""" + self.assertFalse(SFTPClient._is_passphrase_error("not a valid key")) + self.assertFalse(SFTPClient._is_passphrase_error("format error")) + self.assertFalse(SFTPClient._is_passphrase_error("")) + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + @patch("os.path.isfile", return_value=True) + @patch("os.path.expanduser", return_value="/home/user/.ssh/id_rsa") + @patch("paramiko.RSAKey") + @patch("paramiko.Ed25519Key") + @patch("paramiko.ECDSAKey") + def test_connect_key_needs_passphrase( + self, + mock_ecdsa, + mock_ed25519, + mock_rsa, + mock_expanduser, + mock_isfile, + mock_sftp_cls, + mock_transport_cls, + ): + """测试私钥需要密码短语时抛出明确错误""" + import paramiko + + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + # 所有密钥格式都模拟需要密码短语的错误 + mock_rsa.from_private_key_file.side_effect = paramiko.SSHException( + "not a valid RSA private key file (password is required)" + ) + mock_ed25519.from_private_key_file.side_effect = paramiko.SSHException( + "not a valid Ed25519 private key file (password is required)" + ) + mock_ecdsa.from_private_key_file.side_effect = paramiko.SSHException( + "not a valid ECDSA private key file (password is required)" + ) + + client = SFTPClient( + self.host, + self.port, + self.user, + key_file=self.key_file, + key_passphrase="", + ) + with self.assertRaises(SFTPError) as ctx: + client.connect() + self.assertIn("passphrase", str(ctx.exception).lower()) + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + @patch("os.path.isfile", return_value=True) + @patch("os.path.expanduser", return_value="/home/user/.ssh/id_rsa") + @patch("paramiko.RSAKey") + def test_connect_key_with_passphrase_ok( + self, mock_rsa, mock_expanduser, mock_isfile, mock_sftp_cls, mock_transport_cls + ): + """测试提供正确密码短语时连接成功""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + mock_pkey = MagicMock() + mock_rsa.from_private_key_file.return_value = mock_pkey + + client = SFTPClient( + self.host, + self.port, + self.user, + key_file=self.key_file, + key_passphrase="correct_passphrase", + ) + client.connect() + + mock_rsa.from_private_key_file.assert_called_once_with( + "/home/user/.ssh/id_rsa", password="correct_passphrase" + ) + mock_transport.auth_publickey.assert_called_once_with( + self.user, mock_pkey + ) + + # ========== 资源清理测试 ========== + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_disconnect_cleans_up_even_if_sftp_close_raises( + self, mock_sftp_cls, mock_transport_cls + ): + """测试 sftp.close() 异常时 transport 仍被清理""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + mock_sftp.close.side_effect = OSError("socket closed") + + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + client.disconnect() + + mock_transport.close.assert_called_once() + self.assertIsNone(client._sftp) + self.assertIsNone(client._transport) + + @patch("paramiko.Transport") + def test_connect_failure_cleans_up_transport(self, mock_transport_cls): + """测试连接失败时 transport 被清理""" + import paramiko + + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_transport.auth_password.side_effect = paramiko.AuthenticationException( + "auth failed" + ) + + client = SFTPClient(self.host, self.port, self.user, self.password) + with self.assertRaises(SFTPError): + client.connect() + + mock_transport.close.assert_called_once() + self.assertIsNone(client._transport) + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_list_remote_files(self, mock_sftp_cls, mock_transport_cls): + """测试列出远程文件""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + + # 模拟 SFTP_ATTR 列表 + mock_attr1 = MagicMock() + mock_attr1.filename = "backup1.zip" + mock_attr1.st_size = 1024 + mock_attr1.st_mtime = 1700000000.0 + mock_attr1.st_mode = 0o100644 # 普通文件 + + mock_attr2 = MagicMock() + mock_attr2.filename = "subdir" + mock_attr2.st_size = 0 + mock_attr2.st_mtime = 1700000000.0 + mock_attr2.st_mode = 0o040755 # 目录 + + mock_sftp.listdir_attr.return_value = [mock_attr1, mock_attr2] + + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + result = client.list_remote_files("/backups") + + self.assertEqual(len(result), 1) # 目录被过滤 + self.assertEqual(result[0]["name"], "backup1.zip") + self.assertEqual(result[0]["size"], 1024) + + @patch("paramiko.Transport") + @patch("paramiko.SFTPClient") + def test_delete_remote_file(self, mock_sftp_cls, mock_transport_cls): + """测试删除远程文件""" + mock_transport = MagicMock() + mock_transport_cls.return_value = mock_transport + mock_sftp = MagicMock() + mock_sftp_cls.from_transport.return_value = mock_sftp + + client = SFTPClient(self.host, self.port, self.user, self.password) + client.connect() + client.delete_remote_file("/backups/backup1.zip") + + mock_sftp.remove.assert_called_once_with("/backups/backup1.zip") + + +class TestResolveSFTPAuth(unittest.TestCase): + """测试 _resolve_sftp_auth 函数的认证凭据解析逻辑""" + + @patch("sbackup.sftp.SFTPClient.resolve_key_passphrase") + @patch("sbackup.sftp.SFTPClient.try_default_key") + def test_password_fallback_true_with_no_passphrase(self, mock_try_key, mock_resolve): + """allow_password_fallback=True 时,用户放弃密码短语应回退到密码认证""" + mock_try_key.return_value = None # 无默认密钥 + mock_resolve.return_value = None + + from sbackup.handlers import _resolve_sftp_auth + + with patch("getpass.getpass", return_value="mypassword"): + key_file, passphrase, password = _resolve_sftp_auth( + "/path/to/key", "", "", interactive=True, allow_password_fallback=True + ) + + self.assertEqual(key_file, "") + self.assertEqual(passphrase, "") + self.assertEqual(password, "mypassword") + + @patch("sbackup.sftp.SFTPClient.resolve_key_passphrase") + def test_password_fallback_false_with_no_passphrase(self, mock_resolve): + """allow_password_fallback=False 时,用户放弃密码短语应返回私钥路径""" + mock_resolve.return_value = None + + from sbackup.handlers import _resolve_sftp_auth + + key_file, passphrase, password = _resolve_sftp_auth( + "/path/to/key", "", "", interactive=True, allow_password_fallback=False + ) + + self.assertEqual(key_file, "/path/to/key") + self.assertEqual(passphrase, "") + self.assertEqual(password, "") + + @patch("sbackup.sftp.SFTPClient.resolve_key_passphrase") + def test_password_fallback_false_with_passphrase(self, mock_resolve): + """allow_password_fallback=False 时,有密码短语应正常返回私钥凭据""" + mock_resolve.return_value = "keypass" + + from sbackup.handlers import _resolve_sftp_auth + + key_file, passphrase, password = _resolve_sftp_auth( + "/path/to/key", "", "", interactive=True, allow_password_fallback=False + ) + + self.assertEqual(key_file, "/path/to/key") + self.assertEqual(passphrase, "keypass") + self.assertEqual(password, "") + + @patch("sbackup.sftp.SFTPClient.try_default_key") + def test_password_fallback_false_no_key_no_default(self, mock_try_key): + """allow_password_fallback=False 时,无密钥无默认密钥应提示输入密码""" + mock_try_key.return_value = None + + from sbackup.handlers import _resolve_sftp_auth + + with patch("getpass.getpass", return_value="fallbackpw"): + key_file, passphrase, password = _resolve_sftp_auth( + "", "", "", interactive=True, allow_password_fallback=False + ) + + self.assertEqual(key_file, "") + self.assertEqual(passphrase, "") + self.assertEqual(password, "fallbackpw") + + def test_explicit_key_and_passphrase(self): + """已有完整私钥凭据时直接返回""" + from sbackup.handlers import _resolve_sftp_auth + + key_file, passphrase, password = _resolve_sftp_auth( + "/path/to/key", "keypass", "", interactive=False + ) + + self.assertEqual(key_file, "/path/to/key") + self.assertEqual(passphrase, "keypass") + self.assertEqual(password, "") + + def test_explicit_password(self): + """已有密码时直接返回""" + from sbackup.handlers import _resolve_sftp_auth + + key_file, passphrase, password = _resolve_sftp_auth( + "", "", "mypassword", interactive=False + ) + + self.assertEqual(key_file, "") + self.assertEqual(passphrase, "") + self.assertEqual(password, "mypassword") + + @patch("sbackup.sftp.SFTPClient.try_default_key") + def test_default_fallback_true_no_key_no_default_interactive(self, mock_try_key): + """allow_password_fallback=True 默认值,无密钥无默认密钥应提示输入密码""" + mock_try_key.return_value = None + + from sbackup.handlers import _resolve_sftp_auth + + with patch("getpass.getpass", return_value="auto_pw"): + key_file, passphrase, password = _resolve_sftp_auth( + "", "", "", interactive=True + ) + + self.assertEqual(key_file, "") + self.assertEqual(passphrase, "") + self.assertEqual(password, "auto_pw") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sbackup/test_task_queue.py b/tests/sbackup/test_task_queue.py new file mode 100644 index 0000000..1b0c407 --- /dev/null +++ b/tests/sbackup/test_task_queue.py @@ -0,0 +1,332 @@ +"""单元测试 for sbackup.task_queue 模块""" + +import os +import shutil +import tempfile +import unittest +from unittest.mock import patch, MagicMock + +from sbackup.task_queue import BackupTask, TaskQueue + + +class TestBackupTask(unittest.TestCase): + """测试 BackupTask 数据类""" + + def test_create_task(self): + """测试创建备份任务""" + task = BackupTask( + id="test-id-1", + name="test-backup", + folder_path="/tmp/test", + zipfile_path=None, + compression_format="ZIP", + config_overrides={}, + ) + self.assertEqual(task.id, "test-id-1") + self.assertEqual(task.name, "test-backup") + self.assertEqual(task.status, "pending") + self.assertEqual(task.compression_format, "ZIP") + + def test_task_to_dict(self): + """测试任务转字典""" + task = BackupTask( + id="id-1", + name="backup1", + folder_path="/tmp/test", + zipfile_path="/tmp/out.zip", + compression_format="ZIP", + config_overrides={"password": "secret"}, + status="completed", + created_at="2026-01-01T00:00:00+00:00", + result_path="/tmp/out.zip", + ) + d = task.to_dict() + self.assertEqual(d["id"], "id-1") + self.assertEqual(d["name"], "backup1") + self.assertEqual(d["status"], "completed") + self.assertEqual(d["config_overrides"], {"password": "secret"}) + self.assertEqual(d["result_path"], "/tmp/out.zip") + + def test_task_from_dict(self): + """测试从字典创建任务""" + data = { + "id": "id-2", + "name": "backup2", + "folder_path": "/tmp/folder", + "zipfile_path": None, + "compression_format": "TAR_GZ", + "config_overrides": {}, + "status": "failed", + "created_at": "2026-01-01T00:00:00+00:00", + "started_at": "", + "finished_at": "2026-01-01T00:01:00+00:00", + "error": "backup failed", + "result_path": "", + } + task = BackupTask.from_dict(data) + self.assertEqual(task.id, "id-2") + self.assertEqual(task.name, "backup2") + self.assertEqual(task.status, "failed") + self.assertEqual(task.error, "backup failed") + self.assertIsNone(task.zipfile_path) + + def test_task_from_dict_defaults(self): + """测试从不完整字典创建任务使用默认值""" + task = BackupTask.from_dict({}) + self.assertEqual(task.id, "") + self.assertEqual(task.status, "pending") + self.assertEqual(task.compression_format, "ZIP") + self.assertEqual(task.config_overrides, {}) + + +class TestTaskQueue(unittest.TestCase): + """测试 TaskQueue 类""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.queue_file = os.path.join(self.test_dir, "test_queue.json") + self.queue = TaskQueue(self.queue_file) + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_init_empty_queue(self): + """测试初始化空队列""" + self.assertEqual(len(self.queue.tasks), 0) + self.assertFalse(os.path.exists(self.queue_file)) + + def test_add_task(self): + """测试添加任务""" + task_id = self.queue.add_task("backup1", "/tmp/test") + self.assertIsNotNone(task_id) + self.assertEqual(len(self.queue.tasks), 1) + self.assertEqual(self.queue.tasks[0].name, "backup1") + self.assertEqual(self.queue.tasks[0].status, "pending") + + def test_add_task_with_options(self): + """测试添加带选项的任务""" + task_id = self.queue.add_task( + name="backup2", + folder_path="/tmp/test", + zipfile_path="/tmp/out.zip", + compression_format="tar.gz", + config_overrides={"password": "123"}, + ) + task = self.queue.get_task(task_id) + self.assertIsNotNone(task) + self.assertEqual(task.zipfile_path, "/tmp/out.zip") + self.assertEqual(task.compression_format, "TAR.GZ") + self.assertEqual(task.config_overrides, {"password": "123"}) + + def test_add_multiple_tasks(self): + """测试添加多个任务""" + self.queue.add_task("task1", "/tmp/test1") + self.queue.add_task("task2", "/tmp/test2") + self.queue.add_task("task3", "/tmp/test3") + self.assertEqual(len(self.queue.tasks), 3) + + def test_list_tasks_all(self): + """测试列出所有任务""" + self.queue.add_task("task1", "/tmp/test1") + self.queue.add_task("task2", "/tmp/test2") + tasks = self.queue.list_tasks() + self.assertEqual(len(tasks), 2) + + def test_list_tasks_by_status(self): + """测试按状态过滤任务""" + id1 = self.queue.add_task("task1", "/tmp/test1") + self.queue.add_task("task2", "/tmp/test2") + self.queue.cancel_task(id1) + pending = self.queue.list_tasks(status="pending") + failed = self.queue.list_tasks(status="failed") + self.assertEqual(len(pending), 1) + self.assertEqual(len(failed), 1) + + def test_get_task(self): + """测试获取指定任务""" + task_id = self.queue.add_task("backup1", "/tmp/test") + task = self.queue.get_task(task_id) + self.assertIsNotNone(task) + self.assertEqual(task.name, "backup1") + + def test_get_task_not_found(self): + """测试获取不存在的任务""" + task = self.queue.get_task("nonexistent-id") + self.assertIsNone(task) + + def test_cancel_task(self): + """测试取消任务""" + task_id = self.queue.add_task("backup1", "/tmp/test") + result = self.queue.cancel_task(task_id) + self.assertTrue(result) + task = self.queue.get_task(task_id) + self.assertEqual(task.status, "failed") + self.assertTrue(len(task.error) > 0) + + def test_cancel_task_not_found(self): + """测试取消不存在的任务""" + result = self.queue.cancel_task("nonexistent") + self.assertFalse(result) + + def test_cancel_task_not_pending(self): + """测试取消非 pending 状态的任务""" + task_id = self.queue.add_task("backup1", "/tmp/test") + self.queue.cancel_task(task_id) # 先取消一次 + result = self.queue.cancel_task(task_id) # 再取消应该失败 + self.assertFalse(result) + + @patch("sbackup.task_queue.load_config") + @patch("sbackup.task_queue.BackupManager") + def test_run_next(self, mock_manager_cls, mock_load_config): + """测试执行下一个 pending 任务""" + # 设置 mock + mock_config = MagicMock() + mock_config.data_file = os.path.join(self.test_dir, "sbackup.json") + mock_load_config.return_value = mock_config + + mock_manager = MagicMock() + mock_manager.add_folder.return_value = True + mock_manager_cls.return_value = mock_manager + + # 添加并执行任务 + self.queue.add_task("backup1", "/tmp/test") + task = self.queue.run_next() + + self.assertIsNotNone(task) + self.assertEqual(task.status, "completed") + mock_manager.add_folder.assert_called_once() + mock_manager.execute_backups.assert_called_once() + + @patch("sbackup.task_queue.load_config") + @patch("sbackup.task_queue.BackupManager") + def test_run_next_failure(self, mock_manager_cls, mock_load_config): + """测试执行任务失败""" + mock_config = MagicMock() + mock_config.data_file = os.path.join(self.test_dir, "sbackup.json") + mock_load_config.return_value = mock_config + + mock_manager = MagicMock() + mock_manager.add_folder.side_effect = Exception("disk full") + mock_manager_cls.return_value = mock_manager + + self.queue.add_task("backup1", "/tmp/test") + task = self.queue.run_next() + + self.assertIsNotNone(task) + self.assertEqual(task.status, "failed") + self.assertIn("disk full", task.error) + + def test_run_next_empty_queue(self): + """测试空队列执行""" + task = self.queue.run_next() + self.assertIsNone(task) + + @patch("sbackup.task_queue.load_config") + @patch("sbackup.task_queue.BackupManager") + def test_run_all(self, mock_manager_cls, mock_load_config): + """测试执行所有 pending 任务""" + mock_config = MagicMock() + mock_config.data_file = os.path.join(self.test_dir, "sbackup.json") + mock_load_config.return_value = mock_config + + mock_manager = MagicMock() + mock_manager.add_folder.return_value = True + mock_manager_cls.return_value = mock_manager + + self.queue.add_task("task1", "/tmp/test1") + self.queue.add_task("task2", "/tmp/test2") + self.queue.add_task("task3", "/tmp/test3") + + executed = self.queue.run_all() + self.assertEqual(len(executed), 3) + self.assertTrue(all(t.status == "completed" for t in executed)) + + def test_run_all_empty(self): + """测试执行空队列""" + executed = self.queue.run_all() + self.assertEqual(len(executed), 0) + + def test_clear_completed(self): + """测试清除已完成任务""" + self.queue.add_task("task1", "/tmp/test1") + self.queue.add_task("task2", "/tmp/test2") + self.queue.add_task("task3", "/tmp/test3") + + # 取消两个任务(设为 failed) + self.queue.cancel_task(self.queue.tasks[0].id) + self.queue.cancel_task(self.queue.tasks[1].id) + + count = self.queue.clear_completed() + self.assertEqual(count, 2) + self.assertEqual(len(self.queue.tasks), 1) + + def test_clear_completed_none(self): + """测试清除无已完成任务""" + self.queue.add_task("task1", "/tmp/test1") + count = self.queue.clear_completed() + self.assertEqual(count, 0) + self.assertEqual(len(self.queue.tasks), 1) + + def test_get_stats(self): + """测试获取统计信息""" + self.queue.add_task("task1", "/tmp/test1") + self.queue.add_task("task2", "/tmp/test2") + self.queue.add_task("task3", "/tmp/test3") + + self.queue.cancel_task(self.queue.tasks[0].id) + + stats = self.queue.get_stats() + self.assertEqual(stats["total"], 3) + self.assertEqual(stats["pending"], 2) + self.assertEqual(stats["failed"], 1) + self.assertEqual(stats["completed"], 0) + self.assertEqual(stats["running"], 0) + + +class TestTaskQueuePersistence(unittest.TestCase): + """测试队列持久化""" + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.queue_file = os.path.join(self.test_dir, "queue.json") + + def tearDown(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_save_and_load(self): + """测试保存后重新加载""" + queue1 = TaskQueue(self.queue_file) + queue1.add_task("task1", "/tmp/test1") + queue1.add_task("task2", "/tmp/test2") + + # 重新加载 + queue2 = TaskQueue(self.queue_file) + self.assertEqual(len(queue2.tasks), 2) + self.assertEqual(queue2.tasks[0].name, "task1") + self.assertEqual(queue2.tasks[1].name, "task2") + + def test_persist_status_changes(self): + """测试状态变更持久化""" + queue1 = TaskQueue(self.queue_file) + task_id = queue1.add_task("task1", "/tmp/test1") + queue1.cancel_task(task_id) + + queue2 = TaskQueue(self.queue_file) + self.assertEqual(len(queue2.tasks), 1) + self.assertEqual(queue2.tasks[0].status, "failed") + + def test_load_corrupted_file(self): + """测试加载损坏的 JSON 文件""" + with open(self.queue_file, "w") as f: + f.write("not valid json {{{") + queue = TaskQueue(self.queue_file) + self.assertEqual(len(queue.tasks), 0) + + def test_load_nonexistent_file(self): + """测试加载不存在的文件""" + queue = TaskQueue("/nonexistent/path/queue.json") + self.assertEqual(len(queue.tasks), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/sbackup/test_webdav.py b/tests/sbackup/test_webdav.py new file mode 100644 index 0000000..841f22c --- /dev/null +++ b/tests/sbackup/test_webdav.py @@ -0,0 +1,251 @@ +""" +WebDAV 模块测试:WebDAVClient 连接/上传/测试 +""" + +import os +import tempfile +import shutil +import unittest +from unittest.mock import patch, MagicMock +from sbackup.webdav import WebDAVClient, WebDAVError +from sbackup.i18n import set_locale + + +class TestWebDAVClient(unittest.TestCase): + """测试 WebDAVClient""" + + def setUp(self): + # 确保 locale 为 zh_CN(防止其他测试污染) + set_locale("zh_CN") + self.url = "https://dav.jianguoyun.com/dav/" + self.user = "test@example.com" + self.password = "secret" + self.client = WebDAVClient(self.url, self.user, self.password) + self.temp_dir = tempfile.mkdtemp() + self.test_file = os.path.join(self.temp_dir, "test.txt") + with open(self.test_file, "w") as f: + f.write("hello webdav") + + def tearDown(self): + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_build_url(self): + """测试 URL 构建""" + self.assertEqual(self.client._build_url(), "https://dav.jianguoyun.com/dav/") + self.assertEqual( + self.client._build_url("backups"), "https://dav.jianguoyun.com/dav/backups" + ) + self.assertEqual( + self.client._build_url("/backups/"), + "https://dav.jianguoyun.com/dav/backups", + ) + + def test_make_auth(self): + """测试 Basic Auth 生成""" + auth = self.client._make_auth() + self.assertTrue(auth.startswith("Basic ")) + + def test_connect_success(self): + """测试连接成功""" + mock_resp = MagicMock() + mock_resp.read.return_value = b"" + self.client._opener.open = MagicMock(return_value=mock_resp) + self.client.connect() + self.client._opener.open.assert_called_once() + + def test_connect_auth_failure(self): + """测试连接认证失败""" + import urllib.error + + self.client._opener.open = MagicMock(side_effect=urllib.error.HTTPError( + self.url, 401, "Unauthorized", {}, None + )) + with self.assertRaises(WebDAVError) as ctx: + self.client.connect() + self.assertIn("认证", str(ctx.exception)) + + def test_connect_network_error(self): + """测试连接网络错误""" + self.client._opener.open = MagicMock(side_effect=OSError("Connection refused")) + with self.assertRaises(WebDAVError): + self.client.connect() + + def test_upload_file_success(self): + """测试文件上传成功""" + mock_resp = MagicMock() + mock_resp.read.return_value = b"" + self.client._opener.open = MagicMock(return_value=mock_resp) + size = self.client.upload_file(self.test_file, "backups/test.txt") + self.assertEqual(size, os.path.getsize(self.test_file)) + + def test_upload_file_not_found(self): + """测试上传不存在的文件""" + with self.assertRaises(WebDAVError) as ctx: + self.client.upload_file("/nonexistent/file.txt", "remote.txt") + self.assertIn("不存在", str(ctx.exception)) + + def test_upload_file_http_error(self): + """测试上传 HTTP 错误""" + import urllib.error + + self.client._opener.open = MagicMock(side_effect=urllib.error.HTTPError( + self.url, 500, "Server Error", {}, None + )) + with self.assertRaises(WebDAVError) as ctx: + self.client.upload_file(self.test_file, "remote.txt") + self.assertIn("失败", str(ctx.exception)) + + def test_ensure_remote_dir_creates(self): + """测试递归创建远程目录""" + mock_resp = MagicMock() + self.client._opener.open = MagicMock(return_value=mock_resp) + self.client._ensure_remote_dir("a/b/c") + # 应该调用 3 次 MKCOL + self.assertEqual(self.client._opener.open.call_count, 3) + + def test_ensure_remote_dir_exists(self): + """测试目录已存在(405)""" + import urllib.error + + self.client._opener.open = MagicMock(side_effect=urllib.error.HTTPError( + self.url, 405, "Method Not Allowed", {}, None + )) + self.client._ensure_remote_dir("existing") # 不应抛出异常 + + def test_ensure_remote_dir_empty(self): + """测试空路径不操作""" + self.client._opener.open = MagicMock() + self.client._ensure_remote_dir("") + self.client._opener.open.assert_not_called() + + def test_test_connection_success(self): + """测试连接测试成功""" + mock_resp = MagicMock() + mock_resp.read.return_value = b"" + self.client._opener.open = MagicMock(return_value=mock_resp) + result = self.client.test_connection() + self.assertTrue(result) + + def test_test_connection_failure(self): + """测试连接测试失败""" + self.client._opener.open = MagicMock(side_effect=OSError("timeout")) + result = self.client.test_connection() + self.assertFalse(result) + + def test_ensure_remote_dir_401(self): + """测试 MKCOL 认证失败""" + import urllib.error + + self.client._opener.open = MagicMock(side_effect=urllib.error.HTTPError( + self.url, 401, "Unauthorized", {}, None + )) + with self.assertRaises(WebDAVError) as ctx: + self.client._ensure_remote_dir("secret") + self.assertIn("认证", str(ctx.exception)) + + def test_ensure_remote_dir_other_error(self): + """测试 MKCOL 其他 HTTP 错误""" + import urllib.error + + self.client._opener.open = MagicMock(side_effect=urllib.error.HTTPError( + self.url, 500, "Server Error", {}, None + )) + with self.assertRaises(WebDAVError): + self.client._ensure_remote_dir("bad_dir") + + def test_ensure_remote_dir_os_error(self): + """测试 MKCOL 网络错误""" + self.client._opener.open = MagicMock(side_effect=OSError("Connection refused")) + with self.assertRaises(WebDAVError): + self.client._ensure_remote_dir("offline_dir") + + def test_upload_file_401(self): + """测试上传时认证失败""" + import urllib.error + + self.client._opener.open = MagicMock(side_effect=urllib.error.HTTPError( + self.url, 401, "Unauthorized", {}, None + )) + with self.assertRaises(WebDAVError) as ctx: + self.client.upload_file(self.test_file, "backups/test.txt") + self.assertIn("认证", str(ctx.exception)) + + def test_upload_file_os_error(self): + """测试上传时网络错误""" + # 首次调用 (_ensure_remote_dir 的 MKCOL) 成功,第二次 (PUT) 失败 + self.client._opener.open = MagicMock(side_effect=[ + MagicMock(), # MKCOL 成功 + OSError("Network unreachable"), # PUT 失败 + ]) + with self.assertRaises(WebDAVError) as ctx: + self.client.upload_file(self.test_file, "backups/test.txt") + self.assertIn("backups/test.txt", str(ctx.exception)) + + def test_connect_non_auth_http_error(self): + """测试连接时非认证 HTTP 错误""" + import urllib.error + + self.client._opener.open = MagicMock(side_effect=urllib.error.HTTPError( + self.url, 502, "Bad Gateway", {}, None + )) + with self.assertRaises(WebDAVError): + self.client.connect() + + def test_context_manager(self): + """测试上下文管理器""" + with WebDAVClient(self.url, self.user, self.password) as client: + self.assertIsInstance(client, WebDAVClient) + + def test_build_request(self): + """测试 _build_request 构建请求对象""" + req = self.client._build_request("GET", "test/path") + self.assertEqual(req.get_method(), "GET") + self.assertIn("Basic", req.get_header("Authorization")) + + def test_build_request_with_data(self): + """测试 _build_request 带数据时正确设置""" + data = b"test data" + req = self.client._build_request("PUT", "upload.txt", data=data) + self.assertEqual(req.get_method(), "PUT") + self.assertEqual(req.full_url, "https://dav.jianguoyun.com/dav/upload.txt") + + def test_list_remote_files(self): + """测试列出远程文件""" + # 模拟 PROPFIND XML 响应 + xml_response = """ + + + /dav/backups/ + HTTP/1.1 200 OK + + + /dav/backups/backup1.zip + + + 1024 + Mon, 01 Jan 2024 00:00:00 GMT + + + HTTP/1.1 200 OK + + +""" + mock_resp = MagicMock() + mock_resp.read.return_value = xml_response.encode("utf-8") + self.client._opener.open = MagicMock(return_value=mock_resp) + + result = self.client.list_remote_files("backups") + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["name"], "backup1.zip") + self.assertEqual(result[0]["size"], 1024) + + def test_delete_remote_file(self): + """测试删除远程文件""" + mock_resp = MagicMock() + self.client._opener.open = MagicMock(return_value=mock_resp) + self.client.delete_remote_file("backups/backup1.zip") + self.client._opener.open.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 2bb7498..78b245a 100644 --- a/uv.lock +++ b/uv.lock @@ -2,10 +2,59 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version < '3.13'", ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/ba4e4ca8d149f8dcc0d952ac0967089e1d759c7e5fcf0865a317eb680fbb/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e", size = 24549, upload-time = "2025-07-30T10:02:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/9b2386cc75ac0bd3210e12a44bfc7fd1632065ed8b80d573036eecb10442/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d", size = 25539, upload-time = "2025-07-30T10:02:00.929Z" }, + { url = "https://files.pythonhosted.org/packages/31/db/740de99a37aa727623730c90d92c22c9e12585b3c98c54b7960f7810289f/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584", size = 28467, upload-time = "2025-07-30T10:02:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/47c4509ea18d755f44e2b92b7178914f0c113946d11e16e626df8eaa2b0b/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690", size = 27355, upload-time = "2025-07-30T10:02:02.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/82/82745642d3c46e7cea25e1885b014b033f4693346ce46b7f47483cf5d448/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520", size = 29187, upload-time = "2025-07-30T10:02:03.674Z" }, +] + [[package]] name = "backports-zstd" version = "1.3.0" @@ -111,6 +160,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/ec/6247be6536668fe1c7dfae3eaa9c94b00b956b716957c0fc986ba78c3cc4/backports_zstd-1.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2524bd6777a828d5e7ccd7bd1a57f9e7007ae654fc2bd1bc1a207f6428674e4a", size = 299684, upload-time = "2025-12-29T17:28:04.856Z" }, ] +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +] + [[package]] name = "brotli" version = "1.2.0" @@ -194,6 +313,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, ] +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + [[package]] name = "cffi" version = "2.0.0" @@ -398,6 +526,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] +[[package]] +name = "cryptography" +version = "47.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, + { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, + { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, + { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, + { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, + { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, + { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, + { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, + { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, + { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "inflate64" version = "1.0.4" @@ -466,6 +666,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/85/33447bb3c4e3c0ae7b1fde3aadc52a18b2b0193cfcf4f585977e924c6463/inflate64-1.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:9ae3ee727235a06dc3cd353ee5761fdd8e3b56ad119c711f61680528972a6ced", size = 34846, upload-time = "2025-11-28T10:55:38.433Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "invoke" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c", size = 343419, upload-time = "2026-04-07T15:17:48.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053", size = 160958, upload-time = "2026-04-07T15:17:46.875Z" }, +] + +[[package]] +name = "minio" +version = "7.2.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi" }, + { name = "certifi" }, + { name = "pycryptodome" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/df/6dfc6540f96a74125a11653cce717603fd5b7d0001a8e847b3e54e72d238/minio-7.2.20.tar.gz", hash = "sha256:95898b7a023fbbfde375985aa77e2cd6a0762268db79cf886f002a9ea8e68598", size = 136113, upload-time = "2025-11-27T00:37:15.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/9a/b697530a882588a84db616580f2ba5d1d515c815e11c30d219145afeec87/minio-7.2.20-py3-none-any.whl", hash = "sha256:eb33dd2fb80e04c3726a76b13241c6be3c4c46f8d81e1d58e757786f6501897e", size = 93751, upload-time = "2025-11-27T00:37:13.993Z" }, +] + [[package]] name = "multivolumefile" version = "0.2.3" @@ -475,6 +709,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/31/ec5f46fd4c83185b806aa9c736e228cb780f13990a9cf4da0beb70025fcc/multivolumefile-0.2.3-py3-none-any.whl", hash = "sha256:237f4353b60af1703087cf7725755a1f6fcaeeea48421e1896940cd1c920d678", size = 17037, upload-time = "2021-04-29T12:18:38.886Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "paramiko" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "invoke" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/e7/81fdcbc7f190cdb058cffc9431587eb289833bdd633e2002455ca9bb13d4/paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f", size = 1630743, upload-time = "2025-08-04T01:02:03.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/90/a744336f5af32c433bd09af7854599682a383b37cfd78f7de263de6ad6cb/paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9", size = 223932, upload-time = "2025-08-04T01:02:02.029Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "psutil" version = "7.2.2" @@ -595,6 +862,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, +] + [[package]] name = "pycryptodomex" version = "3.23.0" @@ -630,6 +932,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/5f/af7da8e6f1e42b52f44a24d08b8e4c726207434e2593732d39e7af5e7256/pycryptodomex-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:14c37aaece158d0ace436f76a7bb19093db3b4deade9797abfc39ec6cd6cc2fe", size = 1806478, upload-time = "2025-05-17T17:23:26.066Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "pyppmd" version = "1.3.1" @@ -698,30 +1044,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/7b/7e9a83848de928c26f0ab3ee105c0da63a932a0804a94807205e6313e73a/pyppmd-1.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:e066cbf1d335fe20480cd8ecc10848ba78d99fe6d1e44ea00def48feaf46afdf", size = 46402, upload-time = "2025-11-27T22:08:42.804Z" }, ] +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "reedsolo" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/61/a67338cbecf370d464e71b10e9a31355f909d6937c3a8d6b17dd5d5beb5e/reedsolo-1.7.0.tar.gz", hash = "sha256:c1359f02742751afe0f1c0de9f0772cc113835aa2855d2db420ea24393c87732", size = 59723, upload-time = "2023-01-17T05:10:19.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/19/1bb346c0e581557c88946d2bb979b2bee8992e72314cfb418b5440e383db/reedsolo-1.7.0-py3-none-any.whl", hash = "sha256:2b6a3e402a1ee3e1eea3f932f81e6c0b7bbc615588074dca1dbbcdeb055002bd", size = 32360, upload-time = "2023-01-17T05:10:17.652Z" }, +] + [[package]] name = "sbackup-cli" -version = "1.0.0" +version = "1.0.1" source = { virtual = "." } dependencies = [ + { name = "minio" }, + { name = "paramiko" }, { name = "py7zr" }, + { name = "reedsolo" }, { name = "tqdm" }, + { name = "watchdog" }, { name = "zstandard" }, ] [package.dev-dependencies] dev = [ { name = "coverage" }, + { name = "pytest" }, ] [package.metadata] requires-dist = [ + { name = "minio", specifier = ">=7.2.20" }, + { name = "paramiko", specifier = ">=4.0.0" }, { name = "py7zr", specifier = ">=1.1.0" }, + { name = "reedsolo", specifier = ">=1.7.0" }, { name = "tqdm", specifier = ">=4.67.3" }, + { name = "watchdog", specifier = ">=6.0.0" }, { name = "zstandard", specifier = ">=0.25.0" }, ] [package.metadata.requires-dev] -dev = [{ name = "coverage", specifier = ">=7.13.5" }] +dev = [ + { name = "coverage", specifier = ">=7.13.5" }, + { name = "pytest", specifier = ">=9.0.3" }, +] [[package]] name = "texttable" @@ -732,6 +1117,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/99/4772b8e00a136f3e01236de33b0efda31ee7077203ba5967fcc76da94d65/texttable-1.7.0-py2.py3-none-any.whl", hash = "sha256:72227d592c82b3d7f672731ae73e4d1f88cd8e2ef5b075a7a7f01a23a3743917", size = 10768, upload-time = "2023-10-03T09:48:10.434Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "tqdm" version = "4.67.3" @@ -744,6 +1183,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "zstandard" version = "0.25.0"