fileflow-cli 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fileflow_cli-0.2.0/src/fileflow_cli.egg-info → fileflow_cli-0.2.1}/PKG-INFO +1 -1
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/pyproject.toml +6 -1
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/__init__.py +1 -1
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/actions.py +1 -1
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/cli.py +66 -40
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/config.py +5 -14
- fileflow_cli-0.2.1/src/fileflow/engine.py +283 -0
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/history.py +8 -8
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/matchers.py +5 -8
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/report.py +1 -3
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/rules.py +1 -2
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/templates.py +10 -4
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1/src/fileflow_cli.egg-info}/PKG-INFO +1 -1
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow_cli.egg-info/SOURCES.txt +1 -0
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/tests/test_actions.py +6 -2
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/tests/test_engine.py +12 -9
- fileflow_cli-0.2.1/tests/test_history.py +132 -0
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/tests/test_matchers.py +1 -2
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/tests/test_templates.py +1 -2
- fileflow_cli-0.2.0/src/fileflow/engine.py +0 -251
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/LICENSE +0 -0
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/README.md +0 -0
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/setup.cfg +0 -0
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow/__main__.py +0 -0
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow_cli.egg-info/dependency_links.txt +0 -0
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow_cli.egg-info/entry_points.txt +0 -0
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow_cli.egg-info/requires.txt +0 -0
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/src/fileflow_cli.egg-info/top_level.txt +0 -0
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/tests/test_config.py +0 -0
- {fileflow_cli-0.2.0 → fileflow_cli-0.2.1}/tests/test_rules.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fileflow-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "🧹 智能文件整理器 — YAML 规则引擎驱动的 CLI 文件分类/移动/清理工具"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -71,6 +71,11 @@ python_version = "3.10"
|
|
|
71
71
|
exclude = ["tests/"]
|
|
72
72
|
ignore_missing_imports = true
|
|
73
73
|
|
|
74
|
+
# Typer decorators don't have type stubs
|
|
75
|
+
[[tool.mypy.overrides]]
|
|
76
|
+
module = "fileflow.cli"
|
|
77
|
+
disable_error_code = ["untyped-decorator"]
|
|
78
|
+
|
|
74
79
|
[tool.pytest.ini_options]
|
|
75
80
|
testpaths = ["tests"]
|
|
76
81
|
python_files = ["test_*.py"]
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Optional
|
|
10
9
|
|
|
11
10
|
import typer
|
|
12
11
|
from rich import box
|
|
@@ -23,7 +22,7 @@ from .config import (
|
|
|
23
22
|
load_config,
|
|
24
23
|
)
|
|
25
24
|
from .engine import run
|
|
26
|
-
from .history import HistoryManager
|
|
25
|
+
from .history import HistoryEntry, HistoryManager
|
|
27
26
|
from .report import (
|
|
28
27
|
print_banner,
|
|
29
28
|
print_config_preview,
|
|
@@ -51,7 +50,9 @@ def _version_callback(value: bool) -> None:
|
|
|
51
50
|
@app.callback()
|
|
52
51
|
def main(
|
|
53
52
|
version: bool = typer.Option(
|
|
54
|
-
False,
|
|
53
|
+
False,
|
|
54
|
+
"--version",
|
|
55
|
+
"-V",
|
|
55
56
|
help="显示版本号并退出",
|
|
56
57
|
callback=_version_callback,
|
|
57
58
|
is_eager=True,
|
|
@@ -64,30 +65,41 @@ def main(
|
|
|
64
65
|
@app.command(name="run")
|
|
65
66
|
def run_command(
|
|
66
67
|
source_dir: str = typer.Argument(
|
|
67
|
-
".",
|
|
68
|
+
".",
|
|
69
|
+
help="要整理的目录路径(默认当前目录)",
|
|
68
70
|
),
|
|
69
|
-
config:
|
|
70
|
-
None,
|
|
71
|
+
config: str | None = typer.Option(
|
|
72
|
+
None,
|
|
73
|
+
"--config",
|
|
74
|
+
"-c",
|
|
71
75
|
help="配置文件路径(默认自动搜索 .fileflow.yaml)",
|
|
72
76
|
),
|
|
73
77
|
force: bool = typer.Option(
|
|
74
|
-
False,
|
|
78
|
+
False,
|
|
79
|
+
"--force",
|
|
80
|
+
"-f",
|
|
75
81
|
help="跳过预览,直接执行整理",
|
|
76
82
|
),
|
|
77
83
|
dry_run: bool = typer.Option(
|
|
78
|
-
False,
|
|
84
|
+
False,
|
|
85
|
+
"--dry-run",
|
|
86
|
+
"-n",
|
|
79
87
|
help="仅预览,不执行任何操作",
|
|
80
88
|
),
|
|
81
89
|
yes: bool = typer.Option(
|
|
82
|
-
False,
|
|
90
|
+
False,
|
|
91
|
+
"--yes",
|
|
92
|
+
"-y",
|
|
83
93
|
help="自动确认所有提示",
|
|
84
94
|
),
|
|
85
95
|
no_recursive: bool = typer.Option(
|
|
86
|
-
False,
|
|
96
|
+
False,
|
|
97
|
+
"--no-recursive",
|
|
87
98
|
help="不递归扫描子目录",
|
|
88
99
|
),
|
|
89
100
|
no_progress: bool = typer.Option(
|
|
90
|
-
False,
|
|
101
|
+
False,
|
|
102
|
+
"--no-progress",
|
|
91
103
|
help="不显示进度条",
|
|
92
104
|
),
|
|
93
105
|
) -> None:
|
|
@@ -116,9 +128,14 @@ def run_command(
|
|
|
116
128
|
console.print("[red]❌ 请先运行 [bold]fileflow init[/] 创建配置文件[/]")
|
|
117
129
|
raise typer.Exit(code=1)
|
|
118
130
|
|
|
119
|
-
|
|
131
|
+
# ── 确定 dry-run 模式 ────────────────────────────
|
|
132
|
+
# 优先级: --force > --dry-run > 配置文件 > 默认值
|
|
120
133
|
if force:
|
|
121
134
|
is_dry_run = False
|
|
135
|
+
elif dry_run:
|
|
136
|
+
is_dry_run = True
|
|
137
|
+
else:
|
|
138
|
+
is_dry_run = None # 由 engine.run() 读取配置文件
|
|
122
139
|
|
|
123
140
|
# ── 执行引擎 ──────────────────────────────────────
|
|
124
141
|
result = run(
|
|
@@ -157,8 +174,6 @@ def run_command(
|
|
|
157
174
|
result.print()
|
|
158
175
|
# 保存操作历史
|
|
159
176
|
if result.results:
|
|
160
|
-
from .history import HistoryEntry, HistoryManager
|
|
161
|
-
|
|
162
177
|
entry = HistoryEntry.from_results(Path(source_dir).resolve(), result.results)
|
|
163
178
|
hm = HistoryManager()
|
|
164
179
|
hm.append(entry)
|
|
@@ -167,8 +182,6 @@ def run_command(
|
|
|
167
182
|
console.print("[dim]已取消,未执行任何操作[/]")
|
|
168
183
|
elif result.results and not result.dry_run:
|
|
169
184
|
# 非 dry-run 模式,直接保存历史
|
|
170
|
-
from .history import HistoryEntry, HistoryManager
|
|
171
|
-
|
|
172
185
|
entry = HistoryEntry.from_results(Path(source_dir).resolve(), result.results)
|
|
173
186
|
hm = HistoryManager()
|
|
174
187
|
hm.append(entry)
|
|
@@ -177,15 +190,14 @@ def run_command(
|
|
|
177
190
|
@app.command()
|
|
178
191
|
def init(
|
|
179
192
|
global_config: bool = typer.Option(
|
|
180
|
-
False,
|
|
193
|
+
False,
|
|
194
|
+
"--global",
|
|
195
|
+
"-g",
|
|
181
196
|
help="生成到用户目录 (~/.fileflow.yaml)",
|
|
182
197
|
),
|
|
183
198
|
) -> None:
|
|
184
199
|
"""🚀 生成默认配置文件 .fileflow.yaml"""
|
|
185
|
-
if global_config
|
|
186
|
-
target = Path.home() / ".fileflow.yaml"
|
|
187
|
-
else:
|
|
188
|
-
target = Path.cwd() / ".fileflow.yaml"
|
|
200
|
+
target = Path.home() / ".fileflow.yaml" if global_config else Path.cwd() / ".fileflow.yaml"
|
|
189
201
|
|
|
190
202
|
if target.exists():
|
|
191
203
|
overwrite = typer.confirm(
|
|
@@ -201,19 +213,23 @@ def init(
|
|
|
201
213
|
|
|
202
214
|
console.print(f"[green]✅ 已生成配置文件:[/] {target}")
|
|
203
215
|
console.print()
|
|
204
|
-
console.print(
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
216
|
+
console.print(
|
|
217
|
+
Panel.fit(
|
|
218
|
+
"[bold]📋 下一步:[/]\n"
|
|
219
|
+
f" 1. 编辑 [cyan]{target.name}[/] 自定义规则\n"
|
|
220
|
+
f" 2. 运行 [bold cyan]fileflow run .[/] 预览效果\n"
|
|
221
|
+
f" 3. 加 [bold yellow]--force[/] 执行整理",
|
|
222
|
+
border_style="green",
|
|
223
|
+
)
|
|
224
|
+
)
|
|
211
225
|
|
|
212
226
|
|
|
213
227
|
@app.command()
|
|
214
228
|
def validate(
|
|
215
|
-
config:
|
|
216
|
-
None,
|
|
229
|
+
config: str | None = typer.Option(
|
|
230
|
+
None,
|
|
231
|
+
"--config",
|
|
232
|
+
"-c",
|
|
217
233
|
help="配置文件路径",
|
|
218
234
|
),
|
|
219
235
|
) -> None:
|
|
@@ -232,11 +248,15 @@ def validate(
|
|
|
232
248
|
@app.command(name="list-rules")
|
|
233
249
|
def list_rules_command(
|
|
234
250
|
verbose: bool = typer.Option(
|
|
235
|
-
False,
|
|
251
|
+
False,
|
|
252
|
+
"--verbose",
|
|
253
|
+
"-v",
|
|
236
254
|
help="显示详细的匹配条件",
|
|
237
255
|
),
|
|
238
|
-
config:
|
|
239
|
-
None,
|
|
256
|
+
config: str | None = typer.Option(
|
|
257
|
+
None,
|
|
258
|
+
"--config",
|
|
259
|
+
"-c",
|
|
240
260
|
help="配置文件路径",
|
|
241
261
|
),
|
|
242
262
|
) -> None:
|
|
@@ -248,7 +268,7 @@ def list_rules_command(
|
|
|
248
268
|
if not rules:
|
|
249
269
|
console.print("[yellow]⚠ 配置中没有定义任何规则[/]")
|
|
250
270
|
console.print("[dim]用 [bold]fileflow init[/] 可以生成默认规则[/]")
|
|
251
|
-
raise typer.Exit()
|
|
271
|
+
raise typer.Exit(code=1)
|
|
252
272
|
|
|
253
273
|
print_rule_list(rules, verbose=verbose)
|
|
254
274
|
except ConfigError as e:
|
|
@@ -259,12 +279,17 @@ def list_rules_command(
|
|
|
259
279
|
@app.command()
|
|
260
280
|
def undo(
|
|
261
281
|
steps: int = typer.Option(
|
|
262
|
-
1,
|
|
282
|
+
1,
|
|
283
|
+
"--steps",
|
|
284
|
+
"-n",
|
|
263
285
|
help="撤销最近 N 步操作(默认 1)",
|
|
264
|
-
min=1,
|
|
286
|
+
min=1,
|
|
287
|
+
max=10,
|
|
265
288
|
),
|
|
266
289
|
all: bool = typer.Option(
|
|
267
|
-
False,
|
|
290
|
+
False,
|
|
291
|
+
"--all",
|
|
292
|
+
"-a",
|
|
268
293
|
help="撤销所有操作",
|
|
269
294
|
),
|
|
270
295
|
) -> None:
|
|
@@ -298,13 +323,14 @@ def undo(
|
|
|
298
323
|
|
|
299
324
|
@app.command()
|
|
300
325
|
def stats(
|
|
301
|
-
config:
|
|
302
|
-
None,
|
|
326
|
+
config: str | None = typer.Option(
|
|
327
|
+
None,
|
|
328
|
+
"--config",
|
|
329
|
+
"-c",
|
|
303
330
|
help="配置文件路径",
|
|
304
331
|
),
|
|
305
332
|
) -> None:
|
|
306
333
|
"""📊 查看整理统计信息."""
|
|
307
|
-
from datetime import datetime
|
|
308
334
|
|
|
309
335
|
# 历史统计
|
|
310
336
|
hm = HistoryManager()
|
|
@@ -7,9 +7,6 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
import yaml
|
|
9
9
|
|
|
10
|
-
from .rules import RuleSet
|
|
11
|
-
|
|
12
|
-
|
|
13
10
|
DEFAULT_CONFIG = {
|
|
14
11
|
"version": "1.0",
|
|
15
12
|
"settings": {
|
|
@@ -63,10 +60,7 @@ def load_config(path: Path | str | None = None) -> dict[str, Any]:
|
|
|
63
60
|
"""
|
|
64
61
|
resolved = find_config(path)
|
|
65
62
|
if resolved is None:
|
|
66
|
-
msg =
|
|
67
|
-
"未找到 .fileflow.yaml 配置文件。\n"
|
|
68
|
-
"请先在目标目录运行: fileflow init"
|
|
69
|
-
)
|
|
63
|
+
msg = "未找到 .fileflow.yaml 配置文件。\n请先在目标目录运行: fileflow init"
|
|
70
64
|
raise ConfigError(msg)
|
|
71
65
|
|
|
72
66
|
try:
|
|
@@ -89,11 +83,9 @@ def _merge_defaults(data: dict[str, Any]) -> dict[str, Any]:
|
|
|
89
83
|
|
|
90
84
|
# 合并 settings
|
|
91
85
|
if "settings" in data and isinstance(data["settings"], dict):
|
|
92
|
-
|
|
93
|
-
merged_settings.
|
|
94
|
-
|
|
95
|
-
if v is not None
|
|
96
|
-
})
|
|
86
|
+
default_settings: dict[str, Any] = merged["settings"] # type: ignore[assignment]
|
|
87
|
+
merged_settings = default_settings.copy()
|
|
88
|
+
merged_settings.update({k: v for k, v in data["settings"].items() if v is not None})
|
|
97
89
|
merged["settings"] = merged_settings
|
|
98
90
|
|
|
99
91
|
return merged
|
|
@@ -106,8 +98,7 @@ def validate_config(config: dict[str, Any]) -> None:
|
|
|
106
98
|
on_conflict = settings.get("on_conflict", "skip")
|
|
107
99
|
if on_conflict not in valid_conflicts:
|
|
108
100
|
raise ConfigError(
|
|
109
|
-
f"无效的 on_conflict 值: {on_conflict!r}。"
|
|
110
|
-
f" 可选: {', '.join(valid_conflicts)}"
|
|
101
|
+
f"无效的 on_conflict 值: {on_conflict!r}。 可选: {', '.join(valid_conflicts)}"
|
|
111
102
|
)
|
|
112
103
|
|
|
113
104
|
rules = config.get("rules", [])
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""核心编排引擎:扫描 → 匹配 → 执行."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich.progress import (
|
|
8
|
+
BarColumn,
|
|
9
|
+
Progress,
|
|
10
|
+
SpinnerColumn,
|
|
11
|
+
TextColumn,
|
|
12
|
+
TimeElapsedColumn,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .actions import ActionResult, ConflictStrategy, get_action
|
|
16
|
+
from .config import load_config
|
|
17
|
+
from .matchers import matches
|
|
18
|
+
from .report import print_summary
|
|
19
|
+
from .rules import RuleSet
|
|
20
|
+
from .templates import resolve_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EngineResult:
|
|
24
|
+
"""一次引擎运行的结果."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
source_dir: Path,
|
|
29
|
+
results: list[ActionResult],
|
|
30
|
+
dry_run: bool,
|
|
31
|
+
rule_matches: dict[str, int],
|
|
32
|
+
total_scanned: int = 0,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.source_dir = source_dir
|
|
35
|
+
self.results = results
|
|
36
|
+
self.dry_run = dry_run
|
|
37
|
+
self.rule_matches = rule_matches
|
|
38
|
+
self.total_scanned = total_scanned
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def success_count(self) -> int:
|
|
42
|
+
return sum(1 for r in self.results if r.success and not r.skipped)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def skipped_count(self) -> int:
|
|
46
|
+
return sum(1 for r in self.results if r.skipped)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def error_count(self) -> int:
|
|
50
|
+
return sum(1 for r in self.results if not r.success)
|
|
51
|
+
|
|
52
|
+
def print(self) -> None:
|
|
53
|
+
"""打印结果到终端."""
|
|
54
|
+
print_summary(self)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run(
|
|
58
|
+
source_dir: str | Path,
|
|
59
|
+
config_path: str | Path | None = None,
|
|
60
|
+
dry_run: bool | None = None,
|
|
61
|
+
force: bool = False,
|
|
62
|
+
recursive: bool = True,
|
|
63
|
+
show_progress: bool = True,
|
|
64
|
+
) -> EngineResult:
|
|
65
|
+
"""运行文件整理.
|
|
66
|
+
|
|
67
|
+
1. 加载配置
|
|
68
|
+
2. 扫描源目录
|
|
69
|
+
3. 对每个文件匹配规则
|
|
70
|
+
4. 执行操作(或预览)
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
source_dir: 要整理的目录路径.
|
|
74
|
+
config_path: 配置文件路径,None 则自动搜索.
|
|
75
|
+
dry_run: 是否仅预览;None 则使用配置中的 dry_run_by_default.
|
|
76
|
+
force: 忽略 dry_run 设置,强制执行.
|
|
77
|
+
recursive: 是否递归扫描子目录.
|
|
78
|
+
show_progress: 是否显示进度条.
|
|
79
|
+
"""
|
|
80
|
+
source = Path(source_dir).resolve()
|
|
81
|
+
if not source.is_dir():
|
|
82
|
+
raise ValueError(f"源路径不是目录: {source}")
|
|
83
|
+
|
|
84
|
+
config = load_config(config_path)
|
|
85
|
+
settings = config.get("settings", {})
|
|
86
|
+
|
|
87
|
+
# 确定 dry-run 模式
|
|
88
|
+
# 优先级: --force > --dry-run > 配置文件 > 默认值
|
|
89
|
+
if force:
|
|
90
|
+
dry_run = False
|
|
91
|
+
elif dry_run is None:
|
|
92
|
+
dry_run = settings.get("dry_run_by_default", True)
|
|
93
|
+
|
|
94
|
+
conflict_str = ConflictStrategy(settings.get("on_conflict", "skip"))
|
|
95
|
+
create_dirs = settings.get("create_target_dirs", True)
|
|
96
|
+
|
|
97
|
+
rule_set = RuleSet.load_from_yaml(config.get("rules", []))
|
|
98
|
+
|
|
99
|
+
# ── 扫描文件 ───────────────────────────────────────────
|
|
100
|
+
if show_progress:
|
|
101
|
+
spinner = Progress(
|
|
102
|
+
SpinnerColumn(),
|
|
103
|
+
TextColumn("[bold cyan]🔍 扫描文件中...[/]"),
|
|
104
|
+
transient=True,
|
|
105
|
+
)
|
|
106
|
+
with spinner:
|
|
107
|
+
spinner.add_task("", total=None)
|
|
108
|
+
files = _scan_files(source, recursive)
|
|
109
|
+
else:
|
|
110
|
+
files = _scan_files(source, recursive)
|
|
111
|
+
|
|
112
|
+
# ── 匹配 + 执行 ─────────────────────────────────────────
|
|
113
|
+
results: list[ActionResult] = []
|
|
114
|
+
rule_matches: dict[str, int] = {}
|
|
115
|
+
|
|
116
|
+
if show_progress and files:
|
|
117
|
+
_run_with_progress(
|
|
118
|
+
files,
|
|
119
|
+
rule_set,
|
|
120
|
+
dry_run,
|
|
121
|
+
source,
|
|
122
|
+
conflict_str,
|
|
123
|
+
create_dirs,
|
|
124
|
+
results,
|
|
125
|
+
rule_matches,
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
_run_simple(
|
|
129
|
+
files,
|
|
130
|
+
rule_set,
|
|
131
|
+
dry_run,
|
|
132
|
+
source,
|
|
133
|
+
conflict_str,
|
|
134
|
+
create_dirs,
|
|
135
|
+
results,
|
|
136
|
+
rule_matches,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return EngineResult(
|
|
140
|
+
source_dir=source,
|
|
141
|
+
results=results,
|
|
142
|
+
dry_run=dry_run,
|
|
143
|
+
rule_matches=rule_matches,
|
|
144
|
+
total_scanned=len(files),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _match_and_execute(
|
|
149
|
+
file_path: Path,
|
|
150
|
+
rule_set: RuleSet,
|
|
151
|
+
dry_run: bool,
|
|
152
|
+
source: Path,
|
|
153
|
+
conflict_str: ConflictStrategy,
|
|
154
|
+
create_dirs: bool,
|
|
155
|
+
results: list[ActionResult],
|
|
156
|
+
rule_matches: dict[str, int],
|
|
157
|
+
) -> bool:
|
|
158
|
+
"""对一个文件尝试所有规则,匹配则执行/记录操作.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
True 表示匹配到了规则,False 表示没有匹配.
|
|
162
|
+
"""
|
|
163
|
+
for rule in rule_set.active_rules:
|
|
164
|
+
if matches(file_path, rule):
|
|
165
|
+
rule_matches[rule.name] = rule_matches.get(rule.name, 0) + 1
|
|
166
|
+
if dry_run:
|
|
167
|
+
try:
|
|
168
|
+
dest = resolve_path(rule.target, file_path, source)
|
|
169
|
+
results.append(
|
|
170
|
+
ActionResult(
|
|
171
|
+
file_path,
|
|
172
|
+
target=dest,
|
|
173
|
+
success=True,
|
|
174
|
+
action=f"[dry-run] {rule.action}",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
results.append(
|
|
179
|
+
ActionResult(
|
|
180
|
+
file_path,
|
|
181
|
+
error=str(e),
|
|
182
|
+
success=False,
|
|
183
|
+
action=rule.action,
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
action_func = get_action(rule.action)
|
|
188
|
+
result = action_func(
|
|
189
|
+
file_path,
|
|
190
|
+
rule.target,
|
|
191
|
+
source,
|
|
192
|
+
create_dirs=create_dirs,
|
|
193
|
+
conflict=conflict_str,
|
|
194
|
+
)
|
|
195
|
+
results.append(result)
|
|
196
|
+
return True
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _run_with_progress(
|
|
201
|
+
files: list[Path],
|
|
202
|
+
rule_set: RuleSet,
|
|
203
|
+
dry_run: bool,
|
|
204
|
+
source: Path,
|
|
205
|
+
conflict_str: ConflictStrategy,
|
|
206
|
+
create_dirs: bool,
|
|
207
|
+
results: list[ActionResult],
|
|
208
|
+
rule_matches: dict[str, int],
|
|
209
|
+
) -> None:
|
|
210
|
+
"""带进度条的文件匹配执行."""
|
|
211
|
+
progress = Progress(
|
|
212
|
+
TextColumn("[bold cyan]📦[/]"),
|
|
213
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
214
|
+
BarColumn(),
|
|
215
|
+
TextColumn("[bold]{task.completed}/{task.total}"),
|
|
216
|
+
TextColumn("[dim]{task.fields[status]}"),
|
|
217
|
+
TimeElapsedColumn(),
|
|
218
|
+
transient=True,
|
|
219
|
+
)
|
|
220
|
+
with progress:
|
|
221
|
+
task = progress.add_task(
|
|
222
|
+
"预览中..." if dry_run else "整理中...",
|
|
223
|
+
total=len(files),
|
|
224
|
+
status="",
|
|
225
|
+
)
|
|
226
|
+
for file_path in files:
|
|
227
|
+
_match_and_execute(
|
|
228
|
+
file_path,
|
|
229
|
+
rule_set,
|
|
230
|
+
dry_run,
|
|
231
|
+
source,
|
|
232
|
+
conflict_str,
|
|
233
|
+
create_dirs,
|
|
234
|
+
results,
|
|
235
|
+
rule_matches,
|
|
236
|
+
)
|
|
237
|
+
progress.update(
|
|
238
|
+
task,
|
|
239
|
+
advance=1,
|
|
240
|
+
status=f"[dim]{file_path.name}[/]",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _run_simple(
|
|
245
|
+
files: list[Path],
|
|
246
|
+
rule_set: RuleSet,
|
|
247
|
+
dry_run: bool,
|
|
248
|
+
source: Path,
|
|
249
|
+
conflict_str: ConflictStrategy,
|
|
250
|
+
create_dirs: bool,
|
|
251
|
+
results: list[ActionResult],
|
|
252
|
+
rule_matches: dict[str, int],
|
|
253
|
+
) -> None:
|
|
254
|
+
"""无进度条的简单文件匹配执行."""
|
|
255
|
+
for file_path in files:
|
|
256
|
+
_match_and_execute(
|
|
257
|
+
file_path,
|
|
258
|
+
rule_set,
|
|
259
|
+
dry_run,
|
|
260
|
+
source,
|
|
261
|
+
conflict_str,
|
|
262
|
+
create_dirs,
|
|
263
|
+
results,
|
|
264
|
+
rule_matches,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _scan_files(directory: Path, recursive: bool) -> list[Path]:
|
|
269
|
+
"""扫描目录中的普通文件(忽略隐藏文件)."""
|
|
270
|
+
files: list[Path] = []
|
|
271
|
+
|
|
272
|
+
if recursive:
|
|
273
|
+
for entry in directory.rglob("*"):
|
|
274
|
+
if entry.is_file() and not any(
|
|
275
|
+
part.startswith(".") for part in entry.relative_to(directory).parts
|
|
276
|
+
):
|
|
277
|
+
files.append(entry)
|
|
278
|
+
else:
|
|
279
|
+
for entry in directory.iterdir():
|
|
280
|
+
if entry.is_file() and not entry.name.startswith("."):
|
|
281
|
+
files.append(entry)
|
|
282
|
+
|
|
283
|
+
return sorted(files)
|
|
@@ -11,8 +11,7 @@ from datetime import datetime
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
-
from .actions import ActionResult,
|
|
15
|
-
|
|
14
|
+
from .actions import ActionResult, ConflictStrategy, move_file
|
|
16
15
|
|
|
17
16
|
HISTORY_FILE = ".fileflow_history.json"
|
|
18
17
|
|
|
@@ -42,11 +41,13 @@ class HistoryEntry:
|
|
|
42
41
|
operations = []
|
|
43
42
|
for r in results:
|
|
44
43
|
if r.success and not r.skipped and r.target and r.target != r.source:
|
|
45
|
-
operations.append(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
operations.append(
|
|
45
|
+
{
|
|
46
|
+
"source": str(r.source),
|
|
47
|
+
"target": str(r.target),
|
|
48
|
+
"action": r.action.replace("[dry-run] ", ""),
|
|
49
|
+
}
|
|
50
|
+
)
|
|
50
51
|
|
|
51
52
|
return cls(
|
|
52
53
|
timestamp=datetime.now().isoformat(),
|
|
@@ -138,7 +139,6 @@ class HistoryManager:
|
|
|
138
139
|
|
|
139
140
|
# 反向执行:把文件从 target 移回 source
|
|
140
141
|
for op in reversed(entry.operations):
|
|
141
|
-
source = Path(op["source"])
|
|
142
142
|
target = Path(op["target"])
|
|
143
143
|
if target.exists():
|
|
144
144
|
try:
|
|
@@ -73,10 +73,7 @@ class ExtensionsMatcher(Matcher):
|
|
|
73
73
|
|
|
74
74
|
def __init__(self, value: list[str]) -> None:
|
|
75
75
|
# 统一处理:确保 . 开头、小写
|
|
76
|
-
self.extensions = {
|
|
77
|
-
e.lower() if e.startswith(".") else f".{e.lower()}"
|
|
78
|
-
for e in value
|
|
79
|
-
}
|
|
76
|
+
self.extensions = {e.lower() if e.startswith(".") else f".{e.lower()}" for e in value}
|
|
80
77
|
|
|
81
78
|
def __call__(self, path: Path) -> bool:
|
|
82
79
|
if not path.is_file():
|
|
@@ -86,7 +83,7 @@ class ExtensionsMatcher(Matcher):
|
|
|
86
83
|
|
|
87
84
|
@register("patterns")
|
|
88
85
|
class PatternsMatcher(Matcher):
|
|
89
|
-
"""按文件名通配符模式匹配(如 *.txt, data_*.csv).
|
|
86
|
+
"""按文件名通配符模式匹配(如 *.txt, data_*.csv)."""
|
|
90
87
|
|
|
91
88
|
def __init__(self, value: list[str]) -> None:
|
|
92
89
|
import fnmatch
|
|
@@ -134,6 +131,8 @@ class SizeMatcher(Matcher):
|
|
|
134
131
|
|
|
135
132
|
def __init__(self, value: float | int) -> None:
|
|
136
133
|
self.limit = value
|
|
134
|
+
self.min_bytes: int | None = None
|
|
135
|
+
self.max_bytes: int | None = None
|
|
137
136
|
|
|
138
137
|
@classmethod
|
|
139
138
|
def from_rule(cls, rule: Rule) -> SizeMatcher | None:
|
|
@@ -171,9 +170,7 @@ class SizeMatcher(Matcher):
|
|
|
171
170
|
|
|
172
171
|
if min_b is not None and size < min_b:
|
|
173
172
|
return False
|
|
174
|
-
|
|
175
|
-
return False
|
|
176
|
-
return True
|
|
173
|
+
return not (max_b is not None and size > max_b)
|
|
177
174
|
|
|
178
175
|
|
|
179
176
|
# ── 规则匹配引擎 ──────────────────────────────────────────
|
|
@@ -2,14 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from pathlib import Path
|
|
6
5
|
from typing import TYPE_CHECKING, Any
|
|
7
6
|
|
|
8
7
|
from rich import box
|
|
9
8
|
from rich.console import Console
|
|
10
9
|
from rich.panel import Panel
|
|
11
10
|
from rich.table import Table
|
|
12
|
-
from rich.text import Text
|
|
13
11
|
|
|
14
12
|
from .actions import ActionResult
|
|
15
13
|
|
|
@@ -22,7 +20,7 @@ console = Console()
|
|
|
22
20
|
def print_banner() -> None:
|
|
23
21
|
"""打印 FileFlow 启动横幅."""
|
|
24
22
|
banner = Panel.fit(
|
|
25
|
-
"[bold cyan]🧹 FileFlow[/] [yellow]v0.2.
|
|
23
|
+
"[bold cyan]🧹 FileFlow[/] [yellow]v0.2.1[/] [dim]— 智能文件整理器[/]",
|
|
26
24
|
border_style="cyan",
|
|
27
25
|
)
|
|
28
26
|
console.print(banner)
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
|
-
from pathlib import Path
|
|
7
6
|
from typing import Any
|
|
8
7
|
|
|
9
8
|
|
|
@@ -49,7 +48,7 @@ class Rule:
|
|
|
49
48
|
)
|
|
50
49
|
|
|
51
50
|
def to_dict(self) -> dict[str, Any]:
|
|
52
|
-
"""转换为可序列化字典(用于历史记录等).
|
|
51
|
+
"""转换为可序列化字典(用于历史记录等)."""
|
|
53
52
|
return {
|
|
54
53
|
"name": self.name,
|
|
55
54
|
"description": self.description,
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import re
|
|
19
20
|
import string
|
|
20
21
|
from dataclasses import dataclass
|
|
21
22
|
from datetime import datetime
|
|
@@ -67,8 +68,15 @@ class TemplateFormatter(string.Formatter):
|
|
|
67
68
|
|
|
68
69
|
_formatter = TemplateFormatter()
|
|
69
70
|
_ALLOWED_VARS = {
|
|
70
|
-
"filename",
|
|
71
|
-
"
|
|
71
|
+
"filename",
|
|
72
|
+
"name",
|
|
73
|
+
"ext",
|
|
74
|
+
"ext_no_dot",
|
|
75
|
+
"year",
|
|
76
|
+
"month",
|
|
77
|
+
"day",
|
|
78
|
+
"hour",
|
|
79
|
+
"minute",
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
|
|
@@ -100,8 +108,6 @@ def render(template: str, path: Path) -> str:
|
|
|
100
108
|
|
|
101
109
|
def _find_unresolved_braces(text: str) -> list[str]:
|
|
102
110
|
"""查找未解析的 {var} 模式."""
|
|
103
|
-
import re
|
|
104
|
-
|
|
105
111
|
return re.findall(r"\{(\w+)\}", text)
|
|
106
112
|
|
|
107
113
|
|
|
@@ -48,7 +48,9 @@ class TestMoveFile:
|
|
|
48
48
|
dest.write_text("existing")
|
|
49
49
|
|
|
50
50
|
result = move_file(
|
|
51
|
-
src,
|
|
51
|
+
src,
|
|
52
|
+
"moved/{filename}",
|
|
53
|
+
temp_dir,
|
|
52
54
|
conflict=ConflictStrategy.RENAME,
|
|
53
55
|
)
|
|
54
56
|
assert result.success is True
|
|
@@ -64,7 +66,9 @@ class TestMoveFile:
|
|
|
64
66
|
dest.write_text("old content")
|
|
65
67
|
|
|
66
68
|
result = move_file(
|
|
67
|
-
src,
|
|
69
|
+
src,
|
|
70
|
+
"moved/{filename}",
|
|
71
|
+
temp_dir,
|
|
68
72
|
conflict=ConflictStrategy.OVERWRITE,
|
|
69
73
|
)
|
|
70
74
|
assert result.success is True
|
|
@@ -7,9 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
import pytest
|
|
8
8
|
import yaml
|
|
9
9
|
|
|
10
|
-
from fileflow.
|
|
11
|
-
from fileflow.engine import EngineResult, _scan_files, run
|
|
12
|
-
from fileflow.rules import RuleSet
|
|
10
|
+
from fileflow.engine import _scan_files, run
|
|
13
11
|
|
|
14
12
|
|
|
15
13
|
class TestScanFiles:
|
|
@@ -129,12 +127,17 @@ class TestEngineResult:
|
|
|
129
127
|
(temp_dir / "test.jpg").write_text("jpeg")
|
|
130
128
|
# 创建临时配置文件
|
|
131
129
|
cfg = temp_dir / ".fileflow.yaml"
|
|
132
|
-
cfg.write_text(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
130
|
+
cfg.write_text(
|
|
131
|
+
yaml.dump(
|
|
132
|
+
{
|
|
133
|
+
"version": "1.0",
|
|
134
|
+
"rules": [
|
|
135
|
+
{"name": "IMG", "match": {"extensions": [".jpg"]}, "target": "Images/"},
|
|
136
|
+
],
|
|
137
|
+
}
|
|
138
|
+
),
|
|
139
|
+
encoding="utf-8",
|
|
140
|
+
)
|
|
138
141
|
|
|
139
142
|
result = run(source_dir=temp_dir, config_path=cfg, dry_run=True)
|
|
140
143
|
result.print()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""测试历史记录和撤销功能."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fileflow.actions import ActionResult
|
|
8
|
+
from fileflow.history import HistoryEntry, HistoryManager
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestHistoryEntry:
|
|
12
|
+
def test_from_results_empty(self) -> None:
|
|
13
|
+
entry = HistoryEntry.from_results(Path("/tmp"), [])
|
|
14
|
+
assert entry.operations == []
|
|
15
|
+
assert entry.action == "run"
|
|
16
|
+
|
|
17
|
+
def test_from_results_filters_skipped(self) -> None:
|
|
18
|
+
results = [
|
|
19
|
+
ActionResult(Path("/a.txt"), target=Path("/b.txt"), success=True),
|
|
20
|
+
ActionResult(Path("/c.txt"), skipped=True),
|
|
21
|
+
ActionResult(Path("/d.txt"), success=False, error="err"),
|
|
22
|
+
]
|
|
23
|
+
entry = HistoryEntry.from_results(Path("/tmp"), results)
|
|
24
|
+
assert len(entry.operations) == 1 # 只记录成功的
|
|
25
|
+
assert entry.operations[0]["source"] == str(Path("/a.txt"))
|
|
26
|
+
|
|
27
|
+
def test_to_dict(self) -> None:
|
|
28
|
+
entry = HistoryEntry(
|
|
29
|
+
timestamp="2026-01-01T00:00:00",
|
|
30
|
+
action="run",
|
|
31
|
+
source_dir="/tmp",
|
|
32
|
+
operations=[{"source": "/a.txt", "target": "/b.txt", "action": "move"}],
|
|
33
|
+
)
|
|
34
|
+
d = entry.to_dict()
|
|
35
|
+
assert d["timestamp"] == "2026-01-01T00:00:00"
|
|
36
|
+
assert len(d["operations"]) == 1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestHistoryManager:
|
|
40
|
+
def test_append_and_load(self, temp_dir: Path) -> None:
|
|
41
|
+
hm = HistoryManager(temp_dir / ".history.json")
|
|
42
|
+
entry = HistoryEntry(
|
|
43
|
+
timestamp="2026-01-01T00:00:00",
|
|
44
|
+
action="run",
|
|
45
|
+
source_dir=str(temp_dir),
|
|
46
|
+
operations=[
|
|
47
|
+
{
|
|
48
|
+
"source": str(temp_dir / "a.txt"),
|
|
49
|
+
"target": str(temp_dir / "Docs" / "a.txt"),
|
|
50
|
+
"action": "move",
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
)
|
|
54
|
+
hm.append(entry)
|
|
55
|
+
|
|
56
|
+
entries = hm.get_all()
|
|
57
|
+
assert len(entries) == 1
|
|
58
|
+
assert entries[0].timestamp == "2026-01-01T00:00:00"
|
|
59
|
+
|
|
60
|
+
def test_get_latest_empty(self) -> None:
|
|
61
|
+
hm = HistoryManager()
|
|
62
|
+
assert hm.get_latest() is None
|
|
63
|
+
|
|
64
|
+
def test_get_latest(self, temp_dir: Path) -> None:
|
|
65
|
+
hm = HistoryManager(temp_dir / ".history.json")
|
|
66
|
+
e1 = HistoryEntry("2026-01-01T00:00:00", "run", str(temp_dir), [])
|
|
67
|
+
e2 = HistoryEntry("2026-01-02T00:00:00", "run", str(temp_dir), [])
|
|
68
|
+
hm.append(e1)
|
|
69
|
+
hm.append(e2)
|
|
70
|
+
|
|
71
|
+
latest = hm.get_latest()
|
|
72
|
+
assert latest is not None
|
|
73
|
+
assert latest.timestamp == "2026-01-02T00:00:00"
|
|
74
|
+
|
|
75
|
+
def test_clear(self, temp_dir: Path) -> None:
|
|
76
|
+
path = temp_dir / ".history.json"
|
|
77
|
+
hm = HistoryManager(path)
|
|
78
|
+
hm.append(HistoryEntry("2026-01-01T00:00:00", "run", str(temp_dir), []))
|
|
79
|
+
assert path.exists()
|
|
80
|
+
hm.clear()
|
|
81
|
+
assert not path.exists()
|
|
82
|
+
|
|
83
|
+
def test_multiple_append(self, temp_dir: Path) -> None:
|
|
84
|
+
hm = HistoryManager(temp_dir / ".history.json")
|
|
85
|
+
for i in range(3):
|
|
86
|
+
hm.append(
|
|
87
|
+
HistoryEntry(
|
|
88
|
+
f"2026-01-0{i + 1}T00:00:00",
|
|
89
|
+
"run",
|
|
90
|
+
str(temp_dir),
|
|
91
|
+
[],
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
assert len(hm.get_all()) == 3
|
|
95
|
+
|
|
96
|
+
def test_invalid_json(self, temp_dir: Path) -> None:
|
|
97
|
+
path = temp_dir / ".history.json"
|
|
98
|
+
path.write_text("invalid json", encoding="utf-8")
|
|
99
|
+
hm = HistoryManager(path)
|
|
100
|
+
assert hm.get_all() == [] # 不会崩溃
|
|
101
|
+
|
|
102
|
+
def test_undo_latest_with_real_files(self, temp_dir: Path) -> None:
|
|
103
|
+
"""集成测试:创建文件 → 记录历史 → 撤销."""
|
|
104
|
+
# 创建源文件
|
|
105
|
+
src = temp_dir / "test.txt"
|
|
106
|
+
src.write_text("hello")
|
|
107
|
+
target = temp_dir / "Moved" / "test.txt"
|
|
108
|
+
target.parent.mkdir(parents=True)
|
|
109
|
+
target.write_text("hello")
|
|
110
|
+
|
|
111
|
+
hm = HistoryManager(temp_dir / ".history.json")
|
|
112
|
+
entry = HistoryEntry(
|
|
113
|
+
timestamp="2026-01-01T00:00:00",
|
|
114
|
+
action="run",
|
|
115
|
+
source_dir=str(temp_dir),
|
|
116
|
+
operations=[
|
|
117
|
+
{
|
|
118
|
+
"source": str(src),
|
|
119
|
+
"target": str(target),
|
|
120
|
+
"action": "move",
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
)
|
|
124
|
+
hm.append(entry)
|
|
125
|
+
|
|
126
|
+
# 执行撤销
|
|
127
|
+
success, failed = hm.undo_latest()
|
|
128
|
+
assert success == 1
|
|
129
|
+
assert failed == 0
|
|
130
|
+
# 文件应该被移回
|
|
131
|
+
assert src.exists()
|
|
132
|
+
assert not target.exists()
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from unittest.mock import patch
|
|
7
6
|
|
|
8
7
|
import pytest
|
|
9
8
|
|
|
@@ -65,7 +64,7 @@ class TestRender:
|
|
|
65
64
|
render("{unknown}", f)
|
|
66
65
|
|
|
67
66
|
def test_directory_path(self, temp_dir: Path) -> None:
|
|
68
|
-
"""对目录路径也能正常渲染(虽然一般不用于目录).
|
|
67
|
+
"""对目录路径也能正常渲染(虽然一般不用于目录)."""
|
|
69
68
|
result = render("Backup/{name}", temp_dir)
|
|
70
69
|
name = temp_dir.name
|
|
71
70
|
assert result == f"Backup/{name}"
|
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
"""核心编排引擎:扫描 → 匹配 → 执行."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
from rich.progress import (
|
|
9
|
-
BarColumn,
|
|
10
|
-
Progress,
|
|
11
|
-
SpinnerColumn,
|
|
12
|
-
TextColumn,
|
|
13
|
-
TimeElapsedColumn,
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
from .actions import (
|
|
17
|
-
ConflictStrategy,
|
|
18
|
-
ActionResult,
|
|
19
|
-
get_action,
|
|
20
|
-
)
|
|
21
|
-
from .config import load_config
|
|
22
|
-
from .matchers import matches
|
|
23
|
-
from .report import print_summary
|
|
24
|
-
from .rules import RuleSet
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class EngineResult:
|
|
28
|
-
"""一次引擎运行的结果."""
|
|
29
|
-
|
|
30
|
-
def __init__(
|
|
31
|
-
self,
|
|
32
|
-
source_dir: Path,
|
|
33
|
-
results: list[ActionResult],
|
|
34
|
-
dry_run: bool,
|
|
35
|
-
rule_matches: dict[str, int],
|
|
36
|
-
total_scanned: int = 0,
|
|
37
|
-
) -> None:
|
|
38
|
-
self.source_dir = source_dir
|
|
39
|
-
self.results = results
|
|
40
|
-
self.dry_run = dry_run
|
|
41
|
-
self.rule_matches = rule_matches
|
|
42
|
-
self.total_scanned = total_scanned
|
|
43
|
-
|
|
44
|
-
@property
|
|
45
|
-
def success_count(self) -> int:
|
|
46
|
-
return sum(1 for r in self.results if r.success and not r.skipped)
|
|
47
|
-
|
|
48
|
-
@property
|
|
49
|
-
def skipped_count(self) -> int:
|
|
50
|
-
return sum(1 for r in self.results if r.skipped)
|
|
51
|
-
|
|
52
|
-
@property
|
|
53
|
-
def error_count(self) -> int:
|
|
54
|
-
return sum(1 for r in self.results if not r.success)
|
|
55
|
-
|
|
56
|
-
def print(self) -> None:
|
|
57
|
-
"""打印结果到终端."""
|
|
58
|
-
print_summary(self)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def run(
|
|
62
|
-
source_dir: str | Path,
|
|
63
|
-
config_path: str | Path | None = None,
|
|
64
|
-
dry_run: bool | None = None,
|
|
65
|
-
force: bool = False,
|
|
66
|
-
recursive: bool = True,
|
|
67
|
-
show_progress: bool = True,
|
|
68
|
-
) -> EngineResult:
|
|
69
|
-
"""运行文件整理.
|
|
70
|
-
|
|
71
|
-
1. 加载配置
|
|
72
|
-
2. 扫描源目录
|
|
73
|
-
3. 对每个文件匹配规则
|
|
74
|
-
4. 执行操作(或预览)
|
|
75
|
-
|
|
76
|
-
Args:
|
|
77
|
-
source_dir: 要整理的目录路径.
|
|
78
|
-
config_path: 配置文件路径,None 则自动搜索.
|
|
79
|
-
dry_run: 是否仅预览;None 则使用配置中的 dry_run_by_default.
|
|
80
|
-
force: 如果 True 且 dry_run 未明确设置,则 dry_run=False.
|
|
81
|
-
recursive: 是否递归扫描子目录.
|
|
82
|
-
show_progress: 是否显示进度条.
|
|
83
|
-
"""
|
|
84
|
-
source = Path(source_dir).resolve()
|
|
85
|
-
if not source.is_dir():
|
|
86
|
-
raise ValueError(f"源路径不是目录: {source}")
|
|
87
|
-
|
|
88
|
-
config = load_config(config_path)
|
|
89
|
-
settings = config.get("settings", {})
|
|
90
|
-
|
|
91
|
-
# 确定 dry-run 模式
|
|
92
|
-
if dry_run is None:
|
|
93
|
-
dry_run = settings.get("dry_run_by_default", True)
|
|
94
|
-
|
|
95
|
-
if force and dry_run is None:
|
|
96
|
-
dry_run = False
|
|
97
|
-
|
|
98
|
-
conflict_str = ConflictStrategy(settings.get("on_conflict", "skip"))
|
|
99
|
-
create_dirs = settings.get("create_target_dirs", True)
|
|
100
|
-
|
|
101
|
-
rule_set = RuleSet.load_from_yaml(config.get("rules", []))
|
|
102
|
-
|
|
103
|
-
# ── 扫描文件 ───────────────────────────────────────────
|
|
104
|
-
if show_progress:
|
|
105
|
-
spinner = Progress(
|
|
106
|
-
SpinnerColumn(),
|
|
107
|
-
TextColumn("[bold cyan]🔍 扫描文件中...[/]"),
|
|
108
|
-
TextColumn("[progress.description]{task.description}"),
|
|
109
|
-
transient=True,
|
|
110
|
-
)
|
|
111
|
-
with spinner:
|
|
112
|
-
scan_task = spinner.add_task("", total=None)
|
|
113
|
-
files = _scan_files(source, recursive)
|
|
114
|
-
spinner.update(scan_task, visible=False)
|
|
115
|
-
else:
|
|
116
|
-
files = _scan_files(source, recursive)
|
|
117
|
-
|
|
118
|
-
# ── 匹配 + 执行 ─────────────────────────────────────────
|
|
119
|
-
results: list[ActionResult] = []
|
|
120
|
-
rule_matches: dict[str, int] = {}
|
|
121
|
-
|
|
122
|
-
if show_progress and files:
|
|
123
|
-
progress = Progress(
|
|
124
|
-
TextColumn("[bold cyan]📦[/]"),
|
|
125
|
-
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
126
|
-
BarColumn(),
|
|
127
|
-
TextColumn("[bold]{task.completed}/{task.total}"),
|
|
128
|
-
TextColumn("[dim]{task.fields[status]}"),
|
|
129
|
-
TimeElapsedColumn(),
|
|
130
|
-
transient=True,
|
|
131
|
-
)
|
|
132
|
-
with progress:
|
|
133
|
-
task = progress.add_task(
|
|
134
|
-
"预览中..." if dry_run else "整理中...",
|
|
135
|
-
total=len(files),
|
|
136
|
-
status="",
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
for file_path in files:
|
|
140
|
-
matched = False
|
|
141
|
-
for rule in rule_set.active_rules:
|
|
142
|
-
if matches(file_path, rule):
|
|
143
|
-
matched = True
|
|
144
|
-
rule_matches[rule.name] = rule_matches.get(rule.name, 0) + 1
|
|
145
|
-
|
|
146
|
-
if dry_run:
|
|
147
|
-
from .templates import resolve_path
|
|
148
|
-
|
|
149
|
-
try:
|
|
150
|
-
dest = resolve_path(rule.target, file_path, source)
|
|
151
|
-
results.append(
|
|
152
|
-
ActionResult(
|
|
153
|
-
file_path,
|
|
154
|
-
target=dest,
|
|
155
|
-
success=True,
|
|
156
|
-
action=f"[dry-run] {rule.action}",
|
|
157
|
-
)
|
|
158
|
-
)
|
|
159
|
-
except Exception as e:
|
|
160
|
-
results.append(
|
|
161
|
-
ActionResult(
|
|
162
|
-
file_path,
|
|
163
|
-
error=str(e),
|
|
164
|
-
success=False,
|
|
165
|
-
action=rule.action,
|
|
166
|
-
)
|
|
167
|
-
)
|
|
168
|
-
else:
|
|
169
|
-
action_func = get_action(rule.action)
|
|
170
|
-
result = action_func(
|
|
171
|
-
file_path,
|
|
172
|
-
rule.target,
|
|
173
|
-
source,
|
|
174
|
-
create_dirs=create_dirs,
|
|
175
|
-
conflict=conflict_str,
|
|
176
|
-
)
|
|
177
|
-
results.append(result)
|
|
178
|
-
break
|
|
179
|
-
|
|
180
|
-
progress.update(
|
|
181
|
-
task,
|
|
182
|
-
advance=1,
|
|
183
|
-
status=f"[dim]{file_path.name}[/]",
|
|
184
|
-
)
|
|
185
|
-
else:
|
|
186
|
-
# 无进度条模式
|
|
187
|
-
for file_path in files:
|
|
188
|
-
matched = False
|
|
189
|
-
for rule in rule_set.active_rules:
|
|
190
|
-
if matches(file_path, rule):
|
|
191
|
-
matched = True
|
|
192
|
-
rule_matches[rule.name] = rule_matches.get(rule.name, 0) + 1
|
|
193
|
-
if dry_run:
|
|
194
|
-
from .templates import resolve_path
|
|
195
|
-
|
|
196
|
-
try:
|
|
197
|
-
dest = resolve_path(rule.target, file_path, source)
|
|
198
|
-
results.append(
|
|
199
|
-
ActionResult(
|
|
200
|
-
file_path,
|
|
201
|
-
target=dest,
|
|
202
|
-
success=True,
|
|
203
|
-
action=f"[dry-run] {rule.action}",
|
|
204
|
-
)
|
|
205
|
-
)
|
|
206
|
-
except Exception as e:
|
|
207
|
-
results.append(
|
|
208
|
-
ActionResult(
|
|
209
|
-
file_path,
|
|
210
|
-
error=str(e),
|
|
211
|
-
success=False,
|
|
212
|
-
action=rule.action,
|
|
213
|
-
)
|
|
214
|
-
)
|
|
215
|
-
else:
|
|
216
|
-
action_func = get_action(rule.action)
|
|
217
|
-
result = action_func(
|
|
218
|
-
file_path,
|
|
219
|
-
rule.target,
|
|
220
|
-
source,
|
|
221
|
-
create_dirs=create_dirs,
|
|
222
|
-
conflict=conflict_str,
|
|
223
|
-
)
|
|
224
|
-
results.append(result)
|
|
225
|
-
break
|
|
226
|
-
|
|
227
|
-
return EngineResult(
|
|
228
|
-
source_dir=source,
|
|
229
|
-
results=results,
|
|
230
|
-
dry_run=dry_run,
|
|
231
|
-
rule_matches=rule_matches,
|
|
232
|
-
total_scanned=len(files),
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def _scan_files(directory: Path, recursive: bool) -> list[Path]:
|
|
237
|
-
"""扫描目录中的普通文件(忽略隐藏文件). """
|
|
238
|
-
files: list[Path] = []
|
|
239
|
-
|
|
240
|
-
if recursive:
|
|
241
|
-
for entry in directory.rglob("*"):
|
|
242
|
-
if entry.is_file() and not any(
|
|
243
|
-
part.startswith(".") for part in entry.relative_to(directory).parts
|
|
244
|
-
):
|
|
245
|
-
files.append(entry)
|
|
246
|
-
else:
|
|
247
|
-
for entry in directory.iterdir():
|
|
248
|
-
if entry.is_file() and not entry.name.startswith("."):
|
|
249
|
-
files.append(entry)
|
|
250
|
-
|
|
251
|
-
return sorted(files)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|