fileflow-cli 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- fileflow/__main__.py +6 -0
- fileflow/actions.py +164 -0
- fileflow/cli.py +193 -0
- fileflow/config.py +183 -0
- fileflow/engine.py +167 -0
- fileflow/matchers.py +214 -0
- fileflow/report.py +164 -0
- fileflow/rules.py +86 -0
- fileflow/templates.py +124 -0
- fileflow_cli-0.1.0.dist-info/METADATA +204 -0
- fileflow_cli-0.1.0.dist-info/RECORD +16 -0
- fileflow_cli-0.1.0.dist-info/WHEEL +5 -0
- fileflow_cli-0.1.0.dist-info/entry_points.txt +2 -0
- fileflow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- fileflow_cli-0.1.0.dist-info/top_level.txt +1 -0
fileflow/engine.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""核心编排引擎:扫描 → 匹配 → 执行."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .actions import (
|
|
9
|
+
ConflictStrategy,
|
|
10
|
+
ActionResult,
|
|
11
|
+
get_action,
|
|
12
|
+
)
|
|
13
|
+
from .config import load_config
|
|
14
|
+
from .matchers import matches
|
|
15
|
+
from .report import format_results, print_summary
|
|
16
|
+
from .rules import Rule, RuleSet
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EngineResult:
|
|
20
|
+
"""一次引擎运行的结果."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
source_dir: Path,
|
|
25
|
+
results: list[ActionResult],
|
|
26
|
+
dry_run: bool,
|
|
27
|
+
rule_matches: dict[str, int],
|
|
28
|
+
) -> None:
|
|
29
|
+
self.source_dir = source_dir
|
|
30
|
+
self.results = results
|
|
31
|
+
self.dry_run = dry_run
|
|
32
|
+
self.rule_matches = rule_matches
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def success_count(self) -> int:
|
|
36
|
+
return sum(1 for r in self.results if r.success and not r.skipped)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def skipped_count(self) -> int:
|
|
40
|
+
return sum(1 for r in self.results if r.skipped)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def error_count(self) -> int:
|
|
44
|
+
return sum(1 for r in self.results if not r.success)
|
|
45
|
+
|
|
46
|
+
def print(self) -> None:
|
|
47
|
+
"""打印结果到终端."""
|
|
48
|
+
print_summary(self)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run(
|
|
52
|
+
source_dir: str | Path,
|
|
53
|
+
config_path: str | Path | None = None,
|
|
54
|
+
dry_run: bool | None = None,
|
|
55
|
+
force: bool = False,
|
|
56
|
+
recursive: bool = True,
|
|
57
|
+
) -> EngineResult:
|
|
58
|
+
"""运行文件整理.
|
|
59
|
+
|
|
60
|
+
1. 加载配置
|
|
61
|
+
2. 扫描源目录
|
|
62
|
+
3. 对每个文件匹配规则
|
|
63
|
+
4. 执行操作(或预览)
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
source_dir: 要整理的目录路径.
|
|
67
|
+
config_path: 配置文件路径,None 则自动搜索.
|
|
68
|
+
dry_run: 是否仅预览;None 则使用配置中的 dry_run_by_default.
|
|
69
|
+
force: 如果 True 且 dry_run 未明确设置,则 dry_run=False.
|
|
70
|
+
recursive: 是否递归扫描子目录.
|
|
71
|
+
"""
|
|
72
|
+
source = Path(source_dir).resolve()
|
|
73
|
+
if not source.is_dir():
|
|
74
|
+
raise ValueError(f"源路径不是目录: {source}")
|
|
75
|
+
|
|
76
|
+
config = load_config(config_path)
|
|
77
|
+
settings = config.get("settings", {})
|
|
78
|
+
|
|
79
|
+
# 确定 dry-run 模式
|
|
80
|
+
if dry_run is None:
|
|
81
|
+
dry_run = settings.get("dry_run_by_default", True)
|
|
82
|
+
|
|
83
|
+
if force and dry_run is None:
|
|
84
|
+
dry_run = False
|
|
85
|
+
|
|
86
|
+
conflict_str = ConflictStrategy(settings.get("on_conflict", "skip"))
|
|
87
|
+
create_dirs = settings.get("create_target_dirs", True)
|
|
88
|
+
|
|
89
|
+
rule_set = RuleSet.load_from_yaml(config.get("rules", []))
|
|
90
|
+
|
|
91
|
+
# 扫描文件
|
|
92
|
+
files = _scan_files(source, recursive)
|
|
93
|
+
results: list[ActionResult] = []
|
|
94
|
+
rule_matches: dict[str, int] = {}
|
|
95
|
+
unmatched_count = 0
|
|
96
|
+
|
|
97
|
+
for file_path in files:
|
|
98
|
+
matched = False
|
|
99
|
+
for rule in rule_set.active_rules:
|
|
100
|
+
if matches(file_path, rule):
|
|
101
|
+
matched = True
|
|
102
|
+
rule_matches[rule.name] = rule_matches.get(rule.name, 0) + 1
|
|
103
|
+
|
|
104
|
+
if dry_run:
|
|
105
|
+
# 预览模式:只记录不执行
|
|
106
|
+
from .templates import resolve_path
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
dest = resolve_path(rule.target, file_path, source)
|
|
110
|
+
results.append(
|
|
111
|
+
ActionResult(
|
|
112
|
+
file_path,
|
|
113
|
+
target=dest,
|
|
114
|
+
success=True,
|
|
115
|
+
action=f"[dry-run] {rule.action}",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
results.append(
|
|
120
|
+
ActionResult(
|
|
121
|
+
file_path,
|
|
122
|
+
error=str(e),
|
|
123
|
+
success=False,
|
|
124
|
+
action=rule.action,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
# 执行模式
|
|
129
|
+
action_func = get_action(rule.action)
|
|
130
|
+
result = action_func(
|
|
131
|
+
file_path,
|
|
132
|
+
rule.target,
|
|
133
|
+
source,
|
|
134
|
+
create_dirs=create_dirs,
|
|
135
|
+
conflict=conflict_str,
|
|
136
|
+
)
|
|
137
|
+
results.append(result)
|
|
138
|
+
break # 一个文件只匹配第一条规则
|
|
139
|
+
|
|
140
|
+
if not matched:
|
|
141
|
+
unmatched_count += 1
|
|
142
|
+
|
|
143
|
+
return EngineResult(
|
|
144
|
+
source_dir=source,
|
|
145
|
+
results=results,
|
|
146
|
+
dry_run=dry_run,
|
|
147
|
+
rule_matches=rule_matches,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _scan_files(directory: Path, recursive: bool) -> list[Path]:
|
|
152
|
+
"""扫描目录中的普通文件(忽略隐藏文件). """
|
|
153
|
+
files: list[Path] = []
|
|
154
|
+
|
|
155
|
+
if recursive:
|
|
156
|
+
# 排除隐藏目录(以 . 开头)
|
|
157
|
+
for entry in directory.rglob("*"):
|
|
158
|
+
if entry.is_file() and not any(
|
|
159
|
+
part.startswith(".") for part in entry.relative_to(directory).parts
|
|
160
|
+
):
|
|
161
|
+
files.append(entry)
|
|
162
|
+
else:
|
|
163
|
+
for entry in directory.iterdir():
|
|
164
|
+
if entry.is_file() and not entry.name.startswith("."):
|
|
165
|
+
files.append(entry)
|
|
166
|
+
|
|
167
|
+
return sorted(files)
|
fileflow/matchers.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""匹配器注册表和内置匹配器.
|
|
2
|
+
|
|
3
|
+
匹配器是一个可调用对象: (Path) -> bool
|
|
4
|
+
注册表模式使新增匹配器只需注册,无需修改其他代码。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .rules import Rule
|
|
15
|
+
|
|
16
|
+
# 类型: 匹配器函数签名
|
|
17
|
+
MatcherFunc = Callable[[Path], bool]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── 匹配器注册表 ──────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
_matchers: dict[str, type[Matcher]] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register(name: str) -> Callable[[type[Matcher]], type[Matcher]]:
|
|
26
|
+
"""装饰器:在注册表中注册一个匹配器类."""
|
|
27
|
+
|
|
28
|
+
def wrapper(cls: type[Matcher]) -> type[Matcher]:
|
|
29
|
+
_matchers[name] = cls
|
|
30
|
+
return cls
|
|
31
|
+
|
|
32
|
+
return wrapper
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_matcher(name: str) -> type[Matcher]:
|
|
36
|
+
"""按名称获取匹配器类."""
|
|
37
|
+
if name not in _matchers:
|
|
38
|
+
raise KeyError(f"未知的匹配器: {name!r},可选: {list(_matchers.keys())}")
|
|
39
|
+
return _matchers[name]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def list_matchers() -> list[str]:
|
|
43
|
+
"""列出所有注册的匹配器名称."""
|
|
44
|
+
return list(_matchers.keys())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── 基类 ────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Matcher:
|
|
51
|
+
"""匹配器基类.
|
|
52
|
+
|
|
53
|
+
子类需实现 __call__(path) -> bool。
|
|
54
|
+
构造时接收匹配条件值(字符串或列表)。
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, value: Any) -> None:
|
|
58
|
+
self.value = value
|
|
59
|
+
|
|
60
|
+
def __call__(self, path: Path) -> bool:
|
|
61
|
+
raise NotImplementedError
|
|
62
|
+
|
|
63
|
+
def __repr__(self) -> str:
|
|
64
|
+
return f"{type(self).__name__}({self.value!r})"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── 内置匹配器 ────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@register("extensions")
|
|
71
|
+
class ExtensionsMatcher(Matcher):
|
|
72
|
+
"""按文件扩展名匹配."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, value: list[str]) -> None:
|
|
75
|
+
# 统一处理:确保 . 开头、小写
|
|
76
|
+
self.extensions = {
|
|
77
|
+
e.lower() if e.startswith(".") else f".{e.lower()}"
|
|
78
|
+
for e in value
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def __call__(self, path: Path) -> bool:
|
|
82
|
+
if not path.is_file():
|
|
83
|
+
return False
|
|
84
|
+
return path.suffix.lower() in self.extensions
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@register("patterns")
|
|
88
|
+
class PatternsMatcher(Matcher):
|
|
89
|
+
"""按文件名通配符模式匹配(如 *.txt, data_*.csv). """
|
|
90
|
+
|
|
91
|
+
def __init__(self, value: list[str]) -> None:
|
|
92
|
+
import fnmatch
|
|
93
|
+
|
|
94
|
+
self._fnmatch = fnmatch
|
|
95
|
+
self.patterns = value
|
|
96
|
+
|
|
97
|
+
def __call__(self, path: Path) -> bool:
|
|
98
|
+
name = path.name
|
|
99
|
+
return any(self._fnmatch.fnmatch(name, p) for p in self.patterns)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@register("name_contains")
|
|
103
|
+
class NameContainsMatcher(Matcher):
|
|
104
|
+
"""按文件名包含子串匹配."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, value: list[str]) -> None:
|
|
107
|
+
self.substrings = value
|
|
108
|
+
|
|
109
|
+
def __call__(self, path: Path) -> bool:
|
|
110
|
+
name = path.name.lower()
|
|
111
|
+
return any(s.lower() in name for s in self.substrings)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@register("name_regex")
|
|
115
|
+
class NameRegexMatcher(Matcher):
|
|
116
|
+
"""按文件名正则表达式匹配."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, value: list[str]) -> None:
|
|
119
|
+
self.patterns = [re.compile(p, re.IGNORECASE) for p in value]
|
|
120
|
+
|
|
121
|
+
def __call__(self, path: Path) -> bool:
|
|
122
|
+
name = path.name
|
|
123
|
+
return any(p.search(name) is not None for p in self.patterns)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@register("min_size_mb")
|
|
127
|
+
@register("max_size_mb")
|
|
128
|
+
class SizeMatcher(Matcher):
|
|
129
|
+
"""按文件大小匹配.
|
|
130
|
+
|
|
131
|
+
支持 min_size_mb / max_size_mb / min_size_bytes / max_size_bytes。
|
|
132
|
+
值由 Rule 对象直接提供,本匹配器不直接构造。
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self, value: float | int) -> None:
|
|
136
|
+
self.limit = value
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_rule(cls, rule: Rule) -> SizeMatcher | None:
|
|
140
|
+
"""从 Rule 构建大小匹配器(合并所有大小条件)."""
|
|
141
|
+
min_bytes = None
|
|
142
|
+
max_bytes = None
|
|
143
|
+
|
|
144
|
+
if rule.min_size_mb is not None:
|
|
145
|
+
min_bytes = int(rule.min_size_mb * 1024 * 1024)
|
|
146
|
+
if rule.min_size_bytes is not None:
|
|
147
|
+
min_bytes = rule.min_size_bytes
|
|
148
|
+
if rule.max_size_mb is not None:
|
|
149
|
+
max_bytes = int(rule.max_size_mb * 1024 * 1024)
|
|
150
|
+
if rule.max_size_bytes is not None:
|
|
151
|
+
max_bytes = rule.max_size_bytes
|
|
152
|
+
|
|
153
|
+
if min_bytes is None and max_bytes is None:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
sm = cls(0)
|
|
157
|
+
sm.min_bytes = min_bytes
|
|
158
|
+
sm.max_bytes = max_bytes
|
|
159
|
+
return sm
|
|
160
|
+
|
|
161
|
+
def __call__(self, path: Path) -> bool:
|
|
162
|
+
if not path.is_file():
|
|
163
|
+
return False
|
|
164
|
+
try:
|
|
165
|
+
size = path.stat().st_size
|
|
166
|
+
except OSError:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
min_b = getattr(self, "min_bytes", None)
|
|
170
|
+
max_b = getattr(self, "max_bytes", None)
|
|
171
|
+
|
|
172
|
+
if min_b is not None and size < min_b:
|
|
173
|
+
return False
|
|
174
|
+
if max_b is not None and size > max_b:
|
|
175
|
+
return False
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── 规则匹配引擎 ──────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def build_matchers(rule: Rule) -> list[MatcherFunc]:
|
|
183
|
+
"""从一条规则构建所有匹配器函数列表.
|
|
184
|
+
|
|
185
|
+
返回值是一个可调用函数列表,每个接受 Path 返回 bool。
|
|
186
|
+
"""
|
|
187
|
+
matchers: list[MatcherFunc] = []
|
|
188
|
+
|
|
189
|
+
if rule.extensions:
|
|
190
|
+
matchers.append(ExtensionsMatcher(rule.extensions))
|
|
191
|
+
if rule.patterns:
|
|
192
|
+
matchers.append(PatternsMatcher(rule.patterns))
|
|
193
|
+
if rule.name_contains:
|
|
194
|
+
matchers.append(NameContainsMatcher(rule.name_contains))
|
|
195
|
+
if rule.name_regex:
|
|
196
|
+
matchers.append(NameRegexMatcher(rule.name_regex))
|
|
197
|
+
|
|
198
|
+
size_matcher = SizeMatcher.from_rule(rule)
|
|
199
|
+
if size_matcher is not None:
|
|
200
|
+
matchers.append(size_matcher)
|
|
201
|
+
|
|
202
|
+
return matchers
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def matches(path: Path, rule: Rule) -> bool:
|
|
206
|
+
"""判断一个文件是否匹配一条规则.
|
|
207
|
+
|
|
208
|
+
- 如果规则没有任何匹配条件,返回 False(不会匹配所有文件)
|
|
209
|
+
- 多个匹配条件是 AND 关系:文件必须满足所有条件
|
|
210
|
+
"""
|
|
211
|
+
checkers = build_matchers(rule)
|
|
212
|
+
if not checkers:
|
|
213
|
+
return False
|
|
214
|
+
return all(check(path) for check in checkers)
|
fileflow/report.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Rich 终端格式化输出."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from rich import box
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from .actions import ActionResult
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .engine import EngineResult
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def print_banner() -> None:
|
|
23
|
+
"""打印 FileFlow 启动横幅."""
|
|
24
|
+
banner = Text()
|
|
25
|
+
banner.append("🧹 FileFlow", style="bold cyan")
|
|
26
|
+
banner.append(" v", style="dim")
|
|
27
|
+
banner.append("0.1.0", style="bold yellow")
|
|
28
|
+
banner.append(" — 智能文件整理器", style="dim")
|
|
29
|
+
console.print(banner)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def print_config_preview(config: dict[str, Any]) -> None:
|
|
33
|
+
"""打印配置预览."""
|
|
34
|
+
rules = config.get("rules", [])
|
|
35
|
+
table = Table(
|
|
36
|
+
title="📋 规则列表",
|
|
37
|
+
box=box.ROUNDED,
|
|
38
|
+
header_style="bold cyan",
|
|
39
|
+
)
|
|
40
|
+
table.add_column("名称", style="bold")
|
|
41
|
+
table.add_column("操作", style="yellow")
|
|
42
|
+
table.add_column("目标路径", style="green")
|
|
43
|
+
table.add_column("条件", style="dim")
|
|
44
|
+
table.add_column("状态")
|
|
45
|
+
|
|
46
|
+
for rule in rules:
|
|
47
|
+
match = rule.get("match", {})
|
|
48
|
+
conditions = []
|
|
49
|
+
if match.get("extensions"):
|
|
50
|
+
conditions.append(f"ext: {', '.join(match['extensions'])}")
|
|
51
|
+
if match.get("patterns"):
|
|
52
|
+
conditions.append(f"pattern: {', '.join(match['patterns'])}")
|
|
53
|
+
if match.get("name_contains"):
|
|
54
|
+
conditions.append(f"name contains: {', '.join(match['name_contains'])}")
|
|
55
|
+
if match.get("max_size_mb"):
|
|
56
|
+
conditions.append(f"< {match['max_size_mb']}MB")
|
|
57
|
+
|
|
58
|
+
status = "[green]✓ 启用[/]" if rule.get("enabled", True) else "[red]✗ 禁用[/]"
|
|
59
|
+
table.add_row(
|
|
60
|
+
rule.get("name", ""),
|
|
61
|
+
rule.get("action", "move"),
|
|
62
|
+
rule.get("target", ""),
|
|
63
|
+
"\n".join(conditions) or "[dim]无[/]",
|
|
64
|
+
status,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
console.print(table)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def format_results(results: list[ActionResult]) -> None:
|
|
71
|
+
"""格式化输出操作结果列表."""
|
|
72
|
+
for result in results:
|
|
73
|
+
style = "green" if result.success else "red"
|
|
74
|
+
icon = "✓" if result.success else "✗"
|
|
75
|
+
if result.skipped:
|
|
76
|
+
style = "yellow"
|
|
77
|
+
icon = "–"
|
|
78
|
+
if result.error:
|
|
79
|
+
console.print(f" [{style}]{icon} {result.source.name}[/] [red]{result.error}[/]")
|
|
80
|
+
elif result.skipped:
|
|
81
|
+
console.print(f" [{style}]{icon} {result.source.name}[/] (已存在)")
|
|
82
|
+
else:
|
|
83
|
+
target = result.target or ""
|
|
84
|
+
console.print(f" [{style}]{icon} {result.source.name}[/] → {target}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def print_summary(result: EngineResult) -> None:
|
|
88
|
+
"""打印引擎运行摘要."""
|
|
89
|
+
header = "[bold cyan]🔍 预览结果[/]" if result.dry_run else "[bold green]📦 整理完成[/]"
|
|
90
|
+
console.print()
|
|
91
|
+
|
|
92
|
+
# 匹配统计
|
|
93
|
+
table = Table(box=box.SIMPLE, header_style="bold")
|
|
94
|
+
table.add_column("规则", style="bold")
|
|
95
|
+
table.add_column("匹配数", justify="right")
|
|
96
|
+
|
|
97
|
+
for rule_name, count in sorted(result.rule_matches.items()):
|
|
98
|
+
table.add_row(rule_name, str(count))
|
|
99
|
+
|
|
100
|
+
if table.row_count > 0:
|
|
101
|
+
console.print(Panel(table, title=header))
|
|
102
|
+
|
|
103
|
+
# 汇总信息
|
|
104
|
+
total = len(result.results)
|
|
105
|
+
success = result.success_count
|
|
106
|
+
skipped = result.skipped_count
|
|
107
|
+
errors = result.error_count
|
|
108
|
+
|
|
109
|
+
summary = Text()
|
|
110
|
+
summary.append(f"\n 共处理 {total} 个文件", style="bold")
|
|
111
|
+
if success:
|
|
112
|
+
summary.append(f" ✅ {success} 成功", style="green")
|
|
113
|
+
if skipped:
|
|
114
|
+
summary.append(f" ⏭ {skipped} 跳过", style="yellow")
|
|
115
|
+
if errors:
|
|
116
|
+
summary.append(f" ❌ {errors} 失败", style="red")
|
|
117
|
+
|
|
118
|
+
console.print(summary)
|
|
119
|
+
|
|
120
|
+
# 打印详细信息
|
|
121
|
+
if result.results:
|
|
122
|
+
console.print("\n[bold]详细信息:[/]")
|
|
123
|
+
format_results(result.results)
|
|
124
|
+
|
|
125
|
+
if not result.dry_run and result.success_count > 0:
|
|
126
|
+
console.print("\n[dim]💡 提示: 使用 fileflow undo 可撤销本次操作[/]")
|
|
127
|
+
|
|
128
|
+
console.print()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def print_rule_list(rules: list[dict[str, Any]], verbose: bool = False) -> None:
|
|
132
|
+
"""打印规则列表."""
|
|
133
|
+
table = Table(
|
|
134
|
+
title="📋 规则配置",
|
|
135
|
+
box=box.ROUNDED,
|
|
136
|
+
header_style="bold cyan",
|
|
137
|
+
)
|
|
138
|
+
table.add_column("#", justify="right")
|
|
139
|
+
table.add_column("名称", style="bold")
|
|
140
|
+
table.add_column("描述", style="dim")
|
|
141
|
+
table.add_column("操作", style="yellow")
|
|
142
|
+
table.add_column("目标")
|
|
143
|
+
table.add_column("状态")
|
|
144
|
+
|
|
145
|
+
for i, rule in enumerate(rules, 1):
|
|
146
|
+
status = "[green]启用[/]" if rule.get("enabled", True) else "[red]禁用[/]"
|
|
147
|
+
table.add_row(
|
|
148
|
+
str(i),
|
|
149
|
+
rule.get("name", ""),
|
|
150
|
+
rule.get("description", ""),
|
|
151
|
+
rule.get("action", "move"),
|
|
152
|
+
rule.get("target", ""),
|
|
153
|
+
status,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if verbose and "match" in rule:
|
|
157
|
+
match = rule["match"]
|
|
158
|
+
details = []
|
|
159
|
+
for k, v in match.items():
|
|
160
|
+
details.append(f" {k}: {v}")
|
|
161
|
+
if details:
|
|
162
|
+
table.add_row("", "", "", "", "", "")
|
|
163
|
+
# 使用 add_row 的 extra 特性要小心,我们直接用 panel
|
|
164
|
+
console.print(table)
|
fileflow/rules.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""规则数据模型."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Rule:
|
|
12
|
+
"""一条整理规则."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
description: str = ""
|
|
16
|
+
action: str = "move"
|
|
17
|
+
target: str = ""
|
|
18
|
+
enabled: bool = True
|
|
19
|
+
|
|
20
|
+
# 匹配条件
|
|
21
|
+
extensions: list[str] = field(default_factory=list)
|
|
22
|
+
patterns: list[str] = field(default_factory=list)
|
|
23
|
+
name_contains: list[str] = field(default_factory=list)
|
|
24
|
+
name_regex: list[str] = field(default_factory=list)
|
|
25
|
+
min_size_mb: float | None = None
|
|
26
|
+
max_size_mb: float | None = None
|
|
27
|
+
min_size_bytes: int | None = None
|
|
28
|
+
max_size_bytes: int | None = None
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_dict(cls, data: dict[str, Any]) -> Rule:
|
|
32
|
+
"""从 YAML 字典构建规则."""
|
|
33
|
+
match: dict[str, Any] = data.get("match", {})
|
|
34
|
+
|
|
35
|
+
return cls(
|
|
36
|
+
name=data.get("name", ""),
|
|
37
|
+
description=data.get("description", ""),
|
|
38
|
+
action=data.get("action", "move"),
|
|
39
|
+
target=data.get("target", ""),
|
|
40
|
+
enabled=data.get("enabled", True),
|
|
41
|
+
extensions=match.get("extensions", []),
|
|
42
|
+
patterns=match.get("patterns", []),
|
|
43
|
+
name_contains=match.get("name_contains", []),
|
|
44
|
+
name_regex=match.get("name_regex", []),
|
|
45
|
+
min_size_mb=match.get("min_size_mb"),
|
|
46
|
+
max_size_mb=match.get("max_size_mb"),
|
|
47
|
+
min_size_bytes=match.get("min_size_bytes"),
|
|
48
|
+
max_size_bytes=match.get("max_size_bytes"),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
"""转换为可序列化字典(用于历史记录等). """
|
|
53
|
+
return {
|
|
54
|
+
"name": self.name,
|
|
55
|
+
"description": self.description,
|
|
56
|
+
"action": self.action,
|
|
57
|
+
"target": self.target,
|
|
58
|
+
"enabled": self.enabled,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RuleSet:
|
|
63
|
+
"""一组规则的集合,支持启用/禁用过滤."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, rules: list[Rule] | None = None) -> None:
|
|
66
|
+
self.rules = rules or []
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def load_from_yaml(cls, data: list[dict[str, Any]]) -> RuleSet:
|
|
70
|
+
"""从 YAML 解析的列表加载规则集."""
|
|
71
|
+
return cls([Rule.from_dict(item) for item in data])
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def active_rules(self) -> list[Rule]:
|
|
75
|
+
"""仅返回启用的规则."""
|
|
76
|
+
return [r for r in self.rules if r.enabled]
|
|
77
|
+
|
|
78
|
+
def add_rule(self, rule: Rule) -> None:
|
|
79
|
+
"""添加一条规则."""
|
|
80
|
+
self.rules.append(rule)
|
|
81
|
+
|
|
82
|
+
def __len__(self) -> int:
|
|
83
|
+
return len(self.rules)
|
|
84
|
+
|
|
85
|
+
def __getitem__(self, index: int) -> Rule:
|
|
86
|
+
return self.rules[index]
|