diff --git a/.gitattributes b/.gitattributes index 3b6490a..c17f5ac 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,10 @@ +# 统一使用 LF 换行符 +* text=auto eol=lf + +# Windows 特定文件保持 CRLF +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# LFS for large files dist/* filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a4004d0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +name: Build and Release + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + strategy: + matrix: + architecture: [x64] + python-version: ['3.11'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests + run: | + pytest -v --cov=remark --cov-report=term-missing + + - name: Install PyInstaller + run: | + pip install pyinstaller + + - name: Build executable + run: | + pyinstaller remark.spec --clean + + - name: Rename executable with version + run: | + if ($env:GITHUB_REF -match "refs/tags/v(.*)") { + $version = $matches[1] + } else { + $version = "dev" + } + Copy-Item "dist\windows-folder-remark.exe" "dist\windows-folder-remark-$version.exe" + + - name: Generate checksum + run: | + if ($env:GITHUB_REF -match "refs/tags/v(.*)") { + $version = $matches[1] + } else { + $version = "dev" + } + $file = "dist\windows-folder-remark-$version.exe" + certutil -hashfile $file SHA256 > "$file.sha256" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: windows-folder-remark-${{ matrix.architecture }} + path: | + dist/*.exe + dist/*.sha256 + + release: + needs: build + runs-on: windows-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Extract version from tag + id: version + run: | + if ($env:GITHUB_REF -match "refs/tags/v(.*)") { + $version = $matches[1] + echo "version=$version" >> $env:GITHUB_OUTPUT + echo "version=$version" + } + + - name: Generate Release Notes + id: release_notes + run: | + $notes = @" + ## windows-folder-remark v${{ steps.version.outputs.version }} + + ### 下载 + - `windows-folder-remark-${{ steps.version.outputs.version }}.exe`: 单文件可执行程序,无需安装 Python + - `.sha256` 文件用于验证下载文件的完整性 + + ### 使用方法 + ```powershell + # 添加备注 + .\windows-folder-remark-${{ steps.version.outputs.version }}.exe "C:\MyFolder" "我的备注" + + # 查看备注 + .\windows-folder-remark-${{ steps.version.outputs.version }}.exe --view "C:\MyFolder" + + # 删除备注 + .\windows-folder-remark-${{ steps.version.outputs.version }}.exe --delete "C:\MyFolder" + + # 交互模式 + .\windows-folder-remark-${{ steps.version.outputs.version }}.exe + ``` + + ### 验证下载 + ```powershell + certutil -hashfile windows-folder-remark-${{ steps.version.outputs.version }}.exe SHA256 + ``` + 然后对比生成的哈希值与 .sha256 文件中的值是否一致。 + + ### 系统要求 + - Windows 7 或更高版本 + "@ + $notes | Out-File -Encoding UTF8 -FilePath release_notes.txt + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + name: windows-folder-remark v${{ steps.version.outputs.version }} + body_path: release_notes.txt + draft: false + prerelease: false + files: | + artifacts/**/*.exe + artifacts/**/*.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e356381 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,69 @@ +name: Test and Build + +on: + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + test: + runs-on: windows-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests + run: pytest -v --cov=remark --cov-report=term-missing + + build-verify: + runs-on: windows-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Install PyInstaller + run: pip install pyinstaller + + - name: Build executable + run: pyinstaller remark.spec --clean + + - name: Verify executable + run: | + $exePath = "dist\windows-folder-remark.exe" + if (-not (Test-Path $exePath)) { + Write-Error "Executable not found!" + exit 1 + } + & $exePath --help + if ($LASTEXITCODE -ne 0) { + Write-Error "Executable failed to run!" + exit 1 + } + Write-Output "Build successful!" + shell: pwsh diff --git a/.gitignore b/.gitignore index b8dcc86..bff0afd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,34 @@ +# IDE .idea -*.spec -build \ No newline at end of file + +# Build +build/ +dist/ +# PyInstaller spec files (keep remark.spec) +# *.spec + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.eggs/ +.venv/ +venv/ +*.egg + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +*.cover + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json +.ruff_cache/ + +# Distribution +*.whl +*.tar.gz diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c9fc224 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,68 @@ +# Pre-commit configuration +# https://pre-commit.com/ + +default_language_version: + python: python3.9 + +default_stages: [pre-commit] + +repos: + # Ruff - 代码检查和格式化 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.4 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format + + # Pre-commit hooks for general checks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace # 删除行尾空格 + - id: end-of-file-fixer # 文件末尾添加换行 + - id: check-yaml # 检查 YAML 语法 + - id: check-toml # 检查 TOML 语法 + - id: check-added-large-files # 防止大文件提交 + args: ['--maxkb=1000'] + - id: check-merge-conflict # 检查合并冲突标记 + - id: check-case-conflict # 检查大小写冲突 + - id: check-docstring-first # 检查 docstring 是否在代码前 + + # Mypy - 静态类型检查 + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.14.1 + hooks: + - id: mypy + additional_dependencies: + - types-setuptools + exclude: ^remark\.py$ + + # Local hooks - pre-push 阶段(测试 + 构建) + - repo: local + hooks: + - id: run-tests + name: Run tests + entry: pytest -v -m "not slow" --cov=remark --cov-report=term-missing + language: system + stages: [pre-push] + pass_filenames: false + + - id: build-exe + name: Build exe (Windows only) + entry: bash -c 'if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then pyinstaller remark.spec --clean; else echo "Skip build on non-Windows"; fi' + language: system + stages: [pre-push] + pass_filenames: false + +# 全局排除 +exclude: | + ^(?: + \.venv/| + \.git/| + \.mypy_cache/| + __pycache__/| + build/| + dist/| + *.egg-info/ + ) diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..d4b278f --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11.7 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..22c9205 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- GitHub Actions CI/CD for automated releases +- PyInstaller configuration for Windows executable builds +- Version management script for release automation +- Support for both 32-bit and 64-bit Windows builds + +## [2.0.0] - Unreleased + +### Added +- UTF-16 encoding detection and conversion for desktop.ini +- User confirmation prompt before encoding conversion +- EncodingConversionCanceled exception for safer error handling +- Smart delete logic: removes InfoTip while preserving other desktop.ini settings +- Top-level exception handling in CLI main() +- Windows platform check before running + +### Changed +- Improved desktop.ini read/write operations with encoding safety +- Better error handling with exception-based flow control +- Enhanced folder comment handling with dedicated storage layer +- Refactored command-line argument parsing with argparse + +### Fixed +- Fixed desktop.ini encoding issues +- Path handling with spaces using os.path.join() +- Exception handling for encoding conversion + +### Removed +- File comment functionality (COM component and Property Store) +- notify_shell_update function (no longer needed) +- File-related imports and handlers + +## [1.0] - 2022-05-03 + +### Added +- Interactive mode with continuous loop for batch processing +- Help system with usage instructions +- Comment length validation +- Complete exception handling mechanism + +### Fixed +- Path with spaces handling issue +- Command injection vulnerability (subprocess replaced os.system) +- Encoding conversion exception handling +- Explicit file write encoding specification + +### Changed +- Improved help messages and usage prompts +- Packaged as Windows executable + +[Unreleased]: https://github.com/piratf/windows-folder-remark/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/piratf/windows-folder-remark/compare/v1.0...v2.0.0 +[1.0]: https://github.com/piratf/windows-folder-remark/releases/tag/v1.0 diff --git a/desktop.ini b/desktop.ini new file mode 100644 index 0000000..2a102b3 Binary files /dev/null and b/desktop.ini differ diff --git a/dist/remark/_internal/VCRUNTIME140.dll b/dist/remark/_internal/VCRUNTIME140.dll deleted file mode 100644 index a9ed5c4..0000000 Binary files a/dist/remark/_internal/VCRUNTIME140.dll and /dev/null differ diff --git a/dist/remark/_internal/_bz2.pyd b/dist/remark/_internal/_bz2.pyd deleted file mode 100644 index 5603b25..0000000 Binary files a/dist/remark/_internal/_bz2.pyd and /dev/null differ diff --git a/dist/remark/_internal/_decimal.pyd b/dist/remark/_internal/_decimal.pyd deleted file mode 100644 index 41eb299..0000000 Binary files a/dist/remark/_internal/_decimal.pyd and /dev/null differ diff --git a/dist/remark/_internal/_hashlib.pyd b/dist/remark/_internal/_hashlib.pyd deleted file mode 100644 index 3e29e17..0000000 Binary files a/dist/remark/_internal/_hashlib.pyd and /dev/null differ diff --git a/dist/remark/_internal/_lzma.pyd b/dist/remark/_internal/_lzma.pyd deleted file mode 100644 index e4b1fc0..0000000 Binary files a/dist/remark/_internal/_lzma.pyd and /dev/null differ diff --git a/dist/remark/_internal/_socket.pyd b/dist/remark/_internal/_socket.pyd deleted file mode 100644 index 4e7666a..0000000 Binary files a/dist/remark/_internal/_socket.pyd and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-console-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-console-l1-1-0.dll deleted file mode 100644 index 4370241..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-console-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-datetime-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-datetime-l1-1-0.dll deleted file mode 100644 index 43a85fd..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-datetime-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-debug-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-debug-l1-1-0.dll deleted file mode 100644 index 7b77bdf..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-debug-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-errorhandling-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-errorhandling-l1-1-0.dll deleted file mode 100644 index 77fc838..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-errorhandling-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-file-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-file-l1-1-0.dll deleted file mode 100644 index f84efbc..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-file-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-file-l1-2-0.dll b/dist/remark/_internal/api-ms-win-core-file-l1-2-0.dll deleted file mode 100644 index 1190482..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-file-l1-2-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-file-l2-1-0.dll b/dist/remark/_internal/api-ms-win-core-file-l2-1-0.dll deleted file mode 100644 index a55333f..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-file-l2-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-handle-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-handle-l1-1-0.dll deleted file mode 100644 index 8ae3417..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-handle-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-heap-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-heap-l1-1-0.dll deleted file mode 100644 index c097369..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-heap-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-interlocked-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-interlocked-l1-1-0.dll deleted file mode 100644 index e561739..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-interlocked-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-libraryloader-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-libraryloader-l1-1-0.dll deleted file mode 100644 index 758c33c..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-libraryloader-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-localization-l1-2-0.dll b/dist/remark/_internal/api-ms-win-core-localization-l1-2-0.dll deleted file mode 100644 index ebad09b..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-localization-l1-2-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-memory-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-memory-l1-1-0.dll deleted file mode 100644 index a57c28d..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-memory-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-namedpipe-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-namedpipe-l1-1-0.dll deleted file mode 100644 index 7309e9f..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-namedpipe-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-processenvironment-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-processenvironment-l1-1-0.dll deleted file mode 100644 index 2c48acf..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-processenvironment-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-processthreads-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-processthreads-l1-1-0.dll deleted file mode 100644 index 277633e..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-processthreads-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-processthreads-l1-1-1.dll b/dist/remark/_internal/api-ms-win-core-processthreads-l1-1-1.dll deleted file mode 100644 index 39ed39a..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-processthreads-l1-1-1.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-profile-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-profile-l1-1-0.dll deleted file mode 100644 index 00a9fca..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-profile-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-rtlsupport-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-rtlsupport-l1-1-0.dll deleted file mode 100644 index 43d7f97..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-rtlsupport-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-string-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-string-l1-1-0.dll deleted file mode 100644 index bcd6ec2..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-string-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-synch-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-synch-l1-1-0.dll deleted file mode 100644 index 178789a..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-synch-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-synch-l1-2-0.dll b/dist/remark/_internal/api-ms-win-core-synch-l1-2-0.dll deleted file mode 100644 index 36ee727..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-synch-l1-2-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-sysinfo-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-sysinfo-l1-1-0.dll deleted file mode 100644 index eccaee9..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-sysinfo-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-timezone-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-timezone-l1-1-0.dll deleted file mode 100644 index 543f8b2..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-timezone-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-core-util-l1-1-0.dll b/dist/remark/_internal/api-ms-win-core-util-l1-1-0.dll deleted file mode 100644 index f653566..0000000 Binary files a/dist/remark/_internal/api-ms-win-core-util-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-crt-conio-l1-1-0.dll b/dist/remark/_internal/api-ms-win-crt-conio-l1-1-0.dll deleted file mode 100644 index 3915f54..0000000 Binary files a/dist/remark/_internal/api-ms-win-crt-conio-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-crt-convert-l1-1-0.dll b/dist/remark/_internal/api-ms-win-crt-convert-l1-1-0.dll deleted file mode 100644 index dbc22b6..0000000 Binary files a/dist/remark/_internal/api-ms-win-crt-convert-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-crt-environment-l1-1-0.dll b/dist/remark/_internal/api-ms-win-crt-environment-l1-1-0.dll deleted file mode 100644 index 744fb47..0000000 Binary files a/dist/remark/_internal/api-ms-win-crt-environment-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-crt-filesystem-l1-1-0.dll b/dist/remark/_internal/api-ms-win-crt-filesystem-l1-1-0.dll deleted file mode 100644 index d752b7f..0000000 Binary files a/dist/remark/_internal/api-ms-win-crt-filesystem-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-crt-heap-l1-1-0.dll b/dist/remark/_internal/api-ms-win-crt-heap-l1-1-0.dll deleted file mode 100644 index dba68f8..0000000 Binary files a/dist/remark/_internal/api-ms-win-crt-heap-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-crt-locale-l1-1-0.dll b/dist/remark/_internal/api-ms-win-crt-locale-l1-1-0.dll deleted file mode 100644 index 6e1e515..0000000 Binary files a/dist/remark/_internal/api-ms-win-crt-locale-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-crt-math-l1-1-0.dll b/dist/remark/_internal/api-ms-win-crt-math-l1-1-0.dll deleted file mode 100644 index c23e67a..0000000 Binary files a/dist/remark/_internal/api-ms-win-crt-math-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-crt-process-l1-1-0.dll b/dist/remark/_internal/api-ms-win-crt-process-l1-1-0.dll deleted file mode 100644 index 0d91930..0000000 Binary files a/dist/remark/_internal/api-ms-win-crt-process-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-crt-runtime-l1-1-0.dll b/dist/remark/_internal/api-ms-win-crt-runtime-l1-1-0.dll deleted file mode 100644 index f475f21..0000000 Binary files a/dist/remark/_internal/api-ms-win-crt-runtime-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-crt-stdio-l1-1-0.dll b/dist/remark/_internal/api-ms-win-crt-stdio-l1-1-0.dll deleted file mode 100644 index 59eb287..0000000 Binary files a/dist/remark/_internal/api-ms-win-crt-stdio-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-crt-string-l1-1-0.dll b/dist/remark/_internal/api-ms-win-crt-string-l1-1-0.dll deleted file mode 100644 index c7b0f6e..0000000 Binary files a/dist/remark/_internal/api-ms-win-crt-string-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-crt-time-l1-1-0.dll b/dist/remark/_internal/api-ms-win-crt-time-l1-1-0.dll deleted file mode 100644 index 23d5350..0000000 Binary files a/dist/remark/_internal/api-ms-win-crt-time-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/api-ms-win-crt-utility-l1-1-0.dll b/dist/remark/_internal/api-ms-win-crt-utility-l1-1-0.dll deleted file mode 100644 index 94fd7e4..0000000 Binary files a/dist/remark/_internal/api-ms-win-crt-utility-l1-1-0.dll and /dev/null differ diff --git a/dist/remark/_internal/base_library.zip b/dist/remark/_internal/base_library.zip deleted file mode 100644 index 0d50a0d..0000000 Binary files a/dist/remark/_internal/base_library.zip and /dev/null differ diff --git a/dist/remark/_internal/libcrypto-3.dll b/dist/remark/_internal/libcrypto-3.dll deleted file mode 100644 index 58e21f3..0000000 Binary files a/dist/remark/_internal/libcrypto-3.dll and /dev/null differ diff --git a/dist/remark/_internal/python312.dll b/dist/remark/_internal/python312.dll deleted file mode 100644 index 2b4b4f8..0000000 Binary files a/dist/remark/_internal/python312.dll and /dev/null differ diff --git a/dist/remark/_internal/select.pyd b/dist/remark/_internal/select.pyd deleted file mode 100644 index 061d79e..0000000 Binary files a/dist/remark/_internal/select.pyd and /dev/null differ diff --git a/dist/remark/_internal/ucrtbase.dll b/dist/remark/_internal/ucrtbase.dll deleted file mode 100644 index 90bd467..0000000 Binary files a/dist/remark/_internal/ucrtbase.dll and /dev/null differ diff --git a/dist/remark/_internal/unicodedata.pyd b/dist/remark/_internal/unicodedata.pyd deleted file mode 100644 index 3878287..0000000 Binary files a/dist/remark/_internal/unicodedata.pyd and /dev/null differ diff --git a/dist/remark/remark.exe b/dist/remark/remark.exe deleted file mode 100644 index fa81a42..0000000 Binary files a/dist/remark/remark.exe and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7495001 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,167 @@ +# pyproject.toml - 现代 Python 项目配置 +# https://packaging.python.org/en/latest/guides/writing-pyproject-toml/ + +[project] +name = "windows-folder-remark" +version = "2.0.0" +description = "Windows 文件夹备注工具" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "Piratf"} +] +keywords = ["windows", "folder", "remark", "desktop.ini"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +# 运行时依赖 +dependencies = [] + +# 开发依赖 +[project.optional-dependencies] +dev = [ + "ruff>=0.8.0", + "mypy>=1.14.0", + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "pytest-xdist>=3.0.0", + "pytest-mock>=3.10.0", + "pyfakefs>=5.0.0", + "pre-commit>=3.0.0", +] + +# 命令行入口 +[project.scripts] +remark = "remark.cli.commands:main" +remark-build = "scripts.build:main" + +# 构建系统配置 +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + + +# ============================================================================= +# 工具配置 +# ============================================================================= + +# Ruff - 代码检查和格式化 +# https://docs.astral.sh/ruff/configuration/ +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "RUF", # ruff-specific rules +] + +ignore = [ + "E501", # line too long (由 formatter 处理) + "B008", # do not perform function calls in argument defaults + "SIM108", # use ternary operator (可读性考虑) + "RUF001", # 中文全角字符误报 + "RUF002", # 中文全角字符误报 + "RUF003", # 中文全角字符误报 +] + +fixable = ["ALL"] +unfixable = [] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101"] # 允许测试中使用 assert + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.ruff.lint.isort] +known-first-party = ["remark"] + + +# Mypy - 静态类型检查 +# https://mypy.readthedocs.io/ +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false +check_untyped_defs = true +show_error_codes = true +show_column_numbers = true +color_output = true +error_summary = true +exclude = [ + '^remark\\.py$', +] + +[[tool.mypy.overrides]] +module = [] +ignore_missing_imports = true + + +# Pytest - 测试框架 +# https://docs.pytest.org/ +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +minversion = "7.0" + +addopts = [ + "-v", # 详细输出 + "-l", # 显示本地变量(失败时) + "-s", # 显示 print 输出 +] + +markers = [ + "unit: 单元测试", + "integration: 集成测试", + "windows: 仅在 Windows 上运行的测试", + "slow: 慢速测试", +] + +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", +] + +# Coverage - 覆盖率配置 +# https://coverage.readthedocs.io/ +[tool.coverage.run] +source = ["remark"] +omit = [ + "*/tests/*", + "*/__pycache__/*", + "*/site-packages/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "@abstractmethod", +] diff --git a/readme.md b/readme.md index 0cd7c15..2355931 100644 --- a/readme.md +++ b/readme.md @@ -1,31 +1,106 @@ -# Windows 下给文件夹添加注释 +# Windows 文件夹备注工具 +一个通过修改 `Desktop.ini` 文件为 Windows 文件夹添加备注/注释的命令行工具。 -## 如何使用 +## 特性 -本工具提供 exe 版本和 python 源码版本,exe 版本为 dist/remark/remark.exe,可以直接双击运行,源码版本支持的版本为 Python 3.x +- 支持中文等多语言字符(UTF-16 编码) +- 命令行模式和交互模式 +- 自动编码检测和修复 +- 单文件 exe 打包,无需 Python 环境 -以下通过展示 exe 版本的使用方法,python 源码版本用法相同。 +## 安装 + +### 方式一:使用 exe 文件(推荐) + +下载 [releases](https://github.com/piratf/windows-folder-remark/releases) 中的 `windows-folder-remark.exe`,直接使用。 + +### 方式二:从源码安装 + +```bash +# 克隆仓库 +git clone https://github.com/piratf/windows-folder-remark.git +cd windows-folder-remark + +# 安装依赖(无外部依赖) +pip install -e . + +# 运行 +python -m remark.cli --help +``` + +## 使用方法 + +### 命令行模式 -有两种典型的使用方式: ```bash -# 1. 直接运行程序的方式 -# 运行后根据提示操作即可 -# 这种方式适合手动给一些文件夹进行备注 -./remark.exe +# 添加备注 +windows-folder-remark.exe "C:\MyFolder" "这是我的文件夹" + +# 查看备注 +windows-folder-remark.exe --view "C:\MyFolder" +# 删除备注 +windows-folder-remark.exe --delete "C:\MyFolder" +``` -# 2. 带参数运行程序的方式 -# 运行后会立即为输入的文件夹加上备注 -# 适合被其他程序或脚本批量调用使用 -./remark.exe [路径] [备注内容] +### 交互模式 +```bash +# 运行后根据提示操作 +windows-folder-remark.exe +``` + +## 编码检测 + +当使用 `--view` 查看备注时,如果检测到 `desktop.ini` 文件不是标准的 UTF-16 编码,工具会提醒你: + +``` +警告: desktop.ini 文件编码为 utf-8,不是标准的 UTF-16。 +这可能导致中文等特殊字符显示异常。 +是否修复编码为 UTF-16?[Y/n]: ``` ---- -# 注意 -该脚本会修改文件夹下隐藏的 Desktop.ini 文件,并为文件夹修饰系统属性 - -# 特别说明 -感谢原版程序解决了目录加备注、分类的问题! -原版程序有一个Bug:当目录路径里含有空格时,程序无法识别为一个目录,从而无法加备注。 -Fork的主要目的就是为了解决这个Bug,希望对有些朋友有点帮助。 + +选择 `Y` 可自动修复编码。 + +## 开发 + +```bash +# 安装开发依赖 +pip install -e ".[dev]" + +# 运行测试 +pytest + +# 代码检查 +ruff check . +ruff format . + +# 类型检查 +mypy remark/ + +# 本地打包 exe +python -m scripts.build +``` + +## 原理说明 + +该工具通过以下步骤实现文件夹备注: + +1. 在文件夹中创建/修改 `Desktop.ini` 文件 +2. 写入 `[.ShellClassInfo]` 段落和 `InfoTip` 属性 +3. 使用 UTF-16 编码保存文件 +4. 将 `Desktop.ini` 设置为隐藏和系统属性 +5. 将文件夹设置为只读属性(使 Windows 读取 `Desktop.ini`) + +参考:[Microsoft 官方文档](https://learn.microsoft.com/en-us/windows/win32/shell/how-to-customize-folders-with-desktop-ini) + +## 注意事项 + +- 修改后可能需要几分钟才能在资源管理器中显示 +- 某些文件管理器可能不支持显示文件夹备注 +- 工具会修改文件夹的系统属性 + +## 许可证 + +MIT License diff --git a/remark.py b/remark.py index 4f58bd6..21a4a9c 100644 --- a/remark.py +++ b/remark.py @@ -1,98 +1,8 @@ -# -*- coding: utf-8 -* -# Filename: comment.py +""" +Windows 文件/文件夹备注工具 - 主入口 +""" -__author__ = 'Piratf' +from remark.cli.commands import main -# Modified By CraikLee 2024-01-26 - -import sys -import os - -# 获取系统编码,确保备注不会出现乱码 -defEncoding = sys.getfilesystemencoding() - - -# 将代码中的字符转换为系统编码 -def sys_encode(content): - return content.encode(defEncoding).decode(defEncoding) - - -def run_command(command): - os.system(command) - - -def re_enter_message(message): - print(sys_encode(u" * " + message)) - print(sys_encode(u" * 继续处理或按 ctrl + c 退出程序") + os.linesep) - - -def get_setting_file_path(dir_path): - return dir_path + os.sep + 'desktop.ini' - - -def update_folder_comment(dir_path, comment): - content = sys_encode(u'[.ShellClassInfo]' + os.linesep + 'InfoTip=') - # 开始设置备注信息 - setting_file_path = get_setting_file_path(dir_path) - with open(setting_file_path, 'w') as f: - f.write(content) - f.write(sys_encode(comment + os.linesep)) - - # 添加保护 - run_command('attrib \"' + setting_file_path + '\" +s +h') - run_command('attrib \"' + dir_path + '\" +s ') - - print(sys_encode(u"备注添加成功~")) - print(sys_encode(u"备注可能过一会才会显示,不要着急")) - - -def add_comment(dir_path=None, comment=None): - input_path_msg = sys_encode(u"请输入文件夹路径(或拖动文件夹到这里): ") - input_comment_msg = sys_encode(u"请输入文件夹备注:") - - # 输入文件夹路径 - if dir_path is None: - dir_path_temp = input(input_path_msg) - #print(dir_path_temp) - #dir_path = "r"+dir_path - dir_path = dir_path_temp.replace('\"', '') - - # 判断路径是否存在文件夹 - while not os.path.isdir(dir_path): - #print(dir_path) - re_enter_message(u"你输入的不是一个文件夹路径") - dir_path_temp = input(input_path_msg) - dir_path = dir_path_temp.replace('\"', '') - - setting_file_path = get_setting_file_path(dir_path) - - # 判断设置文件是否已经存在 - if os.path.exists(setting_file_path): - # 去除保护属性 - run_command('attrib \"' + setting_file_path + '\" -s -h') - - # 输入文件夹的备注 - if comment is None: - comment = input(input_comment_msg) - - while not comment: - re_enter_message(u"备注不要为空哦") - comment = input(input_comment_msg) - - update_folder_comment(dir_path, comment) - - -if __name__ == '__main__': - if len(sys.argv) == 3: - add_comment(sys.argv[1], sys.argv[2]) - elif len(sys.argv) == 1: - while True: - try: - add_comment() - except KeyboardInterrupt: - print(sys_encode(u" ❤ 感谢使用")) - break - re_enter_message("成功完成一次备注") - else: - print('Usage .1: %s [folder path] [content]' % sys.argv[0]) - print('Usage .2: %s' % sys.argv[0]) +if __name__ == "__main__": + main() diff --git a/remark.spec b/remark.spec new file mode 100644 index 0000000..fa20ba3 --- /dev/null +++ b/remark.spec @@ -0,0 +1,87 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +PyInstaller spec file for windows-folder-remark + +Usage: + pyinstaller remark.spec +""" + +import os +import sys + +from PyInstaller.utils.hooks import collect_submodules + +# ============================================================================= +# Configuration +# ============================================================================= + +block_cipher = None +app_name = "windows-folder-remark" +app_version = "2.0.0" +app_description = "Windows 文件夹备注工具" + +# ============================================================================= +# Python Interpreter Options +# ============================================================================= + +# 强制启用 UTF-8 模式,支持中文等特殊字符输出 +# See: https://pyinstaller.org/en/stable/spec-files.html#specifying-python-interpreter-options +options = [ + ('X utf8', None, 'OPTION'), +] + +# ============================================================================= +# Analysis +# ============================================================================= + +# Collect all submodules from remark package +hiddenimports = collect_submodules('remark') + +a = Analysis( + [os.path.join("remark", "cli", "commands.py")], + pathex=[], + binaries=[], + datas=[], + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +# ============================================================================= +# PYZ +# ============================================================================= + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +# ============================================================================= +# EXE +# ============================================================================= + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + options, # Python 解释器选项:启用 UTF-8 模式 + [], + name=app_name, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, # Console application for interactive mode + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/remark/__init__.py b/remark/__init__.py new file mode 100644 index 0000000..7bd13b3 --- /dev/null +++ b/remark/__init__.py @@ -0,0 +1,13 @@ +""" +Windows Folder Remark - 为 Windows 文件夹添加备注工具 +""" + +__author__ = "Piratf" + +from remark.core.base import CommentHandler +from remark.core.folder_handler import FolderCommentHandler + +__all__ = [ + "CommentHandler", + "FolderCommentHandler", +] diff --git a/remark/cli/__init__.py b/remark/cli/__init__.py new file mode 100644 index 0000000..2678385 --- /dev/null +++ b/remark/cli/__init__.py @@ -0,0 +1,7 @@ +""" +命令行接口模块 +""" + +from remark.cli.commands import CLI + +__all__ = ["CLI"] diff --git a/remark/cli/commands.py b/remark/cli/commands.py new file mode 100644 index 0000000..06292f8 --- /dev/null +++ b/remark/cli/commands.py @@ -0,0 +1,247 @@ +""" +命令行接口 +""" + +import argparse +import os +import sys + +from remark.core.folder_handler import FolderCommentHandler +from remark.utils.path_resolver import find_candidates +from remark.utils.platform import check_platform + + +def get_version(): + """动态获取版本号""" + try: + from importlib.metadata import version + + return version("windows-folder-remark") + except Exception: + return "unknown" + + +class CLI: + """命令行接口""" + + def __init__(self): + self.handler = FolderCommentHandler() + + def _validate_folder(self, path): + """验证路径是否为文件夹""" + if not os.path.exists(path): + print("路径不存在:", path) + return False + if not self.handler.supports(path): + print("路径不是文件夹:", path) + return False + return True + + def add_comment(self, path, comment): + """添加备注""" + if self._validate_folder(path): + return self.handler.set_comment(path, comment) + return False + + def delete_comment(self, path): + """删除备注""" + if self._validate_folder(path): + return self.handler.delete_comment(path) + return False + + def view_comment(self, path): + """查看备注""" + if self._validate_folder(path): + # 检查 desktop.ini 编码 + from remark.storage.desktop_ini import DesktopIniHandler + + if DesktopIniHandler.exists(path): + desktop_ini_path = DesktopIniHandler.get_path(path) + detected_encoding, is_utf16 = DesktopIniHandler.detect_encoding(desktop_ini_path) + if not is_utf16: + print( + f"警告: desktop.ini 文件编码为 {detected_encoding or '未知'},不是标准的 UTF-16。" + ) + print("这可能导致中文等特殊字符显示异常。") + + # 询问是否修复 + while True: + response = input("是否修复编码为 UTF-16?[Y/n]: ").strip().lower() + if response in ("", "y", "yes"): + if DesktopIniHandler.fix_encoding(desktop_ini_path, detected_encoding): + print("✓ 已修复为 UTF-16 编码") + else: + print("✗ 修复失败") + break + elif response in ("n", "no"): + print("跳过编码修复") + break + else: + print("请输入 Y 或 n") + print() # 空行分隔 + + comment = self.handler.get_comment(path) + if comment: + print("当前备注:", comment) + else: + print("该文件夹没有备注") + + def interactive_mode(self): + """交互模式""" + version = get_version() + print("Windows 文件夹备注工具 v" + version) + print("提示: 按 Ctrl + C 退出程序" + os.linesep) + + input_path_msg = "请输入文件夹路径(或拖动到这里): " + input_comment_msg = "请输入备注:" + + while True: + try: + path = input(input_path_msg).replace('"', "").strip() + + if not os.path.exists(path): + print("路径不存在,请重新输入") + continue + + if not os.path.isdir(path): + print('这是一个"文件",当前仅支持为"文件夹"添加备注') + continue + + comment = input(input_comment_msg) + while not comment: + print("备注不要为空哦") + comment = input(input_comment_msg) + + self.add_comment(path, comment) + + except KeyboardInterrupt: + print(" ❤ 感谢使用") + break + print(os.linesep + "继续处理或按 Ctrl + C 退出程序" + os.linesep) + + def show_help(self): + """显示帮助信息""" + print("Windows 文件夹备注工具") + print("使用方法:") + print(" 交互模式: python remark.py") + print(" 命令行模式: python remark.py [选项] [参数]") + print("选项:") + print(" --delete <路径> 删除备注") + print(" --view <路径> 查看备注") + print(" --help, -h 显示帮助信息") + print("示例:") + print(' [添加备注] python remark.py "C:\\\\MyFolder" "这是我的文件夹"') + print(' [删除备注] python remark.py --delete "C:\\\\MyFolder"') + print(' [查看当前备注] python remark.py --view "C:\\\\MyFolder"') + + def _handle_ambiguous_path(self, args_list: list[str]) -> tuple[str | None, str | None]: + """ + 处理模糊路径,返回 (最终路径, 备注内容) + + Args: + args_list: 位置参数列表 + + Returns: + (path, comment) 或 (None, None) 如果用户取消 + """ + + candidates = find_candidates(args_list) + + if not candidates: + print("错误: 路径不存在或未使用引号") + print("提示: 路径包含空格时请使用引号") + print(' windows-folder-remark "C:\\\\My Documents" "备注内容"') + return None, None + + if len(candidates) == 1: + path, remaining, path_type = candidates[0] + print(f"检测到路径: {path}") + + if path_type == "file": + print("错误: 这是一个文件,工具只能为文件夹设置备注") + return None, None + + if remaining: + comment = " ".join(remaining) + print(f"备注内容: {comment}") + else: + print("(将查看现有备注)") + + if input("是否继续? [Y/n]: ").lower() in ("", "y", "yes"): + return str(path), " ".join(remaining) if remaining else None + + return None, None + + # 多个候选,让用户选择 + print("检测到多个可能的路径,请选择:") + for i, (p, r, t) in enumerate(candidates, 1): + type_mark = " [文件]" if t == "file" else "" + print(f"\n[{i}] 路径: {p}{type_mark}") + if r: + print(f" 剩余备注: {' '.join(r)}") + else: + print(" (将查看现有备注)") + print("\n[0] 取消") + + while True: + choice = input(f"\n请选择 [0-{len(candidates)}]: ").strip() + if choice == "0": + return None, None + if choice.isdigit() and 1 <= int(choice) <= len(candidates): + path, remaining, path_type = candidates[int(choice) - 1] + if path_type == "file": + print("\n错误: 这是一个文件,工具只能为文件夹设置备注,请重新选择") + continue + return str(path), " ".join(remaining) if remaining else None + print("无效选择,请重试") + + def run(self, argv=None): + """运行 CLI""" + if not check_platform(): + sys.exit(1) + + parser = argparse.ArgumentParser(description="Windows 文件夹备注工具", add_help=False) + parser.add_argument("args", nargs="*", help="位置参数(路径和备注)") + parser.add_argument("--delete", metavar="PATH", help="删除备注") + parser.add_argument("--view", metavar="PATH", help="查看备注") + parser.add_argument("--help", "-h", action="store_true", help="显示帮助信息") + + args = parser.parse_args(argv) + + if args.help: + self.show_help() + elif args.delete: + self.delete_comment(args.delete) + elif args.view: + self.view_comment(args.view) + elif args.args: + # 处理位置参数 + path, comment = self._handle_ambiguous_path(args.args) + if path: + if comment: + self.add_comment(path, comment) + else: + self.view_comment(path) + else: + # 用户取消或解析失败,显示帮助 + self.show_help() + else: + # 无参数,进入交互模式 + self.interactive_mode() + + +def main(): + """主入口""" + try: + cli = CLI() + cli.run() + except KeyboardInterrupt: + print("\n操作已取消") + sys.exit(0) + except Exception as e: + print("发生错误:", str(e)) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/remark/core/__init__.py b/remark/core/__init__.py new file mode 100644 index 0000000..a8056fe --- /dev/null +++ b/remark/core/__init__.py @@ -0,0 +1,11 @@ +""" +核心功能模块 +""" + +from remark.core.base import CommentHandler +from remark.core.folder_handler import FolderCommentHandler + +__all__ = [ + "CommentHandler", + "FolderCommentHandler", +] diff --git a/remark/core/base.py b/remark/core/base.py new file mode 100644 index 0000000..ffe1a8d --- /dev/null +++ b/remark/core/base.py @@ -0,0 +1,29 @@ +""" +基础接口定义 +""" + +from abc import ABC, abstractmethod + + +class CommentHandler(ABC): + """备注处理器基类""" + + @abstractmethod + def set_comment(self, path, comment): + """设置备注""" + pass + + @abstractmethod + def get_comment(self, path): + """获取备注""" + pass + + @abstractmethod + def delete_comment(self, path): + """删除备注""" + pass + + @abstractmethod + def supports(self, path): + """检查是否支持该路径类型""" + pass diff --git a/remark/core/folder_handler.py b/remark/core/folder_handler.py new file mode 100644 index 0000000..b1b5459 --- /dev/null +++ b/remark/core/folder_handler.py @@ -0,0 +1,101 @@ +""" +文件夹备注处理器 - 使用 desktop.ini + +使用 Microsoft 官方支持的 desktop.ini 方式设置文件夹备注。 + +参考文档: +https://learn.microsoft.com/en-us/windows/win32/shell/how-to-customize-folders-with-desktop-ini +""" + +import os + +from remark.core.base import CommentHandler +from remark.storage.desktop_ini import DesktopIniHandler +from remark.utils.constants import MAX_COMMENT_LENGTH + + +class FolderCommentHandler(CommentHandler): + """文件夹备注处理器""" + + def set_comment(self, folder_path, comment): + """设置文件夹备注""" + if not os.path.isdir(folder_path): + print("路径不是文件夹:", folder_path) + return False + + if len(comment) > MAX_COMMENT_LENGTH: + print("备注长度超过限制,最大长度为", MAX_COMMENT_LENGTH, "个字符") + comment = comment[:MAX_COMMENT_LENGTH] + + return self._set_comment_desktop_ini(folder_path, comment) + + @staticmethod + def _set_comment_desktop_ini(folder_path, comment): + """使用 desktop.ini 设置备注""" + desktop_ini_path = DesktopIniHandler.get_path(folder_path) + + try: + # 清除文件属性以便修改 + if DesktopIniHandler.exists( + folder_path + ) and not DesktopIniHandler.clear_file_attributes(desktop_ini_path): + print("清除文件属性失败") + return False + + # 使用 UTF-16 编码写入 desktop.ini + if not DesktopIniHandler.write_info_tip(folder_path, comment): + print("写入 desktop.ini 失败") + return False + + # 设置 desktop.ini 文件为隐藏和系统属性 + if not DesktopIniHandler.set_file_hidden_system_attributes(desktop_ini_path): + print("设置文件属性失败") + return False + + # 设置文件夹为只读属性(使 desktop.ini 生效) + if not DesktopIniHandler.set_folder_system_attributes(folder_path): + print("设置文件夹属性失败") + return False + + print(f"已经为文件夹 [{folder_path}] 设置备注 [{comment}]") + print("备注添加成功,可能需要过几分钟才会显示") + return True + except Exception as e: + print("设置备注失败:", str(e)) + return False + + def get_comment(self, folder_path): + """获取文件夹备注""" + return DesktopIniHandler.read_info_tip(folder_path) + + def delete_comment(self, folder_path): + """删除文件夹备注""" + desktop_ini_path = DesktopIniHandler.get_path(folder_path) + + if not DesktopIniHandler.exists(folder_path): + print("该文件夹没有备注") + return True + + # 清除文件属性以便修改 + if not DesktopIniHandler.clear_file_attributes(desktop_ini_path): + print("清除文件属性失败") + return False + + # 移除 InfoTip 行(保留其他设置如 IconResource) + if not DesktopIniHandler.remove_info_tip(folder_path): + print("移除备注失败") + return False + + # 如果 desktop.ini 仍存在,恢复文件属性 + if DesktopIniHandler.exists( + folder_path + ) and not DesktopIniHandler.set_file_hidden_system_attributes(desktop_ini_path): + print("恢复文件属性失败") + return False + + print("备注删除成功") + return True + + def supports(self, path): + """检查是否支持该路径""" + return os.path.isdir(path) diff --git a/remark/storage/__init__.py b/remark/storage/__init__.py new file mode 100644 index 0000000..dae79f1 --- /dev/null +++ b/remark/storage/__init__.py @@ -0,0 +1,7 @@ +""" +存储层模块 - 提供统一的存储接口 +""" + +from .desktop_ini import DesktopIniHandler, EncodingConversionCanceled + +__all__ = ["DesktopIniHandler", "EncodingConversionCanceled"] diff --git a/remark/storage/desktop_ini.py b/remark/storage/desktop_ini.py new file mode 100644 index 0000000..f62effe --- /dev/null +++ b/remark/storage/desktop_ini.py @@ -0,0 +1,518 @@ +""" +Desktop.ini 交互层 + +根据 Microsoft 官方文档要求,desktop.ini 文件必须使用 Unicode 格式 +才能正确存储和显示本地化字符串。 + +参考文档: +https://learn.microsoft.com/en-us/windows/win32/shell/how-to-customize-folders-with-desktop-ini + +引用: "Make sure the Desktop.ini file that you create is in the Unicode format. +This is necessary to store the localized strings that can be displayed to users." +""" + +import codecs +import os + + +class EncodingConversionCanceled(Exception): # noqa: N818 + """编码转换被用户取消""" + + pass + + +# Windows desktop.ini 标准编码格式 +# 使用 'utf-16' 编码,codecs 会自动添加 UTF-16 LE BOM (0xFF 0xFE) +DESKTOP_INI_ENCODING = "utf-16" +# Windows 行尾符 +LINE_ENDING = "\r\n" + + +class DesktopIniHandler: + """ + Desktop.ini 处理器 + + 提供对 desktop.ini 文件的读写操作,确保使用正确的编码格式 + 以支持资源管理器正确显示中文等非 ASCII 字符。 + """ + + # desktop.ini 文件名 + FILENAME = "desktop.ini" + # ShellClassInfo 段落 + SECTION_SHELL_CLASS_INFO = "[.ShellClassInfo]" + # InfoTip 属性 + PROPERTY_INFOTIP = "InfoTip" + + @staticmethod + def get_path(folder_path): + """ + 获取 desktop.ini 文件路径 + + Args: + folder_path: 文件夹路径 + + Returns: + desktop.ini 文件的完整路径 + """ + return os.path.join(folder_path, DesktopIniHandler.FILENAME) + + @staticmethod + def exists(folder_path): + """ + 检查 desktop.ini 是否存在 + + Args: + folder_path: 文件夹路径 + + Returns: + bool: desktop.ini 是否存在 + """ + return os.path.exists(DesktopIniHandler.get_path(folder_path)) + + @staticmethod + def read_info_tip(folder_path): + """ + 读取 desktop.ini 中的 InfoTip 值 + + 使用 UTF-16 编码读取(与写入逻辑一致),支持中文等非 ASCII 字符。 + 如果 UTF-16 读取失败,会尝试其他编码以处理外部程序创建的文件。 + + Args: + folder_path: 文件夹路径 + + Returns: + str: InfoTip 值,如果不存在或读取失败返回 None + """ + desktop_ini_path = DesktopIniHandler.get_path(folder_path) + + if not os.path.exists(desktop_ini_path): + return None + + # 优先使用标准编码 UTF-16(与写入逻辑一致) + # 降级编码用于处理外部程序创建的文件 + encodings = [DESKTOP_INI_ENCODING, "utf-16-le", "utf-8-sig", "utf-8", "gbk", "mbcs"] + + for encoding in encodings: + try: + with codecs.open(desktop_ini_path, "r", encoding=encoding) as f: + content = f.read() + + # 验证是否是合法的 desktop.ini 结构(必须包含 [.ShellClassInfo]) + # 如果不包含,说明编码不对,继续尝试下一个 + if DesktopIniHandler.SECTION_SHELL_CLASS_INFO not in content: + continue + + # 解析 InfoTip + if DesktopIniHandler.PROPERTY_INFOTIP in content: + # 找到 InfoTip= 的位置 + start = content.index(DesktopIniHandler.PROPERTY_INFOTIP + "=") + start += len(DesktopIniHandler.PROPERTY_INFOTIP + "=") + + # 找到行尾 + end = len(content) + for line_ending in ["\r\n", "\n", "\r"]: + pos = content.find(line_ending, start) + if pos != -1 and pos < end: + end = pos + break + + value = content[start:end].strip() + if value: + return value + # 成功读取且结构正确,但没有 InfoTip + return None + except (UnicodeDecodeError, UnicodeError): + # 当前编码失败,尝试下一个 + continue + except Exception: + # 其他错误(文件不存在、权限问题等),直接返回 + break + + return None + + @staticmethod + def write_info_tip(folder_path, info_tip): + """ + 写入 InfoTip 到 desktop.ini + + 使用 UTF-16 编码写入(自动添加 BOM),符合 Microsoft 官方文档要求。 + 这确保中文等非 ASCII 字符在资源管理器中正确显示。 + + 如果 desktop.ini 已存在且包含其他设置(如 IconResource),会保留这些设置。 + + Args: + folder_path: 文件夹路径 + info_tip: 要写入的 InfoTip 值 + + Returns: + bool: 写入是否成功 + + Raises: + EncodingConversionCanceled: 用户拒绝编码转换 + """ + if not info_tip: + return False + + desktop_ini_path = DesktopIniHandler.get_path(folder_path) + + try: + # 如果文件已存在,读取并更新 + if os.path.exists(desktop_ini_path): + # 确保是 UTF-16 编码(用户拒绝会抛出异常) + DesktopIniHandler.ensure_utf16_encoding(desktop_ini_path) + + with codecs.open(desktop_ini_path, "r", encoding=DESKTOP_INI_ENCODING) as f: + content = f.read() + + # 检查是否已有 InfoTip + lines = content.splitlines() + new_lines = [] + info_tip_updated = False + + for line in lines: + stripped = line.strip() + # 更新现有 InfoTip 行 + if stripped.startswith( + DesktopIniHandler.PROPERTY_INFOTIP + "=" + ) or stripped.startswith(DesktopIniHandler.PROPERTY_INFOTIP + " "): + new_lines.append(DesktopIniHandler.PROPERTY_INFOTIP + "=" + info_tip) + info_tip_updated = True + else: + new_lines.append(line) + + # 如果没有 InfoTip,添加它 + if not info_tip_updated: + # 找到 [.ShellClassInfo] 后插入 + inserted = False + for i, line in enumerate(new_lines): + if line.strip().startswith("[.ShellClassInfo]"): + new_lines.insert( + i + 1, DesktopIniHandler.PROPERTY_INFOTIP + "=" + info_tip + ) + inserted = True + break + if not inserted: + # 没找到 section,添加整个 section + new_lines = [ + DesktopIniHandler.SECTION_SHELL_CLASS_INFO, + DesktopIniHandler.PROPERTY_INFOTIP + "=" + info_tip, + ] + + new_content = LINE_ENDING.join(new_lines) + else: + # 新建文件 + new_content = ( + DesktopIniHandler.SECTION_SHELL_CLASS_INFO + + LINE_ENDING + + DesktopIniHandler.PROPERTY_INFOTIP + + "=" + + info_tip + + LINE_ENDING + ) + + # 使用 UTF-16 编码写入 + with codecs.open(desktop_ini_path, "w", encoding=DESKTOP_INI_ENCODING) as f: + f.write(new_content) + + return True + + except EncodingConversionCanceled: + return False + except Exception: + return False + + @staticmethod + def detect_encoding(file_path): + """ + 检测文件编码 + + Args: + file_path: 文件路径 + + Returns: + tuple: (encoding_name, is_utf16) + - encoding_name: 检测到的编码名称 + - is_utf16: 是否为 UTF-16 编码 + """ + # 检查 BOM + try: + with open(file_path, "rb") as f: + bom = f.read(4) + + if bom[:2] == b"\xff\xfe": # UTF-16 LE BOM + return "utf-16-le", True + elif bom[:2] == b"\xfe\xff": # UTF-16 BE BOM + return "utf-16-be", True + elif bom[:3] == b"\xef\xbb\xbf": # UTF-8 BOM + return "utf-8-sig", False + except Exception: + pass + + # 尝试检测其他编码 + for encoding in ["utf-8", "gbk", "mbcs"]: + try: + with codecs.open(file_path, "r", encoding=encoding) as f: + f.read() + return encoding, False + except (UnicodeDecodeError, UnicodeError): + continue + + return None, False + + @staticmethod + def fix_encoding(file_path, current_encoding): + """ + 修复文件编码为 UTF-16 + + Args: + file_path: 文件路径 + current_encoding: 当前编码名称 + + Returns: + bool: 修复是否成功 + """ + try: + # 读取当前内容 + with codecs.open(file_path, "r", encoding=current_encoding or "utf-8") as f: + content = f.read() + + # 写入 UTF-16 编码 + with codecs.open(file_path, "w", encoding=DESKTOP_INI_ENCODING) as f: + f.write(content) + + return True + except Exception: + return False + + @staticmethod + def ensure_utf16_encoding(file_path): + """ + 确保文件是 UTF-16 编码,如果不是则提示用户确认转换 + + 如果用户拒绝转换,抛出 EncodingConversionCanceled 异常。 + + Args: + file_path: 文件路径 + + Raises: + EncodingConversionCanceled: 用户拒绝转换 + """ + encoding, is_utf16 = DesktopIniHandler.detect_encoding(file_path) + + if is_utf16: + return # 已经是 UTF-16 + + # 文件不是 UTF-16,需要用户确认 + print(f"警告:desktop.ini 文件编码为 {encoding or '未知'},不是标准的 UTF-16。") + print("修改此文件前需要先转换为 UTF-16 编码。") + print("原内容会被保留,仅改变编码格式。") + + try: + # 显示文件预览 + with codecs.open(file_path, "r", encoding=encoding or "utf-8") as f: + content = f.read() + + print("\n当前文件内容:") + print("-" * 40) + print(content) + print("-" * 40) + + # 用户确认 + while True: + response = input("\n是否转换为 UTF-16 编码后继续?[Y/n]: ").strip().lower() + if response in ("", "y", "yes"): + break + elif response in ("n", "no"): + print("操作已取消。") + raise EncodingConversionCanceled("用户拒绝编码转换") + else: + print("请输入 Y 或 n") + + # 执行转换 + with codecs.open(file_path, "w", encoding=DESKTOP_INI_ENCODING) as f: + f.write(content) + + print("✓ 已转换为 UTF-16 编码。") + + except EncodingConversionCanceled: + raise + except Exception as e: + print(f"转换失败: {e}") + print("操作已取消。") + raise EncodingConversionCanceled(f"编码转换失败: {e}") from e + + @staticmethod + def remove_info_tip(folder_path): + """ + 移除 desktop.ini 中的 InfoTip + + 只删除 InfoTip 行,保留其他设置(如 IconResource, Logo 等)。 + + Args: + folder_path: 文件夹路径 + + Returns: + bool: 操作是否成功 + + Raises: + EncodingConversionCanceled: 用户拒绝编码转换 + """ + desktop_ini_path = DesktopIniHandler.get_path(folder_path) + + if not os.path.exists(desktop_ini_path): + return True + + try: + # 确保文件是 UTF-16 编码 + DesktopIniHandler.ensure_utf16_encoding(desktop_ini_path) + + # 读取内容(UTF-16) + with codecs.open(desktop_ini_path, "r", encoding=DESKTOP_INI_ENCODING) as f: + content = f.read() + + # 移除 InfoTip 行 + lines = content.splitlines() + new_lines = [] + for line in lines: + # 跳过 InfoTip 行(支持 = 前后有/无空格) + stripped = line.strip() + if stripped.startswith( + DesktopIniHandler.PROPERTY_INFOTIP + "=" + ) or stripped.startswith(DesktopIniHandler.PROPERTY_INFOTIP + " "): + continue + new_lines.append(line) + + # 检查是否还有有效内容 + has_content = False + for line in new_lines: + stripped = line.strip() + if stripped and not stripped.startswith("[.ShellClassInfo]"): + has_content = True + break + + # 如果没有其他内容,删除文件 + if not has_content: + os.remove(desktop_ini_path) + return True + + # 用 UTF-16 写回 + new_content = LINE_ENDING.join(new_lines) + with codecs.open(desktop_ini_path, "w", encoding=DESKTOP_INI_ENCODING) as f: + f.write(new_content) + + return True + + except EncodingConversionCanceled: + return False + except Exception: + return False + + @staticmethod + def delete(folder_path): + """ + 删除 desktop.ini 文件 + + Args: + folder_path: 文件夹路径 + + Returns: + bool: 删除是否成功 + """ + desktop_ini_path = DesktopIniHandler.get_path(folder_path) + + if not os.path.exists(desktop_ini_path): + return True + + try: + os.remove(desktop_ini_path) + return True + except Exception: + return False + + @staticmethod + def set_folder_system_attributes(folder_path): + """ + 设置文件夹为只读属性 + + 根据 Microsoft 文档和社区讨论,文件夹必须设置为只读属性 + Windows 才会读取 desktop.ini 中的自定义设置。 + + 参考: "Apply the read-only attribute for each folder. + This will make Explorer process the desktop.ini file for that folder." + https://superuser.com/questions/1117824/how-to-get-windows-to-read-copied-desktop-ini-file + + Args: + folder_path: 文件夹路径 + + Returns: + bool: 设置是否成功 + """ + try: + import ctypes + import subprocess + + # 使用 Windows API 检查文件夹是否已有只读属性 + FILE_ATTRIBUTE_READONLY = 0x01 # noqa: N806 - Windows API 常量 + GetFileAttributesW = ctypes.windll.kernel32.GetFileAttributesW # noqa: N806 - Windows API + + attrs = GetFileAttributesW(folder_path) + if attrs == 0xFFFFFFFF: # INVALID_FILE_ATTRIBUTES + return False + + # 如果已有只读属性,无需再次设置 + if attrs & FILE_ATTRIBUTE_READONLY: + return True + + # 设置文件夹为只读属性 + result = subprocess.call( + 'attrib +r "' + folder_path + '"', + shell=True, + stdout=subprocess.DEVNULL, # 抑制输出 + stderr=subprocess.DEVNULL, + ) + return result == 0 + except Exception: + return False + + @staticmethod + def set_file_hidden_system_attributes(file_path): + """ + 设置 desktop.ini 文件为隐藏和系统属性 + + 根据 Microsoft 文档,desktop.ini 应该被标记为隐藏和系统文件 + 以防止普通用户看到或修改它。 + + Args: + file_path: desktop.ini 文件路径 + + Returns: + bool: 设置是否成功 + """ + try: + import subprocess + + result = subprocess.call('attrib +h +s "' + file_path + '"', shell=True) + return result == 0 + except Exception: + return False + + @staticmethod + def clear_file_attributes(file_path): + """ + 清除文件的隐藏和系统属性 + + 在修改 desktop.ini 之前需要调用,以便能够写入文件 + + Args: + file_path: 文件路径 + + Returns: + bool: 清除是否成功 + """ + try: + import subprocess + + result = subprocess.call('attrib -s -h "' + file_path + '"', shell=True) + return result == 0 + except Exception: + return False diff --git a/remark/utils/__init__.py b/remark/utils/__init__.py new file mode 100644 index 0000000..275538f --- /dev/null +++ b/remark/utils/__init__.py @@ -0,0 +1,11 @@ +""" +工具模块 +""" + +from remark.utils.constants import MAX_COMMENT_LENGTH +from remark.utils.platform import check_platform + +__all__ = [ + "MAX_COMMENT_LENGTH", + "check_platform", +] diff --git a/remark/utils/constants.py b/remark/utils/constants.py new file mode 100644 index 0000000..5e7d660 --- /dev/null +++ b/remark/utils/constants.py @@ -0,0 +1,5 @@ +""" +常量定义 +""" + +MAX_COMMENT_LENGTH = 260 diff --git a/remark/utils/encoding.py b/remark/utils/encoding.py new file mode 100644 index 0000000..696fff1 --- /dev/null +++ b/remark/utils/encoding.py @@ -0,0 +1,3 @@ +""" +编码处理工具 +""" diff --git a/remark/utils/path_resolver.py b/remark/utils/path_resolver.py new file mode 100644 index 0000000..fdfefc3 --- /dev/null +++ b/remark/utils/path_resolver.py @@ -0,0 +1,357 @@ +""" +路径解析模块 + +处理未加引号的含空格路径,智能重建完整路径。 +""" + +import posixpath +import re +from collections import deque +from copy import deepcopy +from dataclasses import dataclass +from enum import Enum +from pathlib import Path, PureWindowsPath + + +class NextResult(Enum): + """Cursor.next() 的返回类型枚举""" + + SEPARATOR = "separator" # 找到路径分隔符 + END_OF_ARG = "end_of_arg" # 找到参数末尾 + + +@dataclass +class Cursor: + """ + 路径解析游标,跟踪当前解析位置 + + 注意:Cursor 在 posix 格式分隔符 (/) 上工作,因为 normalized_args 使用 posix 格式 + + Attributes: + arg_index: 当前指向第几个参数(从 0 开始) + char_index: 当前指向参数中第几个字符(路径归一化后的参数为准,从 0 开始) + """ + + arg_index: int + char_index: int + + def jump_to_last_separator(self, normalized_args: list[str]) -> None: + """ + 跳转到当前参数的最后一个系统分隔符位置 + 如果找不到,则留在当前位置 (后面没有分隔符) + + :param normalized_args: 归一化后的参数列表 + """ + norm_path = normalized_args[self.arg_index] + last_sep = norm_path.rfind(posixpath.sep) + if last_sep >= 0: + self.char_index = last_sep + else: + self.char_index = -1 + + def next(self, normalized_args: list[str]) -> tuple["Cursor", NextResult] | None: + """ + 从当前位置向后查找,找到下一个路径分隔符或参数末尾 + + 搜索从 char_index + 1 开始(跳过当前位置),找到下一个分隔符或参数末尾。 + 找到分隔符时,end cursor 停在分隔符上(与 jump_to_last_separator 保持一致)。 + + :param normalized_args: 归一化后的参数列表 + :return: (新 cursor, NextResult) 或 None(如果无法继续) + """ + new_cursor = Cursor(self.arg_index, self.char_index) + + while new_cursor.arg_index < len(normalized_args): + current_arg = normalized_args[new_cursor.arg_index] + arg_len = len(current_arg) + + # 如果当前位置已在参数末尾,跳到下一个参数开头 + if new_cursor.char_index >= arg_len: + new_cursor.arg_index += 1 + new_cursor.char_index = 0 + continue + + # 从 char_index + 1 开始查找分隔符(跳过当前位置) + search_start = new_cursor.char_index + 1 + sep_pos = current_arg.find(posixpath.sep, search_start) + + if sep_pos >= 0: + # 找到分隔符,新 cursor 停在分隔符上 + new_cursor.char_index = sep_pos + return new_cursor, NextResult.SEPARATOR + + # 没有找到分隔符,跳到当前参数末尾 + new_cursor.char_index = arg_len + return new_cursor, NextResult.END_OF_ARG + + # 已经到达最后一个参数的末尾,无法继续 + return None + + +def get_between(begin: Cursor, end: Cursor, normalized_args: list[str]) -> list[str]: + """ + 获取两个 cursor 之间的内容,可能跨多个参数 + + :param begin: 起始 cursor(不包含) + :param end: 结束 cursor(不包含) + :param normalized_args: 归一化后的参数列表 + :return: 字符串片段列表 + """ + if begin.arg_index == end.arg_index: + # 同一个参数内,从 begin.char_index + 1 开始(不包含 begin 位置) + arg = normalized_args[begin.arg_index] + return [arg[begin.char_index + 1 : end.char_index]] + + # 跨多个参数 + result = [] + + # 第一个参数的部分,从 begin.char_index + 1 开始(不包含 begin 位置) + first_arg = normalized_args[begin.arg_index] + result.append(first_arg[begin.char_index + 1 :]) + + # 中间的完整参数 + for i in range(begin.arg_index + 1, end.arg_index): + result.append(normalized_args[i]) + + # 最后一个参数的部分 + if end.arg_index < len(normalized_args): + last_arg = normalized_args[end.arg_index] + result.append(last_arg[: end.char_index]) + + return result + + +def build_pattern(parts: list[str]) -> re.Pattern: + r""" + 将多个字符串片段构建为宽容搜索的正则表达式 + + 由于终端用空格分割参数,用户输入的 "My Folder" 会被分割成 ["My", "Folder"]。 + 此函数构建一个宽容的正则表达式来匹配可能的文件名。 + + 宽容规则: + - 片段之间允许任意空白字符(用 \s+ 连接) + - 转义所有正则表达式特殊字符,用户输入不含正则表达式 + - 使用 re.IGNORECASE 忽略大小写(Windows 文件系统不区分大小写) + + :param parts: 字符串片段列表 + :return: 编译后的正则表达式模式 + """ + if not parts: + return re.compile(r"") + + # 转义每个片段中的正则表达式特殊字符 + escaped_parts = [re.escape(part) for part in parts] + + # 用 \s+ 连接片段,允许片段之间有任意空白 + pattern = r"\s+".join(escaped_parts) + + # 首尾精确匹配 + pattern = r"^" + pattern + r"$" + + # 忽略大小写匹配 + return re.compile(pattern, re.IGNORECASE) + + +def get_current_working_path( + first_arg: str, cursor: Cursor | None = None, normalized_args: list[str] | None = None +) -> tuple[PureWindowsPath, Cursor]: + """ + 从第一个参数中提取工作目录和剩余内容 + + 规则: + - 使用 PureWindowsPath 规范化路径并获取父目录 + - 空字符串 → (".", "") + + :param first_arg: 一个路径 + :param cursor: 游标,如果为 None 则初始化为 (0, 0) + :param normalized_args: 归一化后的参数列表,如果为 None 则使用 first_arg 初始化 + :return: (工作目录, Cursor) + """ + # 如果 cursor 为 None,初始化为 (0, 0) + if cursor is None: + cursor = Cursor(0, 0) + + # 如果 normalized_args 为 None,归一化 first_arg + if normalized_args is None: + normalized_args = [PureWindowsPath(first_arg).as_posix()] + + # 空字符串处理 + if not first_arg: + return PureWindowsPath(), cursor + + # 使用 pathlib 获取父目录 + path_obj = PureWindowsPath(first_arg) + parent = path_obj.parent + + # 跳转到最后一个分隔符位置,因为 parent 可能是 "." 等特殊情况,根据最后一个分隔符判断是安全的 + cursor.jump_to_last_separator(normalized_args) + return parent if parent else PureWindowsPath(), cursor + + +def get_inner_items_list(current_working_path: Path) -> list[Path]: + """ + 获取指定路径下的所有文件和文件夹列表 + + :param current_working_path: 当前工作目录路径 + :return: 文件和文件夹名称列表,如果路径不存在或不是目录则返回空列表 + """ + return list(current_working_path.iterdir()) + + +def find_candidates( + args_list: list[str], +) -> list[tuple[Path, list[str], str]]: + """ + 递归查找所有可能的路径重建候选 + + 返回所有候选,按优先级排序(消耗更多 args 的优先) + + Args: + args_list: argparse 解析后的位置参数列表 + 例如: ["C:\\Program", "Files", "App"] 或 ["My", "Folder/App", "备注"] + + Returns: + List[Tuple[full_path, remaining_args, type]]: 所有候选 + - full_path: 完整路径 + - remaining_args: 剩余参数(作为备注内容) + - type: "folder" 或 "file" + + """ + if not args_list: + return [] + + # 归一化所有参数 + normalized_args = [PureWindowsPath(arg).as_posix() for arg in args_list] + + # 构建游标 + cursor = Cursor(0, 0) + + # 根据第一个参数,判断当前工作目录 + current_working_path, cursor = get_current_working_path(args_list[0], cursor, normalized_args) + + # 处理剩余内容 + # 接下来是一个经典的 BFS 搜索问题,我们使用队列来保存当前工作目录和游标作为搜索起点(值拷贝) + # - 如果队列为空,结束搜索 + # 获取当前队头的工作目录对应的文件列表 + # - 如果为空,则工作目录加入候选,弹出队列 + # - 否则继续 + # 接下来使用三指针策略,一个新的 next_ 指针沿着当前 cursor 向后找,一个 last_ 保存上一次找到的位置,一个 start_ 保存起始位置,每次 + # - 找到下一个路径分隔符 + # - 或者找到下一个参数末尾 + # Cursor 应当提供一个 next 接口,返回新的指针和以上两种类型之一,但是不要修改当前 cursor 的值 + # Cursor 应当提供一个 get_between 接口,返回两个指针之间的全部字符串内容(可能跨多个参数,因此可能有多个字符串) + # 当前模块应当提供一个把多个字符串组合成一个正则表达式的函数 + # 如果找到的是路径分隔符 + # - 将当前找到的内容()**放到一个正则表达式**中(抽象为一个函数),然后在文件列表中搜索匹配 + # - 如果匹配成功 + # - 将成功的一个或多个匹配作为新的工作目录,带着新 cursor 值,进行深拷贝并加入候选项 + # - 如果没有匹配成功 + # - 结束搜索,返回当前可选项 + # - 弹出当前工作目录 + # 如果找到的是参数末尾 + # - 将当前找到的内容**放到一个正则表达式**中,然后在文件列表中搜索匹配 + # - 如果匹配成功 + # - 将成功的一个或多个匹配作为新的工作目录,带着新 cursor 值,进行深拷贝并加入候选项 + # - 如果没有匹配成功 + # - 当前 cursor 不变,队列也不变,新 Cursor 继续向后找 + # - 无论匹配是否成功,当前工作目录都不变 + # 如果没有下一个参数,新 Cursor 无法前进 + # - 弹出队列中的当前工作目录 + + candidates: list[tuple[Path, list[str], str]] = [] + # 队列元素: (current_working_path, cursor) + queue: deque[tuple[Path, Cursor, Cursor]] = deque() + # working_path, start, last + queue.append((Path(current_working_path), deepcopy(cursor), deepcopy(cursor))) + + while queue: + work_path, start_cursor, cur = queue.popleft() + if not work_path.is_dir(): + continue + + # 尝试从当前 cursor 向后推进 + next_result = cur.next(normalized_args) + if next_result is None: + # 无法继续,弹出队列中的当前工作目录(已处理) + continue + + next_cursor, result_type = next_result + + # 获取当前 cursor 和 next_cursor 之间的内容 + parts = get_between(start_cursor, next_cursor, normalized_args) + + # 构建正则表达式 + pattern = build_pattern(parts) + + # 获取当前工作目录的文件列表 + inner_items = get_inner_items_list(work_path) + + if not inner_items: + # 工作目录为空,说明某个 A\\B 的路径不正确 (A 是空目录) + # 搜索失败 + continue + + # 在文件列表中搜索匹配 + matches = [item.name for item in inner_items if pattern.search(item.name)] + + if result_type == NextResult.SEPARATOR: + # 找到分隔符 + if matches: + # 匹配成功,将匹配项作为新的工作目录加入队列 + # 需要将 cursor 推进到分隔符之后 + for match in matches: + new_work_path = work_path / match + queue.append((new_work_path, next_cursor, next_cursor)) + else: + # 匹配失败,结束搜索,返回当前候选 + break + + elif result_type == NextResult.END_OF_ARG: + # 找到参数末尾 + if matches: + # 匹配成功,将匹配项加入候选 + for match in matches: + full_path = work_path / match + is_folder = full_path.is_dir() + entry_type = "folder" if is_folder else "file" + remaining = get_remaining_args(next_cursor, normalized_args) + candidates.append((full_path, remaining, entry_type)) + + # 无论匹配是否成功,当前工作目录不变,继续尝试向前推进 + # 将当前工作目录和 next_cursor 重新加入队列 + queue.append((work_path, start_cursor, next_cursor)) + + def _candidate_key(item: tuple[Path, list[str], str]) -> tuple[bool, int]: + """候选排序键函数:folder 优先,路径越长越优先""" + return ( + item[2] != "folder", # folder 优先 + -len(str(item[0])), # 路径越长越优先 + ) + + # 匹配到的路径越长越优先(消耗的参数越多,剩余参数越少) + candidates.sort(key=_candidate_key) + + return candidates if candidates else [] + + +def get_remaining_args(cursor: Cursor, normalized_args: list[str]) -> list[str]: + """ + 获取 cursor 之后的所有剩余参数,作为备注内容 + + :param cursor: 当前游标 + :param normalized_args: 归一化后的参数列表 + :return: 剩余参数拼接的字符串 + """ + remaining = [] + + # 当前参数的剩余部分 + if cursor.arg_index < len(normalized_args): + current_arg = normalized_args[cursor.arg_index] + if cursor.char_index < len(current_arg): + remaining.append(current_arg[cursor.char_index :]) + + # 后续完整参数 + for i in range(cursor.arg_index + 1, len(normalized_args)): + remaining.append(normalized_args[i]) + + return remaining diff --git a/remark/utils/platform.py b/remark/utils/platform.py new file mode 100644 index 0000000..7141acc --- /dev/null +++ b/remark/utils/platform.py @@ -0,0 +1,14 @@ +""" +平台检查工具 +""" + +import platform + + +def check_platform(): + """检查是否为 Windows 系统""" + if platform.system() != "Windows": + print("错误: 此工具为 Windows 系统中的文件/文件夹添加备注,暂不支持其他系统。") + print("当前系统:", platform.system()) + return False + return True diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..862f852 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Scripts package.""" diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 0000000..acfb558 --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,124 @@ +""" +本地打包脚本 + +使用方法: + # 打包为单文件 exe + python scripts/build.py + + # 清理构建文件 + python scripts/build.py --clean +""" + +import argparse +import io +import os +import shutil +import subprocess +import sys + +# 设置 UTF-8 输出编码(Windows 兼容) +if sys.platform == "win32": + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8") + +# 项目根目录 +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def get_project_version(): + """获取项目版本号""" + toml_file = os.path.join(ROOT_DIR, "pyproject.toml") + with open(toml_file, encoding="utf-8") as f: + content = f.read() + import re + + match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content) + if match: + return match.group(1) + return "unknown" + + +def clean_build_files(): + """清理构建文件""" + print("清理构建文件...") + + dirs_to_remove = ["build", "dist", "spec"] + + for dir_name in dirs_to_remove: + dir_path = os.path.join(ROOT_DIR, dir_name) + if os.path.exists(dir_path): + shutil.rmtree(dir_path) + print(f" 已删除: {dir_name}/") + + print("✓ 清理完成") + + +def build_exe(): + """使用 PyInstaller 打包为单文件 exe""" + print("开始打包...") + + spec_file = os.path.join(ROOT_DIR, "remark.spec") + if not os.path.exists(spec_file): + print(f"错误: 找不到 {spec_file}") + return False + + # 检查 PyInstaller 是否安装 + try: + import PyInstaller + + print(f"PyInstaller 版本: {PyInstaller.__version__}") + except ImportError: + print("错误: 未安装 PyInstaller") + print("请运行: pip install pyinstaller") + return False + + # 运行 PyInstaller + try: + subprocess.run( + ["pyinstaller", spec_file, "--clean"], + cwd=ROOT_DIR, + check=True, + ) + print("✓ 打包完成") + print("\n输出文件: dist/windows-folder-remark.exe") + return True + except subprocess.CalledProcessError as e: + print(f"✗ 打包失败: {e}") + return False + except FileNotFoundError: + print("错误: 找不到 pyinstaller 命令") + print("请运行: pip install pyinstaller") + return False + + +def main(): + parser = argparse.ArgumentParser(description="本地打包工具") + parser.add_argument("--clean", "-c", action="store_true", help="仅清理构建文件,不进行打包") + + args = parser.parse_args() + + if args.clean: + clean_build_files() + return + + # 打包前先清理 + clean_build_files() + print() + + # 开始打包 + version = get_project_version() + print(f"项目版本: {version}") + print() + + if build_exe(): + print("\n" + "=" * 50) + print("打包成功!") + print(f"版本: {version}") + print("位置: dist/windows-folder-remark.exe") + print("=" * 50) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..8ac7e1f --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,245 @@ +""" +版本发布脚本 + +使用方法: + # 查看当前版本 + python scripts/release.py + + # 递增补丁版本 (2.0.0 -> 2.0.1) + python scripts/release.py patch + + # 递增次版本 (2.0.0 -> 2.1.0) + python scripts/release.py minor + + # 递增主版本 (2.0.0 -> 3.0.0) + python scripts/release.py major + + # 设置特定版本 + python scripts/release.py 2.1.0 + + # 创建并推送 release tag + python scripts/release.py patch --push +""" + +import argparse +import os +import re +import subprocess +import sys + +# 项目根目录 +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def get_current_version(): + """获取当前版本号(从 pyproject.toml)""" + toml_file = os.path.join(ROOT_DIR, "pyproject.toml") + with open(toml_file, encoding="utf-8") as f: + content = f.read() + match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content) + if match: + return match.group(1) + raise ValueError("无法在 pyproject.toml 中找到版本号") + + +def update_version(new_version): + """更新 pyproject.toml 中的版本号""" + toml_file = os.path.join(ROOT_DIR, "pyproject.toml") + with open(toml_file, encoding="utf-8") as f: + content = f.read() + content = re.sub(r'(version\s*=\s*["\'])([^"\']+)(["\'])', rf"\g<1>{new_version}\g<3>", content) + with open(toml_file, "w", encoding="utf-8") as f: + f.write(content) + return new_version + + +def bump_version(current, part="patch"): + """递增版本号""" + major, minor, patch = map(int, current.split(".")) + + if part == "major": + major += 1 + minor = 0 + patch = 0 + elif part == "minor": + minor += 1 + patch = 0 + else: # patch + patch += 1 + + return f"{major}.{minor}.{patch}" + + +def create_tag(version): + """创建 git tag""" + tag_name = f"v{version}" + subprocess.run(["git", "tag", "-a", tag_name, "-m", f"Release {tag_name}"], check=True) + print(f"已创建 tag: {tag_name}") + return tag_name + + +def push_tag(tag_name): + """推送 tag 到远程仓库""" + subprocess.run(["git", "push", "origin", tag_name], check=True) + print(f"已推送 tag: {tag_name}") + + +def commit_version_changes(): + """提交版本变更""" + current_version = get_current_version() + subprocess.run(["git", "add", "pyproject.toml"], check=True) + subprocess.run(["git", "commit", "-m", f"bump: version to {current_version}"], check=True) + print(f"已提交版本变更: {current_version}") + + +def check_branch(): + """检查当前分支是否为主分支""" + result = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + text=True, + check=True, + ) + current_branch = result.stdout.strip() + return current_branch + + +def check_working_directory_clean(): + """检查工作目录是否有未提交的改动""" + result = subprocess.run( + ["git", "status", "--porcelain"], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() == "" + + +def check_remote_sync(): + """检查本地是否与远程同步""" + result = subprocess.run( + ["git", "status", "-sb"], + capture_output=True, + text=True, + check=True, + ) + status_line = result.stdout.split("\n")[0] + # 检查是否包含 "behind" 字样 + return "behind" not in status_line.lower() + + +def validate_version_increment(current: str, new: str) -> bool: + """验证新版本号是否大于当前版本号""" + curr_major, curr_minor, curr_patch = map(int, current.split(".")) + new_major, new_minor, new_patch = map(int, new.split(".")) + + return ( + new_major > curr_major + or (new_major == curr_major and new_minor > curr_minor) + or (new_major == curr_major and new_minor == curr_minor and new_patch > curr_patch) + ) + + +def main(): + parser = argparse.ArgumentParser(description="版本发布管理工具") + parser.add_argument( + "version", nargs="?", help="新版本号 (如: 2.1.0) 或递增类型: patch/minor/major" + ) + parser.add_argument( + "--push", "-p", action="store_true", help="创建并推送 tag 到远程仓库(触发 GitHub Actions)" + ) + parser.add_argument("--commit", "-c", action="store_true", help="提交版本变更到 git") + parser.add_argument( + "--dry-run", "-n", action="store_true", help="只显示将要执行的操作,不实际执行" + ) + parser.add_argument( + "--skip-branch-check", + action="store_true", + help="跳过分支检查(不推荐)", + ) + + args = parser.parse_args() + + # --push 自动包含 --commit(确保 tag 指向包含版本变更的提交) + if args.push and not args.commit: + args.commit = True + + current = get_current_version() + print(f"当前版本: {current}") + + if not args.version: + return + + # 确定新版本号 + if args.version in ("patch", "minor", "major"): + new_version = bump_version(current, args.version) + else: + # 验证版本号格式 + if not re.match(r"^\d+\.\d+\.\d+$", args.version): + print("错误: 版本号格式应为 x.y.z") + sys.exit(1) + new_version = args.version + + print(f"新版本: {new_version}") + + # 版本号递增验证 + if not validate_version_increment(current, new_version): + print(f"错误: 新版本 {new_version} 不大于当前版本 {current}") + print("版本号必须递增") + sys.exit(1) + + # 分支检查 + if not args.skip_branch_check: + current_branch = check_branch() + if current_branch not in ("main", "master"): + print(f"警告: 当前分支 '{current_branch}' 不是主分支") + print("建议在 main 或 master 分支进行发布") + response = input("是否继续? (yes/no): ") + if response.lower() not in ("yes", "y"): + print("已取消") + sys.exit(1) + + # 工作目录状态检查 + if not check_working_directory_clean(): + print("错误: 工作目录有未提交的改动") + print("请先提交或暂存所有改动后再进行发布") + sys.exit(1) + + # 远程同步检查 + if not check_remote_sync(): + print("警告: 本地分支落后于远程分支") + print("建议先执行 'git pull' 同步最新代码") + response = input("是否继续? (yes/no): ") + if response.lower() not in ("yes", "y"): + print("已取消") + sys.exit(1) + + if args.dry_run: + print("\n[DRY RUN] 将执行以下操作:") + print(f" 1. 更新版本号: {current} -> {new_version}") + if args.commit: + print(" 2. 提交版本变更") + if args.push: + print(f" 3. 创建并推送 tag v{new_version}") + return + + # 更新版本文件 + update_version(new_version) + print(f"已更新版本号到: {new_version}") + + # 提交变更 + if args.commit: + commit_version_changes() + + # 创建并推送 tag + if args.push: + tag_name = create_tag(new_version) + push_tag(tag_name) + print(f"\n✓ Release v{new_version} 已准备就绪!") + print(" GitHub Actions 将自动构建并发布") + else: + print("\n提示: 使用 --push 参数创建并推送 tag 以触发 release") + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..2c62db7 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +""" +测试模块 +""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4510db3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,34 @@ +"""共享测试配置""" + +import sys +from pathlib import Path + +import pytest + +# 项目根目录添加到路径 +sys.path.insert(0, str(Path(__file__).parent.parent)) + + +def pytest_configure(config): + """pytest 配置钩子 - 定义 markers""" + config.addinivalue_line("markers", "unit: 单元测试(使用 mock)") + config.addinivalue_line("markers", "integration: 集成测试(真实文件系统)") + config.addinivalue_line("markers", "windows: 仅在 Windows 上运行") + config.addinivalue_line("markers", "slow: 慢速测试") + + # 强制使用 UTF-8 编码输出,支持 emoji 等特殊字符 + import io + + if sys.stdout.encoding.lower() not in ("utf-8", "utf-16", "utf-16-le", "utf-16-be"): + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + if sys.stderr.encoding.lower() not in ("utf-8", "utf-16", "utf-16-le", "utf-16-be"): + sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") + + +def pytest_collection_modifyitems(config, items): + """自动跳过非 Windows 平台上的 Windows 测试""" + if sys.platform != "win32": + skip_windows = pytest.mark.skip(reason="Windows only test") + for item in items: + if "windows" in item.keywords: + item.add_marker(skip_windows) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..45c3345 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""集成测试模块""" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..e171822 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,29 @@ +"""集成测试专用 fixtures""" + +import codecs + +import pytest + + +@pytest.fixture +def utf16_encoded_file(tmp_path): + """创建 UTF-16 编码的测试文件""" + file_path = tmp_path / "utf16_test.ini" + content = "[.ShellClassInfo]\r\nInfoTip=UTF-16 测试\r\n" + + with codecs.open(str(file_path), "w", encoding="utf-16") as f: + f.write(content) + + return str(file_path) + + +@pytest.fixture +def utf8_encoded_file(tmp_path): + """创建 UTF-8 编码的测试文件""" + file_path = tmp_path / "utf8_test.ini" + content = "[.ShellClassInfo]\r\nInfoTip=UTF-8 测试\r\n" + + with open(str(file_path), "w", encoding="utf-8") as f: + f.write(content) + + return str(file_path) diff --git a/tests/integration/test_encoding_handling.py b/tests/integration/test_encoding_handling.py new file mode 100644 index 0000000..88e7451 --- /dev/null +++ b/tests/integration/test_encoding_handling.py @@ -0,0 +1,177 @@ +"""编码处理集成测试""" + +import codecs +import os + +import pytest + +from remark.storage.desktop_ini import DesktopIniHandler + + +@pytest.mark.integration +class TestEncodingHandling: + """编码处理集成测试""" + + def test_write_and_read_utf16(self, tmp_path): + """测试 UTF-16 编码读写""" + folder = str(tmp_path / "test") + os.makedirs(folder) + + # 写入 + result = DesktopIniHandler.write_info_tip(folder, "UTF-16 测试") + assert result is True + + # 验证文件存在 + ini_path = os.path.join(folder, "desktop.ini") + assert os.path.exists(ini_path) + + # 读取 + read_result = DesktopIniHandler.read_info_tip(folder) + assert read_result == "UTF-16 测试" + + def test_read_gbk_encoded_file(self, tmp_path): + """测试读取 GBK 编码的文件(降级兼容)""" + folder = str(tmp_path / "gbk_test") + os.makedirs(folder) + ini_path = os.path.join(folder, "desktop.ini") + + # 使用 codecs.open 确保行尾符正确处理 + with codecs.open(ini_path, "w", encoding="gbk") as f: + f.write("[.ShellClassInfo]\r\nInfoTip=GBK Test\r\n") + + result = DesktopIniHandler.read_info_tip(folder) + assert result == "GBK Test" + + def test_read_utf8_encoded_file(self, tmp_path): + """测试读取 UTF-8 编码的文件""" + folder = str(tmp_path / "utf8_test") + os.makedirs(folder) + ini_path = os.path.join(folder, "desktop.ini") + + # 使用 codecs.open 确保 UTF-8 编码正确 + with codecs.open(ini_path, "w", encoding="utf-8") as f: + f.write("[.ShellClassInfo]\r\nInfoTip=UTF-8 Test\r\n") + + result = DesktopIniHandler.read_info_tip(folder) + assert result == "UTF-8 Test" + + def test_encoding_detection_utf16(self, utf16_encoded_file): + """测试编码检测 - UTF-16""" + encoding, is_utf16 = DesktopIniHandler.detect_encoding(utf16_encoded_file) + assert is_utf16 is True + assert "utf-16" in encoding + + def test_encoding_detection_utf8(self, utf8_encoded_file): + """测试编码检测 - UTF-8""" + encoding, is_utf16 = DesktopIniHandler.detect_encoding(utf8_encoded_file) + assert is_utf16 is False + assert encoding == "utf-8" + + @pytest.mark.parametrize( + "comment", + [ + "简体中文", + "繁體中文", + "日本語", + "한국어", + "Emoji 🔥", + "Mixed 中英文 Mixed", + "Special chars: !@#$%^&*()", + ], + ) + def test_write_various_characters(self, tmp_path, comment): + """测试写入各种字符""" + folder = str(tmp_path / "chinese") + os.makedirs(folder) + + result = DesktopIniHandler.write_info_tip(folder, comment) + assert result is True + + read_result = DesktopIniHandler.read_info_tip(folder) + assert read_result == comment + + def test_write_long_comment(self, tmp_path): + """测试写入长备注""" + folder = str(tmp_path / "long") + os.makedirs(folder) + + # 260 字符(MAX_COMMENT_LENGTH) + long_comment = "A" * 260 + result = DesktopIniHandler.write_info_tip(folder, long_comment) + assert result is True + + read_result = DesktopIniHandler.read_info_tip(folder) + assert read_result == long_comment + + def test_update_preserves_encoding(self, tmp_path): + """测试更新备注保持编码""" + folder = str(tmp_path / "update") + os.makedirs(folder) + + # 第一次写入 + DesktopIniHandler.write_info_tip(folder, "初始备注") + + # 获取文件编码 + ini_path = os.path.join(folder, "desktop.ini") + encoding1, is_utf16_1 = DesktopIniHandler.detect_encoding(ini_path) + assert is_utf16_1 is True + + # 更新备注 + DesktopIniHandler.write_info_tip(folder, "更新备注") + + # 验证编码仍然是 UTF-16 + encoding2, is_utf16_2 = DesktopIniHandler.detect_encoding(ini_path) + assert is_utf16_2 is True + assert encoding1.split("-")[0] == encoding2.split("-")[0] + + def test_write_new_line_endings(self, tmp_path): + """测试写入使用 Windows 行尾符""" + folder = str(tmp_path / "line_ending") + os.makedirs(folder) + + DesktopIniHandler.write_info_tip(folder, "行尾测试") + + ini_path = os.path.join(folder, "desktop.ini") + + # UTF-16 LE 编码中,\r\n 被存储为 \x00\r\x00\n(每个字符前有 null byte) + # 或者可以简单地读取文本内容验证行尾符 + with codecs.open(ini_path, "r", encoding="utf-16") as f: + text_content = f.read() + + # 验证文本内容包含 CRLF + assert "\r\n" in text_content + + def test_read_without_bom(self, tmp_path): + """测试读取没有 BOM 的文件(降级到 utf-8)""" + folder = str(tmp_path / "no_bom") + os.makedirs(folder) + + ini_path = os.path.join(folder, "desktop.ini") + # 使用 codecs.open 确保编码正确 + with codecs.open(ini_path, "w", encoding="utf-8") as f: + f.write("[.ShellClassInfo]\r\nInfoTip=No BOM Test\r\n") + + result = DesktopIniHandler.read_info_tip(folder) + assert result == "No BOM Test" + + def test_empty_folder(self, tmp_path): + """测试空文件夹""" + folder = str(tmp_path / "empty") + os.makedirs(folder) + + result = DesktopIniHandler.read_info_tip(folder) + assert result is None + + def test_corrupted_ini_file(self, tmp_path): + """测试损坏的 ini 文件""" + folder = str(tmp_path / "corrupted") + os.makedirs(folder) + + ini_path = os.path.join(folder, "desktop.ini") + with open(ini_path, "wb") as f: + f.write(b"\x00\x01\x02\x03\x04\x05") # 二进制垃圾数据 + + # 应该返回 None 而不是崩溃 + result = DesktopIniHandler.read_info_tip(folder) + # 可能成功解码(某些编码会接受)或返回 None + assert result is None or isinstance(result, str) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..0ae4595 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""单元测试模块""" diff --git a/tests/unit/test_cli_commands.py b/tests/unit/test_cli_commands.py new file mode 100644 index 0000000..b2acff9 --- /dev/null +++ b/tests/unit/test_cli_commands.py @@ -0,0 +1,343 @@ +"""CLI 命令单元测试""" + +import os + +import pytest + +from remark.cli.commands import CLI, get_version + + +@pytest.mark.unit +class TestCLI: + """CLI 命令测试""" + + def test_init(self): + """测试 CLI 初始化""" + cli = CLI() + assert cli.handler is not None + + def test_validate_folder_not_exists(self, capsys): + """测试验证不存在的路径""" + cli = CLI() + result = cli._validate_folder("/invalid/path") + assert result is False + captured = capsys.readouterr() + assert "路径不存在" in captured.out + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_validate_folder_not_dir(self, fs, capsys): + """测试验证非文件夹路径""" + fs.create_file("/file.txt") + cli = CLI() + result = cli._validate_folder("/file.txt") + assert result is False + captured = capsys.readouterr() + assert "不是文件夹" in captured.out + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_validate_folder_success(self, fs): + """测试验证有效文件夹""" + fs.create_dir("/valid/folder") + cli = CLI() + result = cli._validate_folder("/valid/folder") + assert result is True + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_add_comment_success(self, fs): + """测试添加备注成功""" + fs.create_dir("/test/folder") + cli = CLI() + result = cli.add_comment("/test/folder", "测试备注") + assert result is True + + def test_add_comment_invalid_folder(self): + """测试添加备注到无效路径""" + cli = CLI() + result = cli.add_comment("/invalid/path", "备注") + assert result is False + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_delete_comment_success(self, fs): + """测试删除备注成功""" + fs.create_dir("/test/folder") + cli = CLI() + result = cli.delete_comment("/test/folder") + assert result is True + + def test_delete_comment_invalid_folder(self): + """测试删除备注失败(无效路径)""" + cli = CLI() + result = cli.delete_comment("/invalid/path") + assert result is False + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_view_comment_with_content(self, fs, capsys): + """测试查看有备注的文件夹""" + fs.create_dir("/test/folder") + from remark.core.folder_handler import FolderCommentHandler + + with pytest.MonkeyPatch().context() as m: + m.setattr(FolderCommentHandler, "get_comment", lambda self, path: "测试备注") + cli = CLI() + cli.view_comment("/test/folder") + captured = capsys.readouterr() + assert "测试备注" in captured.out + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_view_comment_without_content(self, fs, capsys): + """测试查看无备注的文件夹""" + fs.create_dir("/test/folder") + from remark.core.folder_handler import FolderCommentHandler + + with pytest.MonkeyPatch().context() as m: + m.setattr(FolderCommentHandler, "get_comment", lambda self, path: None) + cli = CLI() + cli.view_comment("/test/folder") + captured = capsys.readouterr() + assert "没有备注" in captured.out + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_view_comment_with_encoding_issue_and_fix(self, fs, capsys): + """测试查看有编码问题的文件夹并选择修复""" + from remark.core.folder_handler import FolderCommentHandler + from remark.storage.desktop_ini import DesktopIniHandler + + fs.create_dir("/test/folder") + fs.create_file("/test/folder/desktop.ini", contents="test content") + + with pytest.MonkeyPatch().context() as m: + m.setattr(DesktopIniHandler, "detect_encoding", lambda file_path: ("utf-8", False)) + m.setattr(DesktopIniHandler, "fix_encoding", lambda file_path, current_encoding: True) + m.setattr(FolderCommentHandler, "get_comment", lambda self, path: "测试备注") + m.setattr("builtins.input", lambda *args, **kwargs: "y") + + cli = CLI() + cli.view_comment("/test/folder") + captured = capsys.readouterr() + assert "编码为 utf-8" in captured.out + assert "已修复为 UTF-16 编码" in captured.out + assert "测试备注" in captured.out + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_view_comment_with_encoding_issue_and_skip(self, fs, capsys): + """测试查看有编码问题的文件夹但选择跳过修复""" + from remark.core.folder_handler import FolderCommentHandler + from remark.storage.desktop_ini import DesktopIniHandler + + fs.create_dir("/test/folder") + fs.create_file("/test/folder/desktop.ini", contents="test content") + + with pytest.MonkeyPatch().context() as m: + m.setattr(DesktopIniHandler, "detect_encoding", lambda file_path: ("gbk", False)) + m.setattr(FolderCommentHandler, "get_comment", lambda self, path: "测试备注") + m.setattr("builtins.input", lambda *args, **kwargs: "n") + + cli = CLI() + cli.view_comment("/test/folder") + captured = capsys.readouterr() + assert "编码为 gbk" in captured.out + assert "跳过编码修复" in captured.out + assert "测试备注" in captured.out + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_view_comment_with_correct_encoding(self, fs, capsys): + """测试查看编码正确的文件夹""" + from remark.core.folder_handler import FolderCommentHandler + from remark.storage.desktop_ini import DesktopIniHandler + + fs.create_dir("/test/folder") + fs.create_file("/test/folder/desktop.ini", contents="test content") + + with pytest.MonkeyPatch().context() as m: + m.setattr( + DesktopIniHandler, + "detect_encoding", + lambda file_path: ("utf-16-le", True), + ) + m.setattr(FolderCommentHandler, "get_comment", lambda self, path: "测试备注") + + cli = CLI() + cli.view_comment("/test/folder") + captured = capsys.readouterr() + # 不应该显示编码警告 + assert "编码" not in captured.out + assert "测试备注" in captured.out + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_interactive_mode_valid_input(self, fs, monkeypatch): + """测试交互模式有效输入""" + fs.create_dir("/folder") + + input_sequence = ["/folder", "测试备注"] + + def mock_input(prompt): + if input_sequence: + return input_sequence.pop(0) + raise KeyboardInterrupt() + + cli = CLI() + + # Mock input to control user input and exit after first iteration + monkeypatch.setattr("builtins.input", mock_input) + + # Mock add_comment to verify it was called + original_add_comment = cli.add_comment + calls = [] + + def mock_add_comment(path, comment): + calls.append((path, comment)) + return original_add_comment(path, comment) + + monkeypatch.setattr(cli, "add_comment", mock_add_comment) + + cli.interactive_mode() + + # 验证 add_comment 被正确调用 + assert len(calls) == 1 + assert calls[0] == ("/folder", "测试备注") + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_interactive_mode_invalid_path_then_valid(self, fs, monkeypatch, capsys): + """测试交互模式先输入无效路径再输入有效路径""" + fs.create_dir("/valid_folder") + + input_sequence = ["/invalid", "/valid_folder", "备注内容"] + + def mock_input(prompt): + if input_sequence: + return input_sequence.pop(0) + raise KeyboardInterrupt() + + cli = CLI() + monkeypatch.setattr("builtins.input", mock_input) + + # Mock add_comment to verify it was called + original_add_comment = cli.add_comment + calls = [] + + def mock_add_comment(path, comment): + calls.append((path, comment)) + return original_add_comment(path, comment) + + monkeypatch.setattr(cli, "add_comment", mock_add_comment) + + cli.interactive_mode() + + # 验证无效路径被提示,最终有效路径被处理 + captured = capsys.readouterr() + assert "路径不存在" in captured.out + assert len(calls) == 1 + assert calls[0] == ("/valid_folder", "备注内容") + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_interactive_mode_empty_comment_retry(self, fs, monkeypatch, capsys): + """测试交互模式空备注重试""" + fs.create_dir("/folder") + + input_sequence = ["/folder", "", "有效备注"] + + def mock_input(prompt): + if input_sequence: + return input_sequence.pop(0) + raise KeyboardInterrupt() + + cli = CLI() + monkeypatch.setattr("builtins.input", mock_input) + + # Mock add_comment to verify it was called + original_add_comment = cli.add_comment + calls = [] + + def mock_add_comment(path, comment): + calls.append((path, comment)) + return original_add_comment(path, comment) + + monkeypatch.setattr(cli, "add_comment", mock_add_comment) + + cli.interactive_mode() + + # 验证空备注被提示重新输入,最终有效备注被处理 + captured = capsys.readouterr() + assert "备注不要为空" in captured.out + assert len(calls) == 1 + assert calls[0] == ("/folder", "有效备注") + + def test_show_help(self, capsys): + """测试帮助信息""" + cli = CLI() + cli.show_help() + captured = capsys.readouterr() + assert "Windows 文件夹备注工具" in captured.out + assert "使用方法" in captured.out + assert "交互模式" in captured.out + + def test_run_with_help(self, capsys): + """测试运行 --help 参数""" + cli = CLI() + cli.run(["--help"]) + captured = capsys.readouterr() + assert "Windows 文件夹备注工具" in captured.out + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_run_with_delete(self, fs): + """测试运行 --delete 参数""" + fs.create_dir("/test/folder") + cli = CLI() + cli.run(["--delete", "/test/folder"]) + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_run_with_view(self, fs): + """测试运行 --view 参数""" + fs.create_dir("/test/folder") + cli = CLI() + cli.run(["--view", "/test/folder"]) + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_run_with_path_and_comment(self, fs, monkeypatch): + """测试运行带路径和备注参数""" + fs.create_dir("/folder") + # Mock input to auto-confirm when path is detected + monkeypatch.setattr("builtins.input", lambda *args, **kwargs: "y") + cli = CLI() + cli.run(["/folder", "备注"]) + + def test_run_interactive_mode(self, monkeypatch): + """测试运行进入交互模式""" + # Mock interactive_mode to avoid actually entering interactive mode + monkeypatch.setattr(CLI, "interactive_mode", lambda cli: None) + cli = CLI() + cli.run([]) + + def test_run_platform_check_fail(self): + """测试非 Windows 平台""" + # check_platform 在 CLI.run 开始时被调用,如果返回 False 会 sys.exit(1) + # 由于 sys.exit 会抛出 SystemExit,我们需要捕获它或 mock 它 + with pytest.MonkeyPatch().context() as m: + m.setattr("remark.cli.commands.check_platform", lambda: False) + cli = CLI() + with pytest.raises(SystemExit) as exc_info: + cli.run([]) + # sys.exit(1) 会被调用 + assert exc_info.value.code == 1 + + +@pytest.mark.unit +class TestGetVersion: + """get_version 函数测试""" + + def test_get_version_from_package(self): + """测试从包获取版本""" + with pytest.MonkeyPatch().context() as m: + m.setattr("importlib.metadata.version", lambda *args, **kwargs: "2.0.0") + version = get_version() + assert version == "2.0.0" + + def test_get_version_fallback(self): + """测试获取版本失败时返回 unknown""" + with pytest.MonkeyPatch().context() as m: + m.setattr( + "importlib.metadata.version", + lambda *args, **kwargs: (_ for _ in ()).throw(Exception()), + ) + version = get_version() + assert version == "unknown" diff --git a/tests/unit/test_desktop_ini.py b/tests/unit/test_desktop_ini.py new file mode 100644 index 0000000..d81e9fa --- /dev/null +++ b/tests/unit/test_desktop_ini.py @@ -0,0 +1,173 @@ +"""desktop.ini 读写单元测试""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from remark.storage.desktop_ini import ( + DESKTOP_INI_ENCODING, + LINE_ENDING, + DesktopIniHandler, + EncodingConversionCanceled, +) + + +@pytest.mark.unit +class TestDesktopIniHandler: + """desktop.ini 处理器测试""" + + def test_get_path(self): + """测试获取路径""" + result = DesktopIniHandler.get_path("/test/folder") + # 使用 normpath 比较以兼容不同平台的路径分隔符 + assert os.path.normpath(result) == os.path.normpath("/test/folder/desktop.ini") + + @pytest.mark.parametrize( + "exists_return,expected", + [(True, True), (False, False)], + ) + def test_exists(self, exists_return, expected): + """测试文件存在检测""" + with patch("os.path.exists", return_value=exists_return): + result = DesktopIniHandler.exists("/folder") + assert result is expected + + def test_read_info_tip_no_file(self): + """测试读取不存在的文件""" + with patch("os.path.exists", return_value=False): + result = DesktopIniHandler.read_info_tip("/folder") + assert result is None + + @pytest.mark.parametrize( + "content,expected", + [ + ("[.ShellClassInfo]\r\nInfoTip=测试备注\r\n", "测试备注"), + ("[.ShellClassInfo]\nInfoTip=测试备注\n", "测试备注"), + ("[.ShellClassInfo]\r\nInfoTip=English Comment\r\n", "English Comment"), + ("[.ShellClassInfo]\r\nInfoTip=备注\r\nIconResource=icon.dll\r\n", "备注"), + ("[.ShellClassInfo]\r\nIconResource=icon.dll\r\n", None), + ("[.ShellClassInfo]\r\n", None), + ("No InfoTip here", None), + ], + ) + def test_read_info_tip_with_content(self, content, expected): + """测试读取各种内容格式""" + with patch("os.path.exists", return_value=True), patch("codecs.open") as mock_open_func: + mock_file = MagicMock() + mock_file.read.return_value = content + mock_open_func.return_value.__enter__.return_value = mock_file + + result = DesktopIniHandler.read_info_tip("/folder") + assert result == expected + + def test_write_info_tip_empty(self): + """测试写入空备注""" + result = DesktopIniHandler.write_info_tip("/folder", "") + assert result is False + + def test_write_info_tip_new_file(self): + """测试写入新文件""" + with patch("os.path.exists", return_value=False), patch("codecs.open") as mock_open_func: + mock_file = MagicMock() + mock_open_func.return_value.__enter__.return_value = mock_file + + result = DesktopIniHandler.write_info_tip("/folder", "新备注") + assert result is True + mock_file.write.assert_called_once() + + def test_write_info_tip_update_existing(self): + """测试更新已有文件""" + existing_content = "[.ShellClassInfo]\r\nInfoTip=旧备注\r\n" + with ( + patch("os.path.exists", return_value=True), + patch("remark.storage.desktop_ini.DesktopIniHandler.ensure_utf16_encoding"), + patch("codecs.open") as mock_open_func, + ): + mock_file_read = MagicMock() + mock_file_read.read.return_value = existing_content + mock_file_write = MagicMock() + mock_open_func.side_effect = [mock_file_read, mock_file_write] + + result = DesktopIniHandler.write_info_tip("/folder", "新备注") + assert result is True + + def test_detect_encoding_utf16_le(self): + """测试检测 UTF-16 LE 编码""" + with patch("builtins.open") as mock_open_func: + mock_file = MagicMock() + mock_file.read.return_value = b"\xff\xfe\x00\x00" + mock_open_func.return_value.__enter__.return_value = mock_file + + encoding, is_utf16 = DesktopIniHandler.detect_encoding("/test.ini") + assert encoding == "utf-16-le" + assert is_utf16 is True + + def test_detect_encoding_utf16_be(self): + """测试检测 UTF-16 BE 编码""" + with patch("builtins.open") as mock_open_func: + mock_file = MagicMock() + mock_file.read.return_value = b"\xfe\xff\x00\x00" + mock_open_func.return_value.__enter__.return_value = mock_file + + encoding, is_utf16 = DesktopIniHandler.detect_encoding("/test.ini") + assert encoding == "utf-16-be" + assert is_utf16 is True + + def test_detect_encoding_utf8_bom(self): + """测试检测 UTF-8 BOM 编码""" + with patch("builtins.open") as mock_open_func: + mock_file = MagicMock() + mock_file.read.return_value = b"\xef\xbb\xbf" + mock_open_func.return_value.__enter__.return_value = mock_file + + encoding, is_utf16 = DesktopIniHandler.detect_encoding("/test.ini") + assert encoding == "utf-8-sig" + assert is_utf16 is False + + def test_set_file_hidden_system_attributes(self): + """测试设置文件隐藏系统属性""" + with patch("subprocess.call", return_value=0) as mock_call: + result = DesktopIniHandler.set_file_hidden_system_attributes("/file") + assert result is True + mock_call.assert_called_once() + args = mock_call.call_args[0][0] + assert "attrib +h +s" in args + assert "/file" in args or '"file"' in args or "file" in args + + def test_clear_file_attributes(self): + """测试清除文件属性""" + with patch("subprocess.call", return_value=0) as mock_call: + result = DesktopIniHandler.clear_file_attributes("/file") + assert result is True + mock_call.assert_called_once() + args = mock_call.call_args[0][0] + assert "attrib -s -h" in args + + def test_delete_file_exists(self): + """测试删除存在的文件""" + with patch("os.path.exists", return_value=True), patch("os.remove"): + result = DesktopIniHandler.delete("/folder") + assert result is True + + def test_delete_no_file(self): + """测试删除不存在的文件""" + with patch("os.path.exists", return_value=False): + result = DesktopIniHandler.delete("/folder") + assert result is True + + def test_constants(self): + """测试常量定义""" + assert DESKTOP_INI_ENCODING == "utf-16" + assert LINE_ENDING == "\r\n" + + +@pytest.mark.unit +class TestEncodingConversionCanceled: + """编码转换取消异常测试""" + + def test_exception_creation(self): + """测试异常创建""" + exc = EncodingConversionCanceled("用户取消") + assert str(exc) == "用户取消" + assert isinstance(exc, Exception) diff --git a/tests/unit/test_folder_handler.py b/tests/unit/test_folder_handler.py new file mode 100644 index 0000000..d1da856 --- /dev/null +++ b/tests/unit/test_folder_handler.py @@ -0,0 +1,151 @@ +"""核心业务逻辑单元测试""" + +from unittest.mock import patch + +import pytest + +from remark.core.folder_handler import MAX_COMMENT_LENGTH, FolderCommentHandler + + +@pytest.mark.unit +class TestFolderCommentHandler: + """文件夹备注处理器测试""" + + def test_init(self): + """测试初始化""" + handler = FolderCommentHandler() + assert handler is not None + + @pytest.mark.parametrize( + "is_dir,expected", + [(True, True), (False, False)], + ) + def test_supports(self, is_dir, expected): + """测试支持检测""" + with patch("os.path.isdir", return_value=is_dir): + handler = FolderCommentHandler() + result = handler.supports("/folder") + assert result is expected + + def test_set_comment_not_folder(self, capsys): + """测试对非文件夹路径设置备注""" + with patch("os.path.isdir", return_value=False): + handler = FolderCommentHandler() + result = handler.set_comment("/file.txt", "备注") + assert result is False + captured = capsys.readouterr() + assert "不是文件夹" in captured.out + + def test_set_comment_too_long(self, capsys): + """测试备注长度超过限制""" + with ( + patch("os.path.isdir", return_value=True), + patch.object( + FolderCommentHandler, "_set_comment_desktop_ini", return_value=True + ) as mock_set, + ): + handler = FolderCommentHandler() + long_comment = "A" * 300 # 超过 MAX_COMMENT_LENGTH + + handler.set_comment("/folder", long_comment) + + # 应该被截断到 MAX_COMMENT_LENGTH + mock_set.assert_called_once() + args = mock_set.call_args[0] + assert len(args[1]) == MAX_COMMENT_LENGTH + assert args[1] == "A" * MAX_COMMENT_LENGTH + + def test_set_comment_success(self): + """测试成功设置备注""" + with ( + patch("os.path.isdir", return_value=True), + patch.object(FolderCommentHandler, "_set_comment_desktop_ini", return_value=True), + ): + handler = FolderCommentHandler() + result = handler.set_comment("/folder", "测试备注") + assert result is True + + @pytest.mark.parametrize( + "read_return,expected", + [ + ("测试备注", "测试备注"), + ("English Comment", "English Comment"), + (None, None), + ], + ) + def test_get_comment(self, read_return, expected): + """测试获取备注""" + with patch( + "remark.storage.desktop_ini.DesktopIniHandler.read_info_tip", + return_value=read_return, + ): + handler = FolderCommentHandler() + result = handler.get_comment("/folder") + assert result == expected + + def test_delete_comment_no_ini(self, capsys): + """测试删除不存在的备注""" + with patch("remark.storage.desktop_ini.DesktopIniHandler.exists", return_value=False): + handler = FolderCommentHandler() + result = handler.delete_comment("/folder") + assert result is True + captured = capsys.readouterr() + assert "没有备注" in captured.out + + def test_delete_comment_with_ini(self): + """测试删除存在的备注""" + with ( + patch("remark.storage.desktop_ini.DesktopIniHandler.exists", return_value=True), + patch( + "remark.storage.desktop_ini.DesktopIniHandler.clear_file_attributes", + return_value=True, + ), + patch( + "remark.storage.desktop_ini.DesktopIniHandler.remove_info_tip", + return_value=True, + ), + ): + handler = FolderCommentHandler() + result = handler.delete_comment("/folder") + assert result is True + + def test_delete_comment_clear_failure(self): + """测试删除时清除属性失败""" + with ( + patch("remark.storage.desktop_ini.DesktopIniHandler.exists", return_value=True), + patch( + "remark.storage.desktop_ini.DesktopIniHandler.clear_file_attributes", + return_value=False, + ), + ): + handler = FolderCommentHandler() + result = handler.delete_comment("/folder") + assert result is False + + def test_set_comment_desktop_ini(self): + """测试 desktop.ini 设置备注的内部方法""" + with ( + patch( + "remark.storage.desktop_ini.DesktopIniHandler.write_info_tip", + return_value=True, + ), + patch( + "remark.storage.desktop_ini.DesktopIniHandler.clear_file_attributes", + return_value=True, + ), + patch( + "remark.storage.desktop_ini.DesktopIniHandler.set_file_hidden_system_attributes", + return_value=True, + ), + patch( + "remark.storage.desktop_ini.DesktopIniHandler.set_folder_system_attributes", + return_value=True, + ), + ): + handler = FolderCommentHandler() + result = handler._set_comment_desktop_ini("/folder", "备注") + assert result is True + + def test_max_comment_length_constant(self): + """测试最大备注长度常量""" + assert MAX_COMMENT_LENGTH == 260 diff --git a/tests/unit/test_path_resolver.py b/tests/unit/test_path_resolver.py new file mode 100644 index 0000000..840c65c --- /dev/null +++ b/tests/unit/test_path_resolver.py @@ -0,0 +1,462 @@ +""" +路径解析模块单元测试 +""" + +import os +from pathlib import Path, PureWindowsPath + +import pytest + +from remark.utils.path_resolver import find_candidates + + +class TestFindCandidates: + """测试 find_candidates 函数""" + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_single_space_split(self, fs): + """ + 用例: 单次空格分割 + + 输入: ["D:\\My", "Documents"] + 模拟环境: + D:\\ 目录下存在文件夹 "My Documents" + listdir("D:\\") -> ["My Documents", "Users", "Windows", "test.txt"] + + 预期输出: + [("D:\\My Documents", [], "folder")] + """ + fs.create_dir("D:\\My Documents") + fs.create_dir("D:\\Users") + fs.create_dir("D:\\Windows") + fs.create_file("D:\\test.txt") + + result = find_candidates(["D:\\My", "Documents"]) + + assert len(result) == 1 + path, remaining, type_ = result[0] + assert path == Path("D:\\My Documents") + assert remaining == [] + assert type_ == "folder" + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_multi_space_folder_name_with_comment(self, fs): + """ + 用例: 文件夹名包含多个空格(有备注) + + 输入: ["D:\\Here", "Is", "My", "Folder", "备注", "内容"] + 模拟环境: + D:\\ 目录下存在文件夹 "Here Is My Folder" + listdir("D:\\") -> ["Here Is My Folder", "Users"] + + 预期输出: + [("D:\\Here Is My Folder", ["备注", "内容"], "folder")] + + 说明: "Here Is My Folder" 被空格分割成 4 个参数,剩余 2 个参数作为备注 + """ + fs.create_dir("D:\\Here Is My Folder") + fs.create_dir("D:\\Users") + + result = find_candidates(["D:\\Here", "Is", "My", "Folder", "备注", "内容"]) + + assert len(result) == 1 + path, remaining, type_ = result[0] + assert path == Path("D:\\Here Is My Folder") + assert remaining == ["备注", "内容"] + assert type_ == "folder" + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_valid_folder_with_comment(self, fs): + """ + 用例: 路径有效 + 备注 + + 输入: ["D:\\ValidFolder", "备注"] + 模拟环境: + D:\\ValidFolder 目录存在且是文件夹 + + 预期输出: + [("D:\\ValidFolder", ["备注"], "folder")] + + 说明: 路径本身完整,剩余参数作为备注 + """ + fs.create_dir("D:\\ValidFolder") + + result = find_candidates(["D:\\ValidFolder", "备注"]) + + assert len(result) == 1 + path, remaining, type_ = result[0] + assert path == Path("D:\\ValidFolder") + assert remaining == ["备注"] + assert type_ == "folder" + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_no_match(self, fs): + """ + 用例: 无匹配 + + 输入: ["D:\\Invalid", "Path"] + 模拟环境: + D:\\ 目录下只有 "Users", "Windows" 文件夹 + listdir("D:\\") -> ["Users", "Windows"] + "D:\\Invalid" 和 "D:\\Invalid Path" 都不存在 + + 预期输出: + [] (空列表) + + 说明: 无法匹配任何路径,返回空列表 + """ + fs.create_dir("D:\\Users") + fs.create_dir("D:\\Windows") + + result = find_candidates(["D:\\Invalid", "Path"]) + + assert result == [] + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_both_single_name_and_extended_exist(self, fs): + """ + 用例: 单个文件夹名和扩展路径都存在 + + 输入: ["My", "Files", "App"] + 模拟环境: + 当前目录下同时存在 "My" 和 "My Files" 两个文件夹 + listdir(".") -> ["My", "My Files", "Other"] + + 预期输出 (按优先级排序): + [ + (包含 "My Files" 的路径, ["App"], "folder"), # 消耗 2 个参数,剩余 1 个 + (包含 "My" 的路径, ["Files", "App"], "folder"), # 消耗 1 个参数,剩余 2 个 + ] + + 说明: 同时匹配 "My Files" 和 "My",按剩余参数数量升序排序(剩余越少优先级越高) + """ + fs.create_dir("My") + fs.create_dir("My Files") + fs.create_dir("Other") + + result = find_candidates(["My", "Files", "App"]) + + assert len(result) == 2 + path1, remaining1, type1 = result[0] + path2, remaining2, type2 = result[1] + + # 验证第一个候选是 "My Files"(包含完整路径) + assert path1 == Path("My Files") + assert remaining1 == ["App"] + assert type1 == "folder" + + # 验证第二个候选是 "My" + assert path2 == Path("My") + assert remaining2 == ["Files", "App"] + assert type2 == "folder" + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_subdirectory_recursive_matching(self, fs): + """ + 用例: 子目录递归匹配(跨参数的子目录链) + + 输入: ["My", "Folder/App", "Folder/New", "Folder", "测试内容"] + 模拟环境: + ./My (文件夹) + ./My Folder/ (文件夹) + ./My Folder/App (文件夹) + ./My Folder/App Folder/ (文件夹) + ./My Folder/App Folder1/ (文件夹) + ./My Folder/App Folder/New Folder/ (文件夹) + ./Other (文件夹) + + listdir(".") -> ["My", "My Folder", "Other"] + listdir("./My Folder") -> ["App", "App Folder", "App Folder1"] + listdir("./My Folder/App Folder") -> ["New Folder", "Other"] + + 预期输出 (按优先级排序,按剩余参数数量升序): + [ + ("My Folder/App Folder/New Folder", ["测试内容"], "folder"), # 剩余 1 个 + ("My Folder/App", ["Folder/New", "Folder", "测试内容"], "folder"), # 剩余 3 个 + ("My", ["Folder/App", "Folder/New", "Folder", "测试内容"], "folder"), # 剩余 4 个 + ] + """ + fs.create_dir("My") + fs.create_dir("My Folder/App") + fs.create_dir("My Folder/App Folder/New Folder") + fs.create_dir("My Folder/App Folder1") + fs.create_dir("Other") + + result = find_candidates(["My", "Folder/App", "Folder/New", "Folder", "测试内容"]) + + # 严格验证所有候选(按优先级排序) + assert len(result) == 3 + + # 候选 1: 最大匹配 + path1, remaining1, type1 = result[0] + assert path1 == Path("My Folder/App Folder/New Folder") + assert remaining1 == ["测试内容"] + assert type1 == "folder" + + # 候选 2: 中间匹配 + path2, remaining2, type2 = result[1] + assert path2 == Path("My Folder/App") + assert remaining2 == ["Folder/New", "Folder", "测试内容"] + assert type2 == "folder" + + # 候选 3: 基础匹配 + path3, remaining3, type3 = result[2] + assert path3 == Path("My") + assert remaining3 == ["Folder/App", "Folder/New", "Folder", "测试内容"] + assert type3 == "folder" + + def test_empty_args(self): + """ + 用例: 空参数列表 + + 输入: [] + 模拟环境: (无需模拟) + + 预期输出: + [] (空列表) + """ + result = find_candidates([]) + assert result == [] + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_multiple_slashes_in_single_arg(self, fs): + """ + 用例: 单个参数包含多个路径分隔符 + + 输入: ["My", "Folder/App/Deep/New", "备注"] + 模拟环境: + ./My (文件夹) + ./My Folder/ (文件夹) + ./My Folder/App/ (文件夹) + ./My Folder/App/Deep/ (文件夹) + ./My Folder/App/Deep/New/ (文件夹) + + listdir(".") -> ["My", "My Folder"] + listdir("./My Folder") -> ["App"] + listdir("./My Folder/App") -> ["Deep"] + listdir("./My Folder/App/Deep") -> ["New"] + + 预期输出 (按优先级排序,按剩余参数数量升序): + [ + ("My Folder/App/Deep/New", ["备注"], "folder"), # 剩余 1 个 + ("My", ["Folder/App/Deep/New", "备注"], "folder"), # 剩余 2 个 + ] + + 说明: + - "Folder/App/Deep/New" 是单个参数,包含 3 个 /,表示 4 级子目录 + - _find_subpath_candidates 应该递归处理每一级 + """ + fs.create_dir("My") + fs.create_dir("My Folder/App/Deep/New") + + result = find_candidates(["My", "Folder/App/Deep/New", "备注"]) + + # 严格验证所有候选 + assert len(result) == 2 + + # 候选 1: 最大匹配 + path1, remaining1, type1 = result[0] + assert path1 == Path("My Folder/App/Deep/New") + assert remaining1 == ["备注"] + assert type1 == "folder" + + # 候选 2: 基础匹配 + path2, remaining2, type2 = result[1] + assert path2 == Path("My") + assert remaining2 == ["Folder/App/Deep/New", "备注"] + assert type2 == "folder" + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_absolute_path_with_slash_arg(self, fs): + """ + 用例: 绝对路径 + 含 / 的参数(跨参数空格匹配) + + 输入: ["D:\\My", "Folder/Sub", "备注"] + 模拟环境: + D:\\My (文件夹) + D:\\My Folder\\ (文件夹) + D:\\My Folder\\Sub\\ (文件夹) + + listdir("D:\\") -> ["My", "My Folder"] + listdir("D:\\My Folder") -> ["Sub"] + + 预期输出 (按优先级排序,按剩余参数数量升序): + [ + ("D:\\My Folder\\Sub", ["备注"], "folder"), # 剩余 1 个 + ("D:\\My", ["Folder/Sub", "备注"], "folder"), # 剩余 2 个 + ] + + 说明: + - "D:\\My" 是绝对路径,"Folder/Sub" 的 "Folder" 与 "My" 正则匹配 → "My Folder" + - "Sub" 在 "My Folder" 中匹配子目录 + - 测试绝对路径与跨参数 / 处理的组合场景 + """ + fs.create_dir("D:\\My") + fs.create_dir("D:\\My Folder\\Sub") + + result = find_candidates(["D:\\My", "Folder/Sub", "备注"]) + + # 严格验证所有候选 + assert len(result) == 2 + + # 候选 1: 最大匹配 + path1, remaining1, type1 = result[0] + assert path1 == Path("D:\\My Folder\\Sub") + assert remaining1 == ["备注"] + assert type1 == "folder" + + # 候选 2: 基础匹配 + path2, remaining2, type2 = result[1] + assert path2 == Path("D:\\My") + assert remaining2 == ["Folder/Sub", "备注"] + assert type2 == "folder" + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_empty_directory(self, fs): + """ + 用例: 空目录处理 + + 输入: ["D:\\Empty", "Folder", "备注"] + 模拟环境: + D:\\ 目录下存在空文件夹 "Empty Folder" + listdir("D:\\") -> ["Empty Folder"] + listdir("D:\\Empty Folder") -> [] + + 预期输出: + [("D:\\Empty Folder\\App", ["备注"], "folder")] + + 说明: 匹配到 "Empty Folder" 后继续搜索,发现目录为空,加入候选 + """ + fs.create_dir("D:\\Empty Folder") + + result = find_candidates(["D:\\Empty", "Folder\\App", "备注"]) + + assert len(result) == 0 + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_separator_no_match(self, fs): + """ + 用例: 分隔符匹配失败 + + 输入: ["D:\\My", "Invalid/Sub", "备注"] + 模拟环境: + D:\\My (文件夹) + D:\\My Folder\\ (文件夹) + listdir("D:\\") -> ["My", "My Folder"] + listdir("D:\\My Folder") -> ["Sub"] + + 预期输出: + [("D:\\My", ["Invalid/Sub", "备注"], "folder")] + + 说明: "My" 匹配后,"Invalid" 在 "My Folder" 中无匹配,搜索结束 + """ + fs.create_dir("D:\\My") + fs.create_dir("D:\\My Folder\\Sub") + + result = find_candidates(["D:\\My", "Invalid/Sub", "备注"]) + + assert len(result) == 1 + path, remaining, type_ = result[0] + assert path == Path("D:\\My") + assert remaining == ["Invalid/Sub", "备注"] + assert type_ == "folder" + + @pytest.mark.skipif(os.name != "nt", reason="Windows only") + def test_file_skipped(self, fs): + """ + 用例: 跳过文件(非目录路径) + + 输入: ["D:\\My", "File", "Folder", "备注内容"] + 模拟环境: + D:\\My File (文件,应被跳过) + D:\\My File Folder\\ (文件夹) + listdir("D:\\") -> ["My File", "My File Folder"] + + 预期输出: + [("D:\\My File Folder", ["备注内容"], "folder")] + + 说明: "My File" 和 "My File Folder" 都匹配 "My File",但 "My File" 是文件被跳过 + """ + fs.create_file("D:\\My File") + fs.create_dir("D:\\My File Folder") + + result = find_candidates(["D:\\My", "File\\Folder", "备注内容"]) + + assert len(result) == 0 + + +@pytest.mark.unit +class TestGetCurrentWorkingPath: + """测试 get_current_working_path 函数""" + + def test_empty_string(self): + """空字符串返回 (".", Cursor(0, 0))""" + from remark.utils.path_resolver import get_current_working_path + + working, cursor = get_current_working_path("") + assert working == PureWindowsPath() + assert cursor.arg_index == 0 + assert cursor.char_index == 0 + + def test_absolute_root(self): + r"""根目录 C:\ 返回 (C:\, Cursor(0, 2))""" + from remark.utils.path_resolver import get_current_working_path + + working, cursor = get_current_working_path("C:\\") + assert working == PureWindowsPath("C:\\") + assert cursor.arg_index == 0 + assert cursor.char_index == 2 + + def test_absolute_with_trailing_slash(self): + r"""C:\MyFolder\ 返回 (C:\, Cursor(0, 2))""" + from remark.utils.path_resolver import get_current_working_path + + working, cursor = get_current_working_path("C:\\MyFolder\\") + assert working == PureWindowsPath("C:\\") + assert cursor.arg_index == 0 + assert cursor.char_index == 2 + + def test_absolute_without_trailing_slash(self): + r"""C:\MyFolder 返回 (C:, Cursor(0, 2))""" + from remark.utils.path_resolver import get_current_working_path + + working, cursor = get_current_working_path("C:\\MyFolder") + assert working == PureWindowsPath("C:\\") + assert cursor.arg_index == 0 + assert cursor.char_index == 2 + + def test_absolute_multi_level(self): + r"""C:\MyFolder\Other 返回 (C:\MyFolder, Cursor(0, 11))""" + from remark.utils.path_resolver import get_current_working_path + + working, cursor = get_current_working_path("C:\\MyFolder\\Other") + assert working == PureWindowsPath("C:\\MyFolder") + assert cursor.arg_index == 0 + assert cursor.char_index == 11 + + def test_forward_slash_normalization(self): + r"""C:/My/Folder 规范化后返回 (C:\My, Cursor(0, 5))""" + from remark.utils.path_resolver import get_current_working_path + + working, cursor = get_current_working_path("C:/My/Folder") + assert working == PureWindowsPath("C:\\My") + assert cursor.arg_index == 0 + assert cursor.char_index == 5 + + def test_relative_single(self): + """MyFolder 返回 (., Cursor(0, 0))""" + from remark.utils.path_resolver import get_current_working_path + + working, cursor = get_current_working_path("MyFolder") + assert working == PureWindowsPath() + assert cursor.arg_index == 0 + assert cursor.char_index == -1 + + def test_relative_with_backslash(self): + r"""My\Folder 返回 (My, Cursor(0, 2))""" + from remark.utils.path_resolver import get_current_working_path + + working, cursor = get_current_working_path("My\\Folder") + assert working == PureWindowsPath("My") + assert cursor.arg_index == 0 + assert cursor.char_index == 2 diff --git a/tests/unit/test_platform.py b/tests/unit/test_platform.py new file mode 100644 index 0000000..36e7f44 --- /dev/null +++ b/tests/unit/test_platform.py @@ -0,0 +1,52 @@ +"""平台检测单元测试""" + +from unittest.mock import patch + +import pytest + +from remark.utils.platform import check_platform + + +@pytest.mark.unit +class TestPlatform: + """平台检测测试""" + + @pytest.mark.parametrize( + "system_name,expected", + [ + ("Windows", True), + # 注意:check_platform 使用 == "Windows" 比较,是大小写敏感的 + # ("windows", True), # 小写会失败 + # ("WINDOWS", True), # 大写会失败 + ], + ) + def test_check_platform_windows(self, system_name, expected): + """测试 Windows 平台检测""" + with patch("remark.utils.platform.platform.system", return_value=system_name): + result = check_platform() + assert result is expected + + @pytest.mark.parametrize( + "system_name", + ["Linux", "Darwin", "FreeBSD", "SunOS"], + ) + def test_check_platform_non_windows(self, system_name, capsys): + """测试非 Windows 平台""" + with patch("remark.utils.platform.platform.system", return_value=system_name): + result = check_platform() + assert result is False + captured = capsys.readouterr() + assert "此工具为 Windows 系统" in captured.out + + @pytest.mark.parametrize( + "system_name", + ["windows", "WINDOWS", "WiNdOwS"], # 大小写不匹配 + ) + def test_check_platform_case_sensitive(self, system_name, capsys): + """测试平台检测大小写敏感""" + with patch("remark.utils.platform.platform.system", return_value=system_name): + result = check_platform() + # 这些会返回 False 因为不等于 "Windows" + assert result is False + captured = capsys.readouterr() + assert "此工具为 Windows 系统" in captured.out diff --git a/tests/unit/test_release.py b/tests/unit/test_release.py new file mode 100644 index 0000000..b051458 --- /dev/null +++ b/tests/unit/test_release.py @@ -0,0 +1,124 @@ +"""release.py 脚本单元测试""" + +from unittest.mock import Mock, patch + +import pytest + +from scripts.release import ( + check_branch, + check_remote_sync, + check_working_directory_clean, + validate_version_increment, +) + + +class TestValidateVersionIncrement: + """测试版本号递增验证""" + + def test_patch_increment(self): + """测试补丁版本递增""" + assert validate_version_increment("1.0.0", "1.0.1") + assert validate_version_increment("2.3.4", "2.3.5") + + def test_minor_increment(self): + """测试次版本递增""" + assert validate_version_increment("1.0.0", "1.1.0") + assert validate_version_increment("2.3.4", "2.4.0") + + def test_major_increment(self): + """测试主版本递增""" + assert validate_version_increment("1.0.0", "2.0.0") + assert validate_version_increment("2.3.4", "3.0.0") + + def test_same_version_fails(self): + """测试相同版本号应失败""" + assert not validate_version_increment("1.0.0", "1.0.0") + + def test_lower_version_fails(self): + """测试更低版本号应失败""" + assert not validate_version_increment("2.0.0", "1.9.9") + assert not validate_version_increment("1.2.3", "1.2.2") + assert not validate_version_increment("2.5.0", "2.4.9") + + +@pytest.mark.parametrize( + ("status_output", "expected_result"), + [ + ("", True), # 无输出表示干净 + ("M file.txt", False), # 有修改 + ("?? new.txt", False), # 有新文件 + ("M modified.txt\n?? new.txt", False), # 多种改动 + ], +) +def test_check_working_directory_clean(status_output, expected_result): + """测试工作目录状态检查""" + with patch("subprocess.run") as mock_run: + mock_result = Mock() + mock_result.stdout = status_output + mock_run.return_value = mock_result + + result = check_working_directory_clean() + assert result is expected_result + + +@pytest.mark.parametrize( + ("branch_name", "is_main"), + [ + ("main", True), + ("master", True), + ("develop", False), + ("feat-new-feature", False), + ], +) +def test_check_branch(branch_name, is_main): + """测试分支检查""" + with patch("subprocess.run") as mock_run: + mock_result = Mock() + mock_result.stdout = branch_name + mock_run.return_value = mock_result + + result = check_branch() + assert result == branch_name + + +@pytest.mark.parametrize( + ("status_output", "is_synced"), + [ + ("## main...origin/main", True), + ("## master...origin/master", True), + ("## main...origin/main [behind 3]", False), + ("## main...origin/main [ahead 2]", True), + ("## main...origin/main [ahead 1, behind 1]", False), + ], +) +def test_check_remote_sync(status_output, is_synced): + """测试远程同步检查""" + with patch("subprocess.run") as mock_run: + mock_result = Mock() + mock_result.stdout = status_output + mock_run.return_value = mock_result + + result = check_remote_sync() + assert result is is_synced + + +class TestPushCommitInteraction: + """测试 --push 和 --commit 的联动""" + + def test_push_implies_commit(self, capsys): + """测试 --push 自动包含 --commit""" + with ( + patch( + "sys.argv", ["release.py", "patch", "--push", "--dry-run", "--skip-branch-check"] + ), + patch("scripts.release.check_working_directory_clean", return_value=True), + patch("scripts.release.check_remote_sync", return_value=True), + patch("scripts.release.get_current_version", return_value="1.0.0"), + patch("scripts.release.check_branch", return_value="main"), + ): + from scripts.release import main + + main() + captured = capsys.readouterr() + # 应该包含 "提交版本变更" 因为 --push 自动启用 --commit + assert "提交版本变更" in captured.out diff --git a/tests/windows/__init__.py b/tests/windows/__init__.py new file mode 100644 index 0000000..7dff6c0 --- /dev/null +++ b/tests/windows/__init__.py @@ -0,0 +1 @@ +"""Windows 特定测试模块""" diff --git a/tests/windows/test_full_workflow.py b/tests/windows/test_full_workflow.py new file mode 100644 index 0000000..53ef4e2 --- /dev/null +++ b/tests/windows/test_full_workflow.py @@ -0,0 +1,311 @@ +"""完整工作流测试(仅 Windows)""" + +import ctypes +import os +import sys + +import pytest + +from remark.core.folder_handler import FolderCommentHandler +from remark.storage.desktop_ini import DesktopIniHandler + + +@pytest.mark.windows +@pytest.mark.integration +@pytest.mark.slow +class TestFullWorkflow: + """完整工作流测试""" + + def test_complete_add_workflow(self, tmp_path): + """测试完整的添加工作流""" + if sys.platform != "win32": + pytest.skip("Windows only test") + + handler = FolderCommentHandler() + folder = str(tmp_path / "workflow_test") + os.makedirs(folder) + + # 1. 设置备注 + result = handler.set_comment(folder, "完整工作流测试") + assert result is True + + # 2. 验证文件存在 + ini_path = os.path.join(folder, "desktop.ini") + assert os.path.exists(ini_path) + + # 3. 读取备注 + comment = handler.get_comment(folder) + assert comment == "完整工作流测试" + + # 4. 验证文件属性(Windows API) + GetFileAttributesW = ctypes.windll.kernel32.GetFileAttributesW # noqa: N806 + FILE_ATTRIBUTE_HIDDEN = 0x02 # noqa: N806 + FILE_ATTRIBUTE_SYSTEM = 0x04 # noqa: N806 + + attrs = GetFileAttributesW(ini_path) + assert attrs != 0xFFFFFFFF + assert attrs & FILE_ATTRIBUTE_HIDDEN + assert attrs & FILE_ATTRIBUTE_SYSTEM + + def test_complete_delete_workflow(self, tmp_path): + """测试完整的删除工作流""" + if sys.platform != "win32": + pytest.skip("Windows only test") + + handler = FolderCommentHandler() + folder = str(tmp_path / "delete_test") + os.makedirs(folder) + + # 1. 先添加备注 + handler.set_comment(folder, "待删除的备注") + + # 2. 验证备注存在 + assert handler.get_comment(folder) == "待删除的备注" + + # 3. 删除备注 + result = handler.delete_comment(folder) + assert result is True + + # 4. 验证备注已删除 + comment = handler.get_comment(folder) + assert comment is None + + def test_update_existing_comment(self, tmp_path): + """测试更新已有备注""" + if sys.platform != "win32": + pytest.skip("Windows only test") + + handler = FolderCommentHandler() + folder = str(tmp_path / "update_test") + os.makedirs(folder) + + # 1. 设置初始备注 + handler.set_comment(folder, "原始备注") + assert handler.get_comment(folder) == "原始备注" + + # 2. 更新备注 + result = handler.set_comment(folder, "更新后的备注") + assert result is True + + # 3. 验证更新 + comment = handler.get_comment(folder) + assert comment == "更新后的备注" + + # 验证文件只有一个 InfoTip + ini_path = os.path.join(folder, "desktop.ini") + with open(ini_path, encoding="utf-16") as f: + content = f.read() + # 计算出现次数 + count = content.count("InfoTip=") + assert count == 1, "应该只有一个 InfoTip 行" + + def test_multiple_folders(self, tmp_path): + """测试多个文件夹""" + if sys.platform != "win32": + pytest.skip("Windows only test") + + handler = FolderCommentHandler() + + folders = [] + for i in range(5): + folder = str(tmp_path / f"folder_{i}") + os.makedirs(folder) + folders.append(folder) + + # 为所有文件夹设置备注 + for i, folder in enumerate(folders): + result = handler.set_comment(folder, f"备注 {i}") + assert result is True + + # 验证所有备注 + for i, folder in enumerate(folders): + comment = handler.get_comment(folder) + assert comment == f"备注 {i}" + + def test_preserve_other_settings(self, tmp_path): + """测试保留其他 desktop.ini 设置""" + if sys.platform != "win32": + pytest.skip("Windows only test") + + import codecs + + folder = str(tmp_path / "preserve_test") + os.makedirs(folder) + + # 创建包含 IconResource 的 desktop.ini + ini_path = os.path.join(folder, "desktop.ini") + content = "[.ShellClassInfo]\r\nIconResource=C:\\icon.ico,0\r\n" + with codecs.open(ini_path, "w", encoding="utf-16") as f: + f.write(content) + + # 添加 InfoTip + handler = FolderCommentHandler() + handler.set_comment(folder, "备注") + + # 验证 IconResource 仍然存在 + with codecs.open(ini_path, "r", encoding="utf-16") as f: + new_content = f.read() + + assert "InfoTip=备注" in new_content + assert "IconResource" in new_content + + def test_empty_comment_removal(self, tmp_path): + """测试删除备注后保留文件(如果有其他设置)""" + if sys.platform != "win32": + pytest.skip("Windows only test") + + import codecs + + folder = str(tmp_path / "empty_removal") + os.makedirs(folder) + + # 创建包含 InfoTip 和 IconResource 的文件 + ini_path = os.path.join(folder, "desktop.ini") + content = "[.ShellClassInfo]\r\nInfoTip=备注\r\nIconResource=C:\\icon.ico,0\r\n" + with codecs.open(ini_path, "w", encoding="utf-16") as f: + f.write(content) + + # 删除备注 + handler = FolderCommentHandler() + handler.delete_comment(folder) + + # 验证文件仍存在(因为有 IconResource) + assert os.path.exists(ini_path) + + # 验证 InfoTip 已删除 + comment = handler.get_comment(folder) + assert comment is None + + def test_special_characters_in_comment(self, tmp_path): + """测试备注中的特殊字符""" + if sys.platform != "win32": + pytest.skip("Windows only test") + + handler = FolderCommentHandler() + folder = str(tmp_path / "special") + os.makedirs(folder) + + special_comments = [ + "换行\n测试", # 包含换行符(会被处理) + "制表符\t测试", + '引号 "测试"', + "单引号 '测试'", + "反斜杠 \\测试", + "斜杠 /测试", + ] + + for comment in special_comments: + result = handler.set_comment(folder, comment) + assert result is True + + read_result = handler.get_comment(folder) + assert read_result == comment + + def test_comment_length_truncation(self, tmp_path): + """测试备注长度截断""" + if sys.platform != "win32": + pytest.skip("Windows only test") + + from remark.core.folder_handler import MAX_COMMENT_LENGTH + + handler = FolderCommentHandler() + folder = str(tmp_path / "truncation") + os.makedirs(folder) + + # 超长备注 + long_comment = "A" * 300 + handler.set_comment(folder, long_comment) + + # 验证被截断 + read_result = handler.get_comment(folder) + assert len(read_result) == MAX_COMMENT_LENGTH + assert read_result == "A" * MAX_COMMENT_LENGTH + + +@pytest.mark.windows +class TestDesktopIniIntegration: + """desktop.ini 集成测试""" + + def test_write_read_cycle(self, tmp_path): + """测试写入读取循环""" + if sys.platform != "win32": + pytest.skip("Windows only test") + + folder = str(tmp_path / "cycle") + os.makedirs(folder) + + test_comments = ["第一次", "第二次", "第三次"] + for comment in test_comments: + DesktopIniHandler.write_info_tip(folder, comment) + read = DesktopIniHandler.read_info_tip(folder) + assert read == comment + + def test_remove_info_tip(self, tmp_path): + """测试移除 InfoTip""" + if sys.platform != "win32": + pytest.skip("Windows only test") + + folder = str(tmp_path / "remove") + os.makedirs(folder) + + # 写入备注 + DesktopIniHandler.write_info_tip(folder, "待移除") + assert DesktopIniHandler.read_info_tip(folder) == "待移除" + + # 移除备注 + result = DesktopIniHandler.remove_info_tip(folder) + assert result is True + + # 验证已移除 + assert DesktopIniHandler.read_info_tip(folder) is None + + def test_file_attributes_workflow(self, tmp_path): + """测试文件属性工作流""" + if sys.platform != "win32": + pytest.skip("Windows only test") + + folder = str(tmp_path / "attributes") + os.makedirs(folder) + + DesktopIniHandler.write_info_tip(folder, "属性测试") + + ini_path = os.path.join(folder, "desktop.ini") + + # 测试清除属性 + assert DesktopIniHandler.clear_file_attributes(ini_path) is True + + # 测试设置隐藏系统属性 + assert DesktopIniHandler.set_file_hidden_system_attributes(ini_path) is True + + # 验证属性 + GetFileAttributesW = ctypes.windll.kernel32.GetFileAttributesW # noqa: N806 + FILE_ATTRIBUTE_HIDDEN = 0x02 # noqa: N806 + FILE_ATTRIBUTE_SYSTEM = 0x04 # noqa: N806 + + attrs = GetFileAttributesW(ini_path) + assert attrs & FILE_ATTRIBUTE_HIDDEN + assert attrs & FILE_ATTRIBUTE_SYSTEM + + def test_folder_readonly_workflow(self, tmp_path): + """测试文件夹只读属性工作流""" + if sys.platform != "win32": + pytest.skip("Windows only test") + + folder = str(tmp_path / "readonly") + os.makedirs(folder) + + # 设置只读属性 + result = DesktopIniHandler.set_folder_system_attributes(folder) + assert result is True + + # 验证只读属性 + FILE_ATTRIBUTE_READONLY = 0x01 # noqa: N806 + GetFileAttributesW = ctypes.windll.kernel32.GetFileAttributesW # noqa: N806 + + attrs = GetFileAttributesW(folder) + assert attrs != 0xFFFFFFFF + assert attrs & FILE_ATTRIBUTE_READONLY + + # 再次设置(应该跳过,已经设置) + result2 = DesktopIniHandler.set_folder_system_attributes(folder) + assert result2 is True