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/action.py
ADDED
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Action definitions for file operations.
|
|
3
|
+
|
|
4
|
+
Actions define what operations to perform on files that match rule conditions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, List, Optional, Callable
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ActionType(Enum):
|
|
22
|
+
"""Available action types."""
|
|
23
|
+
# File operations
|
|
24
|
+
MOVE = "move"
|
|
25
|
+
COPY = "copy"
|
|
26
|
+
RENAME = "rename"
|
|
27
|
+
DELETE = "delete"
|
|
28
|
+
TRASH = "trash" # Move to Trash
|
|
29
|
+
|
|
30
|
+
# Archive operations
|
|
31
|
+
ARCHIVE = "archive" # Create archive
|
|
32
|
+
EXTRACT = "extract" # Extract archive
|
|
33
|
+
|
|
34
|
+
# macOS operations
|
|
35
|
+
ADD_TAGS = "add_tags"
|
|
36
|
+
REMOVE_TAGS = "remove_tags"
|
|
37
|
+
SET_TAGS = "set_tags" # Replace all tags
|
|
38
|
+
SET_COMMENT = "set_comment" # Finder comment
|
|
39
|
+
SET_LABEL = "set_label" # Finder color label
|
|
40
|
+
REVEAL_IN_FINDER = "reveal_in_finder"
|
|
41
|
+
|
|
42
|
+
# Application operations
|
|
43
|
+
OPEN_WITH = "open_with"
|
|
44
|
+
IMPORT_TO_PHOTOS = "import_to_photos"
|
|
45
|
+
IMPORT_TO_MUSIC = "import_to_music"
|
|
46
|
+
|
|
47
|
+
# Script operations
|
|
48
|
+
RUN_SHELL = "run_shell"
|
|
49
|
+
RUN_APPLESCRIPT = "run_applescript"
|
|
50
|
+
RUN_AUTOMATOR = "run_automator"
|
|
51
|
+
RUN_SHORTCUT = "run_shortcut"
|
|
52
|
+
|
|
53
|
+
# Notification operations
|
|
54
|
+
NOTIFY = "notify"
|
|
55
|
+
|
|
56
|
+
# Utility operations
|
|
57
|
+
NOTHING = "nothing" # Do nothing (for testing)
|
|
58
|
+
STOP = "stop" # Stop processing rules
|
|
59
|
+
CONTINUE = "continue" # Continue processing (opposite of default)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ArchiveFormat(Enum):
|
|
63
|
+
"""Archive format options."""
|
|
64
|
+
ZIP = "zip"
|
|
65
|
+
TAR = "tar"
|
|
66
|
+
TAR_GZ = "tar.gz"
|
|
67
|
+
TAR_BZ2 = "tar.bz2"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FinderLabel(Enum):
|
|
71
|
+
"""macOS Finder color labels."""
|
|
72
|
+
NONE = 0
|
|
73
|
+
GRAY = 1
|
|
74
|
+
GREEN = 2
|
|
75
|
+
PURPLE = 3
|
|
76
|
+
BLUE = 4
|
|
77
|
+
YELLOW = 5
|
|
78
|
+
RED = 6
|
|
79
|
+
ORANGE = 7
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class ActionResult:
|
|
84
|
+
"""
|
|
85
|
+
Result of an action execution.
|
|
86
|
+
|
|
87
|
+
Attributes:
|
|
88
|
+
success: Whether the action succeeded.
|
|
89
|
+
action_type: Type of action that was executed.
|
|
90
|
+
source_path: Original file path.
|
|
91
|
+
destination_path: New file path (if applicable).
|
|
92
|
+
message: Human-readable result message.
|
|
93
|
+
error: Error message if action failed.
|
|
94
|
+
metadata: Additional metadata about the action.
|
|
95
|
+
"""
|
|
96
|
+
success: bool
|
|
97
|
+
action_type: ActionType
|
|
98
|
+
source_path: str
|
|
99
|
+
destination_path: Optional[str] = None
|
|
100
|
+
message: str = ""
|
|
101
|
+
error: Optional[str] = None
|
|
102
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
103
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
104
|
+
|
|
105
|
+
def __str__(self) -> str:
|
|
106
|
+
status = "✓" if self.success else "✗"
|
|
107
|
+
return f"[{status}] {self.action_type.value}: {self.message}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class Action:
|
|
112
|
+
"""
|
|
113
|
+
An action to perform on a matching file.
|
|
114
|
+
|
|
115
|
+
Attributes:
|
|
116
|
+
action_type: Type of action to perform.
|
|
117
|
+
params: Parameters for the action.
|
|
118
|
+
enabled: Whether the action is active.
|
|
119
|
+
stop_on_error: Stop rule processing if this action fails.
|
|
120
|
+
id: Unique identifier.
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
>>> action = Action("move", destination="~/Documents/PDFs")
|
|
124
|
+
>>> result = action.execute("/path/to/file.pdf", {})
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
action_type: str | ActionType
|
|
128
|
+
params: Dict[str, Any] = field(default_factory=dict)
|
|
129
|
+
enabled: bool = True
|
|
130
|
+
stop_on_error: bool = True
|
|
131
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
action_type: str | ActionType,
|
|
136
|
+
enabled: bool = True,
|
|
137
|
+
stop_on_error: bool = True,
|
|
138
|
+
id: Optional[str] = None,
|
|
139
|
+
**kwargs: Any
|
|
140
|
+
):
|
|
141
|
+
"""
|
|
142
|
+
Initialize an action.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
action_type: Type of action.
|
|
146
|
+
enabled: Whether action is enabled.
|
|
147
|
+
stop_on_error: Stop processing on error.
|
|
148
|
+
id: Unique identifier.
|
|
149
|
+
**kwargs: Action-specific parameters.
|
|
150
|
+
"""
|
|
151
|
+
if isinstance(action_type, str):
|
|
152
|
+
self.action_type = ActionType(action_type)
|
|
153
|
+
else:
|
|
154
|
+
self.action_type = action_type
|
|
155
|
+
|
|
156
|
+
self.params = kwargs
|
|
157
|
+
self.enabled = enabled
|
|
158
|
+
self.stop_on_error = stop_on_error
|
|
159
|
+
self.id = id or str(uuid.uuid4())
|
|
160
|
+
|
|
161
|
+
def execute(
|
|
162
|
+
self,
|
|
163
|
+
file_path: str,
|
|
164
|
+
file_info: Dict[str, Any],
|
|
165
|
+
preview: bool = False,
|
|
166
|
+
) -> ActionResult:
|
|
167
|
+
"""
|
|
168
|
+
Execute the action on a file.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
file_path: Path to the file.
|
|
172
|
+
file_info: Dictionary containing file attributes.
|
|
173
|
+
preview: If True, don't actually perform the action.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
ActionResult with execution status.
|
|
177
|
+
"""
|
|
178
|
+
if not self.enabled:
|
|
179
|
+
return ActionResult(
|
|
180
|
+
success=True,
|
|
181
|
+
action_type=self.action_type,
|
|
182
|
+
source_path=file_path,
|
|
183
|
+
message="Action disabled, skipped",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Expand any variables in parameters
|
|
187
|
+
expanded_params = self._expand_params(file_path, file_info)
|
|
188
|
+
|
|
189
|
+
if preview:
|
|
190
|
+
return ActionResult(
|
|
191
|
+
success=True,
|
|
192
|
+
action_type=self.action_type,
|
|
193
|
+
source_path=file_path,
|
|
194
|
+
destination_path=expanded_params.get("destination"),
|
|
195
|
+
message=f"[PREVIEW] Would execute {self.action_type.value}",
|
|
196
|
+
metadata={"params": expanded_params},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Dispatch to specific handler
|
|
200
|
+
handler = self._get_handler()
|
|
201
|
+
if handler:
|
|
202
|
+
try:
|
|
203
|
+
return handler(file_path, expanded_params, file_info)
|
|
204
|
+
except Exception as e:
|
|
205
|
+
return ActionResult(
|
|
206
|
+
success=False,
|
|
207
|
+
action_type=self.action_type,
|
|
208
|
+
source_path=file_path,
|
|
209
|
+
error=str(e),
|
|
210
|
+
message=f"Action failed: {e}",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
return ActionResult(
|
|
214
|
+
success=False,
|
|
215
|
+
action_type=self.action_type,
|
|
216
|
+
source_path=file_path,
|
|
217
|
+
error=f"No handler for action type: {self.action_type}",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def _get_handler(self) -> Optional[Callable]:
|
|
221
|
+
"""Get the handler function for this action type."""
|
|
222
|
+
handlers = {
|
|
223
|
+
ActionType.MOVE: self._do_move,
|
|
224
|
+
ActionType.COPY: self._do_copy,
|
|
225
|
+
ActionType.RENAME: self._do_rename,
|
|
226
|
+
ActionType.DELETE: self._do_delete,
|
|
227
|
+
ActionType.TRASH: self._do_trash,
|
|
228
|
+
ActionType.ARCHIVE: self._do_archive,
|
|
229
|
+
ActionType.ADD_TAGS: self._do_add_tags,
|
|
230
|
+
ActionType.REMOVE_TAGS: self._do_remove_tags,
|
|
231
|
+
ActionType.SET_TAGS: self._do_set_tags,
|
|
232
|
+
ActionType.SET_COMMENT: self._do_set_comment,
|
|
233
|
+
ActionType.OPEN_WITH: self._do_open_with,
|
|
234
|
+
ActionType.RUN_SHELL: self._do_run_shell,
|
|
235
|
+
ActionType.RUN_APPLESCRIPT: self._do_run_applescript,
|
|
236
|
+
ActionType.RUN_SHORTCUT: self._do_run_shortcut,
|
|
237
|
+
ActionType.NOTIFY: self._do_notify,
|
|
238
|
+
ActionType.NOTHING: self._do_nothing,
|
|
239
|
+
ActionType.REVEAL_IN_FINDER: self._do_reveal_in_finder,
|
|
240
|
+
}
|
|
241
|
+
return handlers.get(self.action_type)
|
|
242
|
+
|
|
243
|
+
def _expand_params(self, file_path: str, file_info: Dict[str, Any]) -> Dict[str, Any]:
|
|
244
|
+
"""
|
|
245
|
+
Expand variables in parameters.
|
|
246
|
+
|
|
247
|
+
Variables:
|
|
248
|
+
- {name}: File name without extension
|
|
249
|
+
- {extension}: File extension
|
|
250
|
+
- {full_name}: Full file name
|
|
251
|
+
- {parent}: Parent folder name
|
|
252
|
+
- {date}: Current date (YYYY-MM-DD)
|
|
253
|
+
- {time}: Current time (HH-MM-SS)
|
|
254
|
+
- {datetime}: Current datetime
|
|
255
|
+
- {year}, {month}, {day}: Date components
|
|
256
|
+
- {created_date}, {modified_date}: File dates
|
|
257
|
+
- {size}: File size
|
|
258
|
+
- {counter}: Auto-incrementing counter
|
|
259
|
+
"""
|
|
260
|
+
path = Path(file_path)
|
|
261
|
+
now = datetime.now()
|
|
262
|
+
|
|
263
|
+
variables = {
|
|
264
|
+
"name": path.stem,
|
|
265
|
+
"extension": path.suffix.lstrip("."),
|
|
266
|
+
"full_name": path.name,
|
|
267
|
+
"parent": path.parent.name,
|
|
268
|
+
"path": str(path.parent),
|
|
269
|
+
"date": now.strftime("%Y-%m-%d"),
|
|
270
|
+
"time": now.strftime("%H-%M-%S"),
|
|
271
|
+
"datetime": now.strftime("%Y-%m-%d_%H-%M-%S"),
|
|
272
|
+
"year": now.strftime("%Y"),
|
|
273
|
+
"month": now.strftime("%m"),
|
|
274
|
+
"day": now.strftime("%d"),
|
|
275
|
+
"hour": now.strftime("%H"),
|
|
276
|
+
"minute": now.strftime("%M"),
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# Add file_info variables
|
|
280
|
+
for key, value in file_info.items():
|
|
281
|
+
if isinstance(value, datetime):
|
|
282
|
+
variables[f"{key}_date"] = value.strftime("%Y-%m-%d")
|
|
283
|
+
variables[f"{key}_year"] = value.strftime("%Y")
|
|
284
|
+
variables[f"{key}_month"] = value.strftime("%m")
|
|
285
|
+
variables[f"{key}_day"] = value.strftime("%d")
|
|
286
|
+
elif isinstance(value, (str, int, float)):
|
|
287
|
+
variables[key] = str(value)
|
|
288
|
+
|
|
289
|
+
# Expand variables in string parameters
|
|
290
|
+
expanded = {}
|
|
291
|
+
for key, value in self.params.items():
|
|
292
|
+
if isinstance(value, str):
|
|
293
|
+
expanded_value = value
|
|
294
|
+
for var_name, var_value in variables.items():
|
|
295
|
+
expanded_value = expanded_value.replace(f"{{{var_name}}}", str(var_value))
|
|
296
|
+
# Expand user home directory
|
|
297
|
+
expanded_value = os.path.expanduser(expanded_value)
|
|
298
|
+
expanded[key] = expanded_value
|
|
299
|
+
else:
|
|
300
|
+
expanded[key] = value
|
|
301
|
+
|
|
302
|
+
return expanded
|
|
303
|
+
|
|
304
|
+
# Action handlers
|
|
305
|
+
|
|
306
|
+
def _do_move(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
307
|
+
"""Move file to destination."""
|
|
308
|
+
destination = params.get("destination")
|
|
309
|
+
if not destination:
|
|
310
|
+
return ActionResult(
|
|
311
|
+
success=False,
|
|
312
|
+
action_type=self.action_type,
|
|
313
|
+
source_path=file_path,
|
|
314
|
+
error="No destination specified",
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
dest_path = Path(destination)
|
|
318
|
+
|
|
319
|
+
# If destination is a directory, move file into it
|
|
320
|
+
if dest_path.is_dir() or not dest_path.suffix:
|
|
321
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
322
|
+
dest_path = dest_path / Path(file_path).name
|
|
323
|
+
else:
|
|
324
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
325
|
+
|
|
326
|
+
# Handle conflicts
|
|
327
|
+
dest_path = self._handle_conflict(dest_path, params.get("if_exists", "rename"))
|
|
328
|
+
|
|
329
|
+
shutil.move(file_path, dest_path)
|
|
330
|
+
|
|
331
|
+
return ActionResult(
|
|
332
|
+
success=True,
|
|
333
|
+
action_type=self.action_type,
|
|
334
|
+
source_path=file_path,
|
|
335
|
+
destination_path=str(dest_path),
|
|
336
|
+
message=f"Moved to {dest_path}",
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def _do_copy(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
340
|
+
"""Copy file to destination."""
|
|
341
|
+
destination = params.get("destination")
|
|
342
|
+
if not destination:
|
|
343
|
+
return ActionResult(
|
|
344
|
+
success=False,
|
|
345
|
+
action_type=self.action_type,
|
|
346
|
+
source_path=file_path,
|
|
347
|
+
error="No destination specified",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
dest_path = Path(destination)
|
|
351
|
+
|
|
352
|
+
if dest_path.is_dir() or not dest_path.suffix:
|
|
353
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
dest_path = dest_path / Path(file_path).name
|
|
355
|
+
else:
|
|
356
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
357
|
+
|
|
358
|
+
dest_path = self._handle_conflict(dest_path, params.get("if_exists", "rename"))
|
|
359
|
+
|
|
360
|
+
shutil.copy2(file_path, dest_path)
|
|
361
|
+
|
|
362
|
+
return ActionResult(
|
|
363
|
+
success=True,
|
|
364
|
+
action_type=self.action_type,
|
|
365
|
+
source_path=file_path,
|
|
366
|
+
destination_path=str(dest_path),
|
|
367
|
+
message=f"Copied to {dest_path}",
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def _do_rename(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
371
|
+
"""Rename file."""
|
|
372
|
+
new_name = params.get("new_name") or params.get("pattern")
|
|
373
|
+
if not new_name:
|
|
374
|
+
return ActionResult(
|
|
375
|
+
success=False,
|
|
376
|
+
action_type=self.action_type,
|
|
377
|
+
source_path=file_path,
|
|
378
|
+
error="No new name specified",
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
path = Path(file_path)
|
|
382
|
+
new_path = path.parent / new_name
|
|
383
|
+
new_path = self._handle_conflict(new_path, params.get("if_exists", "rename"))
|
|
384
|
+
|
|
385
|
+
path.rename(new_path)
|
|
386
|
+
|
|
387
|
+
return ActionResult(
|
|
388
|
+
success=True,
|
|
389
|
+
action_type=self.action_type,
|
|
390
|
+
source_path=file_path,
|
|
391
|
+
destination_path=str(new_path),
|
|
392
|
+
message=f"Renamed to {new_path.name}",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def _do_delete(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
396
|
+
"""Delete file permanently."""
|
|
397
|
+
path = Path(file_path)
|
|
398
|
+
|
|
399
|
+
if params.get("confirm", True) and not params.get("force", False):
|
|
400
|
+
# In a real implementation, we might prompt for confirmation
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
if path.is_file():
|
|
404
|
+
path.unlink()
|
|
405
|
+
elif path.is_dir():
|
|
406
|
+
shutil.rmtree(path)
|
|
407
|
+
|
|
408
|
+
return ActionResult(
|
|
409
|
+
success=True,
|
|
410
|
+
action_type=self.action_type,
|
|
411
|
+
source_path=file_path,
|
|
412
|
+
message="Deleted permanently",
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
def _do_trash(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
416
|
+
"""Move file to Trash."""
|
|
417
|
+
try:
|
|
418
|
+
# Use macOS-specific trash command
|
|
419
|
+
subprocess.run(
|
|
420
|
+
["osascript", "-e", f'tell application "Finder" to delete POSIX file "{file_path}"'],
|
|
421
|
+
check=True,
|
|
422
|
+
capture_output=True,
|
|
423
|
+
)
|
|
424
|
+
return ActionResult(
|
|
425
|
+
success=True,
|
|
426
|
+
action_type=self.action_type,
|
|
427
|
+
source_path=file_path,
|
|
428
|
+
message="Moved to Trash",
|
|
429
|
+
)
|
|
430
|
+
except subprocess.CalledProcessError as e:
|
|
431
|
+
return ActionResult(
|
|
432
|
+
success=False,
|
|
433
|
+
action_type=self.action_type,
|
|
434
|
+
source_path=file_path,
|
|
435
|
+
error=f"Failed to move to Trash: {e}",
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def _do_archive(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
439
|
+
"""Create archive from file."""
|
|
440
|
+
format_str = params.get("format", "zip")
|
|
441
|
+
destination = params.get("destination")
|
|
442
|
+
|
|
443
|
+
path = Path(file_path)
|
|
444
|
+
|
|
445
|
+
if destination:
|
|
446
|
+
archive_path = Path(destination)
|
|
447
|
+
else:
|
|
448
|
+
archive_path = path.parent / f"{path.stem}.{format_str}"
|
|
449
|
+
|
|
450
|
+
archive_path = self._handle_conflict(archive_path, params.get("if_exists", "rename"))
|
|
451
|
+
|
|
452
|
+
if format_str == "zip":
|
|
453
|
+
shutil.make_archive(str(archive_path.with_suffix("")), "zip", path.parent, path.name)
|
|
454
|
+
elif format_str == "tar":
|
|
455
|
+
shutil.make_archive(str(archive_path.with_suffix("")), "tar", path.parent, path.name)
|
|
456
|
+
elif format_str == "tar.gz":
|
|
457
|
+
shutil.make_archive(str(archive_path.with_suffix("")), "gztar", path.parent, path.name)
|
|
458
|
+
elif format_str == "tar.bz2":
|
|
459
|
+
shutil.make_archive(str(archive_path.with_suffix("")), "bztar", path.parent, path.name)
|
|
460
|
+
|
|
461
|
+
# Delete original if requested
|
|
462
|
+
if params.get("delete_original", False):
|
|
463
|
+
if path.is_file():
|
|
464
|
+
path.unlink()
|
|
465
|
+
elif path.is_dir():
|
|
466
|
+
shutil.rmtree(path)
|
|
467
|
+
|
|
468
|
+
return ActionResult(
|
|
469
|
+
success=True,
|
|
470
|
+
action_type=self.action_type,
|
|
471
|
+
source_path=file_path,
|
|
472
|
+
destination_path=str(archive_path),
|
|
473
|
+
message=f"Archived to {archive_path}",
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def _do_add_tags(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
477
|
+
"""Add macOS tags to file."""
|
|
478
|
+
tags = params.get("tags", [])
|
|
479
|
+
if isinstance(tags, str):
|
|
480
|
+
tags = [tags]
|
|
481
|
+
|
|
482
|
+
if not tags:
|
|
483
|
+
return ActionResult(
|
|
484
|
+
success=False,
|
|
485
|
+
action_type=self.action_type,
|
|
486
|
+
source_path=file_path,
|
|
487
|
+
error="No tags specified",
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# Use xattr to add tags on macOS
|
|
491
|
+
try:
|
|
492
|
+
from sortmeout.macos.tags import add_tags
|
|
493
|
+
add_tags(file_path, tags)
|
|
494
|
+
return ActionResult(
|
|
495
|
+
success=True,
|
|
496
|
+
action_type=self.action_type,
|
|
497
|
+
source_path=file_path,
|
|
498
|
+
message=f"Added tags: {', '.join(tags)}",
|
|
499
|
+
metadata={"tags": tags},
|
|
500
|
+
)
|
|
501
|
+
except ImportError:
|
|
502
|
+
# Fallback to shell command
|
|
503
|
+
tag_str = ",".join(tags)
|
|
504
|
+
subprocess.run(
|
|
505
|
+
["tag", "-a", tag_str, file_path],
|
|
506
|
+
check=True,
|
|
507
|
+
capture_output=True,
|
|
508
|
+
)
|
|
509
|
+
return ActionResult(
|
|
510
|
+
success=True,
|
|
511
|
+
action_type=self.action_type,
|
|
512
|
+
source_path=file_path,
|
|
513
|
+
message=f"Added tags: {tag_str}",
|
|
514
|
+
metadata={"tags": tags},
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
def _do_remove_tags(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
518
|
+
"""Remove macOS tags from file."""
|
|
519
|
+
tags = params.get("tags", [])
|
|
520
|
+
if isinstance(tags, str):
|
|
521
|
+
tags = [tags]
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
from sortmeout.macos.tags import remove_tags
|
|
525
|
+
remove_tags(file_path, tags)
|
|
526
|
+
return ActionResult(
|
|
527
|
+
success=True,
|
|
528
|
+
action_type=self.action_type,
|
|
529
|
+
source_path=file_path,
|
|
530
|
+
message=f"Removed tags: {', '.join(tags)}",
|
|
531
|
+
metadata={"tags": tags},
|
|
532
|
+
)
|
|
533
|
+
except ImportError:
|
|
534
|
+
if tags:
|
|
535
|
+
tag_str = ",".join(tags)
|
|
536
|
+
subprocess.run(
|
|
537
|
+
["tag", "-r", tag_str, file_path],
|
|
538
|
+
check=True,
|
|
539
|
+
capture_output=True,
|
|
540
|
+
)
|
|
541
|
+
return ActionResult(
|
|
542
|
+
success=True,
|
|
543
|
+
action_type=self.action_type,
|
|
544
|
+
source_path=file_path,
|
|
545
|
+
message=f"Removed tags: {', '.join(tags)}",
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
def _do_set_tags(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
549
|
+
"""Set macOS tags (replace all existing)."""
|
|
550
|
+
tags = params.get("tags", [])
|
|
551
|
+
if isinstance(tags, str):
|
|
552
|
+
tags = [tags]
|
|
553
|
+
|
|
554
|
+
try:
|
|
555
|
+
from sortmeout.macos.tags import set_tags
|
|
556
|
+
set_tags(file_path, tags)
|
|
557
|
+
return ActionResult(
|
|
558
|
+
success=True,
|
|
559
|
+
action_type=self.action_type,
|
|
560
|
+
source_path=file_path,
|
|
561
|
+
message=f"Set tags: {', '.join(tags)}",
|
|
562
|
+
metadata={"tags": tags},
|
|
563
|
+
)
|
|
564
|
+
except ImportError:
|
|
565
|
+
tag_str = ",".join(tags)
|
|
566
|
+
subprocess.run(
|
|
567
|
+
["tag", "-s", tag_str, file_path],
|
|
568
|
+
check=True,
|
|
569
|
+
capture_output=True,
|
|
570
|
+
)
|
|
571
|
+
return ActionResult(
|
|
572
|
+
success=True,
|
|
573
|
+
action_type=self.action_type,
|
|
574
|
+
source_path=file_path,
|
|
575
|
+
message=f"Set tags: {tag_str}",
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
def _do_set_comment(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
579
|
+
"""Set Finder comment."""
|
|
580
|
+
comment = params.get("comment", "")
|
|
581
|
+
|
|
582
|
+
script = f'''
|
|
583
|
+
tell application "Finder"
|
|
584
|
+
set comment of (POSIX file "{file_path}" as alias) to "{comment}"
|
|
585
|
+
end tell
|
|
586
|
+
'''
|
|
587
|
+
|
|
588
|
+
subprocess.run(["osascript", "-e", script], check=True, capture_output=True)
|
|
589
|
+
|
|
590
|
+
return ActionResult(
|
|
591
|
+
success=True,
|
|
592
|
+
action_type=self.action_type,
|
|
593
|
+
source_path=file_path,
|
|
594
|
+
message=f"Set comment: {comment[:50]}...",
|
|
595
|
+
metadata={"comment": comment},
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
def _do_open_with(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
599
|
+
"""Open file with application."""
|
|
600
|
+
app = params.get("application") or params.get("app")
|
|
601
|
+
if not app:
|
|
602
|
+
# Open with default application
|
|
603
|
+
subprocess.run(["open", file_path], check=True)
|
|
604
|
+
else:
|
|
605
|
+
subprocess.run(["open", "-a", app, file_path], check=True)
|
|
606
|
+
|
|
607
|
+
return ActionResult(
|
|
608
|
+
success=True,
|
|
609
|
+
action_type=self.action_type,
|
|
610
|
+
source_path=file_path,
|
|
611
|
+
message=f"Opened with {app or 'default application'}",
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
def _do_run_shell(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
615
|
+
"""Run shell script."""
|
|
616
|
+
script = params.get("script") or params.get("command")
|
|
617
|
+
if not script:
|
|
618
|
+
return ActionResult(
|
|
619
|
+
success=False,
|
|
620
|
+
action_type=self.action_type,
|
|
621
|
+
source_path=file_path,
|
|
622
|
+
error="No script specified",
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
# Set environment variables
|
|
626
|
+
env = os.environ.copy()
|
|
627
|
+
env["SORTMEOUT_FILE"] = file_path
|
|
628
|
+
env["SORTMEOUT_NAME"] = Path(file_path).stem
|
|
629
|
+
env["SORTMEOUT_EXTENSION"] = Path(file_path).suffix.lstrip(".")
|
|
630
|
+
env["SORTMEOUT_FOLDER"] = str(Path(file_path).parent)
|
|
631
|
+
|
|
632
|
+
result = subprocess.run(
|
|
633
|
+
script,
|
|
634
|
+
shell=True,
|
|
635
|
+
env=env,
|
|
636
|
+
capture_output=True,
|
|
637
|
+
text=True,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
if result.returncode != 0:
|
|
641
|
+
return ActionResult(
|
|
642
|
+
success=False,
|
|
643
|
+
action_type=self.action_type,
|
|
644
|
+
source_path=file_path,
|
|
645
|
+
error=f"Script failed: {result.stderr}",
|
|
646
|
+
metadata={"stdout": result.stdout, "stderr": result.stderr},
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
return ActionResult(
|
|
650
|
+
success=True,
|
|
651
|
+
action_type=self.action_type,
|
|
652
|
+
source_path=file_path,
|
|
653
|
+
message="Script executed successfully",
|
|
654
|
+
metadata={"stdout": result.stdout},
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
def _do_run_applescript(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
658
|
+
"""Run AppleScript."""
|
|
659
|
+
script = params.get("script")
|
|
660
|
+
script_file = params.get("script_file")
|
|
661
|
+
|
|
662
|
+
if script_file:
|
|
663
|
+
result = subprocess.run(
|
|
664
|
+
["osascript", os.path.expanduser(script_file), file_path],
|
|
665
|
+
capture_output=True,
|
|
666
|
+
text=True,
|
|
667
|
+
)
|
|
668
|
+
elif script:
|
|
669
|
+
# Replace placeholder with actual file path
|
|
670
|
+
script = script.replace("{file}", file_path)
|
|
671
|
+
result = subprocess.run(
|
|
672
|
+
["osascript", "-e", script],
|
|
673
|
+
capture_output=True,
|
|
674
|
+
text=True,
|
|
675
|
+
)
|
|
676
|
+
else:
|
|
677
|
+
return ActionResult(
|
|
678
|
+
success=False,
|
|
679
|
+
action_type=self.action_type,
|
|
680
|
+
source_path=file_path,
|
|
681
|
+
error="No AppleScript specified",
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
if result.returncode != 0:
|
|
685
|
+
return ActionResult(
|
|
686
|
+
success=False,
|
|
687
|
+
action_type=self.action_type,
|
|
688
|
+
source_path=file_path,
|
|
689
|
+
error=f"AppleScript failed: {result.stderr}",
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
return ActionResult(
|
|
693
|
+
success=True,
|
|
694
|
+
action_type=self.action_type,
|
|
695
|
+
source_path=file_path,
|
|
696
|
+
message="AppleScript executed",
|
|
697
|
+
metadata={"output": result.stdout},
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
def _do_run_shortcut(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
701
|
+
"""Run Shortcuts workflow."""
|
|
702
|
+
shortcut_name = params.get("shortcut") or params.get("name")
|
|
703
|
+
if not shortcut_name:
|
|
704
|
+
return ActionResult(
|
|
705
|
+
success=False,
|
|
706
|
+
action_type=self.action_type,
|
|
707
|
+
source_path=file_path,
|
|
708
|
+
error="No shortcut name specified",
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
result = subprocess.run(
|
|
712
|
+
["shortcuts", "run", shortcut_name, "-i", file_path],
|
|
713
|
+
capture_output=True,
|
|
714
|
+
text=True,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
if result.returncode != 0:
|
|
718
|
+
return ActionResult(
|
|
719
|
+
success=False,
|
|
720
|
+
action_type=self.action_type,
|
|
721
|
+
source_path=file_path,
|
|
722
|
+
error=f"Shortcut failed: {result.stderr}",
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
return ActionResult(
|
|
726
|
+
success=True,
|
|
727
|
+
action_type=self.action_type,
|
|
728
|
+
source_path=file_path,
|
|
729
|
+
message=f"Ran shortcut: {shortcut_name}",
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
def _do_notify(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
733
|
+
"""Send notification."""
|
|
734
|
+
title = params.get("title", "SortMeOut")
|
|
735
|
+
message = params.get("message", f"Processed: {Path(file_path).name}")
|
|
736
|
+
sound = params.get("sound", "default")
|
|
737
|
+
|
|
738
|
+
script = f'''
|
|
739
|
+
display notification "{message}" with title "{title}" sound name "{sound}"
|
|
740
|
+
'''
|
|
741
|
+
|
|
742
|
+
subprocess.run(["osascript", "-e", script], check=True, capture_output=True)
|
|
743
|
+
|
|
744
|
+
return ActionResult(
|
|
745
|
+
success=True,
|
|
746
|
+
action_type=self.action_type,
|
|
747
|
+
source_path=file_path,
|
|
748
|
+
message=f"Notification sent: {message}",
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
def _do_nothing(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
752
|
+
"""Do nothing (for testing or placeholders)."""
|
|
753
|
+
return ActionResult(
|
|
754
|
+
success=True,
|
|
755
|
+
action_type=self.action_type,
|
|
756
|
+
source_path=file_path,
|
|
757
|
+
message="No action performed",
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
def _do_reveal_in_finder(self, file_path: str, params: Dict[str, Any], file_info: Dict[str, Any]) -> ActionResult:
|
|
761
|
+
"""Reveal file in Finder."""
|
|
762
|
+
subprocess.run(["open", "-R", file_path], check=True)
|
|
763
|
+
|
|
764
|
+
return ActionResult(
|
|
765
|
+
success=True,
|
|
766
|
+
action_type=self.action_type,
|
|
767
|
+
source_path=file_path,
|
|
768
|
+
message="Revealed in Finder",
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
def _handle_conflict(self, path: Path, strategy: str = "rename") -> Path:
|
|
772
|
+
"""
|
|
773
|
+
Handle file name conflicts.
|
|
774
|
+
|
|
775
|
+
Args:
|
|
776
|
+
path: Target path.
|
|
777
|
+
strategy: How to handle conflicts:
|
|
778
|
+
- "rename": Add number suffix
|
|
779
|
+
- "overwrite": Replace existing
|
|
780
|
+
- "skip": Return original path (caller should check existence)
|
|
781
|
+
|
|
782
|
+
Returns:
|
|
783
|
+
Final path to use.
|
|
784
|
+
"""
|
|
785
|
+
if strategy == "overwrite" or not path.exists():
|
|
786
|
+
return path
|
|
787
|
+
|
|
788
|
+
if strategy == "skip":
|
|
789
|
+
return path
|
|
790
|
+
|
|
791
|
+
# Rename strategy: add number suffix
|
|
792
|
+
base = path.stem
|
|
793
|
+
suffix = path.suffix
|
|
794
|
+
parent = path.parent
|
|
795
|
+
counter = 1
|
|
796
|
+
|
|
797
|
+
while path.exists():
|
|
798
|
+
path = parent / f"{base} ({counter}){suffix}"
|
|
799
|
+
counter += 1
|
|
800
|
+
|
|
801
|
+
return path
|
|
802
|
+
|
|
803
|
+
def duplicate(self) -> "Action":
|
|
804
|
+
"""Create a copy of this action."""
|
|
805
|
+
return Action(
|
|
806
|
+
action_type=self.action_type,
|
|
807
|
+
enabled=self.enabled,
|
|
808
|
+
stop_on_error=self.stop_on_error,
|
|
809
|
+
**self.params
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
813
|
+
"""Convert action to dictionary for serialization."""
|
|
814
|
+
return {
|
|
815
|
+
"id": self.id,
|
|
816
|
+
"action_type": self.action_type.value,
|
|
817
|
+
"params": self.params,
|
|
818
|
+
"enabled": self.enabled,
|
|
819
|
+
"stop_on_error": self.stop_on_error,
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
@classmethod
|
|
823
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Action":
|
|
824
|
+
"""Create an action from a dictionary."""
|
|
825
|
+
return cls(
|
|
826
|
+
id=data.get("id"),
|
|
827
|
+
action_type=data["action_type"],
|
|
828
|
+
enabled=data.get("enabled", True),
|
|
829
|
+
stop_on_error=data.get("stop_on_error", True),
|
|
830
|
+
**data.get("params", {}),
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
def __str__(self) -> str:
|
|
834
|
+
"""Human-readable representation."""
|
|
835
|
+
status = "✓" if self.enabled else "✗"
|
|
836
|
+
params_str = ", ".join(f"{k}={v!r}" for k, v in self.params.items())
|
|
837
|
+
return f"[{status}] {self.action_type.value}({params_str})"
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
# Convenience functions for creating common actions
|
|
841
|
+
|
|
842
|
+
def move_to(destination: str, if_exists: str = "rename") -> Action:
|
|
843
|
+
"""Create a move action."""
|
|
844
|
+
return Action("move", destination=destination, if_exists=if_exists)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def copy_to(destination: str, if_exists: str = "rename") -> Action:
|
|
848
|
+
"""Create a copy action."""
|
|
849
|
+
return Action("copy", destination=destination, if_exists=if_exists)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def rename(pattern: str, if_exists: str = "rename") -> Action:
|
|
853
|
+
"""Create a rename action."""
|
|
854
|
+
return Action("rename", new_name=pattern, if_exists=if_exists)
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def delete(force: bool = False) -> Action:
|
|
858
|
+
"""Create a delete action."""
|
|
859
|
+
return Action("delete", force=force)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def trash() -> Action:
|
|
863
|
+
"""Create a trash action."""
|
|
864
|
+
return Action("trash")
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def archive(format: str = "zip", delete_original: bool = False) -> Action:
|
|
868
|
+
"""Create an archive action."""
|
|
869
|
+
return Action("archive", format=format, delete_original=delete_original)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def add_tags(*tags: str) -> Action:
|
|
873
|
+
"""Create an add tags action."""
|
|
874
|
+
return Action("add_tags", tags=list(tags))
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def notify(title: str = "SortMeOut", message: str = "{full_name} processed") -> Action:
|
|
878
|
+
"""Create a notification action."""
|
|
879
|
+
return Action("notify", title=title, message=message)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def run_shell(script: str) -> Action:
|
|
883
|
+
"""Create a shell script action."""
|
|
884
|
+
return Action("run_shell", script=script)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def open_with(application: str) -> Action:
|
|
888
|
+
"""Create an open with action."""
|
|
889
|
+
return Action("open_with", application=application)
|