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/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SortMeOut - Open-source file automation and organization tool for macOS.
|
|
3
|
+
|
|
4
|
+
A powerful, rule-based file organization system inspired by Noodlesoft Hazel.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
__author__ = "SortMeOut Contributors"
|
|
9
|
+
__license__ = "MIT"
|
|
10
|
+
|
|
11
|
+
from sortmeout.app import SortMeOut
|
|
12
|
+
from sortmeout.core.rule import Rule
|
|
13
|
+
from sortmeout.core.condition import Condition, ConditionGroup
|
|
14
|
+
from sortmeout.core.action import Action
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"SortMeOut",
|
|
18
|
+
"Rule",
|
|
19
|
+
"Condition",
|
|
20
|
+
"ConditionGroup",
|
|
21
|
+
"Action",
|
|
22
|
+
"__version__",
|
|
23
|
+
]
|
sortmeout/app.py
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main SortMeOut application class.
|
|
3
|
+
|
|
4
|
+
This module provides the main entry point for the SortMeOut application,
|
|
5
|
+
managing folder watches, rules, and the overall lifecycle of the app.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, List, Optional, Callable, Any
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
|
|
19
|
+
from sortmeout.core.watcher import FolderWatcher, WatcherManager
|
|
20
|
+
from sortmeout.core.rule import Rule
|
|
21
|
+
from sortmeout.core.engine import RuleEngine
|
|
22
|
+
from sortmeout.config.manager import ConfigManager
|
|
23
|
+
from sortmeout.config.settings import Settings
|
|
24
|
+
from sortmeout.utils.logger import setup_logging, get_logger
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SortMeOut:
|
|
30
|
+
"""
|
|
31
|
+
Main application class for SortMeOut file automation.
|
|
32
|
+
|
|
33
|
+
This class manages:
|
|
34
|
+
- Folder watchers for monitoring file system changes
|
|
35
|
+
- Rules and their associations with folders
|
|
36
|
+
- Configuration persistence
|
|
37
|
+
- Application lifecycle
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
>>> app = SortMeOut()
|
|
41
|
+
>>> app.add_folder("~/Downloads")
|
|
42
|
+
>>> rule = Rule(name="PDF Organizer", ...)
|
|
43
|
+
>>> app.add_rule("~/Downloads", rule)
|
|
44
|
+
>>> app.start()
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
config_path: Optional[str] = None,
|
|
50
|
+
preview_mode: bool = False,
|
|
51
|
+
verbose: bool = False,
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Initialize SortMeOut application.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
config_path: Path to configuration file. If None, uses default location.
|
|
58
|
+
preview_mode: If True, rules are evaluated but actions are not executed.
|
|
59
|
+
verbose: Enable verbose logging.
|
|
60
|
+
"""
|
|
61
|
+
self._preview_mode = preview_mode
|
|
62
|
+
self._verbose = verbose
|
|
63
|
+
self._running = False
|
|
64
|
+
self._lock = threading.RLock()
|
|
65
|
+
|
|
66
|
+
# Set up logging
|
|
67
|
+
log_level = logging.DEBUG if verbose else logging.INFO
|
|
68
|
+
setup_logging(level=log_level)
|
|
69
|
+
|
|
70
|
+
# Initialize components
|
|
71
|
+
self._config_manager = ConfigManager(config_path)
|
|
72
|
+
self._settings = self._config_manager.load_settings()
|
|
73
|
+
self._watcher_manager = WatcherManager()
|
|
74
|
+
self._rule_engine = RuleEngine(preview_mode=preview_mode)
|
|
75
|
+
|
|
76
|
+
# Folder -> Rules mapping
|
|
77
|
+
self._folder_rules: Dict[str, List[Rule]] = {}
|
|
78
|
+
|
|
79
|
+
# Event callbacks
|
|
80
|
+
self._callbacks: Dict[str, List[Callable[..., Any]]] = {
|
|
81
|
+
"rule_matched": [],
|
|
82
|
+
"action_executed": [],
|
|
83
|
+
"error": [],
|
|
84
|
+
"file_processed": [],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Statistics
|
|
88
|
+
self._stats = {
|
|
89
|
+
"files_processed": 0,
|
|
90
|
+
"rules_matched": 0,
|
|
91
|
+
"actions_executed": 0,
|
|
92
|
+
"errors": 0,
|
|
93
|
+
"start_time": None,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# Load saved configuration
|
|
97
|
+
self._load_saved_config()
|
|
98
|
+
|
|
99
|
+
logger.info("SortMeOut initialized (preview_mode=%s)", preview_mode)
|
|
100
|
+
|
|
101
|
+
def _load_saved_config(self) -> None:
|
|
102
|
+
"""Load folders and rules from saved configuration."""
|
|
103
|
+
config = self._config_manager.load_config()
|
|
104
|
+
|
|
105
|
+
for folder_config in config.get("folders", []):
|
|
106
|
+
folder_path = folder_config["path"]
|
|
107
|
+
self._folder_rules[folder_path] = []
|
|
108
|
+
|
|
109
|
+
for rule_dict in folder_config.get("rules", []):
|
|
110
|
+
rule = Rule.from_dict(rule_dict)
|
|
111
|
+
self._folder_rules[folder_path].append(rule)
|
|
112
|
+
|
|
113
|
+
logger.debug("Loaded folder: %s with %d rules",
|
|
114
|
+
folder_path, len(self._folder_rules[folder_path]))
|
|
115
|
+
|
|
116
|
+
def add_folder(
|
|
117
|
+
self,
|
|
118
|
+
path: str,
|
|
119
|
+
recursive: bool = False,
|
|
120
|
+
enabled: bool = True,
|
|
121
|
+
) -> bool:
|
|
122
|
+
"""
|
|
123
|
+
Add a folder to watch for file changes.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
path: Path to the folder to watch.
|
|
127
|
+
recursive: Watch subdirectories as well.
|
|
128
|
+
enabled: Whether watching is enabled.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True if folder was added successfully.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ValueError: If path is not a valid directory.
|
|
135
|
+
"""
|
|
136
|
+
# Expand user path
|
|
137
|
+
expanded_path = os.path.expanduser(path)
|
|
138
|
+
resolved_path = str(Path(expanded_path).resolve())
|
|
139
|
+
|
|
140
|
+
if not os.path.isdir(resolved_path):
|
|
141
|
+
raise ValueError(f"Path is not a valid directory: {path}")
|
|
142
|
+
|
|
143
|
+
with self._lock:
|
|
144
|
+
if resolved_path in self._folder_rules:
|
|
145
|
+
logger.warning("Folder already being watched: %s", resolved_path)
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
self._folder_rules[resolved_path] = []
|
|
149
|
+
|
|
150
|
+
if self._running and enabled:
|
|
151
|
+
self._watcher_manager.add_watch(
|
|
152
|
+
resolved_path,
|
|
153
|
+
callback=self._on_file_event,
|
|
154
|
+
recursive=recursive,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
logger.info("Added folder: %s (recursive=%s)", resolved_path, recursive)
|
|
158
|
+
self._save_config()
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
def remove_folder(self, path: str) -> bool:
|
|
162
|
+
"""
|
|
163
|
+
Remove a folder from watching.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
path: Path to the folder to remove.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
True if folder was removed successfully.
|
|
170
|
+
"""
|
|
171
|
+
expanded_path = os.path.expanduser(path)
|
|
172
|
+
resolved_path = str(Path(expanded_path).resolve())
|
|
173
|
+
|
|
174
|
+
with self._lock:
|
|
175
|
+
if resolved_path not in self._folder_rules:
|
|
176
|
+
logger.warning("Folder not found: %s", resolved_path)
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
del self._folder_rules[resolved_path]
|
|
180
|
+
|
|
181
|
+
if self._running:
|
|
182
|
+
self._watcher_manager.remove_watch(resolved_path)
|
|
183
|
+
|
|
184
|
+
logger.info("Removed folder: %s", resolved_path)
|
|
185
|
+
self._save_config()
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
def get_folders(self) -> List[str]:
|
|
189
|
+
"""Get list of all watched folders."""
|
|
190
|
+
with self._lock:
|
|
191
|
+
return list(self._folder_rules.keys())
|
|
192
|
+
|
|
193
|
+
def add_rule(self, folder_path: str, rule: Rule) -> bool:
|
|
194
|
+
"""
|
|
195
|
+
Add a rule to a watched folder.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
folder_path: Path to the folder.
|
|
199
|
+
rule: Rule to add.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
True if rule was added successfully.
|
|
203
|
+
"""
|
|
204
|
+
expanded_path = os.path.expanduser(folder_path)
|
|
205
|
+
resolved_path = str(Path(expanded_path).resolve())
|
|
206
|
+
|
|
207
|
+
with self._lock:
|
|
208
|
+
if resolved_path not in self._folder_rules:
|
|
209
|
+
raise ValueError(f"Folder not being watched: {folder_path}")
|
|
210
|
+
|
|
211
|
+
# Check for duplicate rule names
|
|
212
|
+
existing_names = {r.name for r in self._folder_rules[resolved_path]}
|
|
213
|
+
if rule.name in existing_names:
|
|
214
|
+
logger.warning("Rule with name '%s' already exists", rule.name)
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
self._folder_rules[resolved_path].append(rule)
|
|
218
|
+
logger.info("Added rule '%s' to folder: %s", rule.name, resolved_path)
|
|
219
|
+
self._save_config()
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
def remove_rule(self, folder_path: str, rule_name: str) -> bool:
|
|
223
|
+
"""
|
|
224
|
+
Remove a rule from a folder.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
folder_path: Path to the folder.
|
|
228
|
+
rule_name: Name of the rule to remove.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
True if rule was removed successfully.
|
|
232
|
+
"""
|
|
233
|
+
expanded_path = os.path.expanduser(folder_path)
|
|
234
|
+
resolved_path = str(Path(expanded_path).resolve())
|
|
235
|
+
|
|
236
|
+
with self._lock:
|
|
237
|
+
if resolved_path not in self._folder_rules:
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
rules = self._folder_rules[resolved_path]
|
|
241
|
+
for i, rule in enumerate(rules):
|
|
242
|
+
if rule.name == rule_name:
|
|
243
|
+
del rules[i]
|
|
244
|
+
logger.info("Removed rule '%s' from folder: %s", rule_name, resolved_path)
|
|
245
|
+
self._save_config()
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
def get_rules(self, folder_path: str) -> List[Rule]:
|
|
251
|
+
"""
|
|
252
|
+
Get all rules for a folder.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
folder_path: Path to the folder.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
List of rules for the folder.
|
|
259
|
+
"""
|
|
260
|
+
expanded_path = os.path.expanduser(folder_path)
|
|
261
|
+
resolved_path = str(Path(expanded_path).resolve())
|
|
262
|
+
|
|
263
|
+
with self._lock:
|
|
264
|
+
return list(self._folder_rules.get(resolved_path, []))
|
|
265
|
+
|
|
266
|
+
def update_rule(self, folder_path: str, rule_name: str, updated_rule: Rule) -> bool:
|
|
267
|
+
"""
|
|
268
|
+
Update an existing rule.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
folder_path: Path to the folder.
|
|
272
|
+
rule_name: Name of the rule to update.
|
|
273
|
+
updated_rule: The updated rule.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
True if rule was updated successfully.
|
|
277
|
+
"""
|
|
278
|
+
expanded_path = os.path.expanduser(folder_path)
|
|
279
|
+
resolved_path = str(Path(expanded_path).resolve())
|
|
280
|
+
|
|
281
|
+
with self._lock:
|
|
282
|
+
if resolved_path not in self._folder_rules:
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
rules = self._folder_rules[resolved_path]
|
|
286
|
+
for i, rule in enumerate(rules):
|
|
287
|
+
if rule.name == rule_name:
|
|
288
|
+
rules[i] = updated_rule
|
|
289
|
+
logger.info("Updated rule '%s' in folder: %s", rule_name, resolved_path)
|
|
290
|
+
self._save_config()
|
|
291
|
+
return True
|
|
292
|
+
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
def reorder_rules(self, folder_path: str, rule_names: List[str]) -> bool:
|
|
296
|
+
"""
|
|
297
|
+
Reorder rules for a folder.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
folder_path: Path to the folder.
|
|
301
|
+
rule_names: List of rule names in desired order.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
True if rules were reordered successfully.
|
|
305
|
+
"""
|
|
306
|
+
expanded_path = os.path.expanduser(folder_path)
|
|
307
|
+
resolved_path = str(Path(expanded_path).resolve())
|
|
308
|
+
|
|
309
|
+
with self._lock:
|
|
310
|
+
if resolved_path not in self._folder_rules:
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
rules = self._folder_rules[resolved_path]
|
|
314
|
+
rule_map = {r.name: r for r in rules}
|
|
315
|
+
|
|
316
|
+
if set(rule_names) != set(rule_map.keys()):
|
|
317
|
+
logger.error("Rule names don't match existing rules")
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
self._folder_rules[resolved_path] = [rule_map[name] for name in rule_names]
|
|
321
|
+
logger.info("Reordered rules in folder: %s", resolved_path)
|
|
322
|
+
self._save_config()
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
def _on_file_event(self, event_type: str, file_path: str, folder_path: str) -> None:
|
|
326
|
+
"""
|
|
327
|
+
Handle file system events.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
event_type: Type of event (created, modified, moved, deleted).
|
|
331
|
+
file_path: Path to the affected file.
|
|
332
|
+
folder_path: Path to the watched folder.
|
|
333
|
+
"""
|
|
334
|
+
# Skip certain files
|
|
335
|
+
if self._should_skip_file(file_path):
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
logger.debug("File event: %s - %s", event_type, file_path)
|
|
339
|
+
|
|
340
|
+
# Process file with rules
|
|
341
|
+
if event_type in ("created", "modified"):
|
|
342
|
+
self._process_file(file_path, folder_path)
|
|
343
|
+
|
|
344
|
+
def _should_skip_file(self, file_path: str) -> bool:
|
|
345
|
+
"""Check if a file should be skipped from processing."""
|
|
346
|
+
path = Path(file_path)
|
|
347
|
+
|
|
348
|
+
# Skip hidden files (starting with .)
|
|
349
|
+
if path.name.startswith("."):
|
|
350
|
+
return True
|
|
351
|
+
|
|
352
|
+
# Skip temporary files
|
|
353
|
+
temp_extensions = {".tmp", ".temp", ".part", ".crdownload", ".download"}
|
|
354
|
+
if path.suffix.lower() in temp_extensions:
|
|
355
|
+
return True
|
|
356
|
+
|
|
357
|
+
# Skip directories
|
|
358
|
+
if path.is_dir():
|
|
359
|
+
return True
|
|
360
|
+
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
def _process_file(self, file_path: str, folder_path: str) -> None:
|
|
364
|
+
"""
|
|
365
|
+
Process a file against all rules for its folder.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
file_path: Path to the file to process.
|
|
369
|
+
folder_path: Path to the watched folder.
|
|
370
|
+
"""
|
|
371
|
+
with self._lock:
|
|
372
|
+
rules = self._folder_rules.get(folder_path, [])
|
|
373
|
+
|
|
374
|
+
if not rules:
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
# Process through rule engine
|
|
378
|
+
result = self._rule_engine.process_file(file_path, rules)
|
|
379
|
+
|
|
380
|
+
# Update statistics
|
|
381
|
+
self._stats["files_processed"] += 1
|
|
382
|
+
if result.matched_rules:
|
|
383
|
+
self._stats["rules_matched"] += len(result.matched_rules)
|
|
384
|
+
if result.executed_actions:
|
|
385
|
+
self._stats["actions_executed"] += len(result.executed_actions)
|
|
386
|
+
if result.errors:
|
|
387
|
+
self._stats["errors"] += len(result.errors)
|
|
388
|
+
|
|
389
|
+
# Fire callbacks
|
|
390
|
+
for rule_name in result.matched_rules:
|
|
391
|
+
self._fire_callback("rule_matched", file_path, rule_name)
|
|
392
|
+
|
|
393
|
+
for action_result in result.executed_actions:
|
|
394
|
+
self._fire_callback("action_executed", file_path, action_result)
|
|
395
|
+
|
|
396
|
+
for error in result.errors:
|
|
397
|
+
self._fire_callback("error", file_path, error)
|
|
398
|
+
|
|
399
|
+
self._fire_callback("file_processed", file_path, result)
|
|
400
|
+
|
|
401
|
+
def process_folder(self, folder_path: str, force: bool = False) -> Dict[str, Any]:
|
|
402
|
+
"""
|
|
403
|
+
Process all existing files in a folder.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
folder_path: Path to the folder to process.
|
|
407
|
+
force: Process files even if already processed.
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Dictionary with processing results.
|
|
411
|
+
"""
|
|
412
|
+
expanded_path = os.path.expanduser(folder_path)
|
|
413
|
+
resolved_path = str(Path(expanded_path).resolve())
|
|
414
|
+
|
|
415
|
+
if resolved_path not in self._folder_rules:
|
|
416
|
+
raise ValueError(f"Folder not being watched: {folder_path}")
|
|
417
|
+
|
|
418
|
+
results = {
|
|
419
|
+
"processed": 0,
|
|
420
|
+
"matched": 0,
|
|
421
|
+
"errors": 0,
|
|
422
|
+
"files": [],
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
for entry in os.scandir(resolved_path):
|
|
426
|
+
if entry.is_file() and not self._should_skip_file(entry.path):
|
|
427
|
+
self._process_file(entry.path, resolved_path)
|
|
428
|
+
results["processed"] += 1
|
|
429
|
+
results["files"].append(entry.path)
|
|
430
|
+
|
|
431
|
+
logger.info("Processed %d files in folder: %s", results["processed"], resolved_path)
|
|
432
|
+
return results
|
|
433
|
+
|
|
434
|
+
def on(self, event: str, callback: Callable[..., Any]) -> None:
|
|
435
|
+
"""
|
|
436
|
+
Register a callback for an event.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
event: Event name (rule_matched, action_executed, error, file_processed).
|
|
440
|
+
callback: Callback function.
|
|
441
|
+
"""
|
|
442
|
+
if event not in self._callbacks:
|
|
443
|
+
raise ValueError(f"Unknown event: {event}")
|
|
444
|
+
|
|
445
|
+
self._callbacks[event].append(callback)
|
|
446
|
+
|
|
447
|
+
def off(self, event: str, callback: Callable[..., Any]) -> None:
|
|
448
|
+
"""
|
|
449
|
+
Unregister a callback for an event.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
event: Event name.
|
|
453
|
+
callback: Callback function to remove.
|
|
454
|
+
"""
|
|
455
|
+
if event in self._callbacks and callback in self._callbacks[event]:
|
|
456
|
+
self._callbacks[event].remove(callback)
|
|
457
|
+
|
|
458
|
+
def _fire_callback(self, event: str, *args: Any) -> None:
|
|
459
|
+
"""Fire all callbacks for an event."""
|
|
460
|
+
for callback in self._callbacks.get(event, []):
|
|
461
|
+
try:
|
|
462
|
+
callback(*args)
|
|
463
|
+
except Exception as e:
|
|
464
|
+
logger.error("Callback error for event '%s': %s", event, e)
|
|
465
|
+
|
|
466
|
+
def _save_config(self) -> None:
|
|
467
|
+
"""Save current configuration to file."""
|
|
468
|
+
config = {
|
|
469
|
+
"folders": [
|
|
470
|
+
{
|
|
471
|
+
"path": folder_path,
|
|
472
|
+
"rules": [rule.to_dict() for rule in rules],
|
|
473
|
+
}
|
|
474
|
+
for folder_path, rules in self._folder_rules.items()
|
|
475
|
+
]
|
|
476
|
+
}
|
|
477
|
+
self._config_manager.save_config(config)
|
|
478
|
+
|
|
479
|
+
def start(self) -> None:
|
|
480
|
+
"""
|
|
481
|
+
Start watching all folders.
|
|
482
|
+
|
|
483
|
+
This method starts the file system watchers for all configured folders.
|
|
484
|
+
It blocks until stop() is called or a signal is received.
|
|
485
|
+
"""
|
|
486
|
+
if self._running:
|
|
487
|
+
logger.warning("Already running")
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
self._running = True
|
|
491
|
+
self._stats["start_time"] = datetime.now()
|
|
492
|
+
|
|
493
|
+
# Set up signal handlers
|
|
494
|
+
signal.signal(signal.SIGINT, self._signal_handler)
|
|
495
|
+
signal.signal(signal.SIGTERM, self._signal_handler)
|
|
496
|
+
|
|
497
|
+
# Start watchers for all folders
|
|
498
|
+
for folder_path in self._folder_rules:
|
|
499
|
+
self._watcher_manager.add_watch(
|
|
500
|
+
folder_path,
|
|
501
|
+
callback=self._on_file_event,
|
|
502
|
+
recursive=False,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
logger.info("SortMeOut started, watching %d folders", len(self._folder_rules))
|
|
506
|
+
|
|
507
|
+
# Start the watcher manager
|
|
508
|
+
self._watcher_manager.start()
|
|
509
|
+
|
|
510
|
+
def start_background(self) -> threading.Thread:
|
|
511
|
+
"""
|
|
512
|
+
Start watching in a background thread.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
The background thread.
|
|
516
|
+
"""
|
|
517
|
+
thread = threading.Thread(target=self.start, daemon=True)
|
|
518
|
+
thread.start()
|
|
519
|
+
return thread
|
|
520
|
+
|
|
521
|
+
def stop(self) -> None:
|
|
522
|
+
"""Stop watching all folders."""
|
|
523
|
+
if not self._running:
|
|
524
|
+
return
|
|
525
|
+
|
|
526
|
+
self._running = False
|
|
527
|
+
self._watcher_manager.stop()
|
|
528
|
+
|
|
529
|
+
logger.info("SortMeOut stopped")
|
|
530
|
+
|
|
531
|
+
def _signal_handler(self, signum: int, frame: Any) -> None:
|
|
532
|
+
"""Handle shutdown signals."""
|
|
533
|
+
logger.info("Received signal %d, shutting down...", signum)
|
|
534
|
+
self.stop()
|
|
535
|
+
sys.exit(0)
|
|
536
|
+
|
|
537
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
538
|
+
"""Get current statistics."""
|
|
539
|
+
stats = self._stats.copy()
|
|
540
|
+
if stats["start_time"]:
|
|
541
|
+
stats["uptime"] = str(datetime.now() - stats["start_time"])
|
|
542
|
+
return stats
|
|
543
|
+
|
|
544
|
+
@property
|
|
545
|
+
def is_running(self) -> bool:
|
|
546
|
+
"""Check if the application is running."""
|
|
547
|
+
return self._running
|
|
548
|
+
|
|
549
|
+
@property
|
|
550
|
+
def preview_mode(self) -> bool:
|
|
551
|
+
"""Check if preview mode is enabled."""
|
|
552
|
+
return self._preview_mode
|
|
553
|
+
|
|
554
|
+
@preview_mode.setter
|
|
555
|
+
def preview_mode(self, value: bool) -> None:
|
|
556
|
+
"""Set preview mode."""
|
|
557
|
+
self._preview_mode = value
|
|
558
|
+
self._rule_engine.preview_mode = value
|
|
559
|
+
|
|
560
|
+
def export_rules(self, folder_path: str, output_path: str) -> bool:
|
|
561
|
+
"""
|
|
562
|
+
Export rules for a folder to a file.
|
|
563
|
+
|
|
564
|
+
Args:
|
|
565
|
+
folder_path: Path to the folder.
|
|
566
|
+
output_path: Path to export to.
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
True if export was successful.
|
|
570
|
+
"""
|
|
571
|
+
import json
|
|
572
|
+
|
|
573
|
+
rules = self.get_rules(folder_path)
|
|
574
|
+
if not rules:
|
|
575
|
+
return False
|
|
576
|
+
|
|
577
|
+
export_data = {
|
|
578
|
+
"version": "1.0",
|
|
579
|
+
"folder": folder_path,
|
|
580
|
+
"rules": [rule.to_dict() for rule in rules],
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
with open(output_path, "w") as f:
|
|
584
|
+
json.dump(export_data, f, indent=2)
|
|
585
|
+
|
|
586
|
+
logger.info("Exported %d rules to: %s", len(rules), output_path)
|
|
587
|
+
return True
|
|
588
|
+
|
|
589
|
+
def import_rules(self, folder_path: str, input_path: str) -> int:
|
|
590
|
+
"""
|
|
591
|
+
Import rules from a file to a folder.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
folder_path: Path to the folder.
|
|
595
|
+
input_path: Path to import from.
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
Number of rules imported.
|
|
599
|
+
"""
|
|
600
|
+
import json
|
|
601
|
+
|
|
602
|
+
expanded_path = os.path.expanduser(folder_path)
|
|
603
|
+
resolved_path = str(Path(expanded_path).resolve())
|
|
604
|
+
|
|
605
|
+
if resolved_path not in self._folder_rules:
|
|
606
|
+
raise ValueError(f"Folder not being watched: {folder_path}")
|
|
607
|
+
|
|
608
|
+
with open(input_path, "r") as f:
|
|
609
|
+
import_data = json.load(f)
|
|
610
|
+
|
|
611
|
+
imported = 0
|
|
612
|
+
for rule_dict in import_data.get("rules", []):
|
|
613
|
+
rule = Rule.from_dict(rule_dict)
|
|
614
|
+
if self.add_rule(folder_path, rule):
|
|
615
|
+
imported += 1
|
|
616
|
+
|
|
617
|
+
logger.info("Imported %d rules from: %s", imported, input_path)
|
|
618
|
+
return imported
|