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/macos/trash.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"""
|
|
2
|
+
macOS Trash management.
|
|
3
|
+
|
|
4
|
+
Provides functions for managing the Trash and implementing App Sweep functionality.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from sortmeout.utils.logger import get_logger
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class TrashItem:
|
|
23
|
+
"""Information about an item in the Trash."""
|
|
24
|
+
path: str
|
|
25
|
+
original_path: str
|
|
26
|
+
name: str
|
|
27
|
+
size: int
|
|
28
|
+
deleted_date: datetime
|
|
29
|
+
kind: str
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def age_days(self) -> int:
|
|
33
|
+
"""Days since item was deleted."""
|
|
34
|
+
return (datetime.now() - self.deleted_date).days
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class TrashInfo:
|
|
39
|
+
"""Overall Trash statistics."""
|
|
40
|
+
item_count: int
|
|
41
|
+
total_size: int
|
|
42
|
+
oldest_item_date: Optional[datetime]
|
|
43
|
+
newest_item_date: Optional[datetime]
|
|
44
|
+
items: List[TrashItem]
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def size_human(self) -> str:
|
|
48
|
+
"""Human-readable size."""
|
|
49
|
+
size = self.total_size
|
|
50
|
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
51
|
+
if size < 1024:
|
|
52
|
+
return f"{size:.1f} {unit}"
|
|
53
|
+
size /= 1024
|
|
54
|
+
return f"{size:.1f} PB"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_trash_path() -> Path:
|
|
58
|
+
"""Get the user's Trash directory path."""
|
|
59
|
+
return Path.home() / ".Trash"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_trash_info() -> TrashInfo:
|
|
63
|
+
"""
|
|
64
|
+
Get information about the Trash contents.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
TrashInfo with statistics and item list.
|
|
68
|
+
"""
|
|
69
|
+
trash_path = get_trash_path()
|
|
70
|
+
items = []
|
|
71
|
+
total_size = 0
|
|
72
|
+
oldest_date = None
|
|
73
|
+
newest_date = None
|
|
74
|
+
|
|
75
|
+
if not trash_path.exists():
|
|
76
|
+
return TrashInfo(
|
|
77
|
+
item_count=0,
|
|
78
|
+
total_size=0,
|
|
79
|
+
oldest_item_date=None,
|
|
80
|
+
newest_item_date=None,
|
|
81
|
+
items=[],
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
for entry in trash_path.iterdir():
|
|
85
|
+
try:
|
|
86
|
+
stat = entry.stat()
|
|
87
|
+
|
|
88
|
+
# Get size (recursively for directories)
|
|
89
|
+
if entry.is_dir():
|
|
90
|
+
size = sum(f.stat().st_size for f in entry.rglob("*") if f.is_file())
|
|
91
|
+
else:
|
|
92
|
+
size = stat.st_size
|
|
93
|
+
|
|
94
|
+
total_size += size
|
|
95
|
+
|
|
96
|
+
# Get deletion date from .DS_Store or use modification time
|
|
97
|
+
deleted_date = datetime.fromtimestamp(stat.st_mtime)
|
|
98
|
+
|
|
99
|
+
# Update date range
|
|
100
|
+
if oldest_date is None or deleted_date < oldest_date:
|
|
101
|
+
oldest_date = deleted_date
|
|
102
|
+
if newest_date is None or deleted_date > newest_date:
|
|
103
|
+
newest_date = deleted_date
|
|
104
|
+
|
|
105
|
+
# Determine kind
|
|
106
|
+
kind = "Folder" if entry.is_dir() else "File"
|
|
107
|
+
|
|
108
|
+
items.append(TrashItem(
|
|
109
|
+
path=str(entry),
|
|
110
|
+
original_path="", # Would need .Trashes metadata
|
|
111
|
+
name=entry.name,
|
|
112
|
+
size=size,
|
|
113
|
+
deleted_date=deleted_date,
|
|
114
|
+
kind=kind,
|
|
115
|
+
))
|
|
116
|
+
|
|
117
|
+
except (PermissionError, OSError) as e:
|
|
118
|
+
logger.debug("Error reading trash item %s: %s", entry, e)
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
return TrashInfo(
|
|
122
|
+
item_count=len(items),
|
|
123
|
+
total_size=total_size,
|
|
124
|
+
oldest_item_date=oldest_date,
|
|
125
|
+
newest_item_date=newest_date,
|
|
126
|
+
items=items,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def empty_trash(secure: bool = False) -> bool:
|
|
131
|
+
"""
|
|
132
|
+
Empty the Trash.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
secure: If True, securely delete files (slower).
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
True if successful.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
if secure:
|
|
142
|
+
script = '''
|
|
143
|
+
tell application "Finder"
|
|
144
|
+
empty trash with security
|
|
145
|
+
end tell
|
|
146
|
+
'''
|
|
147
|
+
else:
|
|
148
|
+
script = '''
|
|
149
|
+
tell application "Finder"
|
|
150
|
+
empty trash
|
|
151
|
+
end tell
|
|
152
|
+
'''
|
|
153
|
+
|
|
154
|
+
result = subprocess.run(
|
|
155
|
+
["osascript", "-e", script],
|
|
156
|
+
capture_output=True,
|
|
157
|
+
timeout=60,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if result.returncode == 0:
|
|
161
|
+
logger.info("Trash emptied")
|
|
162
|
+
return True
|
|
163
|
+
else:
|
|
164
|
+
logger.error("Failed to empty trash: %s", result.stderr)
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.error("Error emptying trash: %s", e)
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def delete_old_trash_items(max_age_days: int) -> List[str]:
|
|
173
|
+
"""
|
|
174
|
+
Delete items from Trash older than specified days.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
max_age_days: Maximum age in days.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
List of deleted item names.
|
|
181
|
+
"""
|
|
182
|
+
trash_info = get_trash_info()
|
|
183
|
+
cutoff = datetime.now() - timedelta(days=max_age_days)
|
|
184
|
+
deleted = []
|
|
185
|
+
|
|
186
|
+
for item in trash_info.items:
|
|
187
|
+
if item.deleted_date < cutoff:
|
|
188
|
+
try:
|
|
189
|
+
path = Path(item.path)
|
|
190
|
+
if path.is_dir():
|
|
191
|
+
import shutil
|
|
192
|
+
shutil.rmtree(path)
|
|
193
|
+
else:
|
|
194
|
+
path.unlink()
|
|
195
|
+
deleted.append(item.name)
|
|
196
|
+
logger.info("Deleted old trash item: %s", item.name)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.error("Failed to delete %s: %s", item.name, e)
|
|
199
|
+
|
|
200
|
+
return deleted
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def trim_trash_to_size(max_size_bytes: int) -> List[str]:
|
|
204
|
+
"""
|
|
205
|
+
Trim Trash to maximum size by removing oldest items first.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
max_size_bytes: Maximum total size in bytes.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
List of deleted item names.
|
|
212
|
+
"""
|
|
213
|
+
trash_info = get_trash_info()
|
|
214
|
+
|
|
215
|
+
if trash_info.total_size <= max_size_bytes:
|
|
216
|
+
return []
|
|
217
|
+
|
|
218
|
+
# Sort by date (oldest first)
|
|
219
|
+
items_by_date = sorted(trash_info.items, key=lambda x: x.deleted_date)
|
|
220
|
+
|
|
221
|
+
deleted = []
|
|
222
|
+
current_size = trash_info.total_size
|
|
223
|
+
|
|
224
|
+
for item in items_by_date:
|
|
225
|
+
if current_size <= max_size_bytes:
|
|
226
|
+
break
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
path = Path(item.path)
|
|
230
|
+
if path.is_dir():
|
|
231
|
+
import shutil
|
|
232
|
+
shutil.rmtree(path)
|
|
233
|
+
else:
|
|
234
|
+
path.unlink()
|
|
235
|
+
|
|
236
|
+
current_size -= item.size
|
|
237
|
+
deleted.append(item.name)
|
|
238
|
+
logger.info("Deleted trash item to free space: %s", item.name)
|
|
239
|
+
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.error("Failed to delete %s: %s", item.name, e)
|
|
242
|
+
|
|
243
|
+
return deleted
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class TrashManager:
|
|
247
|
+
"""
|
|
248
|
+
Automatic trash management.
|
|
249
|
+
|
|
250
|
+
Monitors and manages the Trash based on configured policies.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
def __init__(
|
|
254
|
+
self,
|
|
255
|
+
max_age_days: int = 30,
|
|
256
|
+
max_size_gb: float = 10.0,
|
|
257
|
+
enabled: bool = True,
|
|
258
|
+
):
|
|
259
|
+
"""
|
|
260
|
+
Initialize trash manager.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
max_age_days: Maximum age for items.
|
|
264
|
+
max_size_gb: Maximum total size in GB.
|
|
265
|
+
enabled: Whether management is enabled.
|
|
266
|
+
"""
|
|
267
|
+
self.max_age_days = max_age_days
|
|
268
|
+
self.max_size_gb = max_size_gb
|
|
269
|
+
self.max_size_bytes = int(max_size_gb * 1024 * 1024 * 1024)
|
|
270
|
+
self.enabled = enabled
|
|
271
|
+
|
|
272
|
+
def run_cleanup(self) -> Dict[str, Any]:
|
|
273
|
+
"""
|
|
274
|
+
Run trash cleanup based on policies.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Cleanup results.
|
|
278
|
+
"""
|
|
279
|
+
if not self.enabled:
|
|
280
|
+
return {"enabled": False, "deleted": []}
|
|
281
|
+
|
|
282
|
+
results = {
|
|
283
|
+
"deleted_by_age": [],
|
|
284
|
+
"deleted_by_size": [],
|
|
285
|
+
"before_size": 0,
|
|
286
|
+
"after_size": 0,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
# Get initial state
|
|
290
|
+
before = get_trash_info()
|
|
291
|
+
results["before_size"] = before.total_size
|
|
292
|
+
|
|
293
|
+
# Delete old items
|
|
294
|
+
if self.max_age_days > 0:
|
|
295
|
+
results["deleted_by_age"] = delete_old_trash_items(self.max_age_days)
|
|
296
|
+
|
|
297
|
+
# Trim to size
|
|
298
|
+
if self.max_size_bytes > 0:
|
|
299
|
+
results["deleted_by_size"] = trim_trash_to_size(self.max_size_bytes)
|
|
300
|
+
|
|
301
|
+
# Get final state
|
|
302
|
+
after = get_trash_info()
|
|
303
|
+
results["after_size"] = after.total_size
|
|
304
|
+
|
|
305
|
+
return results
|
|
306
|
+
|
|
307
|
+
def get_status(self) -> Dict[str, Any]:
|
|
308
|
+
"""
|
|
309
|
+
Get current trash status.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Status information.
|
|
313
|
+
"""
|
|
314
|
+
info = get_trash_info()
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
"item_count": info.item_count,
|
|
318
|
+
"total_size": info.total_size,
|
|
319
|
+
"size_human": info.size_human,
|
|
320
|
+
"oldest_item_age_days": (datetime.now() - info.oldest_item_date).days if info.oldest_item_date else 0,
|
|
321
|
+
"over_size_limit": info.total_size > self.max_size_bytes,
|
|
322
|
+
"has_old_items": info.oldest_item_date and (datetime.now() - info.oldest_item_date).days > self.max_age_days,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# App Sweep functionality
|
|
327
|
+
|
|
328
|
+
def find_app_support_files(app_name: str) -> List[str]:
|
|
329
|
+
"""
|
|
330
|
+
Find support files for an application.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
app_name: Application name or bundle identifier.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
List of support file paths.
|
|
337
|
+
"""
|
|
338
|
+
support_dirs = [
|
|
339
|
+
Path.home() / "Library" / "Application Support",
|
|
340
|
+
Path.home() / "Library" / "Preferences",
|
|
341
|
+
Path.home() / "Library" / "Caches",
|
|
342
|
+
Path.home() / "Library" / "Containers",
|
|
343
|
+
Path.home() / "Library" / "Logs",
|
|
344
|
+
Path.home() / "Library" / "Saved Application State",
|
|
345
|
+
Path("/Library/Application Support"),
|
|
346
|
+
Path("/Library/Preferences"),
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
found_files = []
|
|
350
|
+
|
|
351
|
+
# Normalize app name for matching
|
|
352
|
+
app_name_lower = app_name.lower()
|
|
353
|
+
# Remove .app extension if present
|
|
354
|
+
if app_name_lower.endswith(".app"):
|
|
355
|
+
app_name_lower = app_name_lower[:-4]
|
|
356
|
+
|
|
357
|
+
for support_dir in support_dirs:
|
|
358
|
+
if not support_dir.exists():
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
for entry in support_dir.iterdir():
|
|
363
|
+
entry_name = entry.name.lower()
|
|
364
|
+
|
|
365
|
+
# Match by name
|
|
366
|
+
if app_name_lower in entry_name:
|
|
367
|
+
found_files.append(str(entry))
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
# Match common patterns
|
|
371
|
+
if any(pattern in entry_name for pattern in [
|
|
372
|
+
app_name_lower.replace(" ", ""),
|
|
373
|
+
app_name_lower.replace(" ", "-"),
|
|
374
|
+
app_name_lower.replace(" ", "_"),
|
|
375
|
+
]):
|
|
376
|
+
found_files.append(str(entry))
|
|
377
|
+
|
|
378
|
+
except PermissionError:
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
return found_files
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def get_app_support_size(app_name: str) -> int:
|
|
385
|
+
"""
|
|
386
|
+
Get total size of support files for an application.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
app_name: Application name.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Total size in bytes.
|
|
393
|
+
"""
|
|
394
|
+
files = find_app_support_files(app_name)
|
|
395
|
+
total = 0
|
|
396
|
+
|
|
397
|
+
for file_path in files:
|
|
398
|
+
path = Path(file_path)
|
|
399
|
+
try:
|
|
400
|
+
if path.is_dir():
|
|
401
|
+
total += sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
|
402
|
+
else:
|
|
403
|
+
total += path.stat().st_size
|
|
404
|
+
except (PermissionError, OSError):
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
return total
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def clean_app_support_files(app_name: str, to_trash: bool = True) -> List[str]:
|
|
411
|
+
"""
|
|
412
|
+
Clean up support files for an application.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
app_name: Application name.
|
|
416
|
+
to_trash: Move to trash instead of deleting permanently.
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
List of cleaned files.
|
|
420
|
+
"""
|
|
421
|
+
files = find_app_support_files(app_name)
|
|
422
|
+
cleaned = []
|
|
423
|
+
|
|
424
|
+
for file_path in files:
|
|
425
|
+
try:
|
|
426
|
+
if to_trash:
|
|
427
|
+
# Move to trash using Finder
|
|
428
|
+
script = f'''
|
|
429
|
+
tell application "Finder"
|
|
430
|
+
delete POSIX file "{file_path}"
|
|
431
|
+
end tell
|
|
432
|
+
'''
|
|
433
|
+
subprocess.run(["osascript", "-e", script], capture_output=True, timeout=10)
|
|
434
|
+
else:
|
|
435
|
+
# Delete directly
|
|
436
|
+
path = Path(file_path)
|
|
437
|
+
if path.is_dir():
|
|
438
|
+
import shutil
|
|
439
|
+
shutil.rmtree(path)
|
|
440
|
+
else:
|
|
441
|
+
path.unlink()
|
|
442
|
+
|
|
443
|
+
cleaned.append(file_path)
|
|
444
|
+
logger.info("Cleaned app support file: %s", file_path)
|
|
445
|
+
|
|
446
|
+
except Exception as e:
|
|
447
|
+
logger.error("Failed to clean %s: %s", file_path, e)
|
|
448
|
+
|
|
449
|
+
return cleaned
|