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
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Condition definitions for rule matching.
|
|
3
|
+
|
|
4
|
+
Conditions define the criteria that files must meet to match a rule.
|
|
5
|
+
They can be combined using groups with AND, OR, NOT logic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConditionAttribute(Enum):
|
|
22
|
+
"""Available attributes for conditions."""
|
|
23
|
+
# File name attributes
|
|
24
|
+
NAME = "name"
|
|
25
|
+
EXTENSION = "extension"
|
|
26
|
+
FULL_NAME = "full_name" # name + extension
|
|
27
|
+
|
|
28
|
+
# Path attributes
|
|
29
|
+
PATH = "path"
|
|
30
|
+
PARENT_FOLDER = "parent_folder"
|
|
31
|
+
|
|
32
|
+
# Size attributes
|
|
33
|
+
SIZE = "size"
|
|
34
|
+
SIZE_BYTES = "size_bytes"
|
|
35
|
+
|
|
36
|
+
# Date attributes
|
|
37
|
+
DATE_CREATED = "date_created"
|
|
38
|
+
DATE_MODIFIED = "date_modified"
|
|
39
|
+
DATE_ACCESSED = "date_accessed"
|
|
40
|
+
DATE_ADDED = "date_added" # macOS specific
|
|
41
|
+
|
|
42
|
+
# Type attributes
|
|
43
|
+
FILE_TYPE = "file_type" # MIME type
|
|
44
|
+
KIND = "kind" # macOS kind (e.g., "PDF document")
|
|
45
|
+
UTI = "uti" # Uniform Type Identifier
|
|
46
|
+
|
|
47
|
+
# Content attributes
|
|
48
|
+
CONTENTS = "contents" # Text file contents
|
|
49
|
+
|
|
50
|
+
# macOS specific
|
|
51
|
+
TAGS = "tags"
|
|
52
|
+
FINDER_COMMENT = "finder_comment"
|
|
53
|
+
WHERE_FROM = "where_from" # Download source URL
|
|
54
|
+
SPOTLIGHT = "spotlight" # Any Spotlight attribute
|
|
55
|
+
|
|
56
|
+
# Custom
|
|
57
|
+
CUSTOM = "custom"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ConditionOperator(Enum):
|
|
61
|
+
"""Available operators for conditions."""
|
|
62
|
+
# String operators
|
|
63
|
+
EQUALS = "equals"
|
|
64
|
+
NOT_EQUALS = "not_equals"
|
|
65
|
+
CONTAINS = "contains"
|
|
66
|
+
NOT_CONTAINS = "not_contains"
|
|
67
|
+
STARTS_WITH = "starts_with"
|
|
68
|
+
ENDS_WITH = "ends_with"
|
|
69
|
+
MATCHES_REGEX = "matches_regex"
|
|
70
|
+
MATCHES_GLOB = "matches_glob"
|
|
71
|
+
|
|
72
|
+
# Numeric operators
|
|
73
|
+
GREATER_THAN = "greater_than"
|
|
74
|
+
LESS_THAN = "less_than"
|
|
75
|
+
GREATER_OR_EQUAL = "greater_or_equal"
|
|
76
|
+
LESS_OR_EQUAL = "less_or_equal"
|
|
77
|
+
BETWEEN = "between"
|
|
78
|
+
|
|
79
|
+
# Date operators
|
|
80
|
+
IS_TODAY = "is_today"
|
|
81
|
+
IS_YESTERDAY = "is_yesterday"
|
|
82
|
+
IS_THIS_WEEK = "is_this_week"
|
|
83
|
+
IS_LAST_WEEK = "is_last_week"
|
|
84
|
+
IS_THIS_MONTH = "is_this_month"
|
|
85
|
+
IS_LAST_MONTH = "is_last_month"
|
|
86
|
+
IS_THIS_YEAR = "is_this_year"
|
|
87
|
+
WITHIN_LAST = "within_last" # e.g., within last 7 days
|
|
88
|
+
NOT_WITHIN_LAST = "not_within_last"
|
|
89
|
+
BEFORE = "before"
|
|
90
|
+
AFTER = "after"
|
|
91
|
+
|
|
92
|
+
# List operators
|
|
93
|
+
IN_LIST = "in_list"
|
|
94
|
+
NOT_IN_LIST = "not_in_list"
|
|
95
|
+
|
|
96
|
+
# Boolean operators
|
|
97
|
+
IS_TRUE = "is_true"
|
|
98
|
+
IS_FALSE = "is_false"
|
|
99
|
+
|
|
100
|
+
# Existence operators
|
|
101
|
+
EXISTS = "exists"
|
|
102
|
+
NOT_EXISTS = "not_exists"
|
|
103
|
+
IS_EMPTY = "is_empty"
|
|
104
|
+
IS_NOT_EMPTY = "is_not_empty"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ConditionGroupMode(Enum):
|
|
108
|
+
"""How conditions in a group are combined."""
|
|
109
|
+
ALL = "all" # AND
|
|
110
|
+
ANY = "any" # OR
|
|
111
|
+
NONE = "none" # NOT
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_size(size_str: str) -> int:
|
|
115
|
+
"""
|
|
116
|
+
Parse a size string into bytes.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
size_str: Size string like "10MB", "1.5GB", "500KB"
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Size in bytes.
|
|
123
|
+
"""
|
|
124
|
+
if isinstance(size_str, (int, float)):
|
|
125
|
+
return int(size_str)
|
|
126
|
+
|
|
127
|
+
size_str = size_str.strip().upper()
|
|
128
|
+
|
|
129
|
+
units = {
|
|
130
|
+
'B': 1,
|
|
131
|
+
'KB': 1024,
|
|
132
|
+
'MB': 1024 ** 2,
|
|
133
|
+
'GB': 1024 ** 3,
|
|
134
|
+
'TB': 1024 ** 4,
|
|
135
|
+
'K': 1024,
|
|
136
|
+
'M': 1024 ** 2,
|
|
137
|
+
'G': 1024 ** 3,
|
|
138
|
+
'T': 1024 ** 4,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Match number followed by optional unit
|
|
142
|
+
match = re.match(r'^([\d.]+)\s*([A-Z]*B?)$', size_str)
|
|
143
|
+
if match:
|
|
144
|
+
number = float(match.group(1))
|
|
145
|
+
unit = match.group(2) or 'B'
|
|
146
|
+
return int(number * units.get(unit, 1))
|
|
147
|
+
|
|
148
|
+
return int(float(size_str))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_duration(duration_str: str) -> timedelta:
|
|
152
|
+
"""
|
|
153
|
+
Parse a duration string into timedelta.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
duration_str: Duration string like "7 days", "2 hours", "30 minutes"
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
timedelta object.
|
|
160
|
+
"""
|
|
161
|
+
if isinstance(duration_str, timedelta):
|
|
162
|
+
return duration_str
|
|
163
|
+
|
|
164
|
+
duration_str = duration_str.strip().lower()
|
|
165
|
+
|
|
166
|
+
patterns = [
|
|
167
|
+
(r'(\d+)\s*(?:d|days?)', lambda m: timedelta(days=int(m.group(1)))),
|
|
168
|
+
(r'(\d+)\s*(?:h|hours?)', lambda m: timedelta(hours=int(m.group(1)))),
|
|
169
|
+
(r'(\d+)\s*(?:m|min(?:utes?)?)', lambda m: timedelta(minutes=int(m.group(1)))),
|
|
170
|
+
(r'(\d+)\s*(?:s|sec(?:onds?)?)', lambda m: timedelta(seconds=int(m.group(1)))),
|
|
171
|
+
(r'(\d+)\s*(?:w|weeks?)', lambda m: timedelta(weeks=int(m.group(1)))),
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
for pattern, handler in patterns:
|
|
175
|
+
match = re.match(pattern, duration_str)
|
|
176
|
+
if match:
|
|
177
|
+
return handler(match)
|
|
178
|
+
|
|
179
|
+
# Default to days if no unit specified
|
|
180
|
+
return timedelta(days=int(duration_str))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class Condition:
|
|
185
|
+
"""
|
|
186
|
+
A single condition for file matching.
|
|
187
|
+
|
|
188
|
+
Attributes:
|
|
189
|
+
attribute: The file attribute to check.
|
|
190
|
+
operator: The comparison operator.
|
|
191
|
+
value: The value to compare against.
|
|
192
|
+
case_sensitive: Whether string comparisons are case-sensitive.
|
|
193
|
+
negate: Invert the condition result.
|
|
194
|
+
id: Unique identifier.
|
|
195
|
+
|
|
196
|
+
Example:
|
|
197
|
+
>>> cond = Condition("extension", "equals", "pdf")
|
|
198
|
+
>>> cond.evaluate({"extension": "pdf"})
|
|
199
|
+
True
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
attribute: str | ConditionAttribute
|
|
203
|
+
operator: str | ConditionOperator
|
|
204
|
+
value: Any
|
|
205
|
+
case_sensitive: bool = False
|
|
206
|
+
negate: bool = False
|
|
207
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
208
|
+
|
|
209
|
+
def __post_init__(self) -> None:
|
|
210
|
+
"""Normalize attribute and operator to enum values."""
|
|
211
|
+
if isinstance(self.attribute, str):
|
|
212
|
+
try:
|
|
213
|
+
self.attribute = ConditionAttribute(self.attribute)
|
|
214
|
+
except ValueError:
|
|
215
|
+
# Keep as string for custom attributes
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
if isinstance(self.operator, str):
|
|
219
|
+
self.operator = ConditionOperator(self.operator)
|
|
220
|
+
|
|
221
|
+
def evaluate(self, file_info: Dict[str, Any]) -> bool:
|
|
222
|
+
"""
|
|
223
|
+
Evaluate the condition against file information.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
file_info: Dictionary containing file attributes.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
True if the condition is satisfied.
|
|
230
|
+
"""
|
|
231
|
+
# Get the attribute value from file_info
|
|
232
|
+
attr_name = self.attribute.value if isinstance(self.attribute, ConditionAttribute) else self.attribute
|
|
233
|
+
actual_value = file_info.get(attr_name)
|
|
234
|
+
|
|
235
|
+
# Handle missing attributes
|
|
236
|
+
if actual_value is None:
|
|
237
|
+
if self.operator in (ConditionOperator.NOT_EXISTS, ConditionOperator.IS_EMPTY):
|
|
238
|
+
result = True
|
|
239
|
+
elif self.operator in (ConditionOperator.EXISTS, ConditionOperator.IS_NOT_EMPTY):
|
|
240
|
+
result = False
|
|
241
|
+
else:
|
|
242
|
+
result = False
|
|
243
|
+
else:
|
|
244
|
+
result = self._compare(actual_value, self.value, self.operator)
|
|
245
|
+
|
|
246
|
+
return not result if self.negate else result
|
|
247
|
+
|
|
248
|
+
def _compare(self, actual: Any, expected: Any, operator: ConditionOperator) -> bool:
|
|
249
|
+
"""
|
|
250
|
+
Perform the comparison.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
actual: The actual value from the file.
|
|
254
|
+
expected: The expected value from the condition.
|
|
255
|
+
operator: The comparison operator.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
True if the comparison succeeds.
|
|
259
|
+
"""
|
|
260
|
+
# String operations
|
|
261
|
+
if operator == ConditionOperator.EQUALS:
|
|
262
|
+
return self._str_compare(actual, expected, lambda a, b: a == b)
|
|
263
|
+
|
|
264
|
+
elif operator == ConditionOperator.NOT_EQUALS:
|
|
265
|
+
return self._str_compare(actual, expected, lambda a, b: a != b)
|
|
266
|
+
|
|
267
|
+
elif operator == ConditionOperator.CONTAINS:
|
|
268
|
+
return self._str_compare(actual, expected, lambda a, b: b in a)
|
|
269
|
+
|
|
270
|
+
elif operator == ConditionOperator.NOT_CONTAINS:
|
|
271
|
+
return self._str_compare(actual, expected, lambda a, b: b not in a)
|
|
272
|
+
|
|
273
|
+
elif operator == ConditionOperator.STARTS_WITH:
|
|
274
|
+
return self._str_compare(actual, expected, lambda a, b: a.startswith(b))
|
|
275
|
+
|
|
276
|
+
elif operator == ConditionOperator.ENDS_WITH:
|
|
277
|
+
return self._str_compare(actual, expected, lambda a, b: a.endswith(b))
|
|
278
|
+
|
|
279
|
+
elif operator == ConditionOperator.MATCHES_REGEX:
|
|
280
|
+
flags = 0 if self.case_sensitive else re.IGNORECASE
|
|
281
|
+
try:
|
|
282
|
+
return bool(re.search(expected, str(actual), flags))
|
|
283
|
+
except re.error:
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
elif operator == ConditionOperator.MATCHES_GLOB:
|
|
287
|
+
actual_str = str(actual)
|
|
288
|
+
if not self.case_sensitive:
|
|
289
|
+
actual_str = actual_str.lower()
|
|
290
|
+
expected = expected.lower()
|
|
291
|
+
return fnmatch.fnmatch(actual_str, expected)
|
|
292
|
+
|
|
293
|
+
# Numeric operations
|
|
294
|
+
elif operator == ConditionOperator.GREATER_THAN:
|
|
295
|
+
return self._num_compare(actual, expected, lambda a, b: a > b)
|
|
296
|
+
|
|
297
|
+
elif operator == ConditionOperator.LESS_THAN:
|
|
298
|
+
return self._num_compare(actual, expected, lambda a, b: a < b)
|
|
299
|
+
|
|
300
|
+
elif operator == ConditionOperator.GREATER_OR_EQUAL:
|
|
301
|
+
return self._num_compare(actual, expected, lambda a, b: a >= b)
|
|
302
|
+
|
|
303
|
+
elif operator == ConditionOperator.LESS_OR_EQUAL:
|
|
304
|
+
return self._num_compare(actual, expected, lambda a, b: a <= b)
|
|
305
|
+
|
|
306
|
+
elif operator == ConditionOperator.BETWEEN:
|
|
307
|
+
if isinstance(expected, (list, tuple)) and len(expected) == 2:
|
|
308
|
+
low, high = expected
|
|
309
|
+
return self._num_compare(actual, low, lambda a, b: a >= b) and \
|
|
310
|
+
self._num_compare(actual, high, lambda a, b: a <= b)
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
# Date operations
|
|
314
|
+
elif operator == ConditionOperator.IS_TODAY:
|
|
315
|
+
return self._is_same_day(actual, datetime.now())
|
|
316
|
+
|
|
317
|
+
elif operator == ConditionOperator.IS_YESTERDAY:
|
|
318
|
+
return self._is_same_day(actual, datetime.now() - timedelta(days=1))
|
|
319
|
+
|
|
320
|
+
elif operator == ConditionOperator.IS_THIS_WEEK:
|
|
321
|
+
return self._is_this_week(actual)
|
|
322
|
+
|
|
323
|
+
elif operator == ConditionOperator.IS_LAST_WEEK:
|
|
324
|
+
return self._is_last_week(actual)
|
|
325
|
+
|
|
326
|
+
elif operator == ConditionOperator.IS_THIS_MONTH:
|
|
327
|
+
return self._is_this_month(actual)
|
|
328
|
+
|
|
329
|
+
elif operator == ConditionOperator.IS_LAST_MONTH:
|
|
330
|
+
return self._is_last_month(actual)
|
|
331
|
+
|
|
332
|
+
elif operator == ConditionOperator.IS_THIS_YEAR:
|
|
333
|
+
return self._is_this_year(actual)
|
|
334
|
+
|
|
335
|
+
elif operator == ConditionOperator.WITHIN_LAST:
|
|
336
|
+
delta = parse_duration(expected) if isinstance(expected, str) else expected
|
|
337
|
+
if isinstance(actual, datetime):
|
|
338
|
+
return actual >= datetime.now() - delta
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
elif operator == ConditionOperator.NOT_WITHIN_LAST:
|
|
342
|
+
delta = parse_duration(expected) if isinstance(expected, str) else expected
|
|
343
|
+
if isinstance(actual, datetime):
|
|
344
|
+
return actual < datetime.now() - delta
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
elif operator == ConditionOperator.BEFORE:
|
|
348
|
+
if isinstance(actual, datetime) and isinstance(expected, datetime):
|
|
349
|
+
return actual < expected
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
elif operator == ConditionOperator.AFTER:
|
|
353
|
+
if isinstance(actual, datetime) and isinstance(expected, datetime):
|
|
354
|
+
return actual > expected
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
# List operations
|
|
358
|
+
elif operator == ConditionOperator.IN_LIST:
|
|
359
|
+
if isinstance(expected, (list, tuple)):
|
|
360
|
+
if not self.case_sensitive and isinstance(actual, str):
|
|
361
|
+
return actual.lower() in [str(e).lower() for e in expected]
|
|
362
|
+
return actual in expected
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
elif operator == ConditionOperator.NOT_IN_LIST:
|
|
366
|
+
if isinstance(expected, (list, tuple)):
|
|
367
|
+
if not self.case_sensitive and isinstance(actual, str):
|
|
368
|
+
return actual.lower() not in [str(e).lower() for e in expected]
|
|
369
|
+
return actual not in expected
|
|
370
|
+
return True
|
|
371
|
+
|
|
372
|
+
# Boolean operations
|
|
373
|
+
elif operator == ConditionOperator.IS_TRUE:
|
|
374
|
+
return bool(actual)
|
|
375
|
+
|
|
376
|
+
elif operator == ConditionOperator.IS_FALSE:
|
|
377
|
+
return not bool(actual)
|
|
378
|
+
|
|
379
|
+
# Existence operations
|
|
380
|
+
elif operator == ConditionOperator.EXISTS:
|
|
381
|
+
return actual is not None
|
|
382
|
+
|
|
383
|
+
elif operator == ConditionOperator.NOT_EXISTS:
|
|
384
|
+
return actual is None
|
|
385
|
+
|
|
386
|
+
elif operator == ConditionOperator.IS_EMPTY:
|
|
387
|
+
if actual is None:
|
|
388
|
+
return True
|
|
389
|
+
if isinstance(actual, str):
|
|
390
|
+
return len(actual.strip()) == 0
|
|
391
|
+
if hasattr(actual, '__len__'):
|
|
392
|
+
return len(actual) == 0
|
|
393
|
+
return False
|
|
394
|
+
|
|
395
|
+
elif operator == ConditionOperator.IS_NOT_EMPTY:
|
|
396
|
+
if actual is None:
|
|
397
|
+
return False
|
|
398
|
+
if isinstance(actual, str):
|
|
399
|
+
return len(actual.strip()) > 0
|
|
400
|
+
if hasattr(actual, '__len__'):
|
|
401
|
+
return len(actual) > 0
|
|
402
|
+
return True
|
|
403
|
+
|
|
404
|
+
return False
|
|
405
|
+
|
|
406
|
+
def _str_compare(self, actual: Any, expected: Any, comparator: Callable[[str, str], bool]) -> bool:
|
|
407
|
+
"""String comparison with case sensitivity handling."""
|
|
408
|
+
actual_str = str(actual)
|
|
409
|
+
expected_str = str(expected)
|
|
410
|
+
|
|
411
|
+
if not self.case_sensitive:
|
|
412
|
+
actual_str = actual_str.lower()
|
|
413
|
+
expected_str = expected_str.lower()
|
|
414
|
+
|
|
415
|
+
return comparator(actual_str, expected_str)
|
|
416
|
+
|
|
417
|
+
def _num_compare(self, actual: Any, expected: Any, comparator: Callable[[float, float], bool]) -> bool:
|
|
418
|
+
"""Numeric comparison with size string parsing."""
|
|
419
|
+
try:
|
|
420
|
+
# Handle size strings
|
|
421
|
+
if isinstance(expected, str) and any(c.isalpha() for c in expected):
|
|
422
|
+
expected = parse_size(expected)
|
|
423
|
+
if isinstance(actual, str) and any(c.isalpha() for c in actual):
|
|
424
|
+
actual = parse_size(actual)
|
|
425
|
+
|
|
426
|
+
return comparator(float(actual), float(expected))
|
|
427
|
+
except (ValueError, TypeError):
|
|
428
|
+
return False
|
|
429
|
+
|
|
430
|
+
def _is_same_day(self, date: Any, target: datetime) -> bool:
|
|
431
|
+
"""Check if date is the same day as target."""
|
|
432
|
+
if isinstance(date, datetime):
|
|
433
|
+
return date.date() == target.date()
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
def _is_this_week(self, date: Any) -> bool:
|
|
437
|
+
"""Check if date is in the current week."""
|
|
438
|
+
if isinstance(date, datetime):
|
|
439
|
+
now = datetime.now()
|
|
440
|
+
start_of_week = now - timedelta(days=now.weekday())
|
|
441
|
+
start_of_week = start_of_week.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
442
|
+
end_of_week = start_of_week + timedelta(days=7)
|
|
443
|
+
return start_of_week <= date < end_of_week
|
|
444
|
+
return False
|
|
445
|
+
|
|
446
|
+
def _is_last_week(self, date: Any) -> bool:
|
|
447
|
+
"""Check if date is in the previous week."""
|
|
448
|
+
if isinstance(date, datetime):
|
|
449
|
+
now = datetime.now()
|
|
450
|
+
start_of_this_week = now - timedelta(days=now.weekday())
|
|
451
|
+
start_of_this_week = start_of_this_week.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
452
|
+
start_of_last_week = start_of_this_week - timedelta(days=7)
|
|
453
|
+
return start_of_last_week <= date < start_of_this_week
|
|
454
|
+
return False
|
|
455
|
+
|
|
456
|
+
def _is_this_month(self, date: Any) -> bool:
|
|
457
|
+
"""Check if date is in the current month."""
|
|
458
|
+
if isinstance(date, datetime):
|
|
459
|
+
now = datetime.now()
|
|
460
|
+
return date.year == now.year and date.month == now.month
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
def _is_last_month(self, date: Any) -> bool:
|
|
464
|
+
"""Check if date is in the previous month."""
|
|
465
|
+
if isinstance(date, datetime):
|
|
466
|
+
now = datetime.now()
|
|
467
|
+
last_month = now.replace(day=1) - timedelta(days=1)
|
|
468
|
+
return date.year == last_month.year and date.month == last_month.month
|
|
469
|
+
return False
|
|
470
|
+
|
|
471
|
+
def _is_this_year(self, date: Any) -> bool:
|
|
472
|
+
"""Check if date is in the current year."""
|
|
473
|
+
if isinstance(date, datetime):
|
|
474
|
+
return date.year == datetime.now().year
|
|
475
|
+
return False
|
|
476
|
+
|
|
477
|
+
def duplicate(self) -> "Condition":
|
|
478
|
+
"""Create a copy of this condition."""
|
|
479
|
+
return Condition(
|
|
480
|
+
attribute=self.attribute,
|
|
481
|
+
operator=self.operator,
|
|
482
|
+
value=self.value,
|
|
483
|
+
case_sensitive=self.case_sensitive,
|
|
484
|
+
negate=self.negate,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
488
|
+
"""Convert condition to dictionary for serialization."""
|
|
489
|
+
attr = self.attribute.value if isinstance(self.attribute, ConditionAttribute) else self.attribute
|
|
490
|
+
op = self.operator.value if isinstance(self.operator, ConditionOperator) else self.operator
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
"id": self.id,
|
|
494
|
+
"attribute": attr,
|
|
495
|
+
"operator": op,
|
|
496
|
+
"value": self.value,
|
|
497
|
+
"case_sensitive": self.case_sensitive,
|
|
498
|
+
"negate": self.negate,
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
@classmethod
|
|
502
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Condition":
|
|
503
|
+
"""Create a condition from a dictionary."""
|
|
504
|
+
return cls(
|
|
505
|
+
id=data.get("id", str(uuid.uuid4())),
|
|
506
|
+
attribute=data["attribute"],
|
|
507
|
+
operator=data["operator"],
|
|
508
|
+
value=data["value"],
|
|
509
|
+
case_sensitive=data.get("case_sensitive", False),
|
|
510
|
+
negate=data.get("negate", False),
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def __str__(self) -> str:
|
|
514
|
+
"""Human-readable representation."""
|
|
515
|
+
attr = self.attribute.value if isinstance(self.attribute, ConditionAttribute) else self.attribute
|
|
516
|
+
op = self.operator.value if isinstance(self.operator, ConditionOperator) else self.operator
|
|
517
|
+
neg = "NOT " if self.negate else ""
|
|
518
|
+
return f"{neg}{attr} {op} {self.value!r}"
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@dataclass
|
|
522
|
+
class ConditionGroup:
|
|
523
|
+
"""
|
|
524
|
+
A group of conditions combined with AND, OR, or NOT logic.
|
|
525
|
+
|
|
526
|
+
Condition groups allow for complex nested logic in rules.
|
|
527
|
+
|
|
528
|
+
Attributes:
|
|
529
|
+
conditions: List of conditions or nested groups.
|
|
530
|
+
mode: How conditions are combined (all, any, none).
|
|
531
|
+
id: Unique identifier.
|
|
532
|
+
|
|
533
|
+
Example:
|
|
534
|
+
>>> group = ConditionGroup(
|
|
535
|
+
... conditions=[
|
|
536
|
+
... Condition("extension", "equals", "pdf"),
|
|
537
|
+
... Condition("size", "greater_than", "1MB"),
|
|
538
|
+
... ],
|
|
539
|
+
... mode=ConditionGroupMode.ALL
|
|
540
|
+
... )
|
|
541
|
+
"""
|
|
542
|
+
|
|
543
|
+
conditions: List[Condition | "ConditionGroup"]
|
|
544
|
+
mode: ConditionGroupMode = ConditionGroupMode.ALL
|
|
545
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
546
|
+
|
|
547
|
+
def __post_init__(self) -> None:
|
|
548
|
+
"""Normalize mode to enum."""
|
|
549
|
+
if isinstance(self.mode, str):
|
|
550
|
+
self.mode = ConditionGroupMode(self.mode)
|
|
551
|
+
|
|
552
|
+
def evaluate(self, file_info: Dict[str, Any]) -> bool:
|
|
553
|
+
"""
|
|
554
|
+
Evaluate the condition group against file information.
|
|
555
|
+
|
|
556
|
+
Args:
|
|
557
|
+
file_info: Dictionary containing file attributes.
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
True if the condition group is satisfied.
|
|
561
|
+
"""
|
|
562
|
+
if not self.conditions:
|
|
563
|
+
return True
|
|
564
|
+
|
|
565
|
+
results = [cond.evaluate(file_info) for cond in self.conditions]
|
|
566
|
+
|
|
567
|
+
if self.mode == ConditionGroupMode.ALL:
|
|
568
|
+
return all(results)
|
|
569
|
+
elif self.mode == ConditionGroupMode.ANY:
|
|
570
|
+
return any(results)
|
|
571
|
+
elif self.mode == ConditionGroupMode.NONE:
|
|
572
|
+
return not any(results)
|
|
573
|
+
|
|
574
|
+
return False
|
|
575
|
+
|
|
576
|
+
def add_condition(self, condition: Condition | "ConditionGroup") -> "ConditionGroup":
|
|
577
|
+
"""Add a condition to the group."""
|
|
578
|
+
self.conditions.append(condition)
|
|
579
|
+
return self
|
|
580
|
+
|
|
581
|
+
def duplicate(self) -> "ConditionGroup":
|
|
582
|
+
"""Create a copy of this condition group."""
|
|
583
|
+
return ConditionGroup(
|
|
584
|
+
conditions=[c.duplicate() for c in self.conditions],
|
|
585
|
+
mode=self.mode,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
589
|
+
"""Convert condition group to dictionary for serialization."""
|
|
590
|
+
return {
|
|
591
|
+
"id": self.id,
|
|
592
|
+
"mode": self.mode.value,
|
|
593
|
+
"conditions": [c.to_dict() for c in self.conditions],
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
@classmethod
|
|
597
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ConditionGroup":
|
|
598
|
+
"""Create a condition group from a dictionary."""
|
|
599
|
+
conditions = []
|
|
600
|
+
for cond_data in data.get("conditions", []):
|
|
601
|
+
if "conditions" in cond_data:
|
|
602
|
+
conditions.append(cls.from_dict(cond_data))
|
|
603
|
+
else:
|
|
604
|
+
conditions.append(Condition.from_dict(cond_data))
|
|
605
|
+
|
|
606
|
+
return cls(
|
|
607
|
+
id=data.get("id", str(uuid.uuid4())),
|
|
608
|
+
mode=ConditionGroupMode(data.get("mode", "all")),
|
|
609
|
+
conditions=conditions,
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
def __str__(self) -> str:
|
|
613
|
+
"""Human-readable representation."""
|
|
614
|
+
mode_str = self.mode.value.upper()
|
|
615
|
+
conditions_str = f" {mode_str} ".join(str(c) for c in self.conditions)
|
|
616
|
+
return f"({conditions_str})"
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
# Convenience functions for creating common conditions
|
|
620
|
+
|
|
621
|
+
def name_equals(value: str, case_sensitive: bool = False) -> Condition:
|
|
622
|
+
"""Create a condition that checks if file name equals value."""
|
|
623
|
+
return Condition("name", "equals", value, case_sensitive=case_sensitive)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def name_contains(value: str, case_sensitive: bool = False) -> Condition:
|
|
627
|
+
"""Create a condition that checks if file name contains value."""
|
|
628
|
+
return Condition("name", "contains", value, case_sensitive=case_sensitive)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def extension_is(ext: str) -> Condition:
|
|
632
|
+
"""Create a condition that checks file extension."""
|
|
633
|
+
# Remove leading dot if present
|
|
634
|
+
ext = ext.lstrip(".")
|
|
635
|
+
return Condition("extension", "equals", ext, case_sensitive=False)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def extension_in(extensions: List[str]) -> Condition:
|
|
639
|
+
"""Create a condition that checks if extension is in list."""
|
|
640
|
+
# Remove leading dots
|
|
641
|
+
extensions = [e.lstrip(".") for e in extensions]
|
|
642
|
+
return Condition("extension", "in_list", extensions, case_sensitive=False)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def size_greater_than(size: str | int) -> Condition:
|
|
646
|
+
"""Create a condition that checks if file size is greater than value."""
|
|
647
|
+
return Condition("size", "greater_than", size)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def size_less_than(size: str | int) -> Condition:
|
|
651
|
+
"""Create a condition that checks if file size is less than value."""
|
|
652
|
+
return Condition("size", "less_than", size)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def modified_within(duration: str) -> Condition:
|
|
656
|
+
"""Create a condition that checks if file was modified within duration."""
|
|
657
|
+
return Condition("date_modified", "within_last", duration)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def created_within(duration: str) -> Condition:
|
|
661
|
+
"""Create a condition that checks if file was created within duration."""
|
|
662
|
+
return Condition("date_created", "within_last", duration)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def has_tag(tag: str) -> Condition:
|
|
666
|
+
"""Create a condition that checks if file has a specific tag."""
|
|
667
|
+
return Condition("tags", "contains", tag)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def from_url(url_pattern: str) -> Condition:
|
|
671
|
+
"""Create a condition that checks download source URL."""
|
|
672
|
+
return Condition("where_from", "contains", url_pattern)
|