├── icon.ico
├── icon.png
├── .gitattributes
├── requirements.txt
├── QPhotoRenamer.ini
├── README.md
├── .github
└── workflows
│ └── python-app.yml
├── LICENSE
└── QphotoRenamer.py
/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Qwejay/QphotoRenamer/HEAD/icon.ico
--------------------------------------------------------------------------------
/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Qwejay/QphotoRenamer/HEAD/icon.png
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | ttkbootstrap>=1.10.1
2 | tkinterdnd2>=0.3.0
3 | Pillow>=10.0.0
4 | exifread>=3.0.0
5 | piexif>=1.1.3
6 | pillow-heif>=0.11.0
7 |
--------------------------------------------------------------------------------
/QPhotoRenamer.ini:
--------------------------------------------------------------------------------
1 | [General]
2 | language = 简体中文
3 | prefix =
4 | suffix =
5 | skip_extensions =
6 | template = imgs-{date}_{time}
7 |
8 | [Date]
9 | date_basis = 拍摄日期
10 | alternate_date_basis = 修改日期
11 |
12 | [FileHandling]
13 | fast_add_mode = false
14 | fast_add_threshold = 10
15 | name_conflict = add_suffix
16 | suffix_option = _001
17 | auto_scroll = true
18 | show_errors_only = false
19 |
20 | [UI]
21 | window_width = 850
22 | window_height = 700
23 |
24 | [Cache]
25 | exif_cache_size = 1000
26 | status_cache_size = 1000
27 | file_hash_cache_size = 1000
28 |
29 | [Template]
30 | default_template = {date}_{time}
31 | template1 = img-{date}_{time}
32 | template2 = {date}_{time}
33 | template3 = {date}_{time}_{camera}_{lens}_{iso}_{focal}_{aperture}
34 | template4 = {date}_{time}_{camera}
35 | template5 = {camera}_{lens}_{iso}_{focal}_{aperture}
36 | template6 = {date}_{time}_{camera}_{lens}_{iso}_{focal}_{aperture}_{shutter}
37 |
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # QphotoRenamer
2 |
3 | QphotoRenamer 是一个简单易用的文件与照片批量重命名工具,支持根据拍摄日期、修改日期或创建日期重命名文件。它专为摄影爱好者和需要管理大量照片的用户设计,帮助您轻松整理各种文件,特别是照片和视频文件。
4 |
5 |
6 |
7 |
8 |
9 | ## 🌟 主要功能
10 |
11 | - **拖放支持**:直接拖拽文件或文件夹到程序界面
12 | - **多格式支持**:处理常见图片格式(JPG、PNG、HEIC等)和视频文件
13 | - **EXIF数据利用**:自动读取照片EXIF信息用于重命名
14 | - **自定义命名格式**:支持多种日期格式和变量组合
15 | - **双语界面**:支持简体中文和英文
16 | - **高效批处理**:优化的多线程处理,支持快速添加模式
17 | - **智能文件名冲突处理**:自动添加后缀或保留原文件名
18 | - **文件信息查看**:查看照片的EXIF信息和详细信息
19 |
20 | ## 📋 版本历史
21 |
22 | ### 版本 2.2 (2025-03-31)
23 | - 代码优化,将全局变量重构为类变量,减少全局状态带来的问题
24 | - 添加类型注解,提高代码可读性和可维护性
25 | - 修复停止按钮无法停止操作的问题
26 |
27 | ### 版本 2.0 (2024-12-29)
28 | - 新增快速添加模式,文件数量超过阈值时跳过文件状态的读取,提升加载速度
29 | - 新增文件名冲突处理选项,支持"增加后缀"或"保留原文件名"
30 | - 优化多线程处理功能,提升重命名效率,支持中途停止
31 | - 新增文件总数显示,状态栏实时更新文件数量
32 | - 新增文件处理队列,支持批量添加文件,后台逐步处理
33 | - 优化EXIF信息缓存,减少重复读取,提升性能
34 | - 优化状态栏显示,实时反馈文件加载状态
35 | - 修复文件重复添加、EXIF读取失败、文件名冲突处理等问题
36 | - 修复排除拓展名需要加"."才生效的问题
37 |
38 | [查看所有版本历史](https://github.com/Qwejay/QphotoRenamer/releases)
39 |
40 | ## 🛠️ 开发构建
41 |
42 | ### PyInstaller 打包
43 |
44 | ```bash
45 | pip install pyinstaller
46 | pyinstaller --onefile --windowed --icon=icon.ico --add-data "QPhotoRenamer.ini;." --add-data "icon.ico;." --add-data "C:\Path\To\Python\Lib\site-packages\tkinterdnd2\tkdnd;tkdnd" --add-data "C:\Path\To\Python\Lib\site-packages\tkinterdnd2;tkinterdnd2" --name QPhotoRenamer QphotoRenamer.py
47 | ```
48 |
49 | ### Nuitka 打包
50 |
51 | ```bash
52 | pip install nuitka
53 | nuitka --standalone --onefile --windows-console-mode=disable --enable-plugin=tk-inter --include-package=exifread --include-package=piexif --include-package=pillow_heif --include-package=ttkbootstrap --include-package=tkinterdnd2 --include-data-file=QphotoRenamer.ini=QphotoRenamer.ini --include-data-file=icon.ico=icon.ico --windows-icon-from-ico=icon.ico QphotoRenamer.py
54 | ```
55 |
56 | ## 📄 许可证
57 |
58 | MIT License © QwejayHuang
59 |
60 | ## 🌟 致谢
61 |
62 | 感谢所有使用和支持QphotoRenamer的用户,您的反馈和建议是我们不断改进的动力!
63 |
64 | ## 🔗 相关链接
65 |
66 | - [GitHub仓库](https://github.com/Qwejay/QphotoRenamer)
67 | - [问题反馈](https://github.com/Qwejay/QphotoRenamer/issues)
68 | - [最新版本](https://github.com/Qwejay/QphotoRenamer/releases)
69 |
70 | ## 功能特性
71 |
72 | - 自动根据照片的拍摄日期和视频文件的媒体创建日期对文件重命名
73 | - 非媒体文件支持通过文件的修改日期和创建日期重命名
74 | - 自定义重命名格式、可添加命名前缀和后缀
75 | - 程序美观简洁易用
76 |
77 | 
78 | 
79 |
80 | ## 下载
81 | [查看所有版本](https://github.com/Qwejay/QphotoRenamer/releases)
82 |
83 |
84 | 你可以使用以下命令安装这些依赖项:
85 |
86 | ```bash
87 | pip install exifread piexif pillow_heif ttkbootstrap tkinterdnd2
88 |
89 | pyinstaller打包参数:
90 | 安装打包工具:pip install pyinstaller
91 | 打包:
92 | pyinstaller --onefile --windowed --icon=logo.ico --add-data "QphotoRenamer.ini;." --add-data icon.ico;." --add-data "tkdnd;tkdnd" QphotoRenamer.py
93 | pyinstaller --onefile --windowed --icon=icon.ico --add-data "QPhotoRenamer.ini;." --add-data "icon.ico;." --add-data "C:\Users\dkm38\AppData\Local\Programs\Python\Python313\Lib\site-packages\tkinterdnd2\tkdnd;tkdnd" --add-data "C:\Users\dkm38\AppData\Local\Programs\Python\Python313\Lib\site-packages\tkinterdnd2;tkinterdnd2" --name QPhotoRenamer QphotoRenamer.py
94 | nuitk打包参数:
95 | 安装打包工具:pip install nuitka
96 | 打包:
97 | nuitka --standalone --onefile --windows-console-mode=disable --enable-plugin=tk-inter --include-package=exifread --include-package=piexif --include-package=pillow_heif --include-package=ttkbootstrap --include-package=tkinterdnd2 --include-data-file=QphotoRenamer.ini=QphotoRenamer.ini --include-data-file=icon.ico=icon.ico --windows-icon-from-ico=icon.ico QphotoRenamer.py
98 |
--------------------------------------------------------------------------------
/.github/workflows/python-app.yml:
--------------------------------------------------------------------------------
1 | name: Build and Package QphotoRenamer
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | build:
13 | runs-on: windows-latest
14 | permissions:
15 | contents: write
16 |
17 | steps:
18 | # 1. 检出代码库
19 | - name: Checkout repository
20 | uses: actions/checkout@v3
21 |
22 | # 2. 设置 Python 环境
23 | - name: Set up Python
24 | uses: actions/setup-python@v4
25 | with:
26 | python-version: '3.12'
27 |
28 | # 3. 安装依赖
29 | - name: Install dependencies
30 | run: |
31 | python -m pip install --upgrade pip
32 | pip install pyinstaller
33 | pip install -r requirements.txt
34 |
35 | # 4. 下载并解压 tkdnd(用于拖放功能)
36 | - name: Download and extract tkdnd
37 | run: |
38 | curl -L -o tkdnd.zip https://github.com/petasis/tkdnd/releases/download/tkdnd-release-test-v2.9.4-rc3/tkdnd-2.9.4-windows-x64.zip
39 | mkdir -p tkdnd
40 | tar -xf tkdnd.zip -C tkdnd
41 |
42 | # 5. 使用 PyInstaller 打包应用
43 | - name: Build with PyInstaller
44 | run: |
45 | pyinstaller --onefile --windowed --icon=icon.ico --add-data "QphotoRenamer.ini;." --add-data "icon.ico;." --add-data "tkdnd/*;tkdnd" QphotoRenamer.py
46 |
47 | # 6. 上传构建产物作为 Artifact
48 | - name: Upload artifact
49 | uses: actions/upload-artifact@v4
50 | with:
51 | name: QphotoRenamer-build
52 | path: dist/QphotoRenamer.exe
53 |
54 | # 7. 获取最新 Release 版本号(修改后的 0.1 递增逻辑)
55 | - name: Get latest release version
56 | id: get_version
57 | uses: actions/github-script@v6
58 | with:
59 | script: |
60 | const response = await github.rest.repos.listReleases({
61 | owner: context.repo.owner,
62 | repo: context.repo.repo,
63 | per_page: 1,
64 | });
65 | let version;
66 | if (response.data.length === 0) {
67 | version = '1.0'; // 初始版本改为 1.0
68 | } else {
69 | const latestTag = response.data[0].tag_name;
70 | // 匹配格式:可选的 v 前缀 + 数字.数字(允许结尾有其他字符但主要取前两部分)
71 | const versionMatch = latestTag.match(/^v?(\d+)\.(\d+)/i);
72 | if (!versionMatch) {
73 | version = '1.0';
74 | } else {
75 | const major = parseInt(versionMatch[1], 10);
76 | const minor = parseInt(versionMatch[2], 10) + 1; // 递增次要版本
77 | version = `${major}.${minor}`;
78 | }
79 | }
80 | // 使用环境文件设置输出
81 | const fs = require('fs');
82 | fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`);
83 |
84 | # 8. 创建 GitHub Release
85 | - name: Create Release
86 | id: create_release
87 | uses: actions/github-script@v6
88 | with:
89 | script: |
90 | const tagName = '${{ steps.get_version.outputs.version }}';
91 | const releaseName = 'QphotoRenamer v' + tagName;
92 | const response = await github.rest.repos.createRelease({
93 | owner: context.repo.owner,
94 | repo: context.repo.repo,
95 | tag_name: tagName,
96 | name: releaseName,
97 | draft: false,
98 | prerelease: false,
99 | });
100 | const fs = require('fs');
101 | fs.appendFileSync(process.env.GITHUB_OUTPUT, `upload_url=${response.data.upload_url}\n`);
102 |
103 | # 9. 上传构建产物到 Release
104 | - name: Upload Release Asset
105 | uses: actions/upload-release-asset@v1
106 | env:
107 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
108 | with:
109 | upload_url: ${{ steps.create_release.outputs.upload_url }}
110 | asset_path: dist/QphotoRenamer.exe
111 | asset_name: QphotoRenamer-v${{ steps.get_version.outputs.version }}.exe
112 | asset_content_type: application/octet-stream
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/QphotoRenamer.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import datetime
4 | from typing import Dict, List, Set, Tuple, Any, Optional, Union, Callable
5 | import exifread
6 | import piexif
7 | import pillow_heif
8 | import ttkbootstrap as ttk
9 | from tkinter import filedialog, Toplevel, Label, Entry, messagebox, Text, WORD, END, INSERT
10 | from tkinterdnd2 import DND_FILES, TkinterDnD
11 | from threading import Thread, Event, Lock
12 | import logging
13 | import re
14 | import subprocess
15 | import webbrowser
16 | from queue import Queue
17 | from concurrent.futures import ThreadPoolExecutor, as_completed
18 | import hashlib
19 | from collections import OrderedDict
20 | from functools import lru_cache
21 | import configparser
22 | import tkinter as tk
23 | import gc
24 | import time
25 | import multiprocessing
26 | import shutil
27 | import queue
28 | import threading
29 | base_path = os.path.dirname(os.path.abspath(__file__))
30 | icon_path = os.path.join(base_path, 'icon.ico')
31 | COMMON_DATE_FORMATS = [
32 | "%Y%m%d_%H%M%S",
33 | "%Y-%m-%d %H:%M:%S",
34 | "%d-%m-%Y %H:%M:%S",
35 | "%Y%m%d",
36 | "%H%M%S",
37 | "%Y-%m-%d",
38 | "%d-%m-%Y"
39 | ]
40 | SUPPORTED_IMAGE_FORMATS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.heic'}
41 | LANGUAGES = {
42 | "简体中文": {
43 | "window_title": "文件与照片批量重命名工具 QphotoRenamer 2.5 —— QwejayHuang",
44 | "description_base": "拖放文件或照片,将按照",
45 | "description_suffix": "重命名文件。若无法获取拍摄日期(或非媒体文件),则使用",
46 | "start_renaming": "开始重命名",
47 | "stop_renaming": "停止重命名",
48 | "settings": "设置",
49 | "clear_list": "清空列表",
50 | "add_files": "添加文件",
51 | "help": "帮助",
52 | "auto_scroll": "自动滚动",
53 | "check_for_updates": "反馈&检查更新",
54 | "ready": "准备就绪",
55 | "rename_pattern": "重命名格式:",
56 | "language": "语言",
57 | "save_settings": "保存设置",
58 | "clear_list": "清空列表",
59 | "add_files": "添加文件",
60 | "help": "帮助",
61 | "auto_scroll": "自动滚动",
62 | "check_for_updates": "反馈&检查更新",
63 | "ready": "准备就绪",
64 | "rename_pattern": "重命名格式:",
65 | "language": "语言",
66 | "save_settings": "保存设置",
67 | "formats_explanation": "常用日期格式示例:\n%Y%m%d_%H%M%S -> 20230729_141530\n%Y-%m-%d %H:%M:%S -> 2023-07-29 14:15:30\n%d-%m-%Y %H:%M:%S -> 29-07-2023 14:15:30\n%Y%m%d -> 20230729\n%H%M%S -> 141530\n%Y-%m-%d -> 2023-07-29\n%d-%m-%Y -> 29-07-2023",
68 | "renaming_in_progress": "正在重命名,请稍候...",
69 | "renaming_stopped": "重命名操作已停止。",
70 | "renaming_success": "成功重命名 {0} 个文件,未重命名 {1} 个文件。",
71 | "renaming_partial": "部分重命名完成:成功 {0} 个,失败 {1} 个。",
72 | "filename": "文件路径",
73 | "status": "命名依据",
74 | "renamed_name": "新名称",
75 | "prepare_rename_by": "{}",
76 | "prepare_rename_keep_name": "准备保留原文件名",
77 | "already_rename_by": "已按 {} 命名",
78 | "ready_to_rename": "待重命名",
79 | "help_text": """使用说明:
80 | 1. 将文件或文件夹拖放到列表中,或点击"添加文件"按钮选择文件。
81 | 2. 点击"开始重命名"按钮,程序将根据设置的日期格式重命名文件。
82 | 3. 若无法获取拍摄日期,程序将根据设置的备用日期(修改日期、创建日期或保留原文件名)进行处理。
83 | 4. 双击列表中的文件名可打开文件。
84 | 5. 右键点击列表中的文件名可移除文件。
85 | 6. 点击"设置"按钮可调整日期格式、前缀、后缀等设置。
86 | 7. 勾选"自动滚动"选项,列表将自动滚动至最新添加的文件。
87 | 8. 点击"清空列表"按钮可清空文件列表。
88 | 9. 点击"停止重命名"按钮可停止当前的重命名操作。
89 | 10. 重命名完成后,已重命名的文件项将显示为绿色,重命名失败的文件项将显示为红色。
90 | 11. 点击文件名可查看文件的EXIF信息。""",
91 | "settings_window_title": "设置",
92 | "prefix": "前缀:",
93 | "suffix": "后缀:",
94 | "skip_extensions": "跳过重命名的文件类型(空格分隔):",
95 | "file_count": "文件总数: {0}",
96 | "fast_add_mode": "启用快速添加模式:",
97 | "name_conflict_prompt": "重命名后文件名冲突时:",
98 | "add_suffix_option": "增加后缀",
99 | "keep_original_option": "保留原文件名",
100 | "other_settings": "其他设置",
101 | "file_handling_settings": "文件处理设置",
102 | "rename_pattern_settings": "重命名格式设置",
103 | "date_bases": [
104 | {"display": "拍摄日期", "value": "拍摄日期"},
105 | {"display": "修改日期", "value": "修改日期"},
106 | {"display": "创建日期", "value": "创建日期"}
107 | ],
108 | "alternate_dates": [
109 | {"display": "修改日期", "value": "修改日期"},
110 | {"display": "创建日期", "value": "创建日期"},
111 | {"display": "保留原文件名", "value": "保留原文件名"}
112 | ],
113 | "suffix_options": ["_001", "-01", "_1"],
114 | "suffix_edit_label": "编辑后缀名称:",
115 | "show_errors_only": "仅显示错误",
116 | "name_conflicts": [
117 | {"display": "增加后缀", "value": "add_suffix"},
118 | {"display": "保留原文件名", "value": "keep_original"}
119 | ],
120 | "date_settings": "日期设置",
121 | "date_basis": "日期基准",
122 | "preferred_date": "首选日期:",
123 | "alternate_date": "备选日期:",
124 | "file_filter": "文件过滤",
125 | "conflict_handling": "冲突处理:",
126 | "suffix_format": "后缀格式:",
127 | "performance_optimization": "性能优化",
128 | "enable_fast_add": "启用快速添加模式(适用于大量文件)",
129 | "file_count_threshold": "文件数量阈值:",
130 | "ui_settings": "界面设置",
131 | "language_settings": "语言设置",
132 | "interface_language": "界面语言:",
133 | "auto_scroll_to_latest": "自动滚动到最新文件",
134 | "about": "关于",
135 | "save_template": "保存",
136 | "delete_template": "删除",
137 | "start_processing_files": "开始处理文件...",
138 | "added_files_to_queue": "已添加 {0} 个文件/目录到队列...",
139 | "start_processing_batch": "开始处理前 {0} 个文件...",
140 | "processing_files": "正在处理文件...(队列中还有{0}个文件待处理)",
141 | "processing_in_progress": "正在处理中...",
142 | "files_ready": "文件已就绪!",
143 | "operation_stopped": "操作已停止",
144 | "drag_hint": "拖入文件,优先使用",
145 | "rename_hint": "重命名,如无拍摄日期则使用",
146 | "rename_end_hint": "重命名。",
147 | "rename_skipped_keep_name": "已跳过(保留原文件名)",
148 | "rename_skipped_no_date": "无拍摄日期",
149 | "rename_skipped_no_name": "无法生成新文件名",
150 | "rename_skipped_exists": "目标文件已存在",
151 | "file_not_found": "文件不存在"
152 | },
153 | "English": {
154 | "window_title": "QphotoRenamer 2.5 —— QwejayHuang",
155 | "description_base": "Drag and drop files or photos to rename them based on ",
156 | "description_suffix": ". If the shooting date is unavailable, use ",
157 | "start_renaming": "Start Renaming",
158 | "stop_renaming": "Stop Renaming",
159 | "settings": "Settings",
160 | "clear_list": "Clear List",
161 | "add_files": "Add Files",
162 | "help": "Help",
163 | "auto_scroll": "Auto Scroll",
164 | "check_for_updates": "CheckUpdates",
165 | "ready": "Ready",
166 | "rename_pattern": "Rename Pattern:",
167 | "language": "Language",
168 | "save_settings": "Save Settings",
169 | "formats_explanation": "Common date format examples:\n%Y%m%d_%H%M%S -> 20230729_141530\n%Y-%m-%d %H:%M:%S -> 2023-07-29 14:15:30\n%d-%m-%Y %H:%M:%S -> 29-07-2023 14:15:30\n%Y%m%d -> 20230729\n%H%M%S -> 141530\n%Y-%m-%d -> 2023-07-29\n%d-%m-%Y -> 29-07-2023",
170 | "renaming_in_progress": "Renaming in progress, please wait...",
171 | "renaming_stopped": "Renaming operation stopped.",
172 | "renaming_success": "Successfully renamed {0} files, {1} files not renamed.",
173 | "renaming_partial": "Partial renaming completed: {0} successful, {1} failed.",
174 | "filename": "File Path",
175 | "status": "Naming Basis",
176 | "renamed_name": "New Name",
177 | "prepare_rename_by": "Prepare to rename by {}",
178 | "prepare_rename_keep_name": "Prepare to keep original filename",
179 | "already_rename_by": "Already named by {}",
180 | "ready_to_rename": "Ready to rename",
181 | "help_text": """Instructions:
182 | 1. Drag and drop files or folders into the list, or click the "Add Files" button to select files.
183 | 2. Click the "Start Renaming" button, and the program will rename files according to the set date format.
184 | 3. If the shooting date cannot be obtained, the program will process according to the set alternate date (modification date, creation date, or keep original filename).
185 | 4. Double-click the filename in the list to open the file.
186 | 5. Right-click the filename in the list to remove the file.
187 | 6. Click the "Settings" button to adjust date format, prefix, suffix, and other settings.
188 | 7. Check the "Auto Scroll" option, and the list will automatically scroll to the latest added file.
189 | 8. Click the "Clear List" button to clear the file list.
190 | 9. Click the "Stop Renaming" button to stop the current renaming operation.
191 | 10. After renaming is complete, successfully renamed file items will be displayed in green, and failed file items will be displayed in red.
192 | 11. Click the filename to view the file's EXIF information.""",
193 | "settings_window_title": "Settings",
194 | "prefix": "Prefix:",
195 | "suffix": "Suffix:",
196 | "skip_extensions": "Skip renaming file types (space separated):",
197 | "file_count": "Total Files: {0}",
198 | "fast_add_mode": "Enable Fast Add Mode:",
199 | "name_conflict_prompt": "When filename conflicts after renaming:",
200 | "add_suffix_option": "Add Suffix",
201 | "keep_original_option": "Keep Original Filename",
202 | "other_settings": "Other Settings",
203 | "file_handling_settings": "File Handling Settings",
204 | "rename_pattern_settings": "Rename Pattern Settings",
205 | "date_bases": [
206 | {"display": "Shooting Date", "value": "Shooting Date"},
207 | {"display": "Modification Date", "value": "Modification Date"},
208 | {"display": "Creation Date", "value": "Creation Date"}
209 | ],
210 | "alternate_dates": [
211 | {"display": "Modification Date", "value": "Modification Date"},
212 | {"display": "Creation Date", "value": "Creation Date"},
213 | {"display": "Keep Original Filename", "value": "Keep Original Filename"}
214 | ],
215 | "suffix_options": ["_001", "-01", "_1"],
216 | "suffix_edit_label": "Edit Suffix Name:",
217 | "show_errors_only": "Show Errors Only",
218 | "name_conflicts": [
219 | {"display": "Add Suffix", "value": "add_suffix"},
220 | {"display": "Keep Original", "value": "keep_original"}
221 | ],
222 | "date_settings": "Date Settings",
223 | "date_basis": "Date Basis",
224 | "preferred_date": "Preferred Date:",
225 | "alternate_date": "Alternate Date:",
226 | "file_filter": "File Filter",
227 | "conflict_handling": "Conflict Handling:",
228 | "suffix_format": "Suffix Format:",
229 | "performance_optimization": "Performance Optimization",
230 | "enable_fast_add": "Enable Fast Add Mode (for large number of files)",
231 | "file_count_threshold": "File Count Threshold:",
232 | "ui_settings": "UI Settings",
233 | "language_settings": "Language Settings",
234 | "interface_language": "Interface Language:",
235 | "auto_scroll_to_latest": "Auto Scroll to Latest File",
236 | "about": "About",
237 | "save_template": "Save",
238 | "delete_template": "Delete",
239 | "start_processing_files": "Start processing files...",
240 | "added_files_to_queue": "{0} files/folders added to queue...",
241 | "start_processing_batch": "Start processing first {0} files...",
242 | "processing_files": "Processing files... ({0} files left in queue)",
243 | "processing_in_progress": "Processing...",
244 | "files_ready": "Files are ready!",
245 | "operation_stopped": "Operation stopped",
246 | "drag_hint": "Rename files using: ",
247 | "rename_hint": "or",
248 | "rename_end_hint": "",
249 | "rename_skipped_keep_name": "Skipped (keep original filename)",
250 | "rename_skipped_no_date": "No shooting date",
251 | "rename_skipped_no_name": "Cannot generate new filename",
252 | "rename_skipped_exists": "Target file already exists",
253 | "file_not_found": "File not found"
254 | }
255 | }
256 | class LRUCache:
257 | def __init__(self, capacity: int):
258 | self.capacity = capacity
259 | self.cache = OrderedDict()
260 | def get(self, key: str) -> Optional[Any]:
261 | if key not in self.cache:
262 | return None
263 | self.cache.move_to_end(key)
264 | return self.cache[key]
265 | def put(self, key: str, value: Any) -> None:
266 | if key in self.cache:
267 | self.cache.move_to_end(key)
268 | self.cache[key] = value
269 | if len(self.cache) > self.capacity:
270 | self.cache.popitem(last=False)
271 | def clear(self) -> None:
272 | """清空缓存"""
273 | self.cache.clear()
274 | class FileInfoCache:
275 | def __init__(self):
276 | self.cache = {}
277 | self.lock = threading.Lock()
278 | def get(self, file_path):
279 | with self.lock:
280 | return self.cache.get(file_path)
281 | def set(self, file_path, info):
282 | with self.lock:
283 | self.cache[file_path] = info
284 | def remove(self, file_path):
285 | with self.lock:
286 | self.cache.pop(file_path, None)
287 | def clear(self):
288 | with self.lock:
289 | self.cache.clear()
290 | class PhotoRenamer:
291 | def __init__(self, root):
292 | self.root = root
293 | self.template_var = ttk.StringVar(value="%Y%m%d_%H%M%S")
294 | self.prefix_var = ttk.StringVar(value="")
295 | self.suffix_var = ttk.StringVar(value="")
296 | self.language_var = ttk.StringVar(value="简体中文")
297 | self.date_basis_var = ttk.StringVar(value="拍摄日期")
298 | self.alternate_date_var = ttk.StringVar(value="修改日期")
299 | self.auto_scroll_var = ttk.BooleanVar(value=True)
300 | self.show_errors_only_var = ttk.BooleanVar(value=False)
301 | self.fast_add_mode_var = ttk.BooleanVar(value=True)
302 | self.fast_add_threshold_var = ttk.IntVar(value=100)
303 | self.date_format = "%Y%m%d_%H%M%S"
304 | self.stop_event = Event()
305 | self.renaming_in_progress = False
306 | self.processed_files = set()
307 | self.unrenamed_files = 0
308 | self.current_renaming_file = None
309 | self.skip_extensions = []
310 | self.exif_cache = LRUCache(1000)
311 | self.status_cache = LRUCache(1000)
312 | self.file_hash_cache = LRUCache(1000)
313 | self.error_cache = {}
314 | self._settings_cache = None
315 | self._update_timer = None
316 | self._pending_updates = set()
317 | self.name_conflict_var = ttk.StringVar(value="add_suffix")
318 | self.suffix_option_var = ttk.StringVar(value="_001")
319 | self.skip_extensions_var = ttk.StringVar(value="")
320 | self.lang = LANGUAGES[self.language_var.get()]
321 | self.file_queue = Queue()
322 | self.processing_thread = None
323 | self.toplevel_windows = []
324 | self.batch_size = 50
325 | self.processing_batch = False
326 | self.last_cleanup_time = time.time()
327 | self.cleanup_interval = 300
328 | self.root.title("文件&照片批量重命名 QphotoRenamer 2.5 —— QwejayHuang")
329 | self.root.geometry("850x600")
330 | self.root.iconbitmap(icon_path)
331 | self.style = ttk.Style('litera')
332 | self.lock = Lock()
333 | self.initialize_ui()
334 | self.load_or_create_settings()
335 | self.remove_invalid_files()
336 | self.root.after(300000, self.cleanup_cache)
337 | self.root.protocol("WM_DELETE_WINDOW", self.on_closing)
338 | self.file_info_cache = FileInfoCache()
339 | self.update_queue = queue.Queue()
340 | self.update_thread = None
341 | self.stop_update_event = threading.Event()
342 | def remove_invalid_files(self):
343 | """自动移除列表中已不存在的文件"""
344 | invalid_items = []
345 | for item in self.files_tree.get_children():
346 | file_path = self.files_tree.item(item)['values'][0]
347 | if not os.path.exists(file_path):
348 | invalid_items.append(item)
349 | for item in invalid_items:
350 | self.files_tree.delete(item)
351 | if invalid_items:
352 | self.update_file_count()
353 | def on_closing(self):
354 | """程序关闭时的清理工作"""
355 | try:
356 | self.stop_all_operations()
357 | try:
358 | self.cleanup_cache()
359 | except Exception as e:
360 | logging.error(f"清理缓存失败: {e}")
361 | for window in self.toplevel_windows:
362 | try:
363 | window.destroy()
364 | except:
365 | pass
366 | self.root.destroy()
367 | except Exception as e:
368 | logging.error(f"关闭程序时出错: {e}")
369 | self.root.destroy()
370 | def load_or_create_settings(self):
371 | """加载或创建设置"""
372 | try:
373 | if not os.path.exists('QphotoRenamer.ini'):
374 | config = configparser.ConfigParser()
375 | config['General'] = {
376 | 'language': '简体中文',
377 | 'prefix': '',
378 | 'suffix': '',
379 | 'skip_extensions': '',
380 | 'auto_scroll': 'True',
381 | 'fast_add_mode': 'False',
382 | 'fast_add_threshold': '100'
383 | }
384 | config['Template'] = {
385 | 'default': '{date}_{time}',
386 | 'template1': '{date}_{time}_{camera}',
387 | 'template2': '{date}_{time}_{camera}_{lens}',
388 | 'template3': '{date}_{time}_{camera}_{lens}_{iso}',
389 | 'template4': '{date}_{time}_{camera}_{lens}_{iso}_{focal}',
390 | 'template5': '{date}_{time}_{camera}_{lens}_{iso}_{focal}_{aperture}'
391 | }
392 | with open('QphotoRenamer.ini', 'w', encoding='utf-8') as f:
393 | config.write(f)
394 | self.load_settings()
395 | except Exception as e:
396 | logging.error(f"加载或创建设置失败: {e}")
397 | self.template_var.set("{date}_{time}")
398 | self.language_var.set("简体中文")
399 | self.prefix_var.set("")
400 | self.suffix_var.set("")
401 | self.skip_extensions_var.set("")
402 | self.auto_scroll_var.set(True)
403 | self.fast_add_mode_var.set(False)
404 | self.fast_add_threshold_var.set(100)
405 | self.update_status_bar("ready")
406 | def load_settings(self):
407 | """加载设置"""
408 | try:
409 | config = configparser.ConfigParser()
410 | config.read('QphotoRenamer.ini', encoding='utf-8')
411 | if not config.has_section('General'):
412 | config.add_section('General')
413 | if not config.has_section('Template'):
414 | config.add_section('Template')
415 | self.language_var.set(config.get('General', 'language', fallback='简体中文'))
416 | self.prefix_var.set(config.get('General', 'prefix', fallback=''))
417 | self.suffix_var.set(config.get('General', 'suffix', fallback=''))
418 | self.skip_extensions_var.set(config.get('General', 'skip_extensions', fallback=''))
419 | self.auto_scroll_var.set(config.getboolean('General', 'auto_scroll', fallback=True))
420 | self.fast_add_mode_var.set(config.getboolean('General', 'fast_add_mode', fallback=False))
421 | self.fast_add_threshold_var.set(config.getint('General', 'fast_add_threshold', fallback=100))
422 | default_template = config.get('Template', 'default', fallback='{date}_{time}')
423 | self.template_var.set(default_template)
424 | self.templates = []
425 | for i in range(1, 6):
426 | template = config.get('Template', f'template{i}', fallback='')
427 | if template:
428 | self.templates.append(template)
429 | if not self.templates:
430 | self.templates = [
431 | '{date}_{time}',
432 | '{date}_{time}_{camera}',
433 | '{date}_{time}_{camera}_{lens}',
434 | '{date}_{time}_{camera}_{lens}_{iso}',
435 | '{date}_{time}_{camera}_{lens}_{iso}_{focal}'
436 | ]
437 | self.set_language(self.language_var.get())
438 | except Exception as e:
439 | logging.error(f"加载设置失败: {e}")
440 | self.handle_error(e, "加载设置")
441 | def get_file_hash(self, file_path: str) -> str:
442 | """计算文件的MD5哈希值"""
443 | try:
444 | file_path = os.path.abspath(file_path)
445 | file_path = os.path.normpath(file_path)
446 | if not os.path.exists(file_path):
447 | logging.error(f"文件不存在: {file_path}")
448 | return ""
449 | if not os.access(file_path, os.R_OK):
450 | logging.error(f"无法访问文件: {file_path},权限不足")
451 | return ""
452 | with open(file_path, 'rb') as f:
453 | data = f.read(8192)
454 | return hashlib.md5(data).hexdigest()
455 | except PermissionError as e:
456 | logging.error(f"权限错误: {file_path}, 错误: {e}")
457 | return ""
458 | except OSError as e:
459 | logging.error(f"系统错误: {file_path}, 错误: {e}")
460 | return ""
461 | except Exception as e:
462 | logging.error(f"计算文件哈希值时出错: {file_path}, 错误: {e}")
463 | return ""
464 | def cleanup_cache(self):
465 | """清理过期缓存"""
466 | try:
467 | current_time = time.time()
468 | expired_errors = [
469 | error_id for error_id, data in self.error_cache.items()
470 | if (current_time - data['last_time']) > 3600
471 | ]
472 | for error_id in expired_errors:
473 | del self.error_cache[error_id]
474 | if self._settings_cache and (current_time - self._settings_cache['timestamp']) > 300:
475 | self._settings_cache = None
476 | if hasattr(self, 'file_hash_cache'):
477 | self.file_hash_cache = LRUCache(1000)
478 | if hasattr(self, 'exif_cache'):
479 | self.exif_cache = LRUCache(1000)
480 | if hasattr(self, 'status_cache'):
481 | self.status_cache = LRUCache(1000)
482 | gc.collect()
483 | except Exception as e:
484 | logging.error(f"清理缓存时出错: {e}")
485 | finally:
486 | self.root.after(300000, self.cleanup_cache)
487 | @lru_cache(maxsize=1000)
488 | def get_exif_data(self, file_path: str) -> Optional[Dict]:
489 | try:
490 | current_time = time.time()
491 | if current_time - self.last_cleanup_time > self.cleanup_interval:
492 | self.cleanup_cache()
493 | self.last_cleanup_time = current_time
494 | if file_path.lower().endswith('.heic'):
495 | return self.get_heic_data(file_path)
496 | with open(file_path, 'rb') as f:
497 | tags = exifread.process_file(f, details=False)
498 | if not tags:
499 | return None
500 | exif_data = {}
501 | date_time_original = None
502 | if 'EXIF DateTimeOriginal' in tags:
503 | date_time_original = str(tags['EXIF DateTimeOriginal'])
504 | elif 'Image DateTime' in tags:
505 | date_time_original = str(tags['Image DateTime'])
506 | if date_time_original:
507 | try:
508 | formats = [
509 | '%Y:%m:%d %H:%M:%S',
510 | '%Y:%m:%d %H:%M',
511 | '%Y/%m/%d %H:%M:%S',
512 | '%Y/%m/%d %H:%M'
513 | ]
514 | for fmt in formats:
515 | try:
516 | exif_data['DateTimeOriginalParsed'] = datetime.datetime.strptime(date_time_original, fmt)
517 | break
518 | except ValueError:
519 | continue
520 | except Exception as e:
521 | logging.error(f"解析EXIF日期格式失败: {file_path}, 原始日期字符串: {date_time_original}, 错误: {e}")
522 | for tag, value in tags.items():
523 | if tag.startswith('EXIF'):
524 | exif_data[tag] = str(value)
525 | elif tag == 'Image Model':
526 | exif_data['Model'] = str(value)
527 | elif tag == 'EXIF LensModel':
528 | exif_data['LensModel'] = str(value)
529 | elif tag == 'EXIF ISOSpeedRatings':
530 | exif_data['ISOSpeedRatings'] = str(value)
531 | elif tag == 'EXIF FNumber':
532 | exif_data['FNumber'] = str(value)
533 | elif tag == 'EXIF ExposureTime':
534 | exif_data['ExposureTime'] = str(value)
535 | elif tag == 'EXIF ExifImageWidth':
536 | exif_data['ImageWidth'] = str(value)
537 | elif tag == 'EXIF ExifImageLength':
538 | exif_data['ImageHeight'] = str(value)
539 | return exif_data
540 | except Exception as e:
541 | self.handle_error(e, f"读取EXIF数据: {file_path}")
542 | return None
543 | def handle_error(self, error: Exception, context: str):
544 | """处理错误,显示在状态栏"""
545 | error_msg = ""
546 | if isinstance(error, PermissionError):
547 | error_msg = f"权限错误: 无法访问文件: {context}\n请检查文件权限。"
548 | elif isinstance(error, FileNotFoundError):
549 | error_msg = f"文件未找到: 无法找到文件: {context}"
550 | elif isinstance(error, OSError):
551 | error_msg = f"系统错误: 操作失败: {context}\n{str(error)}"
552 | elif isinstance(error, ValueError):
553 | error_msg = f"数据错误: 无效的数据: {context}\n{str(error)}"
554 | else:
555 | error_msg = f"发生未知错误: {context}\n{str(error)}"
556 | logging.error(error_msg)
557 | self.update_status_bar(error_msg)
558 | def initialize_ui(self):
559 | main_frame = ttk.Frame(self.root)
560 | main_frame.pack(fill=ttk.BOTH, expand=True, padx=10, pady=10)
561 | date_basis_frame = ttk.Frame(main_frame)
562 | date_basis_frame.pack(fill=ttk.X, pady=5)
563 | self.drag_hint_label = ttk.Label(date_basis_frame, text=self.lang["drag_hint"])
564 | self.drag_hint_label.pack(side=ttk.LEFT)
565 | self.date_basis_var = ttk.StringVar(value=self.lang["date_bases"][0]["display"])
566 | self.date_basis_combobox = ttk.Combobox(
567 | date_basis_frame,
568 | textvariable=self.date_basis_var,
569 | values=[item["display"] for item in self.lang["date_bases"]],
570 | state="readonly", width=15)
571 | self.date_basis_combobox.pack(side=ttk.LEFT, padx=5)
572 | self.date_basis_combobox.bind("<>", self.on_date_basis_selected)
573 | self.rename_hint_label = ttk.Label(date_basis_frame, text=self.lang["rename_hint"])
574 | self.rename_hint_label.pack(side=ttk.LEFT, padx=5)
575 | self.alternate_date_var = ttk.StringVar(value=self.lang["alternate_dates"][0]["display"])
576 | self.alternate_date_combobox = ttk.Combobox(
577 | date_basis_frame,
578 | textvariable=self.alternate_date_var,
579 | values=[item["display"] for item in self.lang["alternate_dates"]],
580 | state="readonly", width=15)
581 | self.alternate_date_combobox.pack(side=ttk.LEFT, padx=5)
582 | self.alternate_date_combobox.bind("<>", self.on_alternate_date_selected)
583 | self.rename_end_hint_label = ttk.Label(date_basis_frame, text=self.lang["rename_end_hint"])
584 | self.rename_end_hint_label.pack(side=ttk.LEFT, padx=5)
585 | columns = ('filename', 'renamed_name', 'status')
586 | tree_frame = ttk.Frame(main_frame)
587 | tree_frame.pack(fill=ttk.BOTH, expand=True, padx=10, pady=10)
588 | self.files_tree = ttk.Treeview(tree_frame, columns=columns, show='headings')
589 | self.files_tree.heading('filename', text=self.lang["filename"], anchor='w')
590 | self.files_tree.heading('renamed_name', text=self.lang["renamed_name"], anchor='w')
591 | self.files_tree.heading('status', text="命名依据", anchor='w')
592 | self.files_tree.column('filename', anchor='w', stretch=True, width=400)
593 | self.files_tree.column('renamed_name', anchor='w', stretch=True, width=200)
594 | self.files_tree.column('status', anchor='w', width=100, minwidth=100)
595 | vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.files_tree.yview)
596 | self.files_tree.configure(yscrollcommand=vsb.set)
597 | self.files_tree.pack(side=ttk.LEFT, fill=ttk.BOTH, expand=True)
598 | vsb.pack(side=ttk.RIGHT, fill=ttk.Y)
599 | self.files_tree.drop_target_register(DND_FILES)
600 | self.files_tree.dnd_bind('<>', lambda e: self.on_drop(e))
601 | self.files_tree.bind('', self.remove_file)
602 | self.files_tree.bind('', self.open_file)
603 | self.files_tree.bind('', self.show_exif_info)
604 | self.files_tree.bind('', self.show_omitted_info)
605 | self.progress_var = ttk.DoubleVar()
606 | progress = ttk.Progressbar(main_frame, variable=self.progress_var, maximum=100)
607 | progress.pack(fill=ttk.X, padx=10, pady=10)
608 | button_frame = ttk.Frame(main_frame)
609 | button_frame.pack(fill=ttk.X, padx=10, pady=10)
610 | self.start_button = ttk.Button(button_frame, text=self.lang["start_renaming"], command=lambda: self.rename_photos())
611 | self.start_button.pack(side=ttk.LEFT, padx=5)
612 | self.start_button.text_key = "start_renaming"
613 | self.stop_button = ttk.Button(button_frame, text=self.lang["stop_renaming"], command=self.stop_all_operations, bootstyle="danger")
614 | self.stop_button.pack(side=ttk.LEFT, padx=5)
615 | self.stop_button.text_key = "stop_renaming"
616 | self.settings_button = ttk.Button(button_frame, text=self.lang["settings"], command=self.open_settings)
617 | self.settings_button.pack(side=ttk.LEFT, padx=5)
618 | self.settings_button.text_key = "settings"
619 | self.clear_button = ttk.Button(button_frame, text=self.lang["clear_list"], command=lambda: self.clear_file_list())
620 | self.clear_button.pack(side=ttk.LEFT, padx=5)
621 | self.clear_button.text_key = "clear_list"
622 | self.select_files_button = ttk.Button(button_frame, text=self.lang["add_files"], command=lambda: self.select_files())
623 | self.select_files_button.pack(side=ttk.LEFT, padx=5)
624 | self.select_files_button.text_key = "add_files"
625 | self.help_button = ttk.Button(button_frame, text=self.lang["help"], command=self.show_help)
626 | self.help_button.pack(side=ttk.LEFT, padx=5)
627 | self.help_button.text_key = "help"
628 | self.auto_scroll_checkbox = ttk.Checkbutton(button_frame, text=self.lang["auto_scroll"], variable=self.auto_scroll_var)
629 | self.auto_scroll_checkbox.pack(side=ttk.LEFT, padx=5)
630 | self.auto_scroll_checkbox.text_key = "auto_scroll"
631 | self.show_errors_only_checkbox = ttk.Checkbutton(button_frame, text=self.lang["show_errors_only"], variable=self.show_errors_only_var)
632 | self.show_errors_only_checkbox.pack(side=ttk.LEFT, padx=5)
633 | self.show_errors_only_checkbox.text_key = "show_errors_only"
634 | self.update_link = ttk.Label(button_frame, text=self.lang.get("check_for_updates", "反馈&检查更新"), foreground="blue", cursor="hand2")
635 | self.update_link.pack(side=ttk.RIGHT, padx=5)
636 | self.update_link.bind("", lambda e: self.open_update_link())
637 | self.update_link.text_key = "check_for_updates"
638 | self.status_bar = ttk.Frame(self.root)
639 | self.status_bar.pack(side=ttk.BOTTOM, fill=ttk.X)
640 | self.status_label = ttk.Label(self.status_bar, text=self.lang["ready"], anchor=ttk.W)
641 | self.status_label.pack(side=ttk.LEFT, fill=ttk.X, expand=True)
642 | self.status_label.text_key = "ready"
643 | self.file_count_label = ttk.Label(self.status_bar, text="文件总数: 0", anchor=ttk.E)
644 | self.file_count_label.pack(side=ttk.RIGHT, padx=10)
645 | self.file_count_label.text_key = "file_count"
646 | def on_date_basis_selected(self, event):
647 | """当日期基准选择改变时,更新文件列表"""
648 | try:
649 | date_basis = self.date_basis_var.get()
650 | if date_basis in ["创建日期", "修改日期"]:
651 | self.alternate_date_combobox.configure(state="disabled")
652 | self.alternate_date_var.set("保留原文件名")
653 | else:
654 | self.alternate_date_combobox.configure(state="readonly")
655 | if hasattr(self, 'status_cache'):
656 | self.status_cache.clear()
657 | if hasattr(self, 'file_hash_cache'):
658 | self.file_hash_cache.clear()
659 | for item in self.files_tree.get_children():
660 | try:
661 | values = self.files_tree.item(item)['values']
662 | if not values or len(values) < 3:
663 | continue
664 | file_path = str(values[0])
665 | if not file_path:
666 | continue
667 | if not os.path.exists(file_path):
668 | new_name = str(values[1]) if values[1] else ""
669 | if new_name and new_name != self.lang["ready_to_rename"]:
670 | directory = os.path.dirname(file_path)
671 | new_path = os.path.join(directory, new_name)
672 | if os.path.exists(new_path):
673 | self.files_tree.item(item, values=(new_path, new_name, values[2]))
674 | file_path = new_path
675 | else:
676 | self.files_tree.set(item, 'status', self.lang["file_not_found"])
677 | continue
678 | exif_data = self.get_exif_data(file_path)
679 | has_shooting_date = exif_data and 'DateTimeOriginalParsed' in exif_data
680 | if date_basis == "拍摄日期":
681 | if has_shooting_date:
682 | status = self.lang["prepare_rename_by"].format("拍摄日期")
683 | else:
684 | alternate = self.alternate_date_var.get()
685 | if alternate == "保留原文件名":
686 | status = self.lang["prepare_rename_keep_name"]
687 | else:
688 | status = self.lang["prepare_rename_by"].format(alternate)
689 | else:
690 | status = self.lang["prepare_rename_by"].format(date_basis)
691 | self.files_tree.set(item, 'status', status)
692 | new_name = self.generate_new_name(file_path, exif_data)
693 | if new_name:
694 | self.files_tree.set(item, 'renamed_name', new_name)
695 | except Exception as e:
696 | logging.error(f"处理文件项时出错: {e}")
697 | continue
698 | self.update_renamed_name_column()
699 | self.root.update_idletasks()
700 | except Exception as e:
701 | logging.error(f"更新日期基准选择时出错: {e}")
702 | self.handle_error(e, "更新日期基准选择")
703 | def on_alternate_date_selected(self, event):
704 | """当备选日期选择改变时,更新文件列表"""
705 | try:
706 | if hasattr(self, 'status_cache'):
707 | self.status_cache.clear()
708 | if hasattr(self, 'file_hash_cache'):
709 | self.file_hash_cache.clear()
710 | date_basis = self.date_basis_var.get()
711 | alternate = self.alternate_date_var.get()
712 | for item in self.files_tree.get_children():
713 | try:
714 | values = self.files_tree.item(item)['values']
715 | if not values or len(values) < 3:
716 | continue
717 | file_path = str(values[0])
718 | if not file_path:
719 | continue
720 | if not os.path.exists(file_path):
721 | new_name = str(values[1]) if values[1] else ""
722 | if new_name and new_name != self.lang["ready_to_rename"]:
723 | directory = os.path.dirname(file_path)
724 | new_path = os.path.join(directory, new_name)
725 | if os.path.exists(new_path):
726 | self.files_tree.item(item, values=(new_path, new_name, values[2]))
727 | file_path = new_path
728 | else:
729 | self.files_tree.set(item, 'status', self.lang["file_not_found"])
730 | continue
731 | exif_data = self.get_exif_data(file_path)
732 | has_shooting_date = exif_data and 'DateTimeOriginalParsed' in exif_data
733 | if date_basis == "拍摄日期":
734 | if has_shooting_date:
735 | status = self.lang["prepare_rename_by"].format("拍摄日期")
736 | else:
737 | if alternate == "保留原文件名":
738 | status = self.lang["prepare_rename_keep_name"]
739 | else:
740 | status = self.lang["prepare_rename_by"].format(alternate)
741 | else:
742 | status = self.lang["prepare_rename_by"].format(date_basis)
743 | self.files_tree.set(item, 'status', status)
744 | new_name = self.generate_new_name(file_path, exif_data)
745 | if new_name:
746 | self.files_tree.set(item, 'renamed_name', new_name)
747 | except Exception as e:
748 | logging.error(f"处理文件项时出错: {e}")
749 | continue
750 | self.update_renamed_name_column()
751 | self.root.update_idletasks()
752 | except Exception as e:
753 | logging.error(f"更新备选日期选择时出错: {e}")
754 | self.handle_error(e, "更新备选日期选择")
755 | def on_template_selected(self, event=None):
756 | """当选择模板时更新编辑器内容"""
757 | try:
758 | selected_template = self.template_combobox.get()
759 | current_template = self.template_text.get("1.0", tk.END).strip()
760 | if selected_template != "(新模板)" and selected_template != current_template:
761 | if self.is_modified:
762 | if messagebox.askyesno("未保存的修改", "当前模板有未保存的修改,是否保存?"):
763 | if "(新模板)" in self.template_combobox['values'] and self.template_combobox.get() == "(新模板)":
764 | if current_template and current_template not in self.templates:
765 | self.templates.append(current_template)
766 | self.update_status_bar("新模板已保存")
767 | self.save_current_template()
768 | if selected_template in self.templates:
769 | self.template_text.delete("1.0", tk.END)
770 | self.template_text.insert("1.0", selected_template)
771 | self.default_template = selected_template
772 | if self.template_var:
773 | self.template_var.set(selected_template)
774 | self.is_modified = False
775 | self.last_saved_content = selected_template
776 | self.update_preview()
777 | if self.main_app and hasattr(self.main_app, 'update_renamed_name_column'):
778 | self.main_app.update_renamed_name_column()
779 | self.update_status_bar(f"已加载模板: {selected_template}")
780 | except Exception as e:
781 | error_msg = f"加载模板失败: {str(e)}"
782 | logging.error(error_msg)
783 | self.update_status_bar(error_msg)
784 | def generate_new_name(self, file_path, exif_data, add_suffix=True):
785 | try:
786 | file_path = os.path.abspath(os.path.normpath(file_path))
787 | directory = os.path.dirname(file_path)
788 | ext = os.path.splitext(file_path)[1].lower()
789 | template = self.template_var.get()
790 | if not template:
791 | try:
792 | if os.path.exists('QphotoRenamer.ini'):
793 | config = configparser.ConfigParser()
794 | config.read('QphotoRenamer.ini', encoding='utf-8')
795 | if config.has_option('Template', 'default_template'):
796 | template = config.get('Template', 'default_template')
797 | except Exception as e:
798 | logging.error(f"加载默认模板失败: {e}")
799 | if not template:
800 | template = "{date}_{time}"
801 | date_basis = self.date_basis_var.get()
802 | date_obj = None
803 | if date_basis == "拍摄日期":
804 | if exif_data and 'DateTimeOriginalParsed' in exif_data:
805 | date_obj = exif_data['DateTimeOriginalParsed']
806 | date_obj = date_obj.replace(microsecond=0)
807 | else:
808 | alternate = self.alternate_date_var.get()
809 | if alternate == "保留原文件名":
810 | return os.path.basename(file_path)
811 | elif alternate == "修改日期":
812 | date_obj = self.get_cached_or_real_modification_date(file_path)
813 | if not date_obj:
814 | date_obj = self.get_cached_or_real_creation_date(file_path)
815 | elif alternate == "创建日期":
816 | date_obj = self.get_cached_or_real_creation_date(file_path)
817 | if not date_obj:
818 | date_obj = self.get_cached_or_real_modification_date(file_path)
819 | if not date_obj:
820 | return os.path.basename(file_path)
821 | elif date_basis == "修改日期":
822 | date_obj = self.get_cached_or_real_modification_date(file_path)
823 | if not date_obj:
824 | date_obj = self.get_cached_or_real_creation_date(file_path)
825 | if not date_obj:
826 | return os.path.basename(file_path)
827 | elif date_basis == "创建日期":
828 | date_obj = self.get_cached_or_real_creation_date(file_path)
829 | if not date_obj:
830 | date_obj = self.get_cached_or_real_modification_date(file_path)
831 | if not date_obj:
832 | return os.path.basename(file_path)
833 | if date_obj:
834 | # 使用字典来存储所有可能的变量值
835 | variables = {
836 | "{date}": date_obj.strftime("%Y%m%d"),
837 | "{time}": date_obj.strftime("%H%M%S"),
838 | "{original}": os.path.splitext(os.path.basename(file_path))[0]
839 | }
840 |
841 | # 添加 EXIF 数据变量
842 | if exif_data:
843 | if 'Model' in exif_data:
844 | variables["{camera}"] = exif_data['Model']
845 | if 'LensModel' in exif_data:
846 | variables["{lens}"] = exif_data['LensModel']
847 | if 'ISOSpeedRatings' in exif_data:
848 | variables["{iso}"] = exif_data['ISOSpeedRatings']
849 | if 'FNumber' in exif_data:
850 | variables["{aperture}"] = f"f{exif_data['FNumber']}"
851 | if 'ExposureTime' in exif_data:
852 | variables["{shutter}"] = exif_data['ExposureTime']
853 | if 'ImageWidth' in exif_data:
854 | variables["{width}"] = exif_data['ImageWidth']
855 | if 'ImageHeight' in exif_data:
856 | variables["{height}"] = exif_data['ImageHeight']
857 |
858 | # 按变量长度降序排序,避免部分替换问题
859 | sorted_vars = sorted(variables.keys(), key=len, reverse=True)
860 |
861 | # 替换所有变量
862 | new_name = template
863 | for var in sorted_vars:
864 | new_name = new_name.replace(var, variables[var])
865 |
866 | if add_suffix:
867 | suffix_style = self.suffix_option_var.get()
868 | base_name = new_name
869 | new_name = self.generate_unique_filename(directory, base_name, ext, suffix_style)
870 | else:
871 | new_name = new_name + ext
872 | return new_name
873 | else:
874 | return os.path.basename(file_path)
875 | except Exception as e:
876 | logging.error(f"生成新名称失败: {file_path}, 错误: {e}")
877 | return os.path.basename(file_path)
878 | def generate_unique_filename(self, directory, base_name, ext, suffix_style):
879 | """生成唯一文件名,避免批量重命名冲突"""
880 | try:
881 | directory = os.path.abspath(os.path.normpath(directory))
882 |
883 | # 检查日期时间格式
884 | date_time_match = re.match(r"(\d{8})_(\d{6})", base_name)
885 | if not date_time_match:
886 | logging.info(f"文件名 {base_name} 不符合日期时间格式,直接返回")
887 | return base_name + ext
888 |
889 | date_part = date_time_match.group(1)
890 | time_part = date_time_match.group(2)
891 | logging.info(f"处理文件: {base_name}, 日期: {date_part}, 时间: {time_part}")
892 |
893 | # 获取所有可能的后缀模式
894 | suffix_patterns = [
895 | r"_\d{3}$", # _001
896 | r"-\d{2}$", # -01
897 | r"_\d+$" # _1
898 | ]
899 |
900 | # 检查文件是否已经包含任何后缀
901 | has_suffix = any(re.search(pattern, base_name) for pattern in suffix_patterns)
902 | if has_suffix:
903 | logging.info(f"文件名 {base_name} 已包含后缀,直接返回")
904 | return base_name + ext
905 |
906 | existing_files = []
907 | for file in os.listdir(directory):
908 | if file.endswith(ext):
909 | file_base = os.path.splitext(file)[0]
910 | # 移除任何现有的后缀
911 | for pattern in suffix_patterns:
912 | file_base = re.sub(pattern, '', file_base)
913 |
914 | file_match = re.match(r"(\d{8})_(\d{6})", file_base)
915 | if file_match:
916 | file_date = file_match.group(1)
917 | file_time = file_match.group(2)
918 | if file_date == date_part and file_time == time_part:
919 | if os.path.join(directory, file) != os.path.join(directory, base_name + ext):
920 | existing_files.append(file)
921 | logging.info(f"找到相同时间点的文件: {file}")
922 |
923 | if not existing_files:
924 | logging.info(f"没有找到相同时间点的文件,使用原始名称: {base_name}{ext}")
925 | return base_name + ext
926 |
927 | counter = 1
928 | max_counter = 999
929 | while counter <= max_counter:
930 | if suffix_style == "_001":
931 | suffix = f"_{counter:03d}"
932 | elif suffix_style == "-01":
933 | suffix = f"-{counter:02d}"
934 | elif suffix_style == "_1":
935 | suffix = f"_{counter}"
936 | else:
937 | suffix = f"_{counter:03d}"
938 | new_filename = f"{base_name}{suffix}{ext}"
939 | if not os.path.exists(os.path.join(directory, new_filename)):
940 | logging.info(f"生成新文件名: {new_filename}")
941 | return new_filename
942 | counter += 1
943 |
944 | logging.info(f"达到最大计数,使用原始名称: {base_name}{ext}")
945 | return base_name + ext
946 | except Exception as e:
947 | logging.error(f"生成唯一文件名失败: {e}")
948 | return base_name + ext
949 | def open_settings(self):
950 | """打开设置窗口"""
951 | try:
952 | settings_window = Toplevel(self.root)
953 | settings_window.title(self.lang["settings"])
954 | settings_window.geometry("660x620")
955 | settings_window.resizable(True, True)
956 | settings_window.transient(self.root)
957 | settings_window.grab_set()
958 | self.toplevel_windows.append(settings_window)
959 | def on_close():
960 | if hasattr(self, 'template_editor') and self.template_editor.has_unsaved_changes():
961 | if messagebox.askyesno("未保存的修改", "模板编辑器中有未保存的修改,是否保存?"):
962 | self.template_editor.save_current_template()
963 | if settings_window in self.toplevel_windows:
964 | self.toplevel_windows.remove(settings_window)
965 | settings_window.destroy()
966 | settings_window.protocol("WM_DELETE_WINDOW", on_close)
967 | notebook = ttk.Notebook(settings_window)
968 | notebook.pack(fill=ttk.BOTH, expand=True, padx=10, pady=10)
969 | template_frame = ttk.Frame(notebook)
970 | notebook.add(template_frame, text=self.lang.get("rename_pattern_settings", "重命名格式设置"))
971 | template_editor = TemplateEditor(template_frame, self.template_var, lang=self.lang, main_app=self)
972 | template_editor.pack(fill=ttk.BOTH, expand=True, padx=10, pady=10)
973 | self.template_editor = template_editor
974 | date_frame = ttk.Frame(notebook)
975 | notebook.add(date_frame, text=self.lang.get("date_settings", "日期设置"))
976 | date_basis_frame = ttk.LabelFrame(date_frame, text=self.lang.get("date_basis", "日期基准"))
977 | date_basis_frame.pack(fill=ttk.X, padx=10, pady=5)
978 | date_basis_select_frame = ttk.Frame(date_basis_frame)
979 | date_basis_select_frame.pack(fill=ttk.X, padx=5, pady=5)
980 | ttk.Label(date_basis_select_frame, text=self.lang.get("preferred_date", "首选日期:")).pack(side=ttk.LEFT)
981 | date_basis_combobox = ttk.Combobox(date_basis_select_frame,
982 | textvariable=self.date_basis_var,
983 | values=[item['display'] for item in self.lang["date_bases"]],
984 | state="readonly")
985 | date_basis_combobox.pack(side=ttk.LEFT, fill=ttk.X, expand=True, padx=5)
986 | alternate_date_select_frame = ttk.Frame(date_basis_frame)
987 | alternate_date_select_frame.pack(fill=ttk.X, padx=5, pady=5)
988 | ttk.Label(alternate_date_select_frame, text=self.lang.get("alternate_date", "备选日期:")).pack(side=ttk.LEFT)
989 | alternate_date_combobox = ttk.Combobox(alternate_date_select_frame,
990 | textvariable=self.alternate_date_var,
991 | values=[item['display'] for item in self.lang["alternate_dates"]],
992 | state="readonly")
993 | alternate_date_combobox.pack(side=ttk.LEFT, fill=ttk.X, expand=True, padx=5)
994 | file_frame = ttk.Frame(notebook)
995 | notebook.add(file_frame, text=self.lang.get("file_handling_settings", "文件处理设置"))
996 | filter_frame = ttk.LabelFrame(file_frame, text=self.lang.get("file_filter", "文件过滤"))
997 | filter_frame.pack(fill=ttk.X, padx=10, pady=5)
998 | skip_extensions_frame = ttk.Frame(filter_frame)
999 | skip_extensions_frame.pack(fill=ttk.X, padx=5, pady=5)
1000 | ttk.Label(skip_extensions_frame, text=self.lang.get("skip_extensions", "跳过的扩展名:")).pack(side=ttk.LEFT)
1001 | skip_extensions_entry = ttk.Entry(skip_extensions_frame, textvariable=self.skip_extensions_var)
1002 | skip_extensions_entry.pack(side=ttk.LEFT, fill=ttk.X, expand=True, padx=5)
1003 | conflict_frame = ttk.LabelFrame(file_frame, text=self.lang.get("name_conflict_prompt", "名称冲突处理"))
1004 | conflict_frame.pack(fill=ttk.X, padx=10, pady=5)
1005 | conflict_select_frame = ttk.Frame(conflict_frame)
1006 | conflict_select_frame.pack(fill=ttk.X, padx=5, pady=5)
1007 | ttk.Label(conflict_select_frame, text=self.lang.get("conflict_handling", "冲突处理:")).pack(side=ttk.LEFT)
1008 | name_conflict_combobox = ttk.Combobox(conflict_select_frame,
1009 | textvariable=self.name_conflict_var,
1010 | values=[item['display'] for item in self.lang["name_conflicts"]],
1011 | state="readonly")
1012 | name_conflict_combobox.pack(side=ttk.LEFT, fill=ttk.X, expand=True, padx=5)
1013 | suffix_option_frame = ttk.Frame(conflict_frame)
1014 | suffix_option_frame.pack(fill=ttk.X, padx=5, pady=5)
1015 | ttk.Label(suffix_option_frame, text=self.lang.get("suffix_format", "后缀格式:")).pack(side=ttk.LEFT)
1016 | suffix_option_combobox = ttk.Combobox(suffix_option_frame,
1017 | textvariable=self.suffix_option_var,
1018 | values=["_001", "_1", " (1)"],
1019 | state="readonly" if self.name_conflict_var.get() == "增加后缀" else "disabled")
1020 | suffix_option_combobox.pack(side=ttk.LEFT, fill=ttk.X, expand=True, padx=5)
1021 | performance_frame = ttk.LabelFrame(file_frame, text=self.lang.get("performance_optimization", "性能优化"))
1022 | performance_frame.pack(fill=ttk.X, padx=10, pady=5)
1023 | fast_add_checkbox = ttk.Checkbutton(performance_frame,
1024 | text=self.lang.get("enable_fast_add", "启用快速添加模式(适用于大量文件)"),
1025 | variable=self.fast_add_mode_var,
1026 | command=lambda: self.toggle_fast_add_threshold_entry(threshold_entry))
1027 | fast_add_checkbox.pack(anchor=ttk.W, padx=5, pady=5)
1028 | threshold_frame = ttk.Frame(performance_frame)
1029 | threshold_frame.pack(fill=ttk.X, padx=5, pady=5)
1030 | ttk.Label(threshold_frame, text=self.lang.get("file_count_threshold", "文件数量阈值:")).pack(side=ttk.LEFT)
1031 | threshold_validate = (settings_window.register(self.validate_threshold_input), '%P')
1032 | threshold_entry = ttk.Entry(threshold_frame,
1033 | textvariable=self.fast_add_threshold_var,
1034 | width=5,
1035 | validate="key",
1036 | validatecommand=threshold_validate,
1037 | state="normal" if self.fast_add_mode_var.get() else "disabled")
1038 | threshold_entry.pack(side=ttk.LEFT, padx=5)
1039 | ui_frame = ttk.Frame(notebook)
1040 | notebook.add(ui_frame, text=self.lang.get("ui_settings", "界面设置"))
1041 | language_frame = ttk.LabelFrame(ui_frame, text=self.lang.get("language_settings", "语言设置"))
1042 | language_frame.pack(fill=ttk.X, padx=10, pady=5)
1043 | ttk.Label(language_frame, text=self.lang.get("interface_language", "界面语言:")).pack(side=ttk.LEFT, padx=5)
1044 | language_combobox = ttk.Combobox(language_frame, textvariable=self.language_var, values=list(LANGUAGES.keys()), state="readonly")
1045 | language_combobox.pack(side=ttk.LEFT, fill=ttk.X, expand=True, padx=5)
1046 | other_frame = ttk.LabelFrame(ui_frame, text=self.lang.get("other_settings", "其他设置"))
1047 | other_frame.pack(fill=ttk.X, padx=10, pady=5)
1048 | auto_scroll_checkbox = ttk.Checkbutton(other_frame,
1049 | text=self.lang.get("auto_scroll_to_latest", "自动滚动到最新文件"),
1050 | variable=self.auto_scroll_var)
1051 | auto_scroll_checkbox.pack(anchor=ttk.W, padx=5, pady=5)
1052 | show_errors_checkbox = ttk.Checkbutton(other_frame,
1053 | text=self.lang.get("show_errors_only", "仅显示错误信息"),
1054 | variable=self.show_errors_only_var)
1055 | show_errors_checkbox.pack(anchor=ttk.W, padx=5, pady=5)
1056 | about_frame = ttk.Frame(notebook)
1057 | notebook.add(about_frame, text=self.lang.get("about", "关于"))
1058 | about_text = """
1059 | QphotoRenamer 2.5
1060 | 一个简单易用的文件与照片批量重命名工具
1061 | 功能特点:
1062 | • 支持拖拽文件/文件夹
1063 | • 支持多种日期格式
1064 | • 支持EXIF信息提取
1065 | • 支持HEIC格式
1066 | • 支持多语言
1067 | • 支持快速添加模式
1068 | • 支持文件名冲突处理
1069 |
1070 | 作者:QwejayHuang
1071 | GitHub:https://github.com/Qwejay/QphotoRenamer
1072 |
1073 | 使用说明:
1074 | 1. 将文件或文件夹拖放到列表中
1075 | 2. 设置重命名格式和选项
1076 | 3. 点击"开始重命名"按钮
1077 | 注意事项:
1078 | • 请确保对目标文件夹有写入权限
1079 | • 建议在重命名前备份重要文件
1080 | """
1081 | about_label = ttk.Label(about_frame, text=about_text, justify=ttk.LEFT)
1082 | about_label.pack(fill=ttk.BOTH, expand=True, padx=10, pady=10)
1083 | save_button = ttk.Button(settings_window, text=self.lang["save_settings"],
1084 | command=lambda: self.save_settings(self.template_var.get(),
1085 | self.language_var.get(),
1086 | self.prefix_var.get(),
1087 | self.suffix_var.get(),
1088 | self.skip_extensions_var.get(),
1089 | settings_window))
1090 | save_button.pack(pady=10)
1091 | save_button.text_key = "save_settings"
1092 | template_frame.columnconfigure(0, weight=1)
1093 | date_frame.columnconfigure(0, weight=1)
1094 | file_frame.columnconfigure(0, weight=1)
1095 | ui_frame.columnconfigure(0, weight=1)
1096 | about_frame.columnconfigure(0, weight=1)
1097 | for widget in settings_window.winfo_children():
1098 | if isinstance(widget, (ttk.Label, ttk.Button, ttk.Checkbutton)):
1099 | widget.text_key = widget.cget("text")
1100 | except Exception as e:
1101 | logging.error(f"打开设置窗口时出错: {e}")
1102 | self.handle_error(e, "打开设置窗口")
1103 | def toggle_suffix_option_edit(self, suffix_option_combobox):
1104 | """根据命名冲突处理方式启用或禁用后缀选项"""
1105 | logging.info(f"切换后缀选项编辑状态, 当前name_conflict_var值: {self.name_conflict_var.get()}")
1106 | logging.info(f"当前语言'增加后缀'对应的文本: {self.lang['add_suffix_option']}")
1107 | add_suffix_value = self.lang["add_suffix_option"]
1108 | if self.name_conflict_var.get() == add_suffix_value or self.name_conflict_var.get() == "增加后缀":
1109 | logging.info("启用后缀选项编辑")
1110 | suffix_option_combobox.config(state="normal")
1111 | else:
1112 | logging.info("禁用后缀选项编辑")
1113 | suffix_option_combobox.config(state="disabled")
1114 | def toggle_fast_add_threshold_entry(self, entry_widget):
1115 | """根据快速添加模式勾选框的状态启用或禁用文件数量阈值输入框"""
1116 | if self.fast_add_mode_var.get():
1117 | entry_widget.config(state="normal")
1118 | else:
1119 | entry_widget.config(state="disabled")
1120 | def validate_threshold_input(self, value):
1121 | """验证文件数量阈值输入是否为 1 到 1000 的数字"""
1122 | if value.isdigit():
1123 | return 1 <= int(value) <= 1000
1124 | return False
1125 | def save_settings(self, template, language, prefix, suffix, skip_extensions_input, settings_window):
1126 | """保存设置到配置文件"""
1127 | try:
1128 | config = configparser.ConfigParser(interpolation=None)
1129 | if os.path.exists("QphotoRenamer.ini"):
1130 | config.read("QphotoRenamer.ini", encoding="utf-8")
1131 | if 'General' not in config:
1132 | config['General'] = {}
1133 | config['General']['language'] = language
1134 | config['General']['template'] = template
1135 | config['General']['prefix'] = prefix
1136 | config['General']['suffix'] = suffix
1137 | config['General']['skip_extensions'] = skip_extensions_input
1138 | with open("QphotoRenamer.ini", "w", encoding="utf-8") as f:
1139 | config.write(f)
1140 | self._apply_settings(config)
1141 | settings_window.destroy()
1142 | except Exception as e:
1143 | logging.error(f"保存设置失败: {e}")
1144 | messagebox.showerror("错误", f"保存设置失败: {str(e)}")
1145 | def fix_config_encoding(self, config: Dict):
1146 | """修复配置文件中的编码问题(只支持INI格式)"""
1147 | pass
1148 | def _apply_settings(self, config: Dict):
1149 | """应用设置到界面"""
1150 | if 'Settings' in config:
1151 | settings = config['Settings']
1152 | self.template_var.set(settings.get('template', ''))
1153 | self.language_var.set(settings.get('language', '简体中文'))
1154 | self.prefix_var.set(settings.get('prefix', ''))
1155 | self.suffix_var.set(settings.get('suffix', ''))
1156 | self.skip_extensions = settings.get('skip_extensions', '').split()
1157 | self.skip_extensions_var.set(" ".join([ext[1:] for ext in self.skip_extensions]))
1158 | self.set_language(self.language_var.get())
1159 | def update_file_count(self):
1160 | """更新状态栏右侧的文件总数"""
1161 | file_count = len(self.files_tree.get_children())
1162 | self.file_count_label.config(text=self.lang["file_count"].format(file_count))
1163 | def select_files(self):
1164 | file_paths = filedialog.askopenfilenames()
1165 | self.add_files_to_queue(file_paths)
1166 | def on_drop(self, event):
1167 | paths = re.findall(r'(?<=\{)[^{}]*(?=\})|[^{}\s]+', event.data)
1168 | self.add_files_to_queue(paths)
1169 | def add_files_to_queue(self, paths):
1170 | """添加文件到队列"""
1171 | try:
1172 | normalized_paths = []
1173 | for path in paths:
1174 | try:
1175 | abs_path = os.path.abspath(path)
1176 | norm_path = os.path.normpath(abs_path)
1177 | if os.path.exists(norm_path):
1178 | normalized_paths.append(norm_path)
1179 | else:
1180 | logging.warning(f"文件不存在,已跳过: {norm_path}")
1181 | except Exception as e:
1182 | logging.error(f"处理文件路径时出错: {path}, 错误: {e}")
1183 | continue
1184 | if not normalized_paths:
1185 | self.update_status_bar("no_valid_files")
1186 | return
1187 | self.start_button.config(state=ttk.DISABLED)
1188 | self.stop_button.config(state=ttk.NORMAL)
1189 | self.stop_event.clear()
1190 | while not self.file_queue.empty():
1191 | self.file_queue.get()
1192 | for path in normalized_paths:
1193 | if os.path.isfile(path):
1194 | self.file_queue.put((path, 'file'))
1195 | elif os.path.isdir(path):
1196 | self.file_queue.put((path, 'directory'))
1197 | if not self.processing_thread or not self.processing_thread.is_alive():
1198 | self.processing_thread = Thread(target=self.process_files_from_queue)
1199 | self.processing_thread.start()
1200 | self.update_status_bar("processing_in_progress")
1201 | except Exception as e:
1202 | logging.error(f"添加文件到队列时出错: {e}")
1203 | self.handle_error(e, "添加文件到队列")
1204 | self.start_button.config(state=ttk.NORMAL)
1205 | self.stop_button.config(state=ttk.DISABLED)
1206 | def process_files_from_queue(self):
1207 | """处理文件队列中的文件"""
1208 | try:
1209 | processed_count = 0
1210 | total_files = self.file_queue.qsize()
1211 | while not self.file_queue.empty() and not self.stop_event.is_set():
1212 | try:
1213 | path, path_type = self.file_queue.get_nowait()
1214 | if path_type == 'file':
1215 | is_duplicate = False
1216 | for item in self.files_tree.get_children():
1217 | current_path = self.files_tree.item(item)['values'][0]
1218 | if path == current_path:
1219 | is_duplicate = True
1220 | break
1221 | if not is_duplicate:
1222 | self.add_file_to_list(path)
1223 | processed_count += 1
1224 | elif path_type == 'directory':
1225 | self.process_directory(path)
1226 | self.root.after(0, lambda: self.update_status_bar("processing_files",
1227 | self.file_queue.qsize(), processed_count, total_files))
1228 | except queue.Empty:
1229 | break
1230 | except Exception as e:
1231 | logging.error(f"处理文件时出错: {path}, 错误: {e}")
1232 | continue
1233 | if not self.stop_event.is_set():
1234 | self.root.after(0, lambda: self.update_status_bar("files_ready"))
1235 | self.root.after(0, lambda: self.start_button.config(state=ttk.NORMAL))
1236 | self.root.after(0, lambda: self.stop_button.config(state=ttk.DISABLED))
1237 | except Exception as e:
1238 | logging.error(f"处理文件队列时出错: {e}")
1239 | self.handle_error(e, "处理文件队列")
1240 | self.root.after(0, lambda: self.start_button.config(state=ttk.NORMAL))
1241 | self.root.after(0, lambda: self.stop_button.config(state=ttk.DISABLED))
1242 | def process_directory(self, dir_path: str):
1243 | """处理目录,使用生成器减少内存使用"""
1244 | try:
1245 | file_count = 0
1246 | file_batch = []
1247 | for root, _, files in os.walk(dir_path):
1248 | if self.stop_event.is_set():
1249 | return
1250 | for file in files:
1251 | if self.stop_event.is_set():
1252 | return
1253 | file_path = os.path.join(root, file)
1254 | self.file_queue.put((file_path, 'file'))
1255 | file_count += 1
1256 | self.root.after(0, lambda count=file_count: self.update_status_bar("added_files_to_queue", count))
1257 | self.root.update_idletasks()
1258 | self.root.after(0, lambda count=file_count: self.update_status_bar("added_files_to_queue", count))
1259 | except Exception as e:
1260 | logging.error(f"处理目录时出错: {dir_path}, 错误: {e}")
1261 | self.handle_error(e, f"处理目录: {dir_path}")
1262 | def schedule_ui_update(self):
1263 | """调度UI更新"""
1264 | if self._update_timer is None:
1265 | self._update_timer = self.root.after(100, self.process_ui_updates)
1266 | def process_ui_updates(self):
1267 | """处理待处理的UI更新"""
1268 | try:
1269 | if 'update_file_count' in self._pending_updates:
1270 | self.root.after(0, self.update_file_count)
1271 | self._pending_updates.remove('update_file_count')
1272 | if 'update_status' in self._pending_updates:
1273 | self.root.after(0, lambda: self.update_status_bar("processing_in_progress"))
1274 | self._pending_updates.remove('update_status')
1275 | if self._pending_updates:
1276 | self._update_timer = self.root.after(100, self.process_ui_updates)
1277 | else:
1278 | self._update_timer = None
1279 | except Exception as e:
1280 | logging.error(f"处理UI更新时出错: {e}")
1281 | self.handle_error(e, "处理UI更新")
1282 | def add_file_to_list(self, file_path):
1283 | """添加文件到列表,使用线程安全的方式更新UI"""
1284 | try:
1285 | for item in self.files_tree.get_children():
1286 | if file_path == self.files_tree.item(item, 'values')[0]:
1287 | logging.info(f"文件已存在,跳过添加: {file_path}")
1288 | return
1289 | file_count = len(self.files_tree.get_children())
1290 | ext = os.path.splitext(file_path)[1].lower()
1291 | status = ""
1292 | new_name = ""
1293 | try:
1294 | exif_data = None
1295 | if ext in SUPPORTED_IMAGE_FORMATS:
1296 | if ext == '.heic':
1297 | exif_data = self.get_heic_data(file_path)
1298 | else:
1299 | exif_data = self.get_exif_data(file_path)
1300 | elif ext in ['.mov', '.mp4', '.avi', '.mkv']:
1301 | date_obj = self.get_video_creation_date(file_path)
1302 | if date_obj:
1303 | exif_data = {'DateTimeOriginalParsed': date_obj}
1304 | if exif_data:
1305 | status = self.detect_file_status(file_path, exif_data)
1306 | new_name = self.generate_new_name(file_path, exif_data)
1307 | else:
1308 | status = self.lang["ready_to_rename"]
1309 | new_name = self.generate_new_name(file_path, None)
1310 | except Exception as e:
1311 | logging.error(f"读取文件信息失败: {file_path}, 错误: {e}")
1312 | self.handle_error(e, f"读取文件信息: {file_path}")
1313 | status = self.lang["ready_to_rename"]
1314 | new_name = os.path.basename(file_path)
1315 | values = (file_path, new_name, status)
1316 | basename = os.path.basename(file_path)
1317 | def update_ui():
1318 | try:
1319 | self.status_label.config(text=f"正在加载: {basename}")
1320 | self.files_tree.insert('', 'end', values=values)
1321 | if self.auto_scroll_var.get() and self.files_tree.get_children():
1322 | self.files_tree.see(self.files_tree.get_children()[-1])
1323 | self.update_file_count()
1324 | except Exception as e:
1325 | logging.error(f"更新UI时出错: {e}")
1326 | self.handle_error(e, "更新UI")
1327 | self.root.after(0, update_ui)
1328 | except Exception as e:
1329 | logging.error(f"添加文件到列表时出错: {file_path}, 错误: {e}")
1330 | self.handle_error(e, f"添加文件到列表: {file_path}")
1331 | def update_renamed_name_column(self):
1332 | """更新重命名后的文件名列"""
1333 | self.remove_invalid_files()
1334 | for item in self.files_tree.get_children():
1335 | try:
1336 | values = self.files_tree.item(item)['values']
1337 | if not values or len(values) < 3:
1338 | continue
1339 | file_path = str(values[0])
1340 | if not file_path:
1341 | continue
1342 | if not os.path.exists(file_path):
1343 | new_name = str(values[1]) if values[1] else ""
1344 | if new_name and new_name != self.lang["ready_to_rename"]:
1345 | directory = os.path.dirname(file_path)
1346 | new_path = os.path.join(directory, new_name)
1347 | if os.path.exists(new_path):
1348 | self.files_tree.item(item, values=(new_path, new_name, values[2]))
1349 | file_path = new_path
1350 | else:
1351 | continue
1352 | exif_data = None
1353 | if file_path.lower().endswith('.heic'):
1354 | exif_data = self.get_heic_data(file_path)
1355 | elif file_path.lower().endswith(tuple(SUPPORTED_IMAGE_FORMATS)):
1356 | exif_data = self.get_exif_data(file_path)
1357 | elif file_path.lower().endswith(('.mov', '.mp4', '.avi', '.mkv')):
1358 | date_obj = self.get_video_creation_date(file_path)
1359 | if date_obj:
1360 | exif_data = {'DateTimeOriginalParsed': date_obj}
1361 | new_name = self.generate_new_name(file_path, exif_data)
1362 | if new_name:
1363 | self.files_tree.set(item, 'renamed_name', new_name)
1364 | except Exception as e:
1365 | logging.error(f"更新重命名列时出错: {e}")
1366 | continue
1367 | def set_language(self, language):
1368 | """设置界面语言"""
1369 | self.lang = LANGUAGES[language]
1370 | self.language_var.set(language)
1371 | self.update_status_bar("ready")
1372 | def update_widget_text(widget):
1373 | if hasattr(widget, 'text_key'):
1374 | key = widget.text_key
1375 | if key in self.lang:
1376 | widget.config(text=self.lang[key])
1377 | for child in widget.winfo_children():
1378 | update_widget_text(child)
1379 | update_widget_text(self.root)
1380 | self.root.title(self.lang["window_title"])
1381 | self.files_tree.heading('filename', text=self.lang["filename"])
1382 | self.files_tree.heading('renamed_name', text=self.lang["renamed_name"])
1383 | self.files_tree.heading('status', text="命名依据")
1384 | self.update_file_count()
1385 | self.update_renamed_name_column()
1386 | self.date_basis_combobox["values"] = [item["display"] for item in self.lang["date_bases"]]
1387 | self.alternate_date_combobox["values"] = [item["display"] for item in self.lang["alternate_dates"]]
1388 | self.date_basis_var.set(self.lang["date_bases"][0]["display"])
1389 | self.alternate_date_var.set(self.lang["alternate_dates"][0]["display"])
1390 | self.drag_hint_label.config(text=self.lang["drag_hint"])
1391 | self.rename_hint_label.config(text=self.lang["rename_hint"])
1392 | self.alternate_date_combobox.config(state="readonly")
1393 | self.rename_end_hint_label.config(text=self.lang["rename_end_hint"])
1394 | def rename_photos(self):
1395 | """启动重命名线程,并在操作期间禁用按钮,结束后恢复"""
1396 | self.stop_event.clear()
1397 | self.renaming_in_progress = True
1398 | self.start_button.config(state=ttk.DISABLED)
1399 | self.stop_button.config(state=ttk.NORMAL)
1400 | Thread(target=self.rename_photos_thread).start()
1401 | def is_already_renamed(self, filename, template, suffix_style, file_path):
1402 | """判断文件名是否已经符合当前模板+后缀规则,并检查日期基准"""
1403 | try:
1404 | current_date_basis = self.date_basis_var.get()
1405 | date_match = re.match(r"(\d{8})_(\d{6})", filename)
1406 | if not date_match:
1407 | return False
1408 | file_date = date_match.group(1)
1409 | file_time = date_match.group(2)
1410 | exif_data = self.get_exif_data(file_path)
1411 | if current_date_basis == "拍摄日期":
1412 | if exif_data and 'DateTimeOriginalParsed' in exif_data:
1413 | exif_date = exif_data['DateTimeOriginalParsed'].strftime("%Y%m%d")
1414 | exif_time = exif_data['DateTimeOriginalParsed'].strftime("%H%M%S")
1415 | if file_date != exif_date or file_time != exif_time:
1416 | return False
1417 | elif current_date_basis == "修改日期":
1418 | mod_date = self.get_file_modification_date(file_path)
1419 | if mod_date:
1420 | mod_date_str = mod_date.strftime("%Y%m%d")
1421 | mod_time_str = mod_date.strftime("%H%M%S")
1422 | if file_date != mod_date_str or file_time != mod_time_str:
1423 | return False
1424 | elif current_date_basis == "创建日期":
1425 | create_date = self.get_file_creation_date(file_path)
1426 | if create_date:
1427 | create_date_str = create_date.strftime("%Y%m%d")
1428 | create_time_str = create_date.strftime("%H%M%S")
1429 | if file_date != create_date_str or file_time != create_time_str:
1430 | return False
1431 | if suffix_style == "_001":
1432 | suffix_regex = r"_\d{3}"
1433 | elif suffix_style == "-01":
1434 | suffix_regex = r"-\d{2}"
1435 | elif suffix_style == "_1":
1436 | suffix_regex = r"_\d+"
1437 | else:
1438 | suffix_regex = r"_\d{3}"
1439 | ext_regex = r"\.[a-zA-Z0-9]+$"
1440 | pattern = f"^{file_date}_{file_time}{suffix_regex}{ext_regex}"
1441 | return re.match(pattern, filename) is not None
1442 | except Exception as e:
1443 | logging.error(f"判断文件名格式失败: {filename}, 错误: {e}")
1444 | return False
1445 | def rename_photos_thread(self):
1446 | """重命名照片的线程函数,结束后恢复按钮"""
1447 | try:
1448 | total_files = len(self.files_tree.get_children())
1449 | renamed_count = 0
1450 | error_count = 0
1451 | skipped_count = 0
1452 | for item in self.files_tree.get_children():
1453 | if self.stop_event.is_set():
1454 | break
1455 | file_path = self.files_tree.item(item)['values'][0]
1456 | try:
1457 | success, result = self.rename_photo(file_path, item)
1458 | if success:
1459 | renamed_count += 1
1460 | self.update_status_bar("renaming_success", renamed_count, total_files)
1461 | else:
1462 | error_count += 1
1463 | logging.error(f"重命名文件失败: {file_path}, 错误: {result}")
1464 | except Exception as e:
1465 | error_count += 1
1466 | self.handle_error(e, f"重命名文件失败: {file_path}")
1467 | if not self.stop_event.is_set():
1468 | if error_count > 0:
1469 | self.update_status_bar("renaming_complete_with_errors", renamed_count, error_count, skipped_count)
1470 | else:
1471 | self.update_status_bar("renaming_complete", renamed_count, skipped_count)
1472 | except Exception as e:
1473 | self.handle_error(e, "重命名过程发生错误")
1474 | finally:
1475 | self.renaming_in_progress = False
1476 | self.stop_event.set()
1477 | self.update_status_bar("ready")
1478 | self.start_button.config(state=ttk.NORMAL)
1479 | self.stop_button.config(state=ttk.DISABLED)
1480 | def sanitize_filename(self, name):
1481 | """过滤文件名中的非法字符并限制长度"""
1482 | illegal_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|', '\0']
1483 | for char in illegal_chars:
1484 | name = name.replace(char, '-')
1485 | max_length = 255
1486 | if len(name) > max_length:
1487 | name = name[:max_length-5] + name[-5:]
1488 | name = name.strip().rstrip('.')
1489 | return name
1490 | def generate_new_name(self, file_path, exif_data, add_suffix=True):
1491 | try:
1492 | file_path = os.path.abspath(os.path.normpath(file_path))
1493 | directory = os.path.dirname(file_path)
1494 | ext = os.path.splitext(file_path)[1].lower()
1495 | template = self.template_var.get()
1496 | if not template:
1497 | try:
1498 | if os.path.exists('QphotoRenamer.ini'):
1499 | config = configparser.ConfigParser()
1500 | config.read('QphotoRenamer.ini', encoding='utf-8')
1501 | if config.has_option('Template', 'default_template'):
1502 | template = config.get('Template', 'default_template')
1503 | except Exception as e:
1504 | logging.error(f"加载默认模板失败: {e}")
1505 | if not template:
1506 | template = "{date}_{time}"
1507 | date_basis = self.date_basis_var.get()
1508 | date_obj = None
1509 | if date_basis == "拍摄日期":
1510 | if exif_data and 'DateTimeOriginalParsed' in exif_data:
1511 | date_obj = exif_data['DateTimeOriginalParsed']
1512 | date_obj = date_obj.replace(microsecond=0)
1513 | else:
1514 | alternate = self.alternate_date_var.get()
1515 | if alternate == "保留原文件名":
1516 | return os.path.basename(file_path)
1517 | elif alternate == "修改日期":
1518 | date_obj = self.get_cached_or_real_modification_date(file_path)
1519 | if not date_obj:
1520 | date_obj = self.get_cached_or_real_creation_date(file_path)
1521 | elif alternate == "创建日期":
1522 | date_obj = self.get_cached_or_real_creation_date(file_path)
1523 | if not date_obj:
1524 | date_obj = self.get_cached_or_real_modification_date(file_path)
1525 | if not date_obj:
1526 | return os.path.basename(file_path)
1527 | elif date_basis == "修改日期":
1528 | date_obj = self.get_cached_or_real_modification_date(file_path)
1529 | if not date_obj:
1530 | date_obj = self.get_cached_or_real_creation_date(file_path)
1531 | if not date_obj:
1532 | return os.path.basename(file_path)
1533 | elif date_basis == "创建日期":
1534 | date_obj = self.get_cached_or_real_creation_date(file_path)
1535 | if not date_obj:
1536 | date_obj = self.get_cached_or_real_modification_date(file_path)
1537 | if not date_obj:
1538 | return os.path.basename(file_path)
1539 | if date_obj:
1540 | # 使用字典来存储所有可能的变量值
1541 | variables = {
1542 | "{date}": date_obj.strftime("%Y%m%d"),
1543 | "{time}": date_obj.strftime("%H%M%S"),
1544 | "{original}": os.path.splitext(os.path.basename(file_path))[0]
1545 | }
1546 |
1547 | # 添加 EXIF 数据变量
1548 | if exif_data:
1549 | if 'Model' in exif_data:
1550 | variables["{camera}"] = exif_data['Model']
1551 | if 'LensModel' in exif_data:
1552 | variables["{lens}"] = exif_data['LensModel']
1553 | if 'ISOSpeedRatings' in exif_data:
1554 | variables["{iso}"] = exif_data['ISOSpeedRatings']
1555 | if 'FNumber' in exif_data:
1556 | variables["{aperture}"] = f"f{exif_data['FNumber']}"
1557 | if 'ExposureTime' in exif_data:
1558 | variables["{shutter}"] = exif_data['ExposureTime']
1559 | if 'ImageWidth' in exif_data:
1560 | variables["{width}"] = exif_data['ImageWidth']
1561 | if 'ImageHeight' in exif_data:
1562 | variables["{height}"] = exif_data['ImageHeight']
1563 |
1564 | # 按变量长度降序排序,避免部分替换问题
1565 | sorted_vars = sorted(variables.keys(), key=len, reverse=True)
1566 |
1567 | # 替换所有变量
1568 | new_name = template
1569 | for var in sorted_vars:
1570 | new_name = new_name.replace(var, variables[var])
1571 |
1572 | if add_suffix:
1573 | suffix_style = self.suffix_option_var.get()
1574 | base_name = new_name
1575 | new_name = self.generate_unique_filename(directory, base_name, ext, suffix_style)
1576 | else:
1577 | new_name = new_name + ext
1578 | return new_name
1579 | else:
1580 | return os.path.basename(file_path)
1581 | except Exception as e:
1582 | logging.error(f"生成新名称失败: {file_path}, 错误: {e}")
1583 | return os.path.basename(file_path)
1584 | def generate_unique_filename(self, directory, base_name, ext, suffix_style):
1585 | """生成唯一文件名,避免批量重命名冲突"""
1586 | try:
1587 | directory = os.path.abspath(os.path.normpath(directory))
1588 |
1589 | # 检查日期时间格式
1590 | date_time_match = re.match(r"(\d{8})_(\d{6})", base_name)
1591 | if not date_time_match:
1592 | logging.info(f"文件名 {base_name} 不符合日期时间格式,直接返回")
1593 | return base_name + ext
1594 |
1595 | date_part = date_time_match.group(1)
1596 | time_part = date_time_match.group(2)
1597 | logging.info(f"处理文件: {base_name}, 日期: {date_part}, 时间: {time_part}")
1598 |
1599 | # 获取所有可能的后缀模式
1600 | suffix_patterns = [
1601 | r"_\d{3}$", # _001
1602 | r"-\d{2}$", # -01
1603 | r"_\d+$" # _1
1604 | ]
1605 |
1606 | # 检查文件是否已经包含任何后缀
1607 | has_suffix = any(re.search(pattern, base_name) for pattern in suffix_patterns)
1608 | if has_suffix:
1609 | logging.info(f"文件名 {base_name} 已包含后缀,直接返回")
1610 | return base_name + ext
1611 |
1612 | existing_files = []
1613 | for file in os.listdir(directory):
1614 | if file.endswith(ext):
1615 | file_base = os.path.splitext(file)[0]
1616 | # 移除任何现有的后缀
1617 | for pattern in suffix_patterns:
1618 | file_base = re.sub(pattern, '', file_base)
1619 |
1620 | file_match = re.match(r"(\d{8})_(\d{6})", file_base)
1621 | if file_match:
1622 | file_date = file_match.group(1)
1623 | file_time = file_match.group(2)
1624 | if file_date == date_part and file_time == time_part:
1625 | if os.path.join(directory, file) != os.path.join(directory, base_name + ext):
1626 | existing_files.append(file)
1627 | logging.info(f"找到相同时间点的文件: {file}")
1628 |
1629 | if not existing_files:
1630 | logging.info(f"没有找到相同时间点的文件,使用原始名称: {base_name}{ext}")
1631 | return base_name + ext
1632 |
1633 | counter = 1
1634 | max_counter = 999
1635 | while counter <= max_counter:
1636 | if suffix_style == "_001":
1637 | suffix = f"_{counter:03d}"
1638 | elif suffix_style == "-01":
1639 | suffix = f"-{counter:02d}"
1640 | elif suffix_style == "_1":
1641 | suffix = f"_{counter}"
1642 | else:
1643 | suffix = f"_{counter:03d}"
1644 | new_filename = f"{base_name}{suffix}{ext}"
1645 | if not os.path.exists(os.path.join(directory, new_filename)):
1646 | logging.info(f"生成新文件名: {new_filename}")
1647 | return new_filename
1648 | counter += 1
1649 |
1650 | logging.info(f"达到最大计数,使用原始名称: {base_name}{ext}")
1651 | return base_name + ext
1652 | except Exception as e:
1653 | logging.error(f"生成唯一文件名失败: {e}")
1654 | return base_name + ext
1655 | def update_caches_on_rename(self, old_path, new_path):
1656 | """重命名后同步所有缓存key,保持界面和数据一致"""
1657 | try:
1658 | info = self.file_info_cache.get(old_path)
1659 | if info:
1660 | new_info = self.cache_file_info(new_path)
1661 | if new_info:
1662 | self.file_info_cache.set(new_path, new_info)
1663 | self.file_info_cache.remove(old_path)
1664 | exif = self.exif_cache.get(old_path)
1665 | if exif is not None:
1666 | self.exif_cache.put(new_path, exif)
1667 | self.exif_cache.put(old_path, None)
1668 | status = self.status_cache.get(old_path)
1669 | if status is not None:
1670 | self.status_cache.put(new_path, status)
1671 | self.status_cache.put(old_path, None)
1672 | old_hash = self.get_file_hash(old_path)
1673 | new_hash = self.get_file_hash(new_path)
1674 | if old_hash:
1675 | val = self.file_hash_cache.get(old_hash)
1676 | if val is not None:
1677 | self.file_hash_cache.put(new_hash, val)
1678 | self.file_hash_cache.put(old_hash, None)
1679 | except Exception as e:
1680 | logging.error(f"更新缓存失败: {old_path} -> {new_path}, 错误: {e}")
1681 | def safe_rename_file(self, source_path: str, target_path: str) -> bool:
1682 | """安全地重命名文件,保留原始文件的创建日期和修改日期"""
1683 | try:
1684 | # 检查源文件是否存在
1685 | if not os.path.exists(source_path):
1686 | logging.error(f"源文件不存在: {source_path}")
1687 | return False
1688 |
1689 | # 检查源文件是否可读
1690 | if not os.access(source_path, os.R_OK):
1691 | logging.error(f"源文件不可读: {source_path}")
1692 | return False
1693 |
1694 | # 检查目标目录是否可写
1695 | target_dir = os.path.dirname(target_path)
1696 | if not os.access(target_dir, os.W_OK):
1697 | logging.error(f"目标目录不可写: {target_dir}")
1698 | return False
1699 |
1700 | # 检查磁盘空间
1701 | source_size = os.path.getsize(source_path)
1702 | free_space = shutil.disk_usage(target_dir).free
1703 | if free_space < source_size:
1704 | logging.error(f"目标磁盘空间不足: {target_dir}")
1705 | return False
1706 |
1707 | # 如果源文件和目标文件是同一个文件,直接返回成功
1708 | if os.path.exists(target_path) and os.path.samefile(source_path, target_path):
1709 | return True
1710 |
1711 | # 创建临时文件
1712 | temp_path = target_path + '.tmp'
1713 | backup_path = source_path + '.bak'
1714 |
1715 | try:
1716 | # 先备份源文件
1717 | shutil.copy2(source_path, backup_path)
1718 |
1719 | # 验证备份文件
1720 | if not os.path.exists(backup_path) or os.path.getsize(backup_path) != source_size:
1721 | raise Exception("备份文件验证失败")
1722 |
1723 | # 复制到临时文件
1724 | shutil.copy2(source_path, temp_path)
1725 |
1726 | # 验证临时文件
1727 | if not os.path.exists(temp_path) or os.path.getsize(temp_path) != source_size:
1728 | raise Exception("临时文件验证失败")
1729 |
1730 | # 如果目标文件存在,先备份
1731 | target_backup = None
1732 | if os.path.exists(target_path):
1733 | target_backup = target_path + '.bak'
1734 | os.rename(target_path, target_backup)
1735 |
1736 | try:
1737 | # 重命名临时文件为目标文件
1738 | os.rename(temp_path, target_path)
1739 |
1740 | # 验证目标文件
1741 | if not os.path.exists(target_path) or os.path.getsize(target_path) != source_size:
1742 | raise Exception("目标文件验证失败")
1743 |
1744 | # 删除源文件
1745 | os.remove(source_path)
1746 |
1747 | # 清理备份文件
1748 | if os.path.exists(backup_path):
1749 | os.remove(backup_path)
1750 | if target_backup and os.path.exists(target_backup):
1751 | os.remove(target_backup)
1752 |
1753 | return True
1754 |
1755 | except Exception as e:
1756 | # 如果重命名失败,尝试恢复
1757 | if os.path.exists(temp_path):
1758 | try:
1759 | os.remove(temp_path)
1760 | except:
1761 | pass
1762 | if target_backup and os.path.exists(target_backup):
1763 | try:
1764 | os.rename(target_backup, target_path)
1765 | except:
1766 | pass
1767 | # 恢复源文件
1768 | if os.path.exists(backup_path):
1769 | try:
1770 | shutil.copy2(backup_path, source_path)
1771 | except:
1772 | pass
1773 | raise e
1774 |
1775 | except Exception as e:
1776 | # 清理所有临时文件
1777 | for path in [temp_path, backup_path]:
1778 | if os.path.exists(path):
1779 | try:
1780 | os.remove(path)
1781 | except:
1782 | pass
1783 | raise e
1784 |
1785 | except Exception as e:
1786 | logging.error(f"重命名文件失败: {source_path} -> {target_path}, 错误: {e}")
1787 | return False
1788 | def rename_photo(self, file_path: str, item: str) -> tuple:
1789 | try:
1790 | # 检查文件是否存在
1791 | if not os.path.exists(file_path):
1792 | return False, self.lang["file_not_found"]
1793 |
1794 | # 检查文件是否可读
1795 | if not os.access(file_path, os.R_OK):
1796 | return False, self.lang["file_not_readable"]
1797 |
1798 | # 检查文件是否被其他程序占用
1799 | try:
1800 | with open(file_path, 'a+b') as f:
1801 | pass
1802 | except PermissionError:
1803 | return False, self.lang["file_in_use"]
1804 |
1805 | # 获取文件信息
1806 | exif_data = self.get_exif_data(file_path)
1807 | date_basis = self.date_basis_var.get()
1808 | date_obj = None
1809 |
1810 | # 根据日期基准获取日期
1811 | if date_basis == "拍摄日期":
1812 | if exif_data and 'DateTimeOriginalParsed' in exif_data:
1813 | date_obj = exif_data['DateTimeOriginalParsed']
1814 | date_obj = date_obj.replace(microsecond=0)
1815 | else:
1816 | alternate = self.alternate_date_var.get()
1817 | if alternate == "保留原文件名":
1818 | return True, os.path.basename(file_path)
1819 | elif alternate == "修改日期":
1820 | date_obj = self.get_cached_or_real_modification_date(file_path)
1821 | if not date_obj:
1822 | date_obj = self.get_cached_or_real_creation_date(file_path)
1823 | elif alternate == "创建日期":
1824 | date_obj = self.get_cached_or_real_creation_date(file_path)
1825 | if not date_obj:
1826 | date_obj = self.get_cached_or_real_modification_date(file_path)
1827 | if not date_obj:
1828 | return True, os.path.basename(file_path)
1829 | elif date_basis == "修改日期":
1830 | date_obj = self.get_cached_or_real_modification_date(file_path)
1831 | if not date_obj:
1832 | date_obj = self.get_cached_or_real_creation_date(file_path)
1833 | if not date_obj:
1834 | return False, self.lang["rename_skipped_no_date"]
1835 | elif date_basis == "创建日期":
1836 | date_obj = self.get_cached_or_real_creation_date(file_path)
1837 | if not date_obj:
1838 | date_obj = self.get_cached_or_real_modification_date(file_path)
1839 | if not date_obj:
1840 | return False, self.lang["rename_skipped_no_date"]
1841 |
1842 | # 生成新文件名
1843 | new_name = self.generate_new_name(file_path, exif_data)
1844 | if not new_name:
1845 | return False, self.lang["rename_skipped_no_name"]
1846 |
1847 | # 检查新文件名是否合法
1848 | if not self.sanitize_filename(new_name):
1849 | return False, self.lang["invalid_filename"]
1850 |
1851 | directory = os.path.dirname(file_path)
1852 | target_path = os.path.join(directory, new_name)
1853 |
1854 | # 检查目标文件是否存在
1855 | if os.path.exists(target_path):
1856 | if os.path.samefile(file_path, target_path):
1857 | # 如果是同一个文件,直接返回成功
1858 | return True, new_name
1859 | else:
1860 | # 如果目标文件存在但不是同一个文件,生成新的唯一文件名
1861 | ext = os.path.splitext(new_name)[1]
1862 | base_name = os.path.splitext(new_name)[0]
1863 | suffix_style = self.suffix_option_var.get()
1864 | new_name = self.generate_unique_filename(directory, base_name, ext, suffix_style)
1865 | target_path = os.path.join(directory, new_name)
1866 |
1867 | # 检查目标目录是否可写
1868 | if not os.access(directory, os.W_OK):
1869 | return False, self.lang["directory_not_writable"]
1870 |
1871 | # 检查磁盘空间
1872 | source_size = os.path.getsize(file_path)
1873 | free_space = shutil.disk_usage(directory).free
1874 | if free_space < source_size:
1875 | return False, self.lang["insufficient_disk_space"]
1876 |
1877 | # 执行重命名
1878 | if self.safe_rename_file(file_path, target_path):
1879 | # 更新缓存
1880 | self.update_caches_on_rename(file_path, target_path)
1881 | return True, new_name
1882 | else:
1883 | return False, self.lang["rename_failed"]
1884 |
1885 | except Exception as e:
1886 | logging.error(f"重命名文件失败: {file_path}, 错误: {e}")
1887 | return False, str(e)
1888 | def get_heic_data(self, file_path):
1889 | """获取 HEIC 文件的 EXIF 信息,并缓存结果"""
1890 | cached_data = self.exif_cache.get(file_path)
1891 | if cached_data:
1892 | return cached_data
1893 | filename = os.path.basename(file_path)
1894 | for date_format in COMMON_DATE_FORMATS:
1895 | try:
1896 | date_pattern = date_format.replace('%Y', r'(\d{4})').replace('%m', r'(\d{2})').replace('%d', r'(\d{2})')
1897 | date_pattern = date_pattern.replace('%H', r'(\d{2})').replace('%M', r'(\d{2})').replace('%S', r'(\d{2})')
1898 | if re.match(date_pattern, os.path.splitext(filename)[0]):
1899 | return None
1900 | except:
1901 | pass
1902 | try:
1903 | heif_file = pillow_heif.read_heif(file_path)
1904 | if 'exif' not in heif_file.info:
1905 | self.exif_cache.put(file_path, {})
1906 | return {}
1907 | exif_dict = piexif.load(heif_file.info['exif'])
1908 | exif_data = {}
1909 | if 'Exif' in exif_dict and piexif.ExifIFD.DateTimeOriginal in exif_dict['Exif']:
1910 | try:
1911 | date_str = exif_dict['Exif'][piexif.ExifIFD.DateTimeOriginal].decode('utf-8')
1912 | date_str = date_str.replace("上午", "").replace("下午", "").strip()
1913 | try:
1914 | exif_data['DateTimeOriginalParsed'] = datetime.datetime.strptime(date_str, '%Y:%m:%d %H:%M:%S')
1915 | except ValueError:
1916 | formats = ['%Y:%m:%d %H:%M', '%Y/%m/%d %H:%M:%S', '%Y/%m/%d %H:%M']
1917 | for fmt in formats:
1918 | try:
1919 | exif_data['DateTimeOriginalParsed'] = datetime.datetime.strptime(date_str, fmt)
1920 | break
1921 | except ValueError:
1922 | continue
1923 | self.exif_cache.put(file_path, exif_data)
1924 | return exif_data
1925 | except Exception as e:
1926 | logging.error(f"解析HEIC日期格式失败: {file_path}, 原始日期字符串: {date_str}, 错误: {e}")
1927 | try:
1928 | match = re.search(r'(\d{4})(\d{2})(\d{2})[-_]?(\d{2})(\d{2})(\d{2})', filename)
1929 | if match:
1930 | year, month, day, hour, minute, second = map(int, match.groups())
1931 | date_obj = datetime.datetime(year, month, day, hour, minute, second)
1932 | exif_data['DateTimeOriginalParsed'] = date_obj
1933 | self.exif_cache.put(file_path, exif_data)
1934 | return exif_data
1935 | except Exception:
1936 | pass
1937 | self.exif_cache.put(file_path, {})
1938 | return {}
1939 | except Exception as e:
1940 | logging.error(f"读取HEIC数据失败: {file_path}, 错误: {e}")
1941 | self.handle_error(e, f"读取HEIC数据: {file_path}")
1942 | self.exif_cache.put(file_path, {})
1943 | return {}
1944 | def create_tooltip(self, widget, text):
1945 | """创建工具提示,鼠标移出或点击时关闭"""
1946 | if hasattr(widget, 'tooltip_window') and widget.tooltip_window:
1947 | widget.tooltip_window.destroy()
1948 | tooltip = Toplevel(widget)
1949 | tooltip.wm_overrideredirect(True)
1950 | x = widget.winfo_pointerx() + 10
1951 | y = widget.winfo_pointery() + 10
1952 | tooltip.geometry(f"+{x}+{y}")
1953 | label = Label(tooltip, text=text, background="lightyellow", relief="solid", borderwidth=1, anchor='w', justify='left')
1954 | label.pack(fill='both', expand=True)
1955 | label.bind("", lambda e: tooltip.destroy())
1956 | tooltip.bind("", lambda e: tooltip.destroy())
1957 | widget.tooltip_window = tooltip
1958 | def show_exif_info(self, event):
1959 | """显示文件的 EXIF 信息"""
1960 | item = self.files_tree.identify_row(event.y)
1961 | if not item:
1962 | return
1963 | file_path = self.files_tree.item(item, 'values')[0]
1964 | def process_info():
1965 | try:
1966 | exif_data = None
1967 | if file_path.lower().endswith('.heic'):
1968 | exif_data = self.get_heic_data(file_path)
1969 | else:
1970 | exif_data = self.get_exif_data(file_path)
1971 | exif_info = self.extract_exif_info(file_path, exif_data)
1972 | self.root.after(0, lambda: self.create_tooltip(event.widget, exif_info))
1973 | except Exception as e:
1974 | logging.error(f"显示EXIF信息出错: {e}")
1975 | self.handle_error(e, "显示EXIF信息")
1976 | Thread(target=process_info, daemon=True).start()
1977 | def extract_exif_info(self, file_path, exif_data):
1978 | """提取 EXIF 信息并生成字符串"""
1979 | exif_info = ""
1980 | original_name = os.path.basename(file_path)
1981 | exif_info += f"旧名称: {original_name}\n"
1982 | new_name = self.generate_new_name(file_path, exif_data)
1983 | exif_info += f"新名称: {new_name}\n"
1984 | mod_date = self.get_file_modification_date(file_path)
1985 | if mod_date:
1986 | exif_info += f"修改日期: {mod_date.strftime('%Y-%m-%d %H:%M:%S')}\n"
1987 | create_date = self.get_file_creation_date(file_path)
1988 | if create_date:
1989 | exif_info += f"创建日期: {create_date.strftime('%Y-%m-%d %H:%M:%S')}\n"
1990 | if exif_data:
1991 | if 'DateTimeOriginalParsed' in exif_data:
1992 | exif_info += f"拍摄日期: {exif_data['DateTimeOriginalParsed'].strftime('%Y-%m-%d %H:%M:%S')}\n"
1993 | if 'Model' in exif_data:
1994 | exif_info += f"拍摄设备: {exif_data['Model']}"
1995 | if 'LensModel' in exif_data:
1996 | exif_info += f"镜头: {exif_data['LensModel']}\n"
1997 | if 'ISOSpeedRatings' in exif_data:
1998 | exif_info += f"ISO: {exif_data['ISOSpeedRatings']}\n"
1999 | if 'FNumber' in exif_data:
2000 | exif_info += f"光圈: f/{exif_data['FNumber']}\n"
2001 | if 'ExposureTime' in exif_data:
2002 | exif_info += f"快门速度: {exif_data['ExposureTime']} 秒\n"
2003 | if 'ImageWidth' in exif_data and 'ImageHeight' in exif_data:
2004 | exif_info += f"分辨率: {exif_data['ImageWidth']}x{exif_data['ImageHeight']}\n"
2005 | return exif_info
2006 | def get_file_modification_date(self, file_path: str) -> Optional[datetime.datetime]:
2007 | """获取文件修改日期"""
2008 | try:
2009 | file_path = os.path.normpath(file_path)
2010 | if sys.platform == 'win32':
2011 | file_path = file_path.replace('/', '\\')
2012 | if not os.path.exists(file_path):
2013 | logging.error(f"文件不存在: {file_path}")
2014 | return None
2015 | if not os.access(file_path, os.R_OK):
2016 | logging.error(f"无法访问文件: {file_path},权限不足")
2017 | return None
2018 | modification_time = os.path.getmtime(file_path)
2019 | return datetime.datetime.fromtimestamp(modification_time)
2020 | except PermissionError as e:
2021 | logging.error(f"权限错误: {file_path}, 错误: {e}")
2022 | return None
2023 | except OSError as e:
2024 | logging.error(f"系统错误: {file_path}, 错误: {e}")
2025 | return None
2026 | except Exception as e:
2027 | logging.error(f"获取文件修改日期失败: {file_path}, 错误: {e}")
2028 | return None
2029 | def get_file_creation_date(self, file_path: str) -> Optional[datetime.datetime]:
2030 | """获取文件创建日期"""
2031 | try:
2032 | file_path = os.path.normpath(file_path)
2033 | if sys.platform == 'win32':
2034 | file_path = file_path.replace('/', '\\')
2035 | if not os.path.exists(file_path):
2036 | logging.error(f"文件不存在: {file_path}")
2037 | return None
2038 | if not os.access(file_path, os.R_OK):
2039 | logging.error(f"无法访问文件: {file_path},权限不足")
2040 | return None
2041 | if sys.platform == 'win32':
2042 | creation_time = os.path.getctime(file_path)
2043 | return datetime.datetime.fromtimestamp(creation_time)
2044 | else:
2045 | stat = os.stat(file_path)
2046 | try:
2047 | birth_time = stat.st_birthtime
2048 | return datetime.datetime.fromtimestamp(birth_time)
2049 | except AttributeError:
2050 | return datetime.datetime.fromtimestamp(stat.st_ctime)
2051 | except PermissionError as e:
2052 | logging.error(f"权限错误: {file_path}, 错误: {e}")
2053 | return None
2054 | except OSError as e:
2055 | logging.error(f"系统错误: {file_path}, 错误: {e}")
2056 | return None
2057 | except Exception as e:
2058 | logging.error(f"获取文件创建日期失败: {file_path}, 错误: {e}")
2059 | return None
2060 | def remove_file(self, event):
2061 | selected_items = self.files_tree.selection()
2062 | for item in selected_items:
2063 | self.files_tree.delete(item)
2064 | self.update_file_count()
2065 | def stop_renaming(self):
2066 | self.stop_event.set()
2067 | def clear_file_list(self):
2068 | self.files_tree.delete(*self.files_tree.get_children())
2069 | self.update_file_count()
2070 | def open_file(self, event):
2071 | """双击打开文件"""
2072 | item = self.files_tree.identify_row(event.y)
2073 | if not item:
2074 | return
2075 | file_path = self.files_tree.item(item, 'values')[0]
2076 | new_name = self.files_tree.item(item, 'values')[1]
2077 | if not os.path.exists(file_path):
2078 | directory = os.path.dirname(file_path)
2079 | if new_name and new_name != self.lang["ready_to_rename"]:
2080 | new_path = os.path.join(directory, new_name)
2081 | if os.path.exists(new_path):
2082 | file_path = new_path
2083 | else:
2084 | self.update_status_bar("file_not_found", file_path)
2085 | return
2086 | else:
2087 | self.update_status_bar("file_not_found", file_path)
2088 | return
2089 | try:
2090 | if sys.platform == 'win32':
2091 | os.startfile(file_path)
2092 | elif sys.platform == 'darwin':
2093 | subprocess.run(['open', file_path])
2094 | else:
2095 | subprocess.run(['xdg-open', file_path])
2096 | except Exception as e:
2097 | logging.error(f"打开文件失败: {file_path}, 错误: {e}")
2098 | self.handle_error(e, "打开文件")
2099 | def update_status_bar(self, message_key, *args):
2100 | """更新状态栏消息"""
2101 | if message_key in self.lang:
2102 | message = self.lang[message_key].format(*args)
2103 | self.status_label.config(text=message)
2104 | else:
2105 | self.status_label.config(text=message_key)
2106 | def show_help(self):
2107 | help_window = Toplevel(self.root)
2108 | help_window.title(self.lang["help"])
2109 | help_text = self.lang["help_text"]
2110 | help_label = Label(help_window, text=help_text, justify='left')
2111 | help_label.pack(padx=10, pady=10)
2112 | def load_language(self):
2113 | if os.path.exists("QphotoRenamer.ini"):
2114 | with open("QphotoRenamer.ini", "r", encoding="utf-8") as f:
2115 | config = json.load(f)
2116 | return config.get("language", "简体中文")
2117 | return "简体中文"
2118 | def open_update_link(self):
2119 | webbrowser.open("https://github.com/Qwejay/QphotoRenamer")
2120 | def detect_file_status(self, file_path: str, exif_data: Optional[Dict] = None) -> str:
2121 | """检测文件状态,使用缓存机制"""
2122 | try:
2123 | date_basis = self.date_basis_var.get()
2124 | used_basis = None
2125 | date_obj = None
2126 | def get_date_by_basis(basis):
2127 | if basis == "拍摄日期":
2128 | if exif_data and 'DateTimeOriginalParsed' in exif_data:
2129 | return "拍摄日期", exif_data['DateTimeOriginalParsed']
2130 | return None, None
2131 | elif basis == "修改日期":
2132 | date = self.get_cached_or_real_modification_date(file_path)
2133 | if date:
2134 | return "修改日期", date
2135 | date = self.get_cached_or_real_creation_date(file_path)
2136 | if date:
2137 | return "创建日期", date
2138 | return None, None
2139 | elif basis == "创建日期":
2140 | date = self.get_cached_or_real_creation_date(file_path)
2141 | if date:
2142 | return "创建日期", date
2143 | date = self.get_cached_or_real_modification_date(file_path)
2144 | if date:
2145 | return "修改日期", date
2146 | return None, None
2147 | return None, None
2148 | used_basis, date_obj = get_date_by_basis(date_basis)
2149 | if date_basis == "拍摄日期" and not date_obj:
2150 | alternate = self.alternate_date_var.get()
2151 | if alternate == "保留原文件名":
2152 | return self.lang["prepare_rename_keep_name"]
2153 | used_basis, date_obj = get_date_by_basis(alternate)
2154 | if not date_obj:
2155 | return self.lang["prepare_rename_keep_name"]
2156 | if used_basis:
2157 | return self.lang["prepare_rename_by"].format(used_basis)
2158 | return self.lang["ready_to_rename"]
2159 | except Exception as e:
2160 | logging.error(f"检测文件状态失败: {file_path}, 错误: {e}")
2161 | return self.lang["prepare_rename_keep_name"]
2162 | def get_video_creation_date(self, file_path):
2163 | """获取视频文件的媒体创建日期"""
2164 | try:
2165 | cmd = [
2166 | 'ffprobe', '-v', 'quiet', '-show_entries', 'format_tags=creation_time',
2167 | '-of', 'default=noprint_wrappers=1:nokey=1', file_path
2168 | ]
2169 | result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
2170 | if result.returncode == 0:
2171 | creation_time = result.stdout.strip()
2172 | if creation_time:
2173 | return datetime.datetime.strptime(creation_time, '%Y-%m-%dT%H:%M:%S.%fZ')
2174 | except Exception as e:
2175 | logging.error(f"获取视频文件创建日期失败: {file_path}, 错误: {e}")
2176 | return None
2177 | def show_omitted_info(self, event):
2178 | """点击某一行时显示快速添加模式所省略的内容"""
2179 | item = self.files_tree.identify_row(event.y)
2180 | if not item:
2181 | return
2182 | file_path = self.files_tree.item(item, 'values')[0]
2183 | new_name = self.files_tree.item(item, 'values')[1]
2184 | if not os.path.exists(file_path):
2185 | directory = os.path.dirname(file_path)
2186 | if new_name and new_name != self.lang["ready_to_rename"]:
2187 | new_path = os.path.join(directory, new_name)
2188 | if os.path.exists(new_path):
2189 | file_path = new_path
2190 | else:
2191 | self.create_tooltip(event.widget, f"文件不存在: {file_path}")
2192 | return
2193 | else:
2194 | self.create_tooltip(event.widget, f"文件不存在: {file_path}")
2195 | return
2196 | def process_info():
2197 | try:
2198 | try:
2199 | stat = os.stat(file_path)
2200 | modification_date = datetime.datetime.fromtimestamp(stat.st_mtime)
2201 | if hasattr(stat, 'st_birthtime'):
2202 | creation_date = datetime.datetime.fromtimestamp(stat.st_birthtime)
2203 | elif hasattr(stat, 'st_ctime'):
2204 | creation_date = datetime.datetime.fromtimestamp(stat.st_ctime)
2205 | else:
2206 | creation_date = datetime.datetime.fromtimestamp(stat.st_ctime)
2207 | except (OSError, AttributeError) as e:
2208 | logging.error(f"获取文件状态失败: {file_path}, 错误: {e}")
2209 | self.root.after(0, lambda: self.create_tooltip(event.widget, f"获取文件状态失败: {str(e)}"))
2210 | return
2211 | exif_data = None
2212 | try:
2213 | if file_path.lower().endswith('.heic'):
2214 | exif_data = self.get_heic_data(file_path)
2215 | else:
2216 | exif_data = self.get_exif_data(file_path)
2217 | except Exception as e:
2218 | logging.error(f"获取EXIF数据失败: {file_path}, 错误: {e}")
2219 | exif_data = None
2220 | if not new_name:
2221 | generated_name = self.generate_new_name(file_path, exif_data)
2222 | self.root.after(0, lambda: self.files_tree.set(item, 'renamed_name', generated_name))
2223 | info = self.extract_omitted_info(file_path, exif_data, generated_name)
2224 | else:
2225 | info = self.extract_omitted_info(file_path, exif_data, new_name)
2226 | self.root.after(0, lambda: self.create_tooltip(event.widget, info))
2227 | except Exception as e:
2228 | logging.error(f"显示文件信息出错: {e}")
2229 | self.handle_error(e, "显示文件信息")
2230 | Thread(target=process_info, daemon=True).start()
2231 | def extract_omitted_info(self, file_path, exif_data, new_name):
2232 | """提取被省略的信息"""
2233 | try:
2234 | info = []
2235 | try:
2236 | stat = os.stat(file_path)
2237 | try:
2238 | mtime = stat.st_mtime
2239 | modification_date = datetime.datetime.fromtimestamp(mtime)
2240 | info.append(f"修改日期: {modification_date.strftime('%Y-%m-%d %H:%M:%S')}")
2241 | except (AttributeError, OSError):
2242 | pass
2243 | try:
2244 | if hasattr(stat, 'st_birthtime'):
2245 | birth_time = stat.st_birthtime
2246 | elif hasattr(stat, 'st_ctime'):
2247 | birth_time = stat.st_ctime
2248 | else:
2249 | birth_time = stat.st_ctime
2250 | creation_date = datetime.datetime.fromtimestamp(birth_time)
2251 | info.append(f"创建日期: {creation_date.strftime('%Y-%m-%d %H:%M:%S')}")
2252 | except (AttributeError, OSError):
2253 | pass
2254 | except (OSError, AttributeError):
2255 | pass
2256 | if exif_data:
2257 | if 'DateTimeOriginalParsed' in exif_data:
2258 | info.append(f"拍摄日期: {exif_data['DateTimeOriginalParsed'].strftime('%Y-%m-%d %H:%M:%S')}")
2259 | if 'Make' in exif_data:
2260 | info.append(f"相机品牌: {exif_data['Make']}")
2261 | if 'Model' in exif_data:
2262 | info.append(f"相机型号: {exif_data['Model']}")
2263 | if 'LensModel' in exif_data:
2264 | info.append(f"镜头型号: {exif_data['LensModel']}")
2265 | if 'FNumber' in exif_data:
2266 | info.append(f"光圈: f/{exif_data['FNumber']}")
2267 | if 'ExposureTime' in exif_data:
2268 | info.append(f"快门速度: {exif_data['ExposureTime']}")
2269 | if 'ISOSpeedRatings' in exif_data:
2270 | info.append(f"ISO: {exif_data['ISOSpeedRatings']}")
2271 | if 'FocalLength' in exif_data:
2272 | info.append(f"焦距: {exif_data['FocalLength']}mm")
2273 | if new_name and new_name != self.lang["ready_to_rename"]:
2274 | info.append(f"新名称: {new_name}")
2275 | return "\n".join(info)
2276 | except Exception as e:
2277 | logging.error(f"提取被省略信息失败: {file_path}, 错误: {e}")
2278 | return f"提取信息失败: {str(e)}"
2279 | def stop_all_operations(self):
2280 | """停止所有操作,包括文件加载、重命名等"""
2281 | try:
2282 | self.stop_event.set()
2283 | self.stop_update_event.set()
2284 | if self.update_thread and self.update_thread.is_alive():
2285 | self.update_thread.join(timeout=1.0)
2286 | while not self.update_queue.empty():
2287 | try:
2288 | self.update_queue.get_nowait()
2289 | self.update_queue.task_done()
2290 | except queue.Empty:
2291 | break
2292 | self.reset_stop_event()
2293 | except Exception as e:
2294 | logging.error(f"停止操作时发生错误: {e}")
2295 | def reset_stop_event(self):
2296 | """重置停止事件"""
2297 | self.stop_event.clear()
2298 | self.stop_update_event.clear()
2299 | def rename_photo_with_name(self, file_path, item, new_name):
2300 | try:
2301 | # 规范化路径
2302 | file_path = os.path.normpath(file_path)
2303 | directory = os.path.normpath(os.path.dirname(file_path))
2304 | new_file_path = os.path.normpath(os.path.join(directory, new_name))
2305 |
2306 | # 检查源文件是否存在
2307 | if not os.path.exists(file_path):
2308 | with self.lock:
2309 | self.files_tree.set(item, 'status', self.lang["file_not_found"])
2310 | self.files_tree.item(item, tags=('failed',))
2311 | self.unrenamed_files += 1
2312 | return False, None
2313 |
2314 | # 检查源文件是否可读
2315 | if not os.access(file_path, os.R_OK):
2316 | with self.lock:
2317 | self.files_tree.set(item, 'status', self.lang["file_not_readable"])
2318 | self.files_tree.item(item, tags=('failed',))
2319 | self.unrenamed_files += 1
2320 | return False, None
2321 |
2322 | # 检查文件是否被其他程序占用
2323 | try:
2324 | with open(file_path, 'a+b') as f:
2325 | pass
2326 | except PermissionError:
2327 | with self.lock:
2328 | self.files_tree.set(item, 'status', self.lang["file_in_use"])
2329 | self.files_tree.item(item, tags=('failed',))
2330 | self.unrenamed_files += 1
2331 | return False, None
2332 |
2333 | # 检查目标目录是否可写
2334 | if not os.access(directory, os.W_OK):
2335 | with self.lock:
2336 | self.files_tree.set(item, 'status', self.lang["directory_not_writable"])
2337 | self.files_tree.item(item, tags=('failed',))
2338 | self.unrenamed_files += 1
2339 | return False, None
2340 |
2341 | # 检查磁盘空间
2342 | source_size = os.path.getsize(file_path)
2343 | free_space = shutil.disk_usage(directory).free
2344 | if free_space < source_size:
2345 | with self.lock:
2346 | self.files_tree.set(item, 'status', self.lang["insufficient_disk_space"])
2347 | self.files_tree.item(item, tags=('failed',))
2348 | self.unrenamed_files += 1
2349 | return False, None
2350 |
2351 | # 检查目标文件是否存在
2352 | if os.path.exists(new_file_path):
2353 | if os.path.samefile(file_path, new_file_path):
2354 | with self.lock:
2355 | self.files_tree.set(item, 'status', '已重命名')
2356 | self.files_tree.item(item, tags=('renamed',))
2357 | return True, new_file_path
2358 | with self.lock:
2359 | self.files_tree.set(item, 'status', '目标文件已存在')
2360 | self.files_tree.item(item, tags=('failed',))
2361 | self.unrenamed_files += 1
2362 | return False, None
2363 |
2364 | # 执行重命名
2365 | if self.safe_rename_file(file_path, new_file_path):
2366 | # 更新缓存
2367 | self.update_caches_on_rename(file_path, new_file_path)
2368 | with self.lock:
2369 | self.files_tree.set(item, 'filename', new_file_path)
2370 | self.files_tree.set(item, 'renamed_name', new_name)
2371 | self.files_tree.set(item, 'status', '已重命名')
2372 | self.files_tree.item(item, tags=('renamed',))
2373 | return True, new_file_path
2374 | else:
2375 | with self.lock:
2376 | self.files_tree.set(item, 'status', self.lang["rename_failed"])
2377 | self.files_tree.item(item, tags=('failed',))
2378 | self.unrenamed_files += 1
2379 | return False, None
2380 |
2381 | except Exception as e:
2382 | logging.error(f"重命名失败: {str(e)}")
2383 | with self.lock:
2384 | self.files_tree.set(item, 'status', f'错误: {e}')
2385 | self.files_tree.item(item, tags=('failed',))
2386 | self.unrenamed_files += 1
2387 | return False, None
2388 | def cache_file_info(self, file_path):
2389 | """缓存文件信息"""
2390 | try:
2391 | file_path = os.path.normpath(file_path)
2392 | if not os.path.exists(file_path):
2393 | return None
2394 | info = {}
2395 | try:
2396 | stat = os.stat(file_path)
2397 | try:
2398 | mtime = stat.st_mtime
2399 | info['modification_date'] = datetime.datetime.fromtimestamp(mtime)
2400 | except (AttributeError, OSError) as e:
2401 | logging.error(f"获取文件修改时间失败: {file_path}, 错误: {e}")
2402 | info['modification_date'] = None
2403 | try:
2404 | if hasattr(stat, 'st_birthtime'):
2405 | birth_time = stat.st_birthtime
2406 | elif hasattr(stat, 'st_ctime'):
2407 | birth_time = stat.st_ctime
2408 | else:
2409 | birth_time = stat.st_ctime
2410 | info['creation_date'] = datetime.datetime.fromtimestamp(birth_time)
2411 | except (AttributeError, OSError) as e:
2412 | logging.error(f"获取文件创建时间失败: {file_path}, 错误: {e}")
2413 | info['creation_date'] = None
2414 | except (OSError, AttributeError) as e:
2415 | logging.error(f"获取文件状态失败: {file_path}, 错误: {e}")
2416 | return None
2417 | ext = os.path.splitext(file_path)[1].lower()
2418 | if ext in SUPPORTED_IMAGE_FORMATS:
2419 | if ext == '.heic':
2420 | info['exif_data'] = self.get_heic_data(file_path)
2421 | else:
2422 | info['exif_data'] = self.get_exif_data(file_path)
2423 | elif ext in ['.mov', '.mp4', '.avi', '.mkv']:
2424 | info['video_date'] = self.get_video_creation_date(file_path)
2425 | if hasattr(self, 'file_info_cache'):
2426 | self.file_info_cache.set(file_path, info)
2427 | return info
2428 | except Exception as e:
2429 | logging.error(f"缓存文件信息失败: {file_path}, 错误: {e}")
2430 | return None
2431 | def start_update_thread(self):
2432 | """启动更新线程"""
2433 | if self.update_thread is None or not self.update_thread.is_alive():
2434 | self.stop_update_event.clear()
2435 | self.update_thread = threading.Thread(target=self._process_updates)
2436 | self.update_thread.daemon = True
2437 | self.update_thread.start()
2438 | def stop_update_thread(self):
2439 | """停止更新线程"""
2440 | self.stop_update_event.set()
2441 | if self.update_thread and self.update_thread.is_alive():
2442 | self.update_thread.join()
2443 | def _process_updates(self):
2444 | """处理更新队列中的任务"""
2445 | while not self.stop_update_event.is_set():
2446 | try:
2447 | task = self.update_queue.get(timeout=0.1)
2448 | if task is None:
2449 | continue
2450 | file_path, date_basis, alternate = task
2451 | self._update_single_file(file_path, date_basis, alternate)
2452 | self.update_queue.task_done()
2453 | except queue.Empty:
2454 | continue
2455 | except Exception as e:
2456 | logging.error(f"处理更新任务失败: {e}")
2457 | def _update_single_file(self, file_path, date_basis, alternate):
2458 | """更新单个文件的新名称"""
2459 | try:
2460 | info = self.file_info_cache.get(file_path)
2461 | if not info:
2462 | info = self.cache_file_info(file_path)
2463 | if not info:
2464 | return
2465 | date_obj = None
2466 | if date_basis == "拍摄日期":
2467 | if info['exif_data'] and 'DateTimeOriginalParsed' in info['exif_data']:
2468 | date_obj = info['exif_data']['DateTimeOriginalParsed']
2469 | elif info['video_date']:
2470 | date_obj = info['video_date']
2471 | elif date_basis == "修改日期":
2472 | date_obj = info['modification_date']
2473 | elif date_basis == "创建日期":
2474 | date_obj = info['creation_date']
2475 | if date_obj:
2476 | exif_data = {'DateTimeOriginalParsed': date_obj}
2477 | new_name = self.generate_new_name(file_path, exif_data)
2478 | if new_name:
2479 | self.root.after(0, lambda: self._update_ui(file_path, new_name))
2480 | except Exception as e:
2481 | logging.error(f"更新文件新名称失败: {file_path}, 错误: {e}")
2482 | def _update_ui(self, file_path, new_name):
2483 | """更新UI显示"""
2484 | for item in self.files_tree.get_children():
2485 | if self.files_tree.item(item)['values'][0] == file_path:
2486 | self.files_tree.set(item, 'renamed_name', new_name)
2487 | self.files_tree.update_idletasks()
2488 | break
2489 | def on_closing(self):
2490 | """程序关闭时的清理工作"""
2491 | try:
2492 | self.stop_all_operations()
2493 | try:
2494 | self.cleanup_cache()
2495 | except Exception as e:
2496 | logging.error(f"清理缓存失败: {e}")
2497 | for window in self.toplevel_windows:
2498 | try:
2499 | window.destroy()
2500 | except:
2501 | pass
2502 | self.root.destroy()
2503 | except Exception as e:
2504 | logging.error(f"关闭程序时出错: {e}")
2505 | self.root.destroy()
2506 | def get_cached_or_real_modification_date(self, file_path):
2507 | """获取文件的修改日期,优先使用缓存"""
2508 | try:
2509 | file_path = os.path.abspath(os.path.normpath(file_path))
2510 | if not os.path.exists(file_path):
2511 | logging.error(f"文件不存在: {file_path}")
2512 | return None
2513 | if not os.access(file_path, os.R_OK):
2514 | logging.error(f"无法访问文件: {file_path},权限不足")
2515 | return None
2516 | modification_time = os.path.getmtime(file_path)
2517 | date_obj = datetime.datetime.fromtimestamp(modification_time)
2518 | date_obj = date_obj.replace(microsecond=0)
2519 | logging.info(f"文件 {file_path} 的修改时间: {date_obj}")
2520 | return date_obj
2521 | except Exception as e:
2522 | logging.error(f"获取文件修改日期失败: {file_path}, 错误: {e}")
2523 | return None
2524 | def get_cached_or_real_creation_date(self, file_path):
2525 | """获取文件的创建日期,优先使用缓存"""
2526 | try:
2527 | file_path = os.path.normpath(file_path)
2528 | if hasattr(self, 'file_info_cache'):
2529 | info = self.file_info_cache.get(file_path)
2530 | if info and 'creation_date' in info and info['creation_date']:
2531 | return info['creation_date']
2532 | if os.path.exists(file_path):
2533 | stat = os.stat(file_path)
2534 | try:
2535 | if hasattr(stat, 'st_birthtime'):
2536 | birth_time = stat.st_birthtime
2537 | elif hasattr(stat, 'st_ctime'):
2538 | birth_time = stat.st_ctime
2539 | else:
2540 | birth_time = stat.st_ctime
2541 | date_obj = datetime.datetime.fromtimestamp(birth_time)
2542 | if hasattr(self, 'file_info_cache'):
2543 | if not info:
2544 | info = {}
2545 | info['creation_date'] = date_obj
2546 | self.file_info_cache.set(file_path, info)
2547 | return date_obj
2548 | except (AttributeError, OSError) as e:
2549 | logging.error(f"获取文件创建时间失败: {file_path}, 错误: {e}")
2550 | return None
2551 | except Exception as e:
2552 | logging.error(f"获取文件创建日期失败: {file_path}, 错误: {e}")
2553 | return None
2554 | def generate_new_name(self, file_path, exif_data, add_suffix=True):
2555 | try:
2556 | file_path = os.path.abspath(os.path.normpath(file_path))
2557 | directory = os.path.dirname(file_path)
2558 | ext = os.path.splitext(file_path)[1].lower()
2559 | template = self.template_var.get()
2560 | if not template:
2561 | try:
2562 | if os.path.exists('QphotoRenamer.ini'):
2563 | config = configparser.ConfigParser()
2564 | config.read('QphotoRenamer.ini', encoding='utf-8')
2565 | if config.has_option('Template', 'default_template'):
2566 | template = config.get('Template', 'default_template')
2567 | except Exception as e:
2568 | logging.error(f"加载默认模板失败: {e}")
2569 | if not template:
2570 | template = "{date}_{time}"
2571 | date_basis = self.date_basis_var.get()
2572 | date_obj = None
2573 | if date_basis == "拍摄日期":
2574 | if exif_data and 'DateTimeOriginalParsed' in exif_data:
2575 | date_obj = exif_data['DateTimeOriginalParsed']
2576 | else:
2577 | alternate = self.alternate_date_var.get()
2578 | if alternate == "保留原文件名":
2579 | return os.path.basename(file_path)
2580 | elif alternate == "修改日期":
2581 | date_obj = self.get_cached_or_real_modification_date(file_path)
2582 | if not date_obj:
2583 | date_obj = self.get_cached_or_real_creation_date(file_path)
2584 | elif alternate == "创建日期":
2585 | date_obj = self.get_cached_or_real_creation_date(file_path)
2586 | if not date_obj:
2587 | date_obj = self.get_cached_or_real_modification_date(file_path)
2588 | if not date_obj:
2589 | return os.path.basename(file_path)
2590 | elif date_basis == "修改日期":
2591 | date_obj = self.get_cached_or_real_modification_date(file_path)
2592 | if not date_obj:
2593 | date_obj = self.get_cached_or_real_creation_date(file_path)
2594 | if not date_obj:
2595 | return os.path.basename(file_path)
2596 | elif date_basis == "创建日期":
2597 | date_obj = self.get_cached_or_real_creation_date(file_path)
2598 | if not date_obj:
2599 | date_obj = self.get_cached_or_real_modification_date(file_path)
2600 | if not date_obj:
2601 | return os.path.basename(file_path)
2602 | if date_obj:
2603 | # 使用字典来存储所有可能的变量值
2604 | variables = {
2605 | "{date}": date_obj.strftime("%Y%m%d"),
2606 | "{time}": date_obj.strftime("%H%M%S"),
2607 | "{original}": os.path.splitext(os.path.basename(file_path))[0]
2608 | }
2609 |
2610 | # 添加 EXIF 数据变量
2611 | if exif_data:
2612 | if 'Model' in exif_data:
2613 | variables["{camera}"] = exif_data['Model']
2614 | if 'LensModel' in exif_data:
2615 | variables["{lens}"] = exif_data['LensModel']
2616 | if 'ISOSpeedRatings' in exif_data:
2617 | variables["{iso}"] = exif_data['ISOSpeedRatings']
2618 | if 'FNumber' in exif_data:
2619 | variables["{aperture}"] = f"f{exif_data['FNumber']}"
2620 | if 'ExposureTime' in exif_data:
2621 | variables["{shutter}"] = exif_data['ExposureTime']
2622 | if 'ImageWidth' in exif_data:
2623 | variables["{width}"] = exif_data['ImageWidth']
2624 | if 'ImageHeight' in exif_data:
2625 | variables["{height}"] = exif_data['ImageHeight']
2626 |
2627 | # 按变量长度降序排序,避免部分替换问题
2628 | sorted_vars = sorted(variables.keys(), key=len, reverse=True)
2629 |
2630 | # 替换所有变量
2631 | new_name = template
2632 | for var in sorted_vars:
2633 | new_name = new_name.replace(var, variables[var])
2634 |
2635 | if add_suffix:
2636 | suffix_style = self.suffix_option_var.get()
2637 | base_name = new_name
2638 | new_name = self.generate_unique_filename(directory, base_name, ext, suffix_style)
2639 | else:
2640 | new_name = new_name + ext
2641 | return new_name
2642 | else:
2643 | return os.path.basename(file_path)
2644 | except Exception as e:
2645 | logging.error(f"生成新名称失败: {file_path}, 错误: {e}")
2646 | return os.path.basename(file_path)
2647 | class TemplateEditor(ttk.Frame):
2648 | def __init__(self, parent, template_var, prefix_var=None, suffix_var=None, lang=None, main_app=None, **kwargs):
2649 | super().__init__(parent, **kwargs)
2650 | self.template_var = template_var
2651 | self.prefix_var = prefix_var
2652 | self.suffix_var = suffix_var
2653 | self.lang = lang or LANGUAGES["简体中文"]
2654 | self.config = configparser.ConfigParser()
2655 | self.config_file = 'QphotoRenamer.ini'
2656 | self.main_app = main_app
2657 | self.is_modified = False
2658 | self.last_saved_content = ""
2659 | self.variables = [
2660 | ("{date}", "日期"),
2661 | ("{time}", "时间"),
2662 | ("{camera}", "相机型号"),
2663 | ("{lens}", "镜头型号"),
2664 | ("{iso}", "ISO"),
2665 | ("{focal}", "焦距"),
2666 | ("{aperture}", "光圈"),
2667 | ("{shutter}", "快门")
2668 | ]
2669 | self.templates = [
2670 | "{date}_{time}",
2671 | "{date}_{time}_{camera}_{lens}_{iso}_{focal}_{aperture}",
2672 | "{date}_{time}_{camera}",
2673 | "{camera}_{lens}_{iso}_{focal}_{aperture}",
2674 | "{date}_{time}_{camera}_{lens}_{iso}_{focal}_{aperture}_{shutter}"
2675 | ]
2676 | self.default_template = None
2677 | self.setup_ui()
2678 | self.load_templates()
2679 | if self.default_template:
2680 | self.set_template(self.default_template)
2681 | self.template_combobox.set(self.default_template)
2682 | elif self.template_var.get():
2683 | self.set_template(self.template_var.get())
2684 | self.template_combobox.set(self.template_var.get())
2685 | else:
2686 | self.set_template(self.templates[0])
2687 | self.template_combobox.set(self.templates[0])
2688 | self.update_preview()
2689 | def setup_ui(self):
2690 | """设置UI界面"""
2691 | top_frame = ttk.Frame(self)
2692 | top_frame.pack(side=ttk.TOP, fill=ttk.BOTH, expand=True, padx=5, pady=5)
2693 | middle_frame = ttk.Frame(self)
2694 | middle_frame.pack(side=ttk.TOP, fill=ttk.BOTH, expand=True, padx=5, pady=5)
2695 | bottom_frame = ttk.Frame(self)
2696 | bottom_frame.pack(side=ttk.TOP, fill=ttk.BOTH, expand=True, padx=5, pady=5)
2697 | preset_frame = ttk.LabelFrame(top_frame, text="选择模板")
2698 | preset_frame.pack(fill=ttk.BOTH, expand=True, padx=5, pady=5)
2699 | preset_top_frame = ttk.Frame(preset_frame)
2700 | preset_top_frame.pack(fill=ttk.X, padx=5, pady=5)
2701 | self.template_combobox = ttk.Combobox(preset_top_frame, values=self.templates, state="readonly", width=50)
2702 | self.template_combobox.pack(side=ttk.LEFT, padx=5, pady=5, fill=ttk.X, expand=True)
2703 | self.template_combobox.bind('<>', self.on_template_selected)
2704 | button_frame = ttk.Frame(preset_top_frame)
2705 | button_frame.pack(side=ttk.RIGHT, padx=5)
2706 | save_button = ttk.Button(button_frame, text=self.lang["save_template"], command=self.save_current_template, width=8)
2707 | save_button.pack(side=ttk.LEFT, padx=2)
2708 | save_button.text_key = "save_template"
2709 | delete_button = ttk.Button(button_frame, text=self.lang["delete_template"], command=self.delete_current_template, width=8)
2710 | delete_button.pack(side=ttk.LEFT, padx=2)
2711 | delete_button.text_key = "delete_template"
2712 | edit_frame = ttk.LabelFrame(top_frame, text="编辑模板")
2713 | edit_frame.pack(fill=ttk.BOTH, expand=True, padx=5, pady=5)
2714 | self.template_text = tk.Text(edit_frame, height=3, wrap=tk.WORD)
2715 | self.template_text.pack(fill=ttk.BOTH, expand=True, padx=5, pady=5)
2716 | def on_text_change(event=None):
2717 | template = self.template_text.get("1.0", END).strip()
2718 | self.template_var.set(template)
2719 | self.update_preview()
2720 | return True
2721 | self.template_text.bind('', on_text_change)
2722 | self.template_text.bind('', on_text_change)
2723 | self.template_text.bind('<>', on_text_change)
2724 | self.template_text.bind('<>', on_text_change)
2725 | self.template_text.bind('', on_text_change)
2726 | self.template_text.bind('', on_text_change)
2727 | variables_frame = ttk.LabelFrame(middle_frame, text="变量")
2728 | variables_frame.pack(fill=ttk.BOTH, expand=True, padx=5, pady=5)
2729 | self.create_variable_buttons(variables_frame, self.variables)
2730 | preview_frame = ttk.LabelFrame(bottom_frame, text="预览")
2731 | preview_frame.pack(fill=ttk.BOTH, expand=True, padx=5, pady=5)
2732 | self.preview_label = ttk.Label(preview_frame, text="", anchor='w', wraplength=0)
2733 | self.preview_label.pack(fill=ttk.X, padx=5, pady=5)
2734 | parent = self.winfo_toplevel()
2735 | original_close_handler = getattr(parent, 'protocol', lambda *args: None)('WM_DELETE_WINDOW')
2736 | def on_parent_close():
2737 | """父窗口关闭时检查是否有未保存的修改"""
2738 | if self.has_unsaved_changes():
2739 | if messagebox.askyesno("未保存的修改", "模板编辑器中有未保存的修改,是否保存?"):
2740 | self.save_current_template()
2741 | if callable(original_close_handler):
2742 | original_close_handler()
2743 | else:
2744 | parent.destroy()
2745 | parent.protocol("WM_DELETE_WINDOW", on_parent_close)
2746 | def update_preview(self, event=None):
2747 | """更新预览"""
2748 | template = self.template_text.get("1.0", END).strip()
2749 | if template != self.last_saved_content:
2750 | self.is_modified = True
2751 | is_new_template = template not in self.templates
2752 | if is_new_template and template:
2753 | current_values = list(self.template_combobox['values'])
2754 | if "(新模板)" not in current_values:
2755 | new_values = current_values + ["(新模板)"]
2756 | self.template_combobox['values'] = new_values
2757 | self.template_combobox.set("(新模板)")
2758 | preview = template
2759 | preview = preview.replace("{date}", "20240315")
2760 | preview = preview.replace("{time}", "143022")
2761 | preview = preview.replace("{camera}", "Canon EOS R5")
2762 | preview = preview.replace("{lens}", "RF 24-70mm F2.8L")
2763 | preview = preview.replace("{iso}", "100")
2764 | preview = preview.replace("{focal}", "50mm")
2765 | preview = preview.replace("{aperture}", "f2.8")
2766 | preview = preview.replace("{shutter}", "1/125")
2767 | preview = preview.replace("{width}", "8192")
2768 | preview = preview.replace("{height}", "5464")
2769 | preview = preview.replace("{original}", "IMG_1234")
2770 | preview = preview.replace("{counter}", "001")
2771 | self.preview_label.config(text=preview)
2772 | if self.main_app and hasattr(self.main_app, 'update_renamed_name_column'):
2773 | self.main_app.update_renamed_name_column()
2774 | return True
2775 | def clear_template(self):
2776 | """清除模板内容"""
2777 | self.template_text.delete("1.0", END)
2778 | self.update_preview()
2779 | self.is_modified = True
2780 | def on_drop(self, event):
2781 | """处理拖放事件"""
2782 | current_content = self.template_text.get("1.0", END).strip()
2783 | self.template_text.insert(INSERT, event.data)
2784 | self.update_preview()
2785 | self.is_modified = True
2786 | def create_variable_buttons(self, parent, variables):
2787 | """创建变量按钮"""
2788 | frame = ttk.Frame(parent)
2789 | frame.pack(fill=ttk.BOTH, expand=True, padx=5, pady=5)
2790 | for i, (var, desc) in enumerate(variables):
2791 | row = i // 4
2792 | col = i % 4
2793 | btn_frame = ttk.Frame(frame)
2794 | btn_frame.grid(row=row, column=col, sticky="ew", padx=2, pady=2)
2795 | btn = ttk.Button(btn_frame, text=desc, width=8,
2796 | command=lambda v=var: self.insert_variable(v))
2797 | btn.pack(side=ttk.LEFT, padx=2)
2798 | lbl = ttk.Label(btn_frame, text=var, foreground="gray", width=8)
2799 | lbl.pack(side=ttk.LEFT, padx=2)
2800 | for i in range(4):
2801 | frame.columnconfigure(i, weight=1)
2802 | def has_unsaved_changes(self):
2803 | """检查是否有未保存的修改"""
2804 | current_content = self.template_text.get("1.0", tk.END).strip()
2805 | return current_content != self.last_saved_content
2806 | def delete_current_template(self):
2807 | """删除当前选中的模板"""
2808 | if self.has_unsaved_changes():
2809 | if messagebox.askyesno("未保存的修改", "当前模板有未保存的修改,是否保存?"):
2810 | self.save_current_template()
2811 | current_template = self.template_combobox.get()
2812 | if current_template in self.templates:
2813 | self.templates.remove(current_template)
2814 | try:
2815 | if os.path.exists(self.config_file):
2816 | self.config.read(self.config_file, encoding='utf-8')
2817 | if 'Template' in self.config:
2818 | for key in list(self.config['Template']):
2819 | if key.startswith('template') and self.config['Template'][key] == current_template:
2820 | del self.config['Template'][key]
2821 | with open(self.config_file, 'w', encoding='utf-8') as f:
2822 | self.config.write(f)
2823 | except Exception as e:
2824 | logging.error(f"从配置文件删除模板失败: {e}")
2825 | self.update_template_combobox()
2826 | if self.templates:
2827 | self.template_combobox.set(self.templates[0])
2828 | self.set_template(self.templates[0])
2829 | else:
2830 | self.clear_template()
2831 | self.template_combobox['values'] = []
2832 | self.template_combobox.set("")
2833 | self.update_status_bar(f"已删除模板: {current_template}")
2834 | def load_templates(self):
2835 | """从配置文件加载模板"""
2836 | try:
2837 | if os.path.exists(self.config_file):
2838 | self.config.read(self.config_file, encoding='utf-8')
2839 | if self.config.has_option('Template', 'default_template'):
2840 | self.default_template = self.config.get('Template', 'default_template')
2841 | self.last_saved_content = self.default_template
2842 | if self.config.has_section('Template'):
2843 | templates = []
2844 | # 首先添加默认模板
2845 | if self.default_template and self.default_template not in templates:
2846 | templates.append(self.default_template)
2847 | # 然后添加其他模板
2848 | for i in range(1, 6):
2849 | template_key = f'template{i}'
2850 | if self.config.has_option('Template', template_key):
2851 | template = self.config.get('Template', template_key)
2852 | if template and template not in templates:
2853 | templates.append(template)
2854 | if templates:
2855 | self.templates = templates
2856 | except Exception as e:
2857 | logging.error(f"加载模板失败: {e}")
2858 | if not self.templates:
2859 | self.templates = [
2860 | "{date}_{time}",
2861 | "{date}_{time}_{camera}_{lens}_{iso}_{focal}_{aperture}",
2862 | "{date}_{time}_{camera}",
2863 | "{camera}_{lens}_{iso}_{focal}_{aperture}",
2864 | "{date}_{time}_{camera}_{lens}_{iso}_{focal}_{aperture}_{shutter}"
2865 | ]
2866 | self.update_template_combobox()
2867 | def save_templates(self):
2868 | """保存模板记录到配置文件"""
2869 | try:
2870 | if os.path.exists(self.config_file):
2871 | self.config.read(self.config_file, encoding='utf-8')
2872 | if 'Template' not in self.config:
2873 | self.config['Template'] = {}
2874 | current_template = self.template_text.get("1.0", END).strip()
2875 | if not current_template:
2876 | self.update_status_bar("模板内容不能为空")
2877 | return
2878 | # 保存为默认模板
2879 | self.config['Template']['default_template'] = current_template
2880 | self.default_template = current_template
2881 |
2882 | # 更新模板列表
2883 | if current_template not in self.templates:
2884 | self.templates.append(current_template)
2885 |
2886 | # 保存所有模板
2887 | for i, template in enumerate(self.templates, 1):
2888 | if template and template != "(新模板)":
2889 | self.config['Template'][f'template{i}'] = template
2890 |
2891 | # 保存到文件
2892 | with open(self.config_file, 'w', encoding='utf-8') as f:
2893 | self.config.write(f)
2894 |
2895 | if self.template_var:
2896 | self.template_var.set(current_template)
2897 | self.update_template_combobox()
2898 | self.template_combobox.set(current_template)
2899 | self.update_status_bar("模板已保存")
2900 | except Exception as e:
2901 | logging.error(f"保存模板记录时出错: {e}")
2902 | self.update_status_bar(f"保存模板失败: {str(e)}")
2903 | def save_current_template(self):
2904 | """保存当前模板"""
2905 | try:
2906 | template = self.template_text.get("1.0", tk.END).strip()
2907 | if not template:
2908 | self.update_status_bar("模板内容不能为空")
2909 | return
2910 | is_new_template = template not in self.templates
2911 | if is_new_template:
2912 | self.templates.append(template)
2913 | self.update_status_bar("新模板已创建并保存")
2914 | else:
2915 | self.update_status_bar("模板已更新")
2916 | self.default_template = template
2917 | self.save_templates()
2918 | self.update_template_combobox()
2919 | self.template_combobox.set(template)
2920 | self.is_modified = False
2921 | self.last_saved_content = template
2922 | if self.main_app and hasattr(self.main_app, 'update_renamed_name_column'):
2923 | self.main_app.update_renamed_name_column()
2924 | except Exception as e:
2925 | error_msg = f"保存模板失败: {str(e)}"
2926 | logging.error(error_msg)
2927 | self.update_status_bar(error_msg)
2928 | def update_status_bar(self, message):
2929 | """更新状态栏消息"""
2930 | try:
2931 | if self.main_app and hasattr(self.main_app, 'status_label'):
2932 | self.main_app.status_label.config(text=message)
2933 | else:
2934 | logging.info(message)
2935 | except Exception as e:
2936 | logging.error(f"更新状态栏失败: {str(e)}")
2937 | logging.info(message)
2938 | def on_template_selected(self, event=None):
2939 | """当选择模板时更新编辑器内容"""
2940 | try:
2941 | selected_template = self.template_combobox.get()
2942 | current_template = self.template_text.get("1.0", tk.END).strip()
2943 | if selected_template != "(新模板)" and selected_template != current_template:
2944 | if self.is_modified:
2945 | if messagebox.askyesno("未保存的修改", "当前模板有未保存的修改,是否保存?"):
2946 | if "(新模板)" in self.template_combobox['values'] and self.template_combobox.get() == "(新模板)":
2947 | if current_template and current_template not in self.templates:
2948 | self.templates.append(current_template)
2949 | self.update_status_bar("新模板已保存")
2950 | self.save_current_template()
2951 | if selected_template in self.templates:
2952 | self.template_text.delete("1.0", tk.END)
2953 | self.template_text.insert("1.0", selected_template)
2954 | self.default_template = selected_template
2955 | self.save_templates()
2956 | self.update_status_bar(f"已加载模板: {selected_template}")
2957 | self.is_modified = False
2958 | self.last_saved_content = selected_template
2959 | if self.template_var:
2960 | self.template_var.set(selected_template)
2961 | if self.main_app and hasattr(self.main_app, 'update_renamed_name_column'):
2962 | self.main_app.update_renamed_name_column()
2963 | except Exception as e:
2964 | error_msg = f"加载模板失败: {str(e)}"
2965 | logging.error(error_msg)
2966 | self.update_status_bar(error_msg)
2967 | def update_template_combobox(self):
2968 | """更新下拉框的选项"""
2969 | current_content = self.template_text.get("1.0", tk.END).strip()
2970 | unique_templates = []
2971 | for t in self.templates:
2972 | if t != "(新模板)" and t not in unique_templates:
2973 | unique_templates.append(t)
2974 | self.templates = unique_templates
2975 | values = self.templates.copy()
2976 | if current_content and current_content not in values and current_content != self.last_saved_content:
2977 | values.append("(新模板)")
2978 | self.template_combobox['values'] = values
2979 | def set_template(self, template):
2980 | """设置模板内容"""
2981 | self.template_text.delete("1.0", END)
2982 | self.template_text.insert("1.0", template)
2983 | self.last_saved_content = template
2984 | self.is_modified = False
2985 | if self.template_var:
2986 | self.template_var.set(template)
2987 | self.update_preview()
2988 | if self.main_app and hasattr(self.main_app, 'update_renamed_name_column'):
2989 | self.main_app.update_renamed_name_column()
2990 | def insert_variable(self, variable):
2991 | """插入变量到模板"""
2992 | current_content = self.template_text.get("1.0", END).strip()
2993 | self.template_text.insert(INSERT, variable)
2994 | self.template_var.set(self.template_text.get("1.0", END).strip())
2995 | self.is_modified = True
2996 | if __name__ == "__main__":
2997 | root = TkinterDnD.Tk()
2998 | try:
2999 | renamer = PhotoRenamer(root)
3000 | root.mainloop()
3001 | except Exception as e:
3002 | messagebox.showerror("致命错误", f"程序遇到严重错误: {str(e)}")
3003 | os._exit(1)
--------------------------------------------------------------------------------