sortmeout 1.0.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.
- sortmeout/__init__.py +23 -0
- sortmeout/app.py +618 -0
- sortmeout/cli.py +550 -0
- sortmeout/config/__init__.py +11 -0
- sortmeout/config/manager.py +313 -0
- sortmeout/config/settings.py +201 -0
- sortmeout/core/__init__.py +21 -0
- sortmeout/core/action.py +889 -0
- sortmeout/core/condition.py +672 -0
- sortmeout/core/engine.py +421 -0
- sortmeout/core/rule.py +254 -0
- sortmeout/core/watcher.py +471 -0
- sortmeout/gui/__init__.py +10 -0
- sortmeout/gui/app.py +325 -0
- sortmeout/macos/__init__.py +19 -0
- sortmeout/macos/spotlight.py +337 -0
- sortmeout/macos/tags.py +308 -0
- sortmeout/macos/trash.py +449 -0
- sortmeout/utils/__init__.py +12 -0
- sortmeout/utils/file_info.py +363 -0
- sortmeout/utils/logger.py +214 -0
- sortmeout-1.0.0.dist-info/METADATA +302 -0
- sortmeout-1.0.0.dist-info/RECORD +27 -0
- sortmeout-1.0.0.dist-info/WHEEL +5 -0
- sortmeout-1.0.0.dist-info/entry_points.txt +3 -0
- sortmeout-1.0.0.dist-info/licenses/LICENSE +21 -0
- sortmeout-1.0.0.dist-info/top_level.txt +1 -0
sortmeout/core/engine.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rule execution engine.
|
|
3
|
+
|
|
4
|
+
The engine processes files against rules, evaluates conditions,
|
|
5
|
+
and executes actions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import mimetypes
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
from sortmeout.core.rule import Rule
|
|
19
|
+
from sortmeout.core.action import Action, ActionResult
|
|
20
|
+
from sortmeout.utils.logger import get_logger
|
|
21
|
+
from sortmeout.utils.file_info import get_file_info
|
|
22
|
+
|
|
23
|
+
logger = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ProcessingResult:
|
|
28
|
+
"""
|
|
29
|
+
Result of processing a file through rules.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
file_path: Path to the processed file.
|
|
33
|
+
matched_rules: Names of rules that matched.
|
|
34
|
+
executed_actions: Results of executed actions.
|
|
35
|
+
errors: Any errors that occurred.
|
|
36
|
+
processing_time: Time taken to process.
|
|
37
|
+
"""
|
|
38
|
+
file_path: str
|
|
39
|
+
matched_rules: List[str] = field(default_factory=list)
|
|
40
|
+
executed_actions: List[ActionResult] = field(default_factory=list)
|
|
41
|
+
errors: List[str] = field(default_factory=list)
|
|
42
|
+
processing_time: float = 0.0
|
|
43
|
+
stopped: bool = False # Processing was stopped by a rule
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def success(self) -> bool:
|
|
47
|
+
"""Check if processing was successful (no errors)."""
|
|
48
|
+
return len(self.errors) == 0
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str:
|
|
51
|
+
return (
|
|
52
|
+
f"ProcessingResult(file={Path(self.file_path).name}, "
|
|
53
|
+
f"matched={len(self.matched_rules)}, "
|
|
54
|
+
f"actions={len(self.executed_actions)}, "
|
|
55
|
+
f"errors={len(self.errors)})"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RuleEngine:
|
|
60
|
+
"""
|
|
61
|
+
Engine for processing files against rules.
|
|
62
|
+
|
|
63
|
+
The engine:
|
|
64
|
+
1. Gathers file information
|
|
65
|
+
2. Evaluates each rule's conditions
|
|
66
|
+
3. Executes actions for matching rules
|
|
67
|
+
4. Handles errors and continues/stops as configured
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
preview_mode: If True, actions are not actually executed.
|
|
71
|
+
stop_on_error: If True, stop processing on first error.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
preview_mode: bool = False,
|
|
77
|
+
stop_on_error: bool = False,
|
|
78
|
+
max_rules_per_file: int = 100,
|
|
79
|
+
):
|
|
80
|
+
"""
|
|
81
|
+
Initialize the rule engine.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
preview_mode: Don't actually execute actions.
|
|
85
|
+
stop_on_error: Stop on first error.
|
|
86
|
+
max_rules_per_file: Maximum rules to process per file (safety limit).
|
|
87
|
+
"""
|
|
88
|
+
self.preview_mode = preview_mode
|
|
89
|
+
self.stop_on_error = stop_on_error
|
|
90
|
+
self.max_rules_per_file = max_rules_per_file
|
|
91
|
+
|
|
92
|
+
# Statistics
|
|
93
|
+
self._stats = {
|
|
94
|
+
"files_processed": 0,
|
|
95
|
+
"rules_evaluated": 0,
|
|
96
|
+
"rules_matched": 0,
|
|
97
|
+
"actions_executed": 0,
|
|
98
|
+
"errors": 0,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def process_file(
|
|
102
|
+
self,
|
|
103
|
+
file_path: str,
|
|
104
|
+
rules: List[Rule],
|
|
105
|
+
file_info: Optional[Dict[str, Any]] = None,
|
|
106
|
+
) -> ProcessingResult:
|
|
107
|
+
"""
|
|
108
|
+
Process a file against a list of rules.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
file_path: Path to the file to process.
|
|
112
|
+
rules: List of rules to evaluate.
|
|
113
|
+
file_info: Pre-computed file information (optional).
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
ProcessingResult with matched rules and action results.
|
|
117
|
+
"""
|
|
118
|
+
start_time = datetime.now()
|
|
119
|
+
result = ProcessingResult(file_path=file_path)
|
|
120
|
+
|
|
121
|
+
# Check if file still exists
|
|
122
|
+
if not os.path.exists(file_path):
|
|
123
|
+
result.errors.append(f"File does not exist: {file_path}")
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
# Get file information
|
|
127
|
+
if file_info is None:
|
|
128
|
+
try:
|
|
129
|
+
file_info = get_file_info(file_path)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
result.errors.append(f"Failed to get file info: {e}")
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
logger.debug("Processing file: %s", file_path)
|
|
135
|
+
logger.debug("File info: %s", file_info)
|
|
136
|
+
|
|
137
|
+
# Track current file path (may change during processing)
|
|
138
|
+
current_path = file_path
|
|
139
|
+
rules_processed = 0
|
|
140
|
+
|
|
141
|
+
# Process each rule
|
|
142
|
+
for rule in rules:
|
|
143
|
+
if rules_processed >= self.max_rules_per_file:
|
|
144
|
+
logger.warning("Max rules limit reached for file: %s", file_path)
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
rules_processed += 1
|
|
148
|
+
self._stats["rules_evaluated"] += 1
|
|
149
|
+
|
|
150
|
+
# Skip disabled rules
|
|
151
|
+
if not rule.enabled:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
# Check if rule matches
|
|
155
|
+
try:
|
|
156
|
+
matches = rule.matches(file_info)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.error("Error evaluating rule '%s': %s", rule.name, e)
|
|
159
|
+
result.errors.append(f"Rule '{rule.name}' evaluation error: {e}")
|
|
160
|
+
if self.stop_on_error:
|
|
161
|
+
break
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
if not matches:
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# Rule matched!
|
|
168
|
+
logger.info("Rule '%s' matched file: %s", rule.name, current_path)
|
|
169
|
+
result.matched_rules.append(rule.name)
|
|
170
|
+
self._stats["rules_matched"] += 1
|
|
171
|
+
|
|
172
|
+
# Execute actions
|
|
173
|
+
action_results, new_path, stop = self._execute_actions(
|
|
174
|
+
rule.actions,
|
|
175
|
+
current_path,
|
|
176
|
+
file_info,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
result.executed_actions.extend(action_results)
|
|
180
|
+
|
|
181
|
+
# Check for action errors
|
|
182
|
+
for ar in action_results:
|
|
183
|
+
if not ar.success:
|
|
184
|
+
result.errors.append(f"Action failed: {ar.error}")
|
|
185
|
+
self._stats["errors"] += 1
|
|
186
|
+
|
|
187
|
+
# Update current path if file was moved/renamed
|
|
188
|
+
if new_path and new_path != current_path:
|
|
189
|
+
current_path = new_path
|
|
190
|
+
# Update file_info for subsequent rules
|
|
191
|
+
try:
|
|
192
|
+
file_info = get_file_info(current_path)
|
|
193
|
+
except:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
# Check if we should stop processing
|
|
197
|
+
if stop or not rule.continue_processing:
|
|
198
|
+
result.stopped = True
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
# Stop on error if configured
|
|
202
|
+
if result.errors and self.stop_on_error:
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
# Calculate processing time
|
|
206
|
+
result.processing_time = (datetime.now() - start_time).total_seconds()
|
|
207
|
+
self._stats["files_processed"] += 1
|
|
208
|
+
|
|
209
|
+
logger.debug("Finished processing: %s", result)
|
|
210
|
+
return result
|
|
211
|
+
|
|
212
|
+
def _execute_actions(
|
|
213
|
+
self,
|
|
214
|
+
actions: List[Action],
|
|
215
|
+
file_path: str,
|
|
216
|
+
file_info: Dict[str, Any],
|
|
217
|
+
) -> tuple[List[ActionResult], Optional[str], bool]:
|
|
218
|
+
"""
|
|
219
|
+
Execute a list of actions on a file.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
actions: Actions to execute.
|
|
223
|
+
file_path: Current file path.
|
|
224
|
+
file_info: File information.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Tuple of (action_results, new_path, should_stop).
|
|
228
|
+
"""
|
|
229
|
+
results = []
|
|
230
|
+
current_path = file_path
|
|
231
|
+
should_stop = False
|
|
232
|
+
|
|
233
|
+
for action in actions:
|
|
234
|
+
if not action.enabled:
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
# Check if file still exists (may have been deleted by previous action)
|
|
238
|
+
if not os.path.exists(current_path):
|
|
239
|
+
logger.warning("File no longer exists, stopping actions: %s", current_path)
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
# Execute the action
|
|
243
|
+
try:
|
|
244
|
+
result = action.execute(
|
|
245
|
+
current_path,
|
|
246
|
+
file_info,
|
|
247
|
+
preview=self.preview_mode,
|
|
248
|
+
)
|
|
249
|
+
results.append(result)
|
|
250
|
+
self._stats["actions_executed"] += 1
|
|
251
|
+
|
|
252
|
+
# Update path if action moved/renamed the file
|
|
253
|
+
if result.success and result.destination_path:
|
|
254
|
+
current_path = result.destination_path
|
|
255
|
+
|
|
256
|
+
# Stop on error if action is configured to do so
|
|
257
|
+
if not result.success and action.stop_on_error:
|
|
258
|
+
logger.warning("Action failed, stopping: %s", result.error)
|
|
259
|
+
should_stop = True
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
except Exception as e:
|
|
263
|
+
logger.error("Action execution error: %s", e)
|
|
264
|
+
results.append(ActionResult(
|
|
265
|
+
success=False,
|
|
266
|
+
action_type=action.action_type,
|
|
267
|
+
source_path=current_path,
|
|
268
|
+
error=str(e),
|
|
269
|
+
))
|
|
270
|
+
|
|
271
|
+
if action.stop_on_error:
|
|
272
|
+
should_stop = True
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
return results, current_path if current_path != file_path else None, should_stop
|
|
276
|
+
|
|
277
|
+
def evaluate_rule(
|
|
278
|
+
self,
|
|
279
|
+
rule: Rule,
|
|
280
|
+
file_path: str,
|
|
281
|
+
file_info: Optional[Dict[str, Any]] = None,
|
|
282
|
+
) -> bool:
|
|
283
|
+
"""
|
|
284
|
+
Evaluate a single rule against a file without executing actions.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
rule: Rule to evaluate.
|
|
288
|
+
file_path: Path to the file.
|
|
289
|
+
file_info: Pre-computed file information.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
True if the rule matches.
|
|
293
|
+
"""
|
|
294
|
+
if file_info is None:
|
|
295
|
+
file_info = get_file_info(file_path)
|
|
296
|
+
|
|
297
|
+
return rule.matches(file_info)
|
|
298
|
+
|
|
299
|
+
def preview_rule(
|
|
300
|
+
self,
|
|
301
|
+
rule: Rule,
|
|
302
|
+
file_path: str,
|
|
303
|
+
file_info: Optional[Dict[str, Any]] = None,
|
|
304
|
+
) -> List[ActionResult]:
|
|
305
|
+
"""
|
|
306
|
+
Preview what actions a rule would perform on a file.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
rule: Rule to preview.
|
|
310
|
+
file_path: Path to the file.
|
|
311
|
+
file_info: Pre-computed file information.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
List of ActionResults (with preview=True).
|
|
315
|
+
"""
|
|
316
|
+
if file_info is None:
|
|
317
|
+
file_info = get_file_info(file_path)
|
|
318
|
+
|
|
319
|
+
if not rule.matches(file_info):
|
|
320
|
+
return []
|
|
321
|
+
|
|
322
|
+
results = []
|
|
323
|
+
for action in rule.actions:
|
|
324
|
+
if action.enabled:
|
|
325
|
+
result = action.execute(file_path, file_info, preview=True)
|
|
326
|
+
results.append(result)
|
|
327
|
+
|
|
328
|
+
return results
|
|
329
|
+
|
|
330
|
+
def get_stats(self) -> Dict[str, int]:
|
|
331
|
+
"""Get engine statistics."""
|
|
332
|
+
return self._stats.copy()
|
|
333
|
+
|
|
334
|
+
def reset_stats(self) -> None:
|
|
335
|
+
"""Reset engine statistics."""
|
|
336
|
+
self._stats = {
|
|
337
|
+
"files_processed": 0,
|
|
338
|
+
"rules_evaluated": 0,
|
|
339
|
+
"rules_matched": 0,
|
|
340
|
+
"actions_executed": 0,
|
|
341
|
+
"errors": 0,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class BatchProcessor:
|
|
346
|
+
"""
|
|
347
|
+
Process multiple files in batch.
|
|
348
|
+
|
|
349
|
+
Useful for processing existing files in a folder when rules are first added.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
def __init__(self, engine: RuleEngine):
|
|
353
|
+
"""
|
|
354
|
+
Initialize batch processor.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
engine: Rule engine to use for processing.
|
|
358
|
+
"""
|
|
359
|
+
self.engine = engine
|
|
360
|
+
|
|
361
|
+
def process_folder(
|
|
362
|
+
self,
|
|
363
|
+
folder_path: str,
|
|
364
|
+
rules: List[Rule],
|
|
365
|
+
recursive: bool = False,
|
|
366
|
+
file_filter: Optional[callable] = None,
|
|
367
|
+
) -> List[ProcessingResult]:
|
|
368
|
+
"""
|
|
369
|
+
Process all files in a folder.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
folder_path: Path to the folder.
|
|
373
|
+
rules: Rules to apply.
|
|
374
|
+
recursive: Process subdirectories.
|
|
375
|
+
file_filter: Optional function to filter files.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
List of ProcessingResults.
|
|
379
|
+
"""
|
|
380
|
+
results = []
|
|
381
|
+
folder = Path(folder_path)
|
|
382
|
+
|
|
383
|
+
if recursive:
|
|
384
|
+
files = folder.rglob("*")
|
|
385
|
+
else:
|
|
386
|
+
files = folder.glob("*")
|
|
387
|
+
|
|
388
|
+
for path in files:
|
|
389
|
+
if not path.is_file():
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
if file_filter and not file_filter(str(path)):
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
result = self.engine.process_file(str(path), rules)
|
|
396
|
+
results.append(result)
|
|
397
|
+
|
|
398
|
+
return results
|
|
399
|
+
|
|
400
|
+
def process_files(
|
|
401
|
+
self,
|
|
402
|
+
file_paths: List[str],
|
|
403
|
+
rules: List[Rule],
|
|
404
|
+
) -> List[ProcessingResult]:
|
|
405
|
+
"""
|
|
406
|
+
Process a list of specific files.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
file_paths: Paths to files.
|
|
410
|
+
rules: Rules to apply.
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
List of ProcessingResults.
|
|
414
|
+
"""
|
|
415
|
+
results = []
|
|
416
|
+
|
|
417
|
+
for file_path in file_paths:
|
|
418
|
+
result = self.engine.process_file(file_path, rules)
|
|
419
|
+
results.append(result)
|
|
420
|
+
|
|
421
|
+
return results
|
sortmeout/core/rule.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rule definition and management.
|
|
3
|
+
|
|
4
|
+
Rules are the core concept in SortMeOut. Each rule consists of:
|
|
5
|
+
- Conditions: Criteria that a file must match
|
|
6
|
+
- Actions: Operations to perform on matching files
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
from enum import Enum
|
|
16
|
+
|
|
17
|
+
from sortmeout.core.condition import Condition, ConditionGroup
|
|
18
|
+
from sortmeout.core.action import Action
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RuleMatchMode(Enum):
|
|
22
|
+
"""How conditions are combined when matching."""
|
|
23
|
+
ALL = "all" # All conditions must match (AND)
|
|
24
|
+
ANY = "any" # Any condition must match (OR)
|
|
25
|
+
NONE = "none" # No conditions must match (NOT)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Rule:
|
|
30
|
+
"""
|
|
31
|
+
A rule that defines conditions and actions for file processing.
|
|
32
|
+
|
|
33
|
+
Rules are evaluated in order. When a file matches a rule's conditions,
|
|
34
|
+
the rule's actions are executed. By default, processing stops after
|
|
35
|
+
the first matching rule, but this can be changed with continue_processing.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
name: Human-readable name for the rule.
|
|
39
|
+
conditions: List of conditions that must be satisfied.
|
|
40
|
+
actions: List of actions to perform on matching files.
|
|
41
|
+
enabled: Whether the rule is active.
|
|
42
|
+
match_mode: How conditions are combined (all, any, none).
|
|
43
|
+
continue_processing: Continue with next rules after match.
|
|
44
|
+
run_on_folder_open: Run rule when folder is first opened.
|
|
45
|
+
id: Unique identifier for the rule.
|
|
46
|
+
created_at: When the rule was created.
|
|
47
|
+
updated_at: When the rule was last updated.
|
|
48
|
+
description: Optional description of the rule.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
>>> rule = Rule(
|
|
52
|
+
... name="Organize PDFs",
|
|
53
|
+
... conditions=[
|
|
54
|
+
... Condition("extension", "equals", "pdf"),
|
|
55
|
+
... Condition("size", "greater_than", "1MB"),
|
|
56
|
+
... ],
|
|
57
|
+
... actions=[
|
|
58
|
+
... Action("move", destination="~/Documents/PDFs"),
|
|
59
|
+
... ]
|
|
60
|
+
... )
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
conditions: List[Condition | ConditionGroup] = field(default_factory=list)
|
|
65
|
+
actions: List[Action] = field(default_factory=list)
|
|
66
|
+
enabled: bool = True
|
|
67
|
+
match_mode: RuleMatchMode = RuleMatchMode.ALL
|
|
68
|
+
continue_processing: bool = False
|
|
69
|
+
run_on_folder_open: bool = True
|
|
70
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
71
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
72
|
+
updated_at: datetime = field(default_factory=datetime.now)
|
|
73
|
+
description: Optional[str] = None
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
"""Validate rule after initialization."""
|
|
77
|
+
if not self.name:
|
|
78
|
+
raise ValueError("Rule name cannot be empty")
|
|
79
|
+
|
|
80
|
+
if isinstance(self.match_mode, str):
|
|
81
|
+
self.match_mode = RuleMatchMode(self.match_mode)
|
|
82
|
+
|
|
83
|
+
def matches(self, file_info: Dict[str, Any]) -> bool:
|
|
84
|
+
"""
|
|
85
|
+
Check if a file matches this rule's conditions.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
file_info: Dictionary containing file attributes.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
True if the file matches the rule's conditions.
|
|
92
|
+
"""
|
|
93
|
+
if not self.enabled:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
if not self.conditions:
|
|
97
|
+
# No conditions means always match
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
results = [cond.evaluate(file_info) for cond in self.conditions]
|
|
101
|
+
|
|
102
|
+
if self.match_mode == RuleMatchMode.ALL:
|
|
103
|
+
return all(results)
|
|
104
|
+
elif self.match_mode == RuleMatchMode.ANY:
|
|
105
|
+
return any(results)
|
|
106
|
+
elif self.match_mode == RuleMatchMode.NONE:
|
|
107
|
+
return not any(results)
|
|
108
|
+
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
def add_condition(self, condition: Condition | ConditionGroup) -> "Rule":
|
|
112
|
+
"""
|
|
113
|
+
Add a condition to the rule.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
condition: Condition to add.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Self for chaining.
|
|
120
|
+
"""
|
|
121
|
+
self.conditions.append(condition)
|
|
122
|
+
self.updated_at = datetime.now()
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def add_action(self, action: Action) -> "Rule":
|
|
126
|
+
"""
|
|
127
|
+
Add an action to the rule.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
action: Action to add.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Self for chaining.
|
|
134
|
+
"""
|
|
135
|
+
self.actions.append(action)
|
|
136
|
+
self.updated_at = datetime.now()
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def remove_condition(self, index: int) -> bool:
|
|
140
|
+
"""
|
|
141
|
+
Remove a condition by index.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
index: Index of condition to remove.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
True if condition was removed.
|
|
148
|
+
"""
|
|
149
|
+
if 0 <= index < len(self.conditions):
|
|
150
|
+
del self.conditions[index]
|
|
151
|
+
self.updated_at = datetime.now()
|
|
152
|
+
return True
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def remove_action(self, index: int) -> bool:
|
|
156
|
+
"""
|
|
157
|
+
Remove an action by index.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
index: Index of action to remove.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
True if action was removed.
|
|
164
|
+
"""
|
|
165
|
+
if 0 <= index < len(self.actions):
|
|
166
|
+
del self.actions[index]
|
|
167
|
+
self.updated_at = datetime.now()
|
|
168
|
+
return True
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
def duplicate(self, new_name: Optional[str] = None) -> "Rule":
|
|
172
|
+
"""
|
|
173
|
+
Create a copy of this rule.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
new_name: Name for the new rule.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
A new Rule instance.
|
|
180
|
+
"""
|
|
181
|
+
return Rule(
|
|
182
|
+
name=new_name or f"{self.name} (Copy)",
|
|
183
|
+
conditions=[c.duplicate() for c in self.conditions],
|
|
184
|
+
actions=[a.duplicate() for a in self.actions],
|
|
185
|
+
enabled=self.enabled,
|
|
186
|
+
match_mode=self.match_mode,
|
|
187
|
+
continue_processing=self.continue_processing,
|
|
188
|
+
run_on_folder_open=self.run_on_folder_open,
|
|
189
|
+
description=self.description,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
193
|
+
"""
|
|
194
|
+
Convert rule to dictionary for serialization.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Dictionary representation of the rule.
|
|
198
|
+
"""
|
|
199
|
+
return {
|
|
200
|
+
"id": self.id,
|
|
201
|
+
"name": self.name,
|
|
202
|
+
"description": self.description,
|
|
203
|
+
"enabled": self.enabled,
|
|
204
|
+
"match_mode": self.match_mode.value,
|
|
205
|
+
"continue_processing": self.continue_processing,
|
|
206
|
+
"run_on_folder_open": self.run_on_folder_open,
|
|
207
|
+
"conditions": [c.to_dict() for c in self.conditions],
|
|
208
|
+
"actions": [a.to_dict() for a in self.actions],
|
|
209
|
+
"created_at": self.created_at.isoformat(),
|
|
210
|
+
"updated_at": self.updated_at.isoformat(),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Rule":
|
|
215
|
+
"""
|
|
216
|
+
Create a rule from a dictionary.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
data: Dictionary containing rule data.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
A new Rule instance.
|
|
223
|
+
"""
|
|
224
|
+
conditions = []
|
|
225
|
+
for cond_data in data.get("conditions", []):
|
|
226
|
+
if "conditions" in cond_data: # It's a condition group
|
|
227
|
+
conditions.append(ConditionGroup.from_dict(cond_data))
|
|
228
|
+
else:
|
|
229
|
+
conditions.append(Condition.from_dict(cond_data))
|
|
230
|
+
|
|
231
|
+
actions = [Action.from_dict(a) for a in data.get("actions", [])]
|
|
232
|
+
|
|
233
|
+
return cls(
|
|
234
|
+
id=data.get("id", str(uuid.uuid4())),
|
|
235
|
+
name=data["name"],
|
|
236
|
+
description=data.get("description"),
|
|
237
|
+
enabled=data.get("enabled", True),
|
|
238
|
+
match_mode=RuleMatchMode(data.get("match_mode", "all")),
|
|
239
|
+
continue_processing=data.get("continue_processing", False),
|
|
240
|
+
run_on_folder_open=data.get("run_on_folder_open", True),
|
|
241
|
+
conditions=conditions,
|
|
242
|
+
actions=actions,
|
|
243
|
+
created_at=datetime.fromisoformat(data["created_at"]) if "created_at" in data else datetime.now(),
|
|
244
|
+
updated_at=datetime.fromisoformat(data["updated_at"]) if "updated_at" in data else datetime.now(),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def __str__(self) -> str:
|
|
248
|
+
"""String representation of the rule."""
|
|
249
|
+
status = "✓" if self.enabled else "✗"
|
|
250
|
+
return f"[{status}] {self.name} ({len(self.conditions)} conditions, {len(self.actions)} actions)"
|
|
251
|
+
|
|
252
|
+
def __repr__(self) -> str:
|
|
253
|
+
"""Detailed string representation."""
|
|
254
|
+
return f"Rule(name={self.name!r}, enabled={self.enabled}, conditions={len(self.conditions)}, actions={len(self.actions)})"
|