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.
@@ -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)