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/cli.py ADDED
@@ -0,0 +1,550 @@
1
+ """
2
+ Command-line interface for SortMeOut.
3
+
4
+ Provides a full-featured CLI for managing file automation rules.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ import click
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+ from rich.panel import Panel
19
+ from rich.syntax import Syntax
20
+ from rich import print as rprint
21
+
22
+ from sortmeout import SortMeOut, Rule, Condition, Action, __version__
23
+ from sortmeout.config.manager import ConfigManager
24
+ from sortmeout.utils.logger import setup_logging
25
+ from sortmeout.macos.trash import get_trash_info, empty_trash, TrashManager
26
+
27
+ console = Console()
28
+
29
+
30
+ @click.group()
31
+ @click.version_option(__version__, prog_name="SortMeOut")
32
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
33
+ @click.option("--config", "-c", type=click.Path(), help="Path to config file")
34
+ @click.pass_context
35
+ def main(ctx, verbose: bool, config: Optional[str]):
36
+ """
37
+ SortMeOut - Automated file organization for macOS.
38
+
39
+ Watch folders and organize files automatically based on rules you define.
40
+ """
41
+ ctx.ensure_object(dict)
42
+ ctx.obj["verbose"] = verbose
43
+ ctx.obj["config"] = config
44
+
45
+ if verbose:
46
+ setup_logging(level="DEBUG")
47
+ else:
48
+ setup_logging(level="INFO")
49
+
50
+
51
+ @main.command()
52
+ @click.option("--preview", "-p", is_flag=True, help="Preview mode (don't execute actions)")
53
+ @click.option("--foreground", "-f", is_flag=True, help="Run in foreground")
54
+ @click.pass_context
55
+ def start(ctx, preview: bool, foreground: bool):
56
+ """Start watching configured folders."""
57
+ app = SortMeOut(
58
+ config_path=ctx.obj.get("config"),
59
+ preview_mode=preview,
60
+ verbose=ctx.obj.get("verbose", False),
61
+ )
62
+
63
+ folders = app.get_folders()
64
+ if not folders:
65
+ console.print("[yellow]No folders configured. Use 'sortmeout folder add' to add folders.[/yellow]")
66
+ return
67
+
68
+ console.print(f"[green]Starting SortMeOut...[/green]")
69
+ console.print(f"Watching {len(folders)} folder(s)")
70
+
71
+ if preview:
72
+ console.print("[yellow]Preview mode: Actions will not be executed[/yellow]")
73
+
74
+ for folder in folders:
75
+ rules = app.get_rules(folder)
76
+ console.print(f" • {folder} ({len(rules)} rules)")
77
+
78
+ if foreground:
79
+ console.print("\nPress Ctrl+C to stop...")
80
+ try:
81
+ app.start()
82
+ except KeyboardInterrupt:
83
+ console.print("\n[yellow]Stopping...[/yellow]")
84
+ app.stop()
85
+ else:
86
+ thread = app.start_background()
87
+ console.print("[green]SortMeOut running in background[/green]")
88
+
89
+
90
+ @main.command()
91
+ @click.pass_context
92
+ def stop(ctx):
93
+ """Stop watching folders."""
94
+ console.print("[yellow]Stopping SortMeOut...[/yellow]")
95
+ # In a real implementation, this would communicate with a running daemon
96
+ console.print("[green]Stopped[/green]")
97
+
98
+
99
+ @main.command()
100
+ @click.pass_context
101
+ def status(ctx):
102
+ """Show current status."""
103
+ app = SortMeOut(config_path=ctx.obj.get("config"))
104
+
105
+ folders = app.get_folders()
106
+ stats = app.get_stats()
107
+
108
+ # Status panel
109
+ console.print(Panel.fit(
110
+ f"[bold]SortMeOut Status[/bold]\n\n"
111
+ f"Folders: {len(folders)}\n"
112
+ f"Running: {'Yes' if app.is_running else 'No'}\n"
113
+ f"Preview Mode: {'Yes' if app.preview_mode else 'No'}",
114
+ title="Status",
115
+ ))
116
+
117
+ # Folders table
118
+ if folders:
119
+ table = Table(title="Watched Folders")
120
+ table.add_column("Folder", style="cyan")
121
+ table.add_column("Rules", justify="right")
122
+
123
+ for folder in folders:
124
+ rules = app.get_rules(folder)
125
+ table.add_row(folder, str(len(rules)))
126
+
127
+ console.print(table)
128
+ else:
129
+ console.print("[yellow]No folders configured[/yellow]")
130
+
131
+
132
+ # Folder management commands
133
+ @main.group()
134
+ def folder():
135
+ """Manage watched folders."""
136
+ pass
137
+
138
+
139
+ @folder.command("add")
140
+ @click.argument("path", type=click.Path(exists=True))
141
+ @click.option("--recursive", "-r", is_flag=True, help="Watch subdirectories")
142
+ @click.pass_context
143
+ def folder_add(ctx, path: str, recursive: bool):
144
+ """Add a folder to watch."""
145
+ app = SortMeOut(config_path=ctx.obj.get("config"))
146
+
147
+ try:
148
+ if app.add_folder(path, recursive=recursive):
149
+ console.print(f"[green]Added folder: {path}[/green]")
150
+ if recursive:
151
+ console.print(" (watching subdirectories)")
152
+ else:
153
+ console.print(f"[yellow]Folder already being watched: {path}[/yellow]")
154
+ except ValueError as e:
155
+ console.print(f"[red]Error: {e}[/red]")
156
+
157
+
158
+ @folder.command("remove")
159
+ @click.argument("path")
160
+ @click.pass_context
161
+ def folder_remove(ctx, path: str):
162
+ """Remove a folder from watching."""
163
+ app = SortMeOut(config_path=ctx.obj.get("config"))
164
+
165
+ if app.remove_folder(path):
166
+ console.print(f"[green]Removed folder: {path}[/green]")
167
+ else:
168
+ console.print(f"[red]Folder not found: {path}[/red]")
169
+
170
+
171
+ @folder.command("list")
172
+ @click.pass_context
173
+ def folder_list(ctx):
174
+ """List all watched folders."""
175
+ app = SortMeOut(config_path=ctx.obj.get("config"))
176
+
177
+ folders = app.get_folders()
178
+
179
+ if not folders:
180
+ console.print("[yellow]No folders configured[/yellow]")
181
+ return
182
+
183
+ table = Table(title="Watched Folders")
184
+ table.add_column("Path", style="cyan")
185
+ table.add_column("Rules", justify="right")
186
+
187
+ for folder in folders:
188
+ rules = app.get_rules(folder)
189
+ table.add_row(folder, str(len(rules)))
190
+
191
+ console.print(table)
192
+
193
+
194
+ @folder.command("process")
195
+ @click.argument("path")
196
+ @click.option("--preview", "-p", is_flag=True, help="Preview only")
197
+ @click.pass_context
198
+ def folder_process(ctx, path: str, preview: bool):
199
+ """Process all existing files in a folder."""
200
+ app = SortMeOut(config_path=ctx.obj.get("config"), preview_mode=preview)
201
+
202
+ try:
203
+ result = app.process_folder(path)
204
+ console.print(f"[green]Processed {result['processed']} files[/green]")
205
+ except ValueError as e:
206
+ console.print(f"[red]Error: {e}[/red]")
207
+
208
+
209
+ # Rule management commands
210
+ @main.group()
211
+ def rule():
212
+ """Manage rules."""
213
+ pass
214
+
215
+
216
+ @rule.command("add")
217
+ @click.argument("folder")
218
+ @click.argument("name")
219
+ @click.option("--condition", "-c", multiple=True, help="Condition (attribute:operator:value)")
220
+ @click.option("--action", "-a", multiple=True, help="Action (type:param=value)")
221
+ @click.pass_context
222
+ def rule_add(ctx, folder: str, name: str, condition: tuple, action: tuple):
223
+ """Add a rule to a folder."""
224
+ app = SortMeOut(config_path=ctx.obj.get("config"))
225
+
226
+ # Parse conditions
227
+ conditions = []
228
+ for cond_str in condition:
229
+ parts = cond_str.split(":", 2)
230
+ if len(parts) >= 2:
231
+ attr, op = parts[0], parts[1]
232
+ value = parts[2] if len(parts) > 2 else ""
233
+ conditions.append(Condition(attr, op, value))
234
+
235
+ # Parse actions
236
+ actions = []
237
+ for action_str in action:
238
+ if ":" in action_str:
239
+ action_type, params_str = action_str.split(":", 1)
240
+ params = {}
241
+ for param in params_str.split(","):
242
+ if "=" in param:
243
+ key, value = param.split("=", 1)
244
+ params[key.strip()] = value.strip()
245
+ actions.append(Action(action_type, **params))
246
+ else:
247
+ actions.append(Action(action_str))
248
+
249
+ try:
250
+ rule = Rule(name=name, conditions=conditions, actions=actions)
251
+
252
+ if app.add_rule(folder, rule):
253
+ console.print(f"[green]Added rule '{name}' to {folder}[/green]")
254
+ else:
255
+ console.print(f"[yellow]Rule '{name}' already exists[/yellow]")
256
+ except ValueError as e:
257
+ console.print(f"[red]Error: {e}[/red]")
258
+
259
+
260
+ @rule.command("remove")
261
+ @click.argument("folder")
262
+ @click.argument("name")
263
+ @click.pass_context
264
+ def rule_remove(ctx, folder: str, name: str):
265
+ """Remove a rule from a folder."""
266
+ app = SortMeOut(config_path=ctx.obj.get("config"))
267
+
268
+ if app.remove_rule(folder, name):
269
+ console.print(f"[green]Removed rule '{name}' from {folder}[/green]")
270
+ else:
271
+ console.print(f"[red]Rule not found: {name}[/red]")
272
+
273
+
274
+ @rule.command("list")
275
+ @click.argument("folder")
276
+ @click.pass_context
277
+ def rule_list(ctx, folder: str):
278
+ """List rules for a folder."""
279
+ app = SortMeOut(config_path=ctx.obj.get("config"))
280
+
281
+ rules = app.get_rules(folder)
282
+
283
+ if not rules:
284
+ console.print(f"[yellow]No rules for folder: {folder}[/yellow]")
285
+ return
286
+
287
+ table = Table(title=f"Rules for {folder}")
288
+ table.add_column("#", justify="right", style="dim")
289
+ table.add_column("Name", style="cyan")
290
+ table.add_column("Enabled", justify="center")
291
+ table.add_column("Conditions")
292
+ table.add_column("Actions")
293
+
294
+ for i, rule in enumerate(rules, 1):
295
+ enabled = "✓" if rule.enabled else "✗"
296
+ conditions = str(len(rule.conditions))
297
+ actions = str(len(rule.actions))
298
+ table.add_row(str(i), rule.name, enabled, conditions, actions)
299
+
300
+ console.print(table)
301
+
302
+
303
+ @rule.command("show")
304
+ @click.argument("folder")
305
+ @click.argument("name")
306
+ @click.pass_context
307
+ def rule_show(ctx, folder: str, name: str):
308
+ """Show details of a rule."""
309
+ app = SortMeOut(config_path=ctx.obj.get("config"))
310
+
311
+ rules = app.get_rules(folder)
312
+ rule = next((r for r in rules if r.name == name), None)
313
+
314
+ if not rule:
315
+ console.print(f"[red]Rule not found: {name}[/red]")
316
+ return
317
+
318
+ # Rule details
319
+ console.print(Panel.fit(
320
+ f"[bold]{rule.name}[/bold]\n\n"
321
+ f"Enabled: {'Yes' if rule.enabled else 'No'}\n"
322
+ f"Match Mode: {rule.match_mode.value}\n"
323
+ f"Continue Processing: {'Yes' if rule.continue_processing else 'No'}\n"
324
+ f"Description: {rule.description or 'None'}",
325
+ title="Rule Details",
326
+ ))
327
+
328
+ # Conditions
329
+ if rule.conditions:
330
+ console.print("\n[bold]Conditions:[/bold]")
331
+ for i, cond in enumerate(rule.conditions, 1):
332
+ console.print(f" {i}. {cond}")
333
+
334
+ # Actions
335
+ if rule.actions:
336
+ console.print("\n[bold]Actions:[/bold]")
337
+ for i, action in enumerate(rule.actions, 1):
338
+ console.print(f" {i}. {action}")
339
+
340
+
341
+ @rule.command("enable")
342
+ @click.argument("folder")
343
+ @click.argument("name")
344
+ @click.pass_context
345
+ def rule_enable(ctx, folder: str, name: str):
346
+ """Enable a rule."""
347
+ app = SortMeOut(config_path=ctx.obj.get("config"))
348
+
349
+ rules = app.get_rules(folder)
350
+ rule = next((r for r in rules if r.name == name), None)
351
+
352
+ if not rule:
353
+ console.print(f"[red]Rule not found: {name}[/red]")
354
+ return
355
+
356
+ rule.enabled = True
357
+ app.update_rule(folder, name, rule)
358
+ console.print(f"[green]Enabled rule: {name}[/green]")
359
+
360
+
361
+ @rule.command("disable")
362
+ @click.argument("folder")
363
+ @click.argument("name")
364
+ @click.pass_context
365
+ def rule_disable(ctx, folder: str, name: str):
366
+ """Disable a rule."""
367
+ app = SortMeOut(config_path=ctx.obj.get("config"))
368
+
369
+ rules = app.get_rules(folder)
370
+ rule = next((r for r in rules if r.name == name), None)
371
+
372
+ if not rule:
373
+ console.print(f"[red]Rule not found: {name}[/red]")
374
+ return
375
+
376
+ rule.enabled = False
377
+ app.update_rule(folder, name, rule)
378
+ console.print(f"[yellow]Disabled rule: {name}[/yellow]")
379
+
380
+
381
+ @rule.command("export")
382
+ @click.argument("folder")
383
+ @click.argument("output", type=click.Path())
384
+ @click.pass_context
385
+ def rule_export(ctx, folder: str, output: str):
386
+ """Export rules to a file."""
387
+ app = SortMeOut(config_path=ctx.obj.get("config"))
388
+
389
+ if app.export_rules(folder, output):
390
+ console.print(f"[green]Exported rules to: {output}[/green]")
391
+ else:
392
+ console.print(f"[red]Failed to export rules[/red]")
393
+
394
+
395
+ @rule.command("import")
396
+ @click.argument("folder")
397
+ @click.argument("input", type=click.Path(exists=True))
398
+ @click.pass_context
399
+ def rule_import(ctx, folder: str, input: str):
400
+ """Import rules from a file."""
401
+ app = SortMeOut(config_path=ctx.obj.get("config"))
402
+
403
+ count = app.import_rules(folder, input)
404
+ console.print(f"[green]Imported {count} rules[/green]")
405
+
406
+
407
+ # Trash management commands
408
+ @main.group()
409
+ def trash():
410
+ """Manage Trash."""
411
+ pass
412
+
413
+
414
+ @trash.command("status")
415
+ def trash_status():
416
+ """Show Trash status."""
417
+ info = get_trash_info()
418
+
419
+ console.print(Panel.fit(
420
+ f"[bold]Trash Status[/bold]\n\n"
421
+ f"Items: {info.item_count}\n"
422
+ f"Size: {info.size_human}\n"
423
+ f"Oldest: {info.oldest_item_date.strftime('%Y-%m-%d') if info.oldest_item_date else 'N/A'}\n"
424
+ f"Newest: {info.newest_item_date.strftime('%Y-%m-%d') if info.newest_item_date else 'N/A'}",
425
+ title="Trash",
426
+ ))
427
+
428
+
429
+ @trash.command("empty")
430
+ @click.option("--secure", "-s", is_flag=True, help="Secure empty")
431
+ @click.confirmation_option(prompt="Are you sure you want to empty the Trash?")
432
+ def trash_empty(secure: bool):
433
+ """Empty the Trash."""
434
+ if empty_trash(secure=secure):
435
+ console.print("[green]Trash emptied[/green]")
436
+ else:
437
+ console.print("[red]Failed to empty Trash[/red]")
438
+
439
+
440
+ @trash.command("clean")
441
+ @click.option("--days", "-d", default=30, help="Delete items older than days")
442
+ @click.option("--size", "-s", default=10.0, help="Maximum size in GB")
443
+ def trash_clean(days: int, size: float):
444
+ """Clean Trash based on age/size limits."""
445
+ manager = TrashManager(max_age_days=days, max_size_gb=size)
446
+ result = manager.run_cleanup()
447
+
448
+ deleted_count = len(result["deleted_by_age"]) + len(result["deleted_by_size"])
449
+ console.print(f"[green]Deleted {deleted_count} items[/green]")
450
+
451
+
452
+ # Config management commands
453
+ @main.group()
454
+ def config():
455
+ """Manage configuration."""
456
+ pass
457
+
458
+
459
+ @config.command("show")
460
+ @click.pass_context
461
+ def config_show(ctx):
462
+ """Show current configuration."""
463
+ manager = ConfigManager(ctx.obj.get("config"))
464
+ cfg = manager.load_config()
465
+
466
+ syntax = Syntax(json.dumps(cfg, indent=2, default=str), "json")
467
+ console.print(syntax)
468
+
469
+
470
+ @config.command("export")
471
+ @click.argument("output", type=click.Path())
472
+ @click.pass_context
473
+ def config_export(ctx, output: str):
474
+ """Export configuration to file."""
475
+ manager = ConfigManager(ctx.obj.get("config"))
476
+
477
+ if manager.export_config(output):
478
+ console.print(f"[green]Configuration exported to: {output}[/green]")
479
+ else:
480
+ console.print("[red]Failed to export configuration[/red]")
481
+
482
+
483
+ @config.command("import")
484
+ @click.argument("input", type=click.Path(exists=True))
485
+ @click.option("--merge", "-m", is_flag=True, help="Merge with existing config")
486
+ @click.pass_context
487
+ def config_import(ctx, input: str, merge: bool):
488
+ """Import configuration from file."""
489
+ manager = ConfigManager(ctx.obj.get("config"))
490
+
491
+ if manager.import_config(input, merge=merge):
492
+ console.print(f"[green]Configuration imported from: {input}[/green]")
493
+ else:
494
+ console.print("[red]Failed to import configuration[/red]")
495
+
496
+
497
+ @config.command("reset")
498
+ @click.confirmation_option(prompt="Are you sure you want to reset configuration?")
499
+ @click.pass_context
500
+ def config_reset(ctx):
501
+ """Reset configuration to defaults."""
502
+ manager = ConfigManager(ctx.obj.get("config"))
503
+
504
+ if manager.reset_config():
505
+ console.print("[green]Configuration reset to defaults[/green]")
506
+ else:
507
+ console.print("[red]Failed to reset configuration[/red]")
508
+
509
+
510
+ # Test command
511
+ @main.command("test")
512
+ @click.argument("file", type=click.Path(exists=True))
513
+ @click.argument("folder")
514
+ @click.option("--preview", "-p", is_flag=True, default=True, help="Preview mode")
515
+ @click.pass_context
516
+ def test_file(ctx, file: str, folder: str, preview: bool):
517
+ """Test rules against a file."""
518
+ app = SortMeOut(config_path=ctx.obj.get("config"), preview_mode=preview)
519
+
520
+ from sortmeout.utils.file_info import get_file_info
521
+ from sortmeout.core.engine import RuleEngine
522
+
523
+ # Get file info
524
+ file_info = get_file_info(file)
525
+ console.print(Panel.fit(
526
+ f"[bold]File: {os.path.basename(file)}[/bold]\n\n"
527
+ f"Size: {file_info.get('size_human', 'N/A')}\n"
528
+ f"Extension: {file_info.get('extension', 'N/A')}\n"
529
+ f"Kind: {file_info.get('kind', 'N/A')}",
530
+ title="File Info",
531
+ ))
532
+
533
+ # Test rules
534
+ rules = app.get_rules(folder)
535
+ engine = RuleEngine(preview_mode=True)
536
+
537
+ console.print("\n[bold]Rule Matching:[/bold]")
538
+ for rule in rules:
539
+ matches = engine.evaluate_rule(rule, file, file_info)
540
+ status = "[green]✓ MATCH[/green]" if matches else "[dim]✗ No match[/dim]"
541
+ console.print(f" {rule.name}: {status}")
542
+
543
+ if matches:
544
+ console.print(" [bold]Actions would execute:[/bold]")
545
+ for action in rule.actions:
546
+ console.print(f" • {action}")
547
+
548
+
549
+ if __name__ == "__main__":
550
+ main()
@@ -0,0 +1,11 @@
1
+ """
2
+ Configuration management for SortMeOut.
3
+ """
4
+
5
+ from sortmeout.config.manager import ConfigManager
6
+ from sortmeout.config.settings import Settings
7
+
8
+ __all__ = [
9
+ "ConfigManager",
10
+ "Settings",
11
+ ]