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
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File information extraction.
|
|
3
|
+
|
|
4
|
+
This module provides functions for extracting various attributes from files,
|
|
5
|
+
including standard file system attributes and macOS-specific metadata.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import mimetypes
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_file_info(file_path: str) -> Dict[str, Any]:
|
|
19
|
+
"""
|
|
20
|
+
Get comprehensive information about a file.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
file_path: Path to the file.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Dictionary containing file attributes.
|
|
27
|
+
"""
|
|
28
|
+
path = Path(file_path)
|
|
29
|
+
stat = path.stat()
|
|
30
|
+
|
|
31
|
+
info = {
|
|
32
|
+
# Basic attributes
|
|
33
|
+
"path": str(path.absolute()),
|
|
34
|
+
"name": path.stem,
|
|
35
|
+
"extension": path.suffix.lstrip(".").lower(),
|
|
36
|
+
"full_name": path.name,
|
|
37
|
+
"parent_folder": path.parent.name,
|
|
38
|
+
"parent_path": str(path.parent),
|
|
39
|
+
|
|
40
|
+
# Size
|
|
41
|
+
"size": stat.st_size,
|
|
42
|
+
"size_bytes": stat.st_size,
|
|
43
|
+
"size_human": _format_size(stat.st_size),
|
|
44
|
+
|
|
45
|
+
# Dates
|
|
46
|
+
"date_created": datetime.fromtimestamp(stat.st_birthtime) if hasattr(stat, "st_birthtime") else datetime.fromtimestamp(stat.st_ctime),
|
|
47
|
+
"date_modified": datetime.fromtimestamp(stat.st_mtime),
|
|
48
|
+
"date_accessed": datetime.fromtimestamp(stat.st_atime),
|
|
49
|
+
|
|
50
|
+
# Type
|
|
51
|
+
"is_file": path.is_file(),
|
|
52
|
+
"is_directory": path.is_dir(),
|
|
53
|
+
"is_symlink": path.is_symlink(),
|
|
54
|
+
"is_hidden": path.name.startswith("."),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# MIME type
|
|
58
|
+
mime_type, _ = mimetypes.guess_type(str(path))
|
|
59
|
+
info["mime_type"] = mime_type
|
|
60
|
+
info["file_type"] = mime_type
|
|
61
|
+
|
|
62
|
+
# Determine kind based on extension/mime
|
|
63
|
+
info["kind"] = _get_file_kind(path.suffix, mime_type)
|
|
64
|
+
|
|
65
|
+
# macOS-specific attributes
|
|
66
|
+
try:
|
|
67
|
+
macos_info = _get_macos_metadata(file_path)
|
|
68
|
+
info.update(macos_info)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
return info
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _format_size(size_bytes: int) -> str:
|
|
76
|
+
"""Format size in bytes to human-readable string."""
|
|
77
|
+
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
|
78
|
+
if size_bytes < 1024:
|
|
79
|
+
return f"{size_bytes:.1f} {unit}"
|
|
80
|
+
size_bytes /= 1024
|
|
81
|
+
return f"{size_bytes:.1f} PB"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_file_kind(extension: str, mime_type: Optional[str]) -> str:
|
|
85
|
+
"""Determine the kind of file based on extension and MIME type."""
|
|
86
|
+
extension = extension.lower().lstrip(".")
|
|
87
|
+
|
|
88
|
+
# Extension-based kinds
|
|
89
|
+
kinds = {
|
|
90
|
+
# Documents
|
|
91
|
+
"pdf": "PDF Document",
|
|
92
|
+
"doc": "Word Document",
|
|
93
|
+
"docx": "Word Document",
|
|
94
|
+
"xls": "Excel Spreadsheet",
|
|
95
|
+
"xlsx": "Excel Spreadsheet",
|
|
96
|
+
"ppt": "PowerPoint Presentation",
|
|
97
|
+
"pptx": "PowerPoint Presentation",
|
|
98
|
+
"txt": "Plain Text",
|
|
99
|
+
"rtf": "Rich Text Document",
|
|
100
|
+
"md": "Markdown Document",
|
|
101
|
+
"pages": "Pages Document",
|
|
102
|
+
"numbers": "Numbers Spreadsheet",
|
|
103
|
+
"key": "Keynote Presentation",
|
|
104
|
+
|
|
105
|
+
# Images
|
|
106
|
+
"jpg": "JPEG Image",
|
|
107
|
+
"jpeg": "JPEG Image",
|
|
108
|
+
"png": "PNG Image",
|
|
109
|
+
"gif": "GIF Image",
|
|
110
|
+
"bmp": "BMP Image",
|
|
111
|
+
"tiff": "TIFF Image",
|
|
112
|
+
"tif": "TIFF Image",
|
|
113
|
+
"webp": "WebP Image",
|
|
114
|
+
"svg": "SVG Image",
|
|
115
|
+
"ico": "Icon",
|
|
116
|
+
"heic": "HEIC Image",
|
|
117
|
+
"heif": "HEIF Image",
|
|
118
|
+
"raw": "RAW Image",
|
|
119
|
+
"psd": "Photoshop Document",
|
|
120
|
+
"ai": "Illustrator Document",
|
|
121
|
+
|
|
122
|
+
# Audio
|
|
123
|
+
"mp3": "MP3 Audio",
|
|
124
|
+
"wav": "WAV Audio",
|
|
125
|
+
"aac": "AAC Audio",
|
|
126
|
+
"flac": "FLAC Audio",
|
|
127
|
+
"m4a": "M4A Audio",
|
|
128
|
+
"ogg": "OGG Audio",
|
|
129
|
+
"wma": "WMA Audio",
|
|
130
|
+
"aiff": "AIFF Audio",
|
|
131
|
+
|
|
132
|
+
# Video
|
|
133
|
+
"mp4": "MP4 Video",
|
|
134
|
+
"mov": "QuickTime Movie",
|
|
135
|
+
"avi": "AVI Video",
|
|
136
|
+
"mkv": "MKV Video",
|
|
137
|
+
"wmv": "WMV Video",
|
|
138
|
+
"flv": "FLV Video",
|
|
139
|
+
"webm": "WebM Video",
|
|
140
|
+
"m4v": "M4V Video",
|
|
141
|
+
|
|
142
|
+
# Archives
|
|
143
|
+
"zip": "ZIP Archive",
|
|
144
|
+
"rar": "RAR Archive",
|
|
145
|
+
"7z": "7-Zip Archive",
|
|
146
|
+
"tar": "TAR Archive",
|
|
147
|
+
"gz": "Gzip Archive",
|
|
148
|
+
"bz2": "Bzip2 Archive",
|
|
149
|
+
"dmg": "Disk Image",
|
|
150
|
+
"iso": "ISO Image",
|
|
151
|
+
|
|
152
|
+
# Code
|
|
153
|
+
"py": "Python Script",
|
|
154
|
+
"js": "JavaScript",
|
|
155
|
+
"ts": "TypeScript",
|
|
156
|
+
"html": "HTML Document",
|
|
157
|
+
"css": "CSS Stylesheet",
|
|
158
|
+
"json": "JSON File",
|
|
159
|
+
"xml": "XML File",
|
|
160
|
+
"yaml": "YAML File",
|
|
161
|
+
"yml": "YAML File",
|
|
162
|
+
"sh": "Shell Script",
|
|
163
|
+
"java": "Java Source",
|
|
164
|
+
"cpp": "C++ Source",
|
|
165
|
+
"c": "C Source",
|
|
166
|
+
"h": "Header File",
|
|
167
|
+
"swift": "Swift Source",
|
|
168
|
+
"rb": "Ruby Script",
|
|
169
|
+
"go": "Go Source",
|
|
170
|
+
"rs": "Rust Source",
|
|
171
|
+
|
|
172
|
+
# Applications
|
|
173
|
+
"app": "Application",
|
|
174
|
+
"exe": "Windows Executable",
|
|
175
|
+
"pkg": "Installer Package",
|
|
176
|
+
|
|
177
|
+
# Other
|
|
178
|
+
"torrent": "Torrent File",
|
|
179
|
+
"ics": "Calendar Event",
|
|
180
|
+
"vcf": "Contact Card",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if extension in kinds:
|
|
184
|
+
return kinds[extension]
|
|
185
|
+
|
|
186
|
+
# MIME-based fallback
|
|
187
|
+
if mime_type:
|
|
188
|
+
if mime_type.startswith("image/"):
|
|
189
|
+
return "Image"
|
|
190
|
+
elif mime_type.startswith("video/"):
|
|
191
|
+
return "Video"
|
|
192
|
+
elif mime_type.startswith("audio/"):
|
|
193
|
+
return "Audio"
|
|
194
|
+
elif mime_type.startswith("text/"):
|
|
195
|
+
return "Text Document"
|
|
196
|
+
elif mime_type.startswith("application/"):
|
|
197
|
+
return "Application"
|
|
198
|
+
|
|
199
|
+
return "Document"
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _get_macos_metadata(file_path: str) -> Dict[str, Any]:
|
|
203
|
+
"""
|
|
204
|
+
Get macOS-specific metadata using mdls command.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
file_path: Path to the file.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Dictionary of macOS metadata.
|
|
211
|
+
"""
|
|
212
|
+
info = {}
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
# Get Spotlight metadata
|
|
216
|
+
result = subprocess.run(
|
|
217
|
+
["mdls", "-plist", "-", file_path],
|
|
218
|
+
capture_output=True,
|
|
219
|
+
text=True,
|
|
220
|
+
timeout=5,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if result.returncode == 0:
|
|
224
|
+
import plistlib
|
|
225
|
+
metadata = plistlib.loads(result.stdout.encode())
|
|
226
|
+
|
|
227
|
+
# Extract useful attributes
|
|
228
|
+
attr_mapping = {
|
|
229
|
+
"kMDItemWhereFroms": "where_from",
|
|
230
|
+
"kMDItemUserTags": "tags",
|
|
231
|
+
"kMDItemFinderComment": "finder_comment",
|
|
232
|
+
"kMDItemContentType": "uti",
|
|
233
|
+
"kMDItemKind": "kind_macos",
|
|
234
|
+
"kMDItemDateAdded": "date_added",
|
|
235
|
+
"kMDItemDownloadedDate": "date_downloaded",
|
|
236
|
+
"kMDItemPixelHeight": "pixel_height",
|
|
237
|
+
"kMDItemPixelWidth": "pixel_width",
|
|
238
|
+
"kMDItemDurationSeconds": "duration_seconds",
|
|
239
|
+
"kMDItemTitle": "title",
|
|
240
|
+
"kMDItemAuthors": "authors",
|
|
241
|
+
"kMDItemCreator": "creator",
|
|
242
|
+
"kMDItemDescription": "description",
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for mdls_key, our_key in attr_mapping.items():
|
|
246
|
+
if mdls_key in metadata and metadata[mdls_key] is not None:
|
|
247
|
+
value = metadata[mdls_key]
|
|
248
|
+
# Handle lists
|
|
249
|
+
if isinstance(value, list) and len(value) == 1:
|
|
250
|
+
value = value[0]
|
|
251
|
+
info[our_key] = value
|
|
252
|
+
|
|
253
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
|
|
254
|
+
pass
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
# Get tags using xattr if not found via mdls
|
|
259
|
+
if "tags" not in info:
|
|
260
|
+
try:
|
|
261
|
+
tags = _get_finder_tags(file_path)
|
|
262
|
+
if tags:
|
|
263
|
+
info["tags"] = tags
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
return info
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _get_finder_tags(file_path: str) -> List[str]:
|
|
271
|
+
"""
|
|
272
|
+
Get Finder tags from a file using xattr.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
file_path: Path to the file.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
List of tag names.
|
|
279
|
+
"""
|
|
280
|
+
try:
|
|
281
|
+
import plistlib
|
|
282
|
+
|
|
283
|
+
result = subprocess.run(
|
|
284
|
+
["xattr", "-p", "com.apple.metadata:_kMDItemUserTags", file_path],
|
|
285
|
+
capture_output=True,
|
|
286
|
+
timeout=5,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if result.returncode == 0 and result.stdout:
|
|
290
|
+
# Parse the binary plist
|
|
291
|
+
tags_data = plistlib.loads(result.stdout)
|
|
292
|
+
return [tag.split("\n")[0] for tag in tags_data if tag]
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
return []
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def get_file_contents(file_path: str, max_bytes: int = 1024 * 1024) -> Optional[str]:
|
|
300
|
+
"""
|
|
301
|
+
Read file contents (for text files).
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
file_path: Path to the file.
|
|
305
|
+
max_bytes: Maximum bytes to read.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
File contents as string, or None if not readable.
|
|
309
|
+
"""
|
|
310
|
+
try:
|
|
311
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
312
|
+
return f.read(max_bytes)
|
|
313
|
+
except Exception:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def get_download_source(file_path: str) -> Optional[str]:
|
|
318
|
+
"""
|
|
319
|
+
Get the URL from which a file was downloaded.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
file_path: Path to the file.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Download URL or None.
|
|
326
|
+
"""
|
|
327
|
+
info = _get_macos_metadata(file_path)
|
|
328
|
+
where_from = info.get("where_from")
|
|
329
|
+
|
|
330
|
+
if isinstance(where_from, list):
|
|
331
|
+
return where_from[0] if where_from else None
|
|
332
|
+
return where_from
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def is_complete_download(file_path: str) -> bool:
|
|
336
|
+
"""
|
|
337
|
+
Check if a file is a complete download (not still being downloaded).
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
file_path: Path to the file.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
True if download is complete.
|
|
344
|
+
"""
|
|
345
|
+
path = Path(file_path)
|
|
346
|
+
|
|
347
|
+
# Check for common incomplete download extensions
|
|
348
|
+
incomplete_extensions = {
|
|
349
|
+
".crdownload", # Chrome
|
|
350
|
+
".part", # Firefox, wget
|
|
351
|
+
".download", # Safari
|
|
352
|
+
".tmp",
|
|
353
|
+
".partial",
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if path.suffix.lower() in incomplete_extensions:
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
# Check for .download companion file (Safari)
|
|
360
|
+
if (path.parent / f"{path.name}.download").exists():
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
return True
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Logging configuration for SortMeOut.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from logging.handlers import RotatingFileHandler
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import appdirs
|
|
16
|
+
|
|
17
|
+
# Application name for directories
|
|
18
|
+
APP_NAME = "SortMeOut"
|
|
19
|
+
APP_AUTHOR = "SortMeOut"
|
|
20
|
+
|
|
21
|
+
# Default log format
|
|
22
|
+
DEFAULT_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
23
|
+
SIMPLE_FORMAT = "%(levelname)s: %(message)s"
|
|
24
|
+
DETAILED_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_log_directory() -> Path:
|
|
28
|
+
"""Get the log directory path."""
|
|
29
|
+
log_dir = Path(appdirs.user_log_dir(APP_NAME, APP_AUTHOR))
|
|
30
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
return log_dir
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def setup_logging(
|
|
35
|
+
level: int = logging.INFO,
|
|
36
|
+
log_file: Optional[str] = None,
|
|
37
|
+
console: bool = True,
|
|
38
|
+
file_logging: bool = True,
|
|
39
|
+
max_file_size: int = 10 * 1024 * 1024, # 10 MB
|
|
40
|
+
backup_count: int = 5,
|
|
41
|
+
format_string: Optional[str] = None,
|
|
42
|
+
) -> logging.Logger:
|
|
43
|
+
"""
|
|
44
|
+
Set up logging for the application.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
level: Logging level.
|
|
48
|
+
log_file: Path to log file. If None, uses default location.
|
|
49
|
+
console: Enable console logging.
|
|
50
|
+
file_logging: Enable file logging.
|
|
51
|
+
max_file_size: Maximum log file size before rotation.
|
|
52
|
+
backup_count: Number of backup files to keep.
|
|
53
|
+
format_string: Custom format string.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Root logger instance.
|
|
57
|
+
"""
|
|
58
|
+
# Get root logger for our package
|
|
59
|
+
root_logger = logging.getLogger("sortmeout")
|
|
60
|
+
root_logger.setLevel(level)
|
|
61
|
+
|
|
62
|
+
# Clear existing handlers
|
|
63
|
+
root_logger.handlers.clear()
|
|
64
|
+
|
|
65
|
+
# Determine format
|
|
66
|
+
if format_string is None:
|
|
67
|
+
format_string = DETAILED_FORMAT if level == logging.DEBUG else DEFAULT_FORMAT
|
|
68
|
+
|
|
69
|
+
formatter = logging.Formatter(format_string)
|
|
70
|
+
|
|
71
|
+
# Console handler
|
|
72
|
+
if console:
|
|
73
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
|
74
|
+
console_handler.setLevel(level)
|
|
75
|
+
console_handler.setFormatter(logging.Formatter(SIMPLE_FORMAT))
|
|
76
|
+
root_logger.addHandler(console_handler)
|
|
77
|
+
|
|
78
|
+
# File handler
|
|
79
|
+
if file_logging:
|
|
80
|
+
if log_file is None:
|
|
81
|
+
log_dir = get_log_directory()
|
|
82
|
+
log_file = str(log_dir / "sortmeout.log")
|
|
83
|
+
|
|
84
|
+
file_handler = RotatingFileHandler(
|
|
85
|
+
log_file,
|
|
86
|
+
maxBytes=max_file_size,
|
|
87
|
+
backupCount=backup_count,
|
|
88
|
+
)
|
|
89
|
+
file_handler.setLevel(level)
|
|
90
|
+
file_handler.setFormatter(formatter)
|
|
91
|
+
root_logger.addHandler(file_handler)
|
|
92
|
+
|
|
93
|
+
return root_logger
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_logger(name: str) -> logging.Logger:
|
|
97
|
+
"""
|
|
98
|
+
Get a logger for a module.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
name: Logger name (usually __name__).
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Logger instance.
|
|
105
|
+
"""
|
|
106
|
+
# Ensure name is under our package namespace
|
|
107
|
+
if not name.startswith("sortmeout"):
|
|
108
|
+
name = f"sortmeout.{name}"
|
|
109
|
+
|
|
110
|
+
return logging.getLogger(name)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ActionLogger:
|
|
114
|
+
"""
|
|
115
|
+
Specialized logger for tracking file actions.
|
|
116
|
+
|
|
117
|
+
Logs actions to a separate file for easy auditing.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, log_file: Optional[str] = None):
|
|
121
|
+
"""
|
|
122
|
+
Initialize action logger.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
log_file: Path to action log file.
|
|
126
|
+
"""
|
|
127
|
+
self.logger = logging.getLogger("sortmeout.actions")
|
|
128
|
+
self.logger.setLevel(logging.INFO)
|
|
129
|
+
|
|
130
|
+
if log_file is None:
|
|
131
|
+
log_dir = get_log_directory()
|
|
132
|
+
log_file = str(log_dir / "actions.log")
|
|
133
|
+
|
|
134
|
+
handler = RotatingFileHandler(
|
|
135
|
+
log_file,
|
|
136
|
+
maxBytes=10 * 1024 * 1024,
|
|
137
|
+
backupCount=10,
|
|
138
|
+
)
|
|
139
|
+
handler.setFormatter(logging.Formatter(
|
|
140
|
+
"%(asctime)s | %(message)s"
|
|
141
|
+
))
|
|
142
|
+
self.logger.addHandler(handler)
|
|
143
|
+
|
|
144
|
+
def log_action(
|
|
145
|
+
self,
|
|
146
|
+
action_type: str,
|
|
147
|
+
source: str,
|
|
148
|
+
destination: Optional[str] = None,
|
|
149
|
+
success: bool = True,
|
|
150
|
+
details: Optional[str] = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""
|
|
153
|
+
Log a file action.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
action_type: Type of action performed.
|
|
157
|
+
source: Source file path.
|
|
158
|
+
destination: Destination path (if applicable).
|
|
159
|
+
success: Whether action succeeded.
|
|
160
|
+
details: Additional details.
|
|
161
|
+
"""
|
|
162
|
+
status = "SUCCESS" if success else "FAILED"
|
|
163
|
+
|
|
164
|
+
message_parts = [
|
|
165
|
+
status,
|
|
166
|
+
action_type.upper(),
|
|
167
|
+
f"src={source}",
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
if destination:
|
|
171
|
+
message_parts.append(f"dst={destination}")
|
|
172
|
+
|
|
173
|
+
if details:
|
|
174
|
+
message_parts.append(f"({details})")
|
|
175
|
+
|
|
176
|
+
message = " | ".join(message_parts)
|
|
177
|
+
|
|
178
|
+
if success:
|
|
179
|
+
self.logger.info(message)
|
|
180
|
+
else:
|
|
181
|
+
self.logger.error(message)
|
|
182
|
+
|
|
183
|
+
def get_recent_actions(self, count: int = 100) -> list:
|
|
184
|
+
"""
|
|
185
|
+
Get recent logged actions.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
count: Number of recent actions to retrieve.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
List of action log entries.
|
|
192
|
+
"""
|
|
193
|
+
log_dir = get_log_directory()
|
|
194
|
+
log_file = log_dir / "actions.log"
|
|
195
|
+
|
|
196
|
+
if not log_file.exists():
|
|
197
|
+
return []
|
|
198
|
+
|
|
199
|
+
with open(log_file, "r") as f:
|
|
200
|
+
lines = f.readlines()
|
|
201
|
+
|
|
202
|
+
return lines[-count:]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# Global action logger instance
|
|
206
|
+
_action_logger: Optional[ActionLogger] = None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get_action_logger() -> ActionLogger:
|
|
210
|
+
"""Get the global action logger instance."""
|
|
211
|
+
global _action_logger
|
|
212
|
+
if _action_logger is None:
|
|
213
|
+
_action_logger = ActionLogger()
|
|
214
|
+
return _action_logger
|