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/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"