[READ-ONLY] Mirror of https://github.com/mrgnw/scripts.
0

Configure Feed

Select the types of activity you want to include in your feed.

scripts / notes-attachment-report.py
11 kB 478 lines
1#!/usr/bin/env python3 2""" 3Generate detailed Apple Notes attachment report with actionable recommendations. 4 5This script creates: 61. HTML report with interactive filtering 72. CSV export for spreadsheet analysis 83. Deletion recommendations based on size and type 9""" 10 11import csv 12import sqlite3 13from collections import defaultdict 14from datetime import datetime 15from pathlib import Path 16 17 18def get_human_readable_size(bytes_val): 19 """Convert bytes to human readable format.""" 20 for unit in ["B", "KB", "MB", "GB", "TB"]: 21 if bytes_val < 1024: 22 return f"{bytes_val:.2f} {unit}" 23 bytes_val /= 1024 24 return f"{bytes_val:.2f} PB" 25 26 27def analyze_database(): 28 """Extract attachment data from NoteStore.sqlite.""" 29 db_path = Path.home() / "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite" 30 31 if not db_path.exists(): 32 print(f"❌ Database not found at {db_path}") 33 return None 34 35 try: 36 conn = sqlite3.connect(str(db_path)) 37 conn.row_factory = sqlite3.Row 38 cursor = conn.cursor() 39 40 query = """ 41 SELECT 42 obj.Z_PK AS object_id, 43 obj.ZFILENAME AS filename, 44 obj.ZTITLE AS title, 45 obj.ZTYPEUTI AS mime_type, 46 obj.ZFILESIZE AS file_size, 47 obj.ZMODIFICATIONDATE AS modification_date, 48 obj.ZNOTE AS note_fk, 49 COALESCE(note.ZTITLE1, '(Untitled)') AS note_title, 50 note.Z_PK AS note_id, 51 note.ZIDENTIFIER AS note_uuid 52 FROM ZICCLOUDSYNCINGOBJECT obj 53 LEFT JOIN ZICCLOUDSYNCINGOBJECT note ON obj.ZNOTE = note.Z_PK 54 WHERE obj.ZFILESIZE > 0 55 ORDER BY obj.ZFILESIZE DESC 56 """ 57 58 cursor.execute(query) 59 results = [dict(row) for row in cursor.fetchall()] 60 61 # Also fetch note identifiers for deep linking 62 note_ids_query = """ 63 SELECT Z_PK, ZIDENTIFIER, ZTITLE1 64 FROM ZICCLOUDSYNCINGOBJECT 65 WHERE ZTYPE IS NULL AND ZTITLE1 IS NOT NULL 66 """ 67 cursor.execute(note_ids_query) 68 note_identifiers = {row[0]: {"id": row[1], "title": row[2]} for row in cursor.fetchall()} 69 70 conn.close() 71 72 return results, note_identifiers 73 except sqlite3.OperationalError as e: 74 print(f"❌ Cannot access database: {e}") 75 print("\nYou need to grant Full Disk Access to Terminal:") 76 print(" 1. System Settings > Security & Privacy > Privacy") 77 print(" 2. Full Disk Access > + button > Terminal.app") 78 return None 79 80 81def get_file_extension(mime_type): 82 """Get common file extension from MIME type.""" 83 mime_to_ext = { 84 "public.jpeg": "jpg", 85 "public.png": "png", 86 "public.heic": "heic", 87 "public.tiff": "tiff", 88 "com.adobe.pdf": "pdf", 89 "public.mpeg-4": "mp4", 90 "com.apple.quicktime-movie": "mov", 91 "com.apple.m4a-audio": "m4a", 92 "public.data": "data", 93 } 94 return mime_to_ext.get(mime_type, mime_type.split(".")[-1] if mime_type else "unknown") 95 96 97def get_notes_app_deep_link(note_uuid): 98 """Generate a deep link to open a note in the Notes app.""" 99 if not note_uuid: 100 return None 101 return f"notes://showNote?identifier={note_uuid}" 102 103 104def categorize_by_type_size(attachments): 105 """Identify large attachments by type for recommendations.""" 106 by_type = defaultdict(lambda: {"count": 0, "total_size": 0, "files": []}) 107 108 for att in attachments: 109 mime_type = att.get("mime_type") or "unknown" 110 size = att.get("file_size", 0) 111 112 by_type[mime_type]["count"] += 1 113 by_type[mime_type]["total_size"] += size 114 by_type[mime_type]["files"].append( 115 { 116 "filename": att.get("filename") or att.get("title") or f"Object_{att.get('object_id')}", 117 "size": size, 118 "note": att.get("note_title", "Untitled"), 119 "object_id": att.get("object_id"), 120 "note_uuid": att.get("note_uuid"), 121 } 122 ) 123 124 return by_type 125 126 127def generate_csv_report(attachments, output_path): 128 """Generate detailed CSV report.""" 129 sorted_files = sorted(attachments, key=lambda x: x.get("file_size", 0), reverse=True) 130 131 with open(output_path, "w", newline="", encoding="utf-8") as f: 132 writer = csv.writer(f) 133 writer.writerow( 134 ["note_title", "attachment_name", "size_bytes", "size_human", "mime_type", "object_id"] 135 ) 136 137 for att in sorted_files: 138 writer.writerow( 139 [ 140 att.get("note_title") or "(Untitled)", 141 att.get("filename") or att.get("title") or f"Object_{att.get('object_id')}", 142 att.get("file_size", 0), 143 get_human_readable_size(att.get("file_size", 0)), 144 att.get("mime_type", "unknown"), 145 att.get("object_id"), 146 ] 147 ) 148 149 print(f"✅ CSV report: {output_path}") 150 151 152def generate_html_report(attachments, output_path): 153 """Generate interactive HTML report grouped by note.""" 154 total_size = sum(att.get("file_size", 0) for att in attachments) 155 total_count = len(attachments) 156 157 # Count unique file types 158 file_types = set(att.get("mime_type", "unknown") for att in attachments) 159 num_file_types = len(file_types) 160 161 # Group attachments by note 162 notes_dict = {} 163 for att in attachments: 164 note_title = att.get("note_title") or "(Untitled)" 165 note_uuid = att.get("note_uuid") 166 167 if note_title not in notes_dict: 168 notes_dict[note_title] = {"uuid": note_uuid, "size": 0, "files": []} 169 170 notes_dict[note_title]["size"] += att.get("file_size", 0) 171 notes_dict[note_title]["files"].append( 172 { 173 "filename": att.get("filename") or att.get("title") or f"Object_{att.get('object_id')}", 174 "size": att.get("file_size", 0), 175 } 176 ) 177 178 # Sort notes by total size (largest first) 179 sorted_notes = sorted(notes_dict.items(), key=lambda x: x[1]["size"], reverse=True) 180 181 html = f""" 182<!DOCTYPE html> 183<html lang="en"> 184<head> 185 <meta charset="UTF-8"> 186 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 187 <title>Apple Notes Attachment Report</title> 188 <style> 189 * {{ 190 margin: 0; 191 padding: 0; 192 box-sizing: border-box; 193 }} 194 195 body {{ 196 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 197 background: #f5f5f5; 198 color: #333; 199 line-height: 1.6; 200 }} 201 202 header {{ 203 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 204 color: white; 205 padding: 20px; 206 text-align: center; 207 }} 208 209 header h1 {{ 210 font-size: 1.8em; 211 margin: 0; 212 }} 213 214 header p {{ 215 font-size: 0.9em; 216 margin: 5px 0 0 0; 217 opacity: 0.9; 218 }} 219 220 .container {{ 221 max-width: 100%; 222 margin: 0; 223 padding: 0; 224 }} 225 226 .summary {{ 227 display: flex; 228 background: white; 229 border-bottom: 1px solid #ddd; 230 padding: 0; 231 }} 232 233 .card {{ 234 flex: 1; 235 padding: 15px 20px; 236 border-right: 1px solid #ddd; 237 text-align: center; 238 }} 239 240 .card:last-child {{ 241 border-right: none; 242 }} 243 244 .card h3 {{ 245 color: #667eea; 246 margin: 0 0 5px 0; 247 font-size: 0.75em; 248 text-transform: uppercase; 249 letter-spacing: 0.5px; 250 }} 251 252 .card .value {{ 253 font-size: 1.5em; 254 font-weight: bold; 255 color: #333; 256 margin: 0; 257 }} 258 259 .types-section {{ 260 margin: 30px 0; 261 }} 262 263 .types-section h2 {{ 264 margin: 0; 265 padding: 20px; 266 font-size: 1.1em; 267 background: #f9f9f9; 268 border-bottom: 1px solid #ddd; 269 }} 270 271 .file-list {{ 272 background: white; 273 overflow-y: auto; 274 max-height: 80vh; 275 }} 276 277 .note-group {{ 278 border-bottom: 1px solid #ddd; 279 padding: 16px 20px; 280 background: white; 281 }} 282 283 .note-group:hover {{ 284 background: #fafafa; 285 }} 286 287 .note-header {{ 288 display: grid; 289 grid-template-columns: 2fr 1fr; 290 gap: 40px; 291 align-items: center; 292 margin-bottom: 12px; 293 }} 294 295 .note-title {{ 296 font-weight: 600; 297 font-size: 1em; 298 color: #333; 299 }} 300 301 .note-title a {{ 302 color: #667eea; 303 text-decoration: none; 304 cursor: pointer; 305 font-weight: 600; 306 }} 307 308 .note-title a:hover {{ 309 color: #764ba2; 310 text-decoration: underline; 311 }} 312 313 .note-size {{ 314 font-weight: bold; 315 color: #667eea; 316 text-align: right; 317 font-size: 0.95em; 318 }} 319 320 .note-files {{ 321 font-family: 'Monaco', 'Courier New', monospace; 322 font-size: 0.85em; 323 color: #666; 324 white-space: pre-wrap; 325 word-break: break-word; 326 line-height: 1.4; 327 margin-left: 0; 328 padding: 0; 329 }} 330 331 .note-link-icon {{ 332 margin-left: 4px; 333 font-size: 0.8em; 334 }} 335 336 337 338 footer {{ 339 text-align: center; 340 padding: 20px; 341 color: #999; 342 font-size: 0.85em; 343 background: #f9f9f9; 344 border-top: 1px solid #ddd; 345 }} 346 347 @media (max-width: 768px) {{ 348 header h1 {{ 349 font-size: 1.8em; 350 }} 351 352 .type-header {{ 353 flex-direction: column; 354 align-items: flex-start; 355 }} 356 357 .type-stats {{ 358 width: 100%; 359 margin-top: 10px; 360 }} 361 }} 362 </style> 363</head> 364<body> 365 <header> 366 <h1>📱 Apple Notes Attachment Analysis</h1> 367 <p>Generated on {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p> 368 </header> 369 370 <div class="container"> 371 <div class="summary"> 372 <div class="card"> 373 <h3>Files</h3> 374 <div class="value">{total_count:,}</div> 375 </div> 376 <div class="card"> 377 <h3>Total Size</h3> 378 <div class="value">{get_human_readable_size(total_size)}</div> 379 </div> 380 <div class="card"> 381 <h3>Types</h3> 382 <div class="value">{num_file_types}</div> 383 </div> 384 <div class="card"> 385 <h3>Avg</h3> 386 <div class="value">{get_human_readable_size(total_size // max(total_count, 1))}</div> 387 </div> 388 </div> 389 390 391 392 <div class="types-section"> 393 <h2>Notes by Storage</h2> 394 <div class="file-list"> 395""" 396 397 # Add notes grouped by size with their files 398 for note_title, note_data in sorted_notes: 399 note_uuid = note_data["uuid"] 400 total_note_size = note_data["size"] 401 files = sorted(note_data["files"], key=lambda x: x["size"], reverse=True) 402 403 if note_uuid: 404 note_link = get_notes_app_deep_link(note_uuid) 405 note_display = f'<a href="{note_link}" title="Click to open this note in Notes.app">{note_title}<span class="note-link-icon">↗</span></a>' 406 else: 407 note_display = note_title 408 409 # Build file list (newline separated) 410 files_text = "\n".join([f["filename"] for f in files]) 411 412 html += f""" 413 <div class="note-group"> 414 <div class="note-header"> 415 <div class="note-title">{note_display}</div> 416 <div class="note-size">{get_human_readable_size(total_note_size)}</div> 417 </div> 418 <div class="note-files">{files_text}</div> 419 </div> 420""" 421 422 html += """ 423 </div> 424 </div> 425 426 <footer> 427 <p>Click any note name to open in Notes.app</p> 428 </footer> 429</body> 430</html> 431""" 432 433 with open(output_path, "w", encoding="utf-8") as f: 434 f.write(html) 435 436 print(f"✅ HTML report: {output_path}") 437 438 439def main(): 440 print("🔍 Generating Apple Notes attachment report...\n") 441 442 # Analyze database 443 print("📚 Analyzing database...") 444 result = analyze_database() 445 446 if result is None: 447 return 448 449 db_attachments, note_identifiers = result 450 451 print(f" Found {len(db_attachments)} attachments\n") 452 453 # Generate reports 454 output_dir = Path.cwd() 455 456 # CSV Report 457 csv_path = output_dir / "notes_attachment_detailed.csv" 458 generate_csv_report(db_attachments, csv_path) 459 460 # HTML Report 461 html_path = output_dir / "notes_attachment_report.html" 462 generate_html_report(db_attachments, html_path) 463 464 print("\n" + "=" * 80) 465 print("✅ Reports generated successfully!") 466 print("=" * 80) 467 print(f"\n📄 Files created:") 468 print(f" 1. {html_path.name} - Open in your browser for interactive view") 469 print(f" 2. {csv_path.name} - Import into spreadsheet for detailed analysis") 470 print(f"\n💡 Next steps:") 471 print(f" 1. Review the HTML report to identify large attachments") 472 print(f" 2. Use the CSV for sorting and filtering in Excel/Sheets") 473 print(f" 3. Delete large attachments from Notes app") 474 print(f" 4. Re-run analyze-notes-space.py to verify cleanup\n") 475 476 477if __name__ == "__main__": 478 main()