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/gui/app.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""
|
|
2
|
+
macOS Menu Bar application for SortMeOut.
|
|
3
|
+
|
|
4
|
+
Provides a menu bar interface for controlling SortMeOut.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import webbrowser
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import rumps
|
|
17
|
+
except ImportError:
|
|
18
|
+
rumps = None
|
|
19
|
+
|
|
20
|
+
from sortmeout import SortMeOut, __version__
|
|
21
|
+
from sortmeout.config.manager import ConfigManager
|
|
22
|
+
from sortmeout.macos.trash import get_trash_info, empty_trash
|
|
23
|
+
from sortmeout.utils.logger import setup_logging, get_logger
|
|
24
|
+
|
|
25
|
+
logger = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MenuBarApp:
|
|
29
|
+
"""
|
|
30
|
+
Menu bar application for SortMeOut.
|
|
31
|
+
|
|
32
|
+
Provides quick access to:
|
|
33
|
+
- Start/stop watching
|
|
34
|
+
- View status
|
|
35
|
+
- Access settings
|
|
36
|
+
- View recent activity
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self):
|
|
40
|
+
"""Initialize the menu bar app."""
|
|
41
|
+
if rumps is None:
|
|
42
|
+
raise ImportError("rumps is required for the GUI. Install with: pip install rumps")
|
|
43
|
+
|
|
44
|
+
self.app = rumps.App(
|
|
45
|
+
"SortMeOut",
|
|
46
|
+
icon=self._get_icon_path(),
|
|
47
|
+
quit_button=None, # We'll add our own
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Initialize SortMeOut
|
|
51
|
+
self.sortmeout = SortMeOut()
|
|
52
|
+
self._running = False
|
|
53
|
+
self._preview_mode = False
|
|
54
|
+
|
|
55
|
+
# Build menu
|
|
56
|
+
self._build_menu()
|
|
57
|
+
|
|
58
|
+
# Set up timer for status updates
|
|
59
|
+
self._status_timer = rumps.Timer(self._update_status, 30)
|
|
60
|
+
self._status_timer.start()
|
|
61
|
+
|
|
62
|
+
def _get_icon_path(self) -> Optional[str]:
|
|
63
|
+
"""Get path to menu bar icon."""
|
|
64
|
+
# Try to find icon in resources
|
|
65
|
+
resource_paths = [
|
|
66
|
+
os.path.join(os.path.dirname(__file__), "..", "resources", "icon.png"),
|
|
67
|
+
os.path.join(os.path.dirname(__file__), "..", "resources", "MenuBarIcon.png"),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
for path in resource_paths:
|
|
71
|
+
if os.path.exists(path):
|
|
72
|
+
return path
|
|
73
|
+
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def _build_menu(self) -> None:
|
|
77
|
+
"""Build the menu bar menu."""
|
|
78
|
+
self.app.menu = [
|
|
79
|
+
rumps.MenuItem("Start Watching", callback=self._toggle_watching),
|
|
80
|
+
rumps.MenuItem("Preview Mode", callback=self._toggle_preview),
|
|
81
|
+
None, # Separator
|
|
82
|
+
rumps.MenuItem("Folders", callback=None),
|
|
83
|
+
rumps.MenuItem("Recent Activity", callback=self._show_activity),
|
|
84
|
+
None,
|
|
85
|
+
rumps.MenuItem("Trash", [
|
|
86
|
+
rumps.MenuItem("View Trash Status", callback=self._show_trash_status),
|
|
87
|
+
rumps.MenuItem("Empty Trash", callback=self._empty_trash),
|
|
88
|
+
]),
|
|
89
|
+
None,
|
|
90
|
+
rumps.MenuItem("Preferences...", callback=self._show_preferences),
|
|
91
|
+
rumps.MenuItem("Help", [
|
|
92
|
+
rumps.MenuItem("Documentation", callback=self._open_docs),
|
|
93
|
+
rumps.MenuItem("Report Issue", callback=self._report_issue),
|
|
94
|
+
rumps.MenuItem(f"About SortMeOut v{__version__}", callback=self._show_about),
|
|
95
|
+
]),
|
|
96
|
+
None,
|
|
97
|
+
rumps.MenuItem("Quit SortMeOut", callback=self._quit),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
# Update folders submenu
|
|
101
|
+
self._update_folders_menu()
|
|
102
|
+
|
|
103
|
+
def _update_folders_menu(self) -> None:
|
|
104
|
+
"""Update the folders submenu."""
|
|
105
|
+
folders_menu = self.app.menu["Folders"]
|
|
106
|
+
|
|
107
|
+
# Clear existing items
|
|
108
|
+
folders_menu.clear()
|
|
109
|
+
|
|
110
|
+
folders = self.sortmeout.get_folders()
|
|
111
|
+
|
|
112
|
+
if not folders:
|
|
113
|
+
folders_menu.add(rumps.MenuItem("No folders configured", callback=None))
|
|
114
|
+
folders_menu["No folders configured"].set_callback(None)
|
|
115
|
+
else:
|
|
116
|
+
for folder in folders:
|
|
117
|
+
rules = self.sortmeout.get_rules(folder)
|
|
118
|
+
folder_name = os.path.basename(folder) or folder
|
|
119
|
+
item = rumps.MenuItem(
|
|
120
|
+
f"{folder_name} ({len(rules)} rules)",
|
|
121
|
+
callback=lambda _, f=folder: self._show_folder_details(f)
|
|
122
|
+
)
|
|
123
|
+
folders_menu.add(item)
|
|
124
|
+
|
|
125
|
+
# Add separator and "Add Folder" option
|
|
126
|
+
folders_menu.add(None)
|
|
127
|
+
folders_menu.add(rumps.MenuItem("Add Folder...", callback=self._add_folder))
|
|
128
|
+
|
|
129
|
+
def _toggle_watching(self, sender: rumps.MenuItem) -> None:
|
|
130
|
+
"""Toggle watching on/off."""
|
|
131
|
+
if self._running:
|
|
132
|
+
self.sortmeout.stop()
|
|
133
|
+
self._running = False
|
|
134
|
+
sender.title = "Start Watching"
|
|
135
|
+
self.app.title = "SortMeOut"
|
|
136
|
+
rumps.notification(
|
|
137
|
+
"SortMeOut",
|
|
138
|
+
"Stopped",
|
|
139
|
+
"File watching has been stopped."
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
self.sortmeout.start_background()
|
|
143
|
+
self._running = True
|
|
144
|
+
sender.title = "Stop Watching"
|
|
145
|
+
self.app.title = "SortMeOut ●"
|
|
146
|
+
rumps.notification(
|
|
147
|
+
"SortMeOut",
|
|
148
|
+
"Started",
|
|
149
|
+
f"Watching {len(self.sortmeout.get_folders())} folder(s)."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def _toggle_preview(self, sender: rumps.MenuItem) -> None:
|
|
153
|
+
"""Toggle preview mode."""
|
|
154
|
+
self._preview_mode = not self._preview_mode
|
|
155
|
+
self.sortmeout.preview_mode = self._preview_mode
|
|
156
|
+
|
|
157
|
+
if self._preview_mode:
|
|
158
|
+
sender.state = 1 # Checkmark
|
|
159
|
+
rumps.notification(
|
|
160
|
+
"SortMeOut",
|
|
161
|
+
"Preview Mode",
|
|
162
|
+
"Actions will be logged but not executed."
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
sender.state = 0
|
|
166
|
+
|
|
167
|
+
def _show_activity(self, _) -> None:
|
|
168
|
+
"""Show recent activity."""
|
|
169
|
+
stats = self.sortmeout.get_stats()
|
|
170
|
+
|
|
171
|
+
rumps.alert(
|
|
172
|
+
title="Recent Activity",
|
|
173
|
+
message=(
|
|
174
|
+
f"Files processed: {stats['files_processed']}\n"
|
|
175
|
+
f"Rules matched: {stats['rules_matched']}\n"
|
|
176
|
+
f"Actions executed: {stats['actions_executed']}\n"
|
|
177
|
+
f"Errors: {stats['errors']}"
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _show_folder_details(self, folder: str) -> None:
|
|
182
|
+
"""Show details for a folder."""
|
|
183
|
+
rules = self.sortmeout.get_rules(folder)
|
|
184
|
+
|
|
185
|
+
if not rules:
|
|
186
|
+
message = "No rules configured"
|
|
187
|
+
else:
|
|
188
|
+
rule_names = "\n".join(f"• {r.name}" for r in rules)
|
|
189
|
+
message = f"Rules:\n{rule_names}"
|
|
190
|
+
|
|
191
|
+
response = rumps.alert(
|
|
192
|
+
title=f"Folder: {os.path.basename(folder)}",
|
|
193
|
+
message=message,
|
|
194
|
+
ok="Close",
|
|
195
|
+
cancel="Process Now"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if response == 0: # Cancel = Process Now
|
|
199
|
+
self.sortmeout.process_folder(folder)
|
|
200
|
+
rumps.notification(
|
|
201
|
+
"SortMeOut",
|
|
202
|
+
"Processing Complete",
|
|
203
|
+
f"Processed files in {os.path.basename(folder)}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def _add_folder(self, _) -> None:
|
|
207
|
+
"""Open dialog to add a folder."""
|
|
208
|
+
try:
|
|
209
|
+
# Use AppleScript to show folder picker
|
|
210
|
+
import subprocess
|
|
211
|
+
script = '''
|
|
212
|
+
tell application "System Events"
|
|
213
|
+
activate
|
|
214
|
+
set theFolder to choose folder with prompt "Select a folder to watch:"
|
|
215
|
+
return POSIX path of theFolder
|
|
216
|
+
end tell
|
|
217
|
+
'''
|
|
218
|
+
result = subprocess.run(
|
|
219
|
+
["osascript", "-e", script],
|
|
220
|
+
capture_output=True,
|
|
221
|
+
text=True,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
225
|
+
folder = result.stdout.strip()
|
|
226
|
+
if self.sortmeout.add_folder(folder):
|
|
227
|
+
self._update_folders_menu()
|
|
228
|
+
rumps.notification(
|
|
229
|
+
"SortMeOut",
|
|
230
|
+
"Folder Added",
|
|
231
|
+
f"Now watching: {os.path.basename(folder)}"
|
|
232
|
+
)
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.error("Failed to add folder: %s", e)
|
|
235
|
+
|
|
236
|
+
def _show_trash_status(self, _) -> None:
|
|
237
|
+
"""Show Trash status."""
|
|
238
|
+
info = get_trash_info()
|
|
239
|
+
|
|
240
|
+
rumps.alert(
|
|
241
|
+
title="Trash Status",
|
|
242
|
+
message=(
|
|
243
|
+
f"Items: {info.item_count}\n"
|
|
244
|
+
f"Size: {info.size_human}\n"
|
|
245
|
+
f"Oldest item: {info.oldest_item_date.strftime('%Y-%m-%d') if info.oldest_item_date else 'N/A'}"
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def _empty_trash(self, _) -> None:
|
|
250
|
+
"""Empty the Trash."""
|
|
251
|
+
response = rumps.alert(
|
|
252
|
+
title="Empty Trash",
|
|
253
|
+
message="Are you sure you want to empty the Trash? This cannot be undone.",
|
|
254
|
+
ok="Empty Trash",
|
|
255
|
+
cancel="Cancel"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if response == 1: # OK
|
|
259
|
+
if empty_trash():
|
|
260
|
+
rumps.notification(
|
|
261
|
+
"SortMeOut",
|
|
262
|
+
"Trash Emptied",
|
|
263
|
+
"The Trash has been emptied."
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _show_preferences(self, _) -> None:
|
|
267
|
+
"""Show preferences window."""
|
|
268
|
+
# In a full implementation, this would open a preferences window
|
|
269
|
+
rumps.alert(
|
|
270
|
+
title="Preferences",
|
|
271
|
+
message="Preferences window coming soon!\n\nFor now, use the CLI:\nsortmeout config show"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _open_docs(self, _) -> None:
|
|
275
|
+
"""Open documentation."""
|
|
276
|
+
webbrowser.open("https://github.com/yourusername/sortmeout/docs")
|
|
277
|
+
|
|
278
|
+
def _report_issue(self, _) -> None:
|
|
279
|
+
"""Open issue tracker."""
|
|
280
|
+
webbrowser.open("https://github.com/yourusername/sortmeout/issues")
|
|
281
|
+
|
|
282
|
+
def _show_about(self, _) -> None:
|
|
283
|
+
"""Show about dialog."""
|
|
284
|
+
rumps.alert(
|
|
285
|
+
title="About SortMeOut",
|
|
286
|
+
message=(
|
|
287
|
+
f"SortMeOut v{__version__}\n\n"
|
|
288
|
+
"Open-source file automation for macOS.\n\n"
|
|
289
|
+
"Inspired by Noodlesoft Hazel.\n\n"
|
|
290
|
+
"https://github.com/yourusername/sortmeout"
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def _update_status(self, _) -> None:
|
|
295
|
+
"""Periodic status update."""
|
|
296
|
+
if self._running:
|
|
297
|
+
stats = self.sortmeout.get_stats()
|
|
298
|
+
# Could update menu bar icon or title based on activity
|
|
299
|
+
|
|
300
|
+
def _quit(self, _) -> None:
|
|
301
|
+
"""Quit the application."""
|
|
302
|
+
if self._running:
|
|
303
|
+
self.sortmeout.stop()
|
|
304
|
+
rumps.quit_application()
|
|
305
|
+
|
|
306
|
+
def run(self) -> None:
|
|
307
|
+
"""Run the menu bar app."""
|
|
308
|
+
self.app.run()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def main():
|
|
312
|
+
"""Main entry point for GUI application."""
|
|
313
|
+
setup_logging()
|
|
314
|
+
|
|
315
|
+
if rumps is None:
|
|
316
|
+
print("Error: rumps is required for the GUI.")
|
|
317
|
+
print("Install with: pip install rumps")
|
|
318
|
+
sys.exit(1)
|
|
319
|
+
|
|
320
|
+
app = MenuBarApp()
|
|
321
|
+
app.run()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
main()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
macOS-specific integrations for SortMeOut.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from sortmeout.macos.tags import get_tags, set_tags, add_tags, remove_tags
|
|
6
|
+
from sortmeout.macos.spotlight import search_spotlight, get_metadata
|
|
7
|
+
from sortmeout.macos.trash import get_trash_info, empty_trash, TrashManager
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"get_tags",
|
|
11
|
+
"set_tags",
|
|
12
|
+
"add_tags",
|
|
13
|
+
"remove_tags",
|
|
14
|
+
"search_spotlight",
|
|
15
|
+
"get_metadata",
|
|
16
|
+
"get_trash_info",
|
|
17
|
+
"empty_trash",
|
|
18
|
+
"TrashManager",
|
|
19
|
+
]
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""
|
|
2
|
+
macOS Spotlight integration.
|
|
3
|
+
|
|
4
|
+
Provides functions for searching and retrieving Spotlight metadata.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import plistlib
|
|
10
|
+
import subprocess
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from sortmeout.utils.logger import get_logger
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_metadata(file_path: str) -> Dict[str, Any]:
|
|
20
|
+
"""
|
|
21
|
+
Get all Spotlight metadata for a file.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
file_path: Path to the file.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Dictionary of metadata attributes.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
["mdls", "-plist", "-", file_path],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
timeout=10,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if result.returncode != 0:
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
metadata = plistlib.loads(result.stdout)
|
|
40
|
+
return metadata
|
|
41
|
+
|
|
42
|
+
except Exception as e:
|
|
43
|
+
logger.error("Failed to get metadata for %s: %s", file_path, e)
|
|
44
|
+
return {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_attribute(file_path: str, attribute: str) -> Optional[Any]:
|
|
48
|
+
"""
|
|
49
|
+
Get a specific Spotlight attribute for a file.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
file_path: Path to the file.
|
|
53
|
+
attribute: Spotlight attribute name (e.g., "kMDItemKind").
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Attribute value or None.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
["mdls", "-name", attribute, "-raw", file_path],
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
timeout=10,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if result.returncode != 0:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
value = result.stdout.strip()
|
|
70
|
+
if value == "(null)":
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.debug("Failed to get attribute %s: %s", attribute, e)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def search_spotlight(
|
|
81
|
+
query: str,
|
|
82
|
+
folder: Optional[str] = None,
|
|
83
|
+
limit: int = 100,
|
|
84
|
+
attributes: Optional[List[str]] = None,
|
|
85
|
+
) -> List[Dict[str, Any]]:
|
|
86
|
+
"""
|
|
87
|
+
Search for files using Spotlight.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
query: Spotlight query string (e.g., "kMDItemKind == 'PDF Document'").
|
|
91
|
+
folder: Optional folder to search in.
|
|
92
|
+
limit: Maximum number of results.
|
|
93
|
+
attributes: Specific attributes to retrieve.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
List of matching files with their metadata.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
cmd = ["mdfind"]
|
|
100
|
+
|
|
101
|
+
if folder:
|
|
102
|
+
cmd.extend(["-onlyin", folder])
|
|
103
|
+
|
|
104
|
+
cmd.append(query)
|
|
105
|
+
|
|
106
|
+
result = subprocess.run(
|
|
107
|
+
cmd,
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
timeout=30,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if result.returncode != 0:
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
files = result.stdout.strip().split("\n")[:limit]
|
|
117
|
+
|
|
118
|
+
if not attributes:
|
|
119
|
+
return [{"path": f} for f in files if f]
|
|
120
|
+
|
|
121
|
+
# Get attributes for each file
|
|
122
|
+
results = []
|
|
123
|
+
for file_path in files:
|
|
124
|
+
if not file_path:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
metadata = {"path": file_path}
|
|
128
|
+
for attr in attributes:
|
|
129
|
+
value = get_attribute(file_path, attr)
|
|
130
|
+
if value is not None:
|
|
131
|
+
metadata[attr] = value
|
|
132
|
+
|
|
133
|
+
results.append(metadata)
|
|
134
|
+
|
|
135
|
+
return results
|
|
136
|
+
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.error("Spotlight search failed: %s", e)
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def find_by_kind(kind: str, folder: Optional[str] = None, limit: int = 100) -> List[str]:
|
|
143
|
+
"""
|
|
144
|
+
Find files by their kind (e.g., "PDF Document", "JPEG image").
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
kind: File kind to search for.
|
|
148
|
+
folder: Optional folder to search in.
|
|
149
|
+
limit: Maximum number of results.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
List of file paths.
|
|
153
|
+
"""
|
|
154
|
+
query = f'kMDItemKind == "{kind}"'
|
|
155
|
+
results = search_spotlight(query, folder, limit)
|
|
156
|
+
return [r["path"] for r in results]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def find_by_extension(extension: str, folder: Optional[str] = None, limit: int = 100) -> List[str]:
|
|
160
|
+
"""
|
|
161
|
+
Find files by extension.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
extension: File extension (without dot).
|
|
165
|
+
folder: Optional folder to search in.
|
|
166
|
+
limit: Maximum number of results.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
List of file paths.
|
|
170
|
+
"""
|
|
171
|
+
extension = extension.lstrip(".")
|
|
172
|
+
query = f'kMDItemFSName == "*.{extension}"'
|
|
173
|
+
results = search_spotlight(query, folder, limit)
|
|
174
|
+
return [r["path"] for r in results]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def find_by_tag(tag: str, folder: Optional[str] = None, limit: int = 100) -> List[str]:
|
|
178
|
+
"""
|
|
179
|
+
Find files by Finder tag.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
tag: Tag name to search for.
|
|
183
|
+
folder: Optional folder to search in.
|
|
184
|
+
limit: Maximum number of results.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
List of file paths.
|
|
188
|
+
"""
|
|
189
|
+
query = f'kMDItemUserTags == "{tag}"'
|
|
190
|
+
results = search_spotlight(query, folder, limit)
|
|
191
|
+
return [r["path"] for r in results]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def find_by_content(text: str, folder: Optional[str] = None, limit: int = 100) -> List[str]:
|
|
195
|
+
"""
|
|
196
|
+
Find files containing specific text.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
text: Text to search for in file contents.
|
|
200
|
+
folder: Optional folder to search in.
|
|
201
|
+
limit: Maximum number of results.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
List of file paths.
|
|
205
|
+
"""
|
|
206
|
+
query = f'kMDItemTextContent == "{text}"c' # c for case-insensitive
|
|
207
|
+
results = search_spotlight(query, folder, limit)
|
|
208
|
+
return [r["path"] for r in results]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def find_modified_after(date: datetime, folder: Optional[str] = None, limit: int = 100) -> List[str]:
|
|
212
|
+
"""
|
|
213
|
+
Find files modified after a specific date.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
date: Cutoff date.
|
|
217
|
+
folder: Optional folder to search in.
|
|
218
|
+
limit: Maximum number of results.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
List of file paths.
|
|
222
|
+
"""
|
|
223
|
+
date_str = date.strftime("%Y-%m-%d")
|
|
224
|
+
query = f'kMDItemFSContentChangeDate >= $time.iso({date_str})'
|
|
225
|
+
results = search_spotlight(query, folder, limit)
|
|
226
|
+
return [r["path"] for r in results]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def find_created_after(date: datetime, folder: Optional[str] = None, limit: int = 100) -> List[str]:
|
|
230
|
+
"""
|
|
231
|
+
Find files created after a specific date.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
date: Cutoff date.
|
|
235
|
+
folder: Optional folder to search in.
|
|
236
|
+
limit: Maximum number of results.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
List of file paths.
|
|
240
|
+
"""
|
|
241
|
+
date_str = date.strftime("%Y-%m-%d")
|
|
242
|
+
query = f'kMDItemFSCreationDate >= $time.iso({date_str})'
|
|
243
|
+
results = search_spotlight(query, folder, limit)
|
|
244
|
+
return [r["path"] for r in results]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def find_by_author(author: str, folder: Optional[str] = None, limit: int = 100) -> List[str]:
|
|
248
|
+
"""
|
|
249
|
+
Find files by author.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
author: Author name to search for.
|
|
253
|
+
folder: Optional folder to search in.
|
|
254
|
+
limit: Maximum number of results.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
List of file paths.
|
|
258
|
+
"""
|
|
259
|
+
query = f'kMDItemAuthors == "{author}"c'
|
|
260
|
+
results = search_spotlight(query, folder, limit)
|
|
261
|
+
return [r["path"] for r in results]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_download_source(file_path: str) -> Optional[str]:
|
|
265
|
+
"""
|
|
266
|
+
Get the URL from which a file was downloaded.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
file_path: Path to the file.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Download URL or None.
|
|
273
|
+
"""
|
|
274
|
+
metadata = get_metadata(file_path)
|
|
275
|
+
where_froms = metadata.get("kMDItemWhereFroms", [])
|
|
276
|
+
|
|
277
|
+
if where_froms:
|
|
278
|
+
return where_froms[0] if isinstance(where_froms, list) else where_froms
|
|
279
|
+
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def get_uti(file_path: str) -> Optional[str]:
|
|
284
|
+
"""
|
|
285
|
+
Get the Uniform Type Identifier for a file.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
file_path: Path to the file.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
UTI string or None.
|
|
292
|
+
"""
|
|
293
|
+
return get_attribute(file_path, "kMDItemContentType")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def file_matches_uti(file_path: str, uti: str) -> bool:
|
|
297
|
+
"""
|
|
298
|
+
Check if a file conforms to a UTI.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
file_path: Path to the file.
|
|
302
|
+
uti: UTI to check against.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
True if file conforms to UTI.
|
|
306
|
+
"""
|
|
307
|
+
file_uti = get_uti(file_path)
|
|
308
|
+
if not file_uti:
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
# Check if file UTI equals or conforms to target UTI
|
|
312
|
+
if file_uti == uti:
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
# Use mdls to check conformance
|
|
316
|
+
type_tree = get_attribute(file_path, "kMDItemContentTypeTree")
|
|
317
|
+
if type_tree:
|
|
318
|
+
if isinstance(type_tree, str):
|
|
319
|
+
type_tree = [type_tree]
|
|
320
|
+
return uti in type_tree
|
|
321
|
+
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# Common UTI constants
|
|
326
|
+
UTI_PDF = "com.adobe.pdf"
|
|
327
|
+
UTI_IMAGE = "public.image"
|
|
328
|
+
UTI_MOVIE = "public.movie"
|
|
329
|
+
UTI_AUDIO = "public.audio"
|
|
330
|
+
UTI_TEXT = "public.plain-text"
|
|
331
|
+
UTI_RTF = "public.rtf"
|
|
332
|
+
UTI_HTML = "public.html"
|
|
333
|
+
UTI_XML = "public.xml"
|
|
334
|
+
UTI_JSON = "public.json"
|
|
335
|
+
UTI_ZIP = "com.pkware.zip-archive"
|
|
336
|
+
UTI_FOLDER = "public.folder"
|
|
337
|
+
UTI_APPLICATION = "com.apple.application-bundle"
|