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,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)