PlanOpticon

planopticon / video_processor / sources / apple_notes_source.py
Source Blame History 178 lines
0981a08… noreply 1 """Apple Notes source connector via osascript (macOS only)."""
0981a08… noreply 2
0981a08… noreply 3 import logging
0981a08… noreply 4 import re
0981a08… noreply 5 import subprocess
0981a08… noreply 6 import sys
0981a08… noreply 7 from pathlib import Path
0981a08… noreply 8 from typing import List, Optional
0981a08… noreply 9
0981a08… noreply 10 from video_processor.sources.base import BaseSource, SourceFile
0981a08… noreply 11
0981a08… noreply 12 logger = logging.getLogger(__name__)
0981a08… noreply 13
0981a08… noreply 14
0981a08… noreply 15 class AppleNotesSource(BaseSource):
0981a08… noreply 16 """
0981a08… noreply 17 Fetch notes from Apple Notes using osascript (AppleScript).
0981a08… noreply 18
0981a08… noreply 19 Only works on macOS. Requires the Notes app to be available
0981a08… noreply 20 and permission for osascript to access it.
0981a08… noreply 21 """
0981a08… noreply 22
0981a08… noreply 23 def __init__(self, folder: Optional[str] = None):
0981a08… noreply 24 self.folder = folder
0981a08… noreply 25
0981a08… noreply 26 def authenticate(self) -> bool:
0981a08… noreply 27 """Check that we are running on macOS."""
0981a08… noreply 28 if sys.platform != "darwin":
0981a08… noreply 29 logger.error("Apple Notes is only available on macOS (current: %s)", sys.platform)
0981a08… noreply 30 return False
0981a08… noreply 31 return True
0981a08… noreply 32
0981a08… noreply 33 def list_videos(
0981a08… noreply 34 self,
0981a08… noreply 35 folder_id: Optional[str] = None,
0981a08… noreply 36 folder_path: Optional[str] = None,
0981a08… noreply 37 patterns: Optional[List[str]] = None,
0981a08… noreply 38 ) -> List[SourceFile]:
0981a08… noreply 39 """List notes from Apple Notes via osascript."""
0981a08… noreply 40 if not self.authenticate():
0981a08… noreply 41 return []
0981a08… noreply 42
0981a08… noreply 43 if self.folder:
0981a08… noreply 44 script = (
0981a08… noreply 45 'tell application "Notes"\n'
0981a08… noreply 46 " set noteList to {}\n"
0981a08… noreply 47 f" repeat with f in folders of default account\n"
0981a08… noreply 48 f' if name of f is "{self.folder}" then\n'
0981a08… noreply 49 " repeat with n in notes of f\n"
0981a08… noreply 50 ' set end of noteList to (id of n) & "|||" & (name of n)\n'
0981a08… noreply 51 " end repeat\n"
0981a08… noreply 52 " end if\n"
0981a08… noreply 53 " end repeat\n"
0981a08… noreply 54 " return noteList\n"
0981a08… noreply 55 "end tell"
0981a08… noreply 56 )
0981a08… noreply 57 else:
0981a08… noreply 58 script = (
0981a08… noreply 59 'tell application "Notes"\n'
0981a08… noreply 60 " set noteList to {}\n"
0981a08… noreply 61 " repeat with n in notes of default account\n"
0981a08… noreply 62 ' set end of noteList to (id of n) & "|||" & (name of n)\n'
0981a08… noreply 63 " end repeat\n"
0981a08… noreply 64 " return noteList\n"
0981a08… noreply 65 "end tell"
0981a08… noreply 66 )
0981a08… noreply 67
0981a08… noreply 68 try:
0981a08… noreply 69 result = subprocess.run(
0981a08… noreply 70 ["osascript", "-e", script],
0981a08… noreply 71 capture_output=True,
0981a08… noreply 72 text=True,
0981a08… noreply 73 timeout=30,
0981a08… noreply 74 )
0981a08… noreply 75 except FileNotFoundError:
0981a08… noreply 76 logger.error("osascript not found. Apple Notes requires macOS.")
0981a08… noreply 77 return []
0981a08… noreply 78 except subprocess.TimeoutExpired:
0981a08… noreply 79 logger.error("osascript timed out while listing notes.")
0981a08… noreply 80 return []
0981a08… noreply 81
0981a08… noreply 82 if result.returncode != 0:
0981a08… noreply 83 logger.error("Failed to list notes: %s", result.stderr.strip())
0981a08… noreply 84 return []
0981a08… noreply 85
0981a08… noreply 86 return self._parse_note_list(result.stdout.strip())
0981a08… noreply 87
0981a08… noreply 88 def _parse_note_list(self, output: str) -> List[SourceFile]:
0981a08… noreply 89 """Parse osascript output into SourceFile objects.
0981a08… noreply 90
0981a08… noreply 91 Expected format: comma-separated items of 'id|||name' pairs.
0981a08… noreply 92 """
0981a08… noreply 93 files: List[SourceFile] = []
0981a08… noreply 94 if not output:
0981a08… noreply 95 return files
0981a08… noreply 96
0981a08… noreply 97 # AppleScript returns a flat comma-separated list
0981a08… noreply 98 entries = output.split(", ")
0981a08… noreply 99 for entry in entries:
0981a08… noreply 100 entry = entry.strip()
0981a08… noreply 101 if "|||" not in entry:
0981a08… noreply 102 continue
0981a08… noreply 103 note_id, _, name = entry.partition("|||")
0981a08… noreply 104 note_id = note_id.strip()
0981a08… noreply 105 name = name.strip()
0981a08… noreply 106 if note_id and name:
0981a08… noreply 107 files.append(
0981a08… noreply 108 SourceFile(
0981a08… noreply 109 name=name,
0981a08… noreply 110 id=note_id,
0981a08… noreply 111 mime_type="text/plain",
0981a08… noreply 112 )
0981a08… noreply 113 )
0981a08… noreply 114
0981a08… noreply 115 logger.info("Found %d notes", len(files))
0981a08… noreply 116 return files
0981a08… noreply 117
0981a08… noreply 118 def download(self, file: SourceFile, destination: Path) -> Path:
0981a08… noreply 119 """Download a note's content as plain text."""
0981a08… noreply 120 destination = Path(destination)
0981a08… noreply 121 destination.parent.mkdir(parents=True, exist_ok=True)
0981a08… noreply 122
0981a08… noreply 123 script = (
0981a08… noreply 124 'tell application "Notes"\n'
0981a08… noreply 125 f' set theNote to note id "{file.id}" of default account\n'
0981a08… noreply 126 " return body of theNote\n"
0981a08… noreply 127 "end tell"
0981a08… noreply 128 )
0981a08… noreply 129
0981a08… noreply 130 try:
0981a08… noreply 131 result = subprocess.run(
0981a08… noreply 132 ["osascript", "-e", script],
0981a08… noreply 133 capture_output=True,
0981a08… noreply 134 text=True,
0981a08… noreply 135 timeout=30,
0981a08… noreply 136 )
0981a08… noreply 137 except FileNotFoundError:
0981a08… noreply 138 raise RuntimeError("osascript not found. Apple Notes requires macOS.")
0981a08… noreply 139 except subprocess.TimeoutExpired:
0981a08… noreply 140 raise RuntimeError(f"osascript timed out fetching note {file.id}")
0981a08… noreply 141
0981a08… noreply 142 if result.returncode != 0:
0981a08… noreply 143 raise RuntimeError(f"Failed to fetch note {file.id}: {result.stderr.strip()}")
0981a08… noreply 144
0981a08… noreply 145 html_body = result.stdout.strip()
0981a08… noreply 146 text = self._html_to_text(html_body)
0981a08… noreply 147
0981a08… noreply 148 # Prepend title
0981a08… noreply 149 content = f"# {file.name}\n\n{text}"
0981a08… noreply 150 destination.write_text(content, encoding="utf-8")
0981a08… noreply 151 logger.info("Saved Apple Note to %s", destination)
0981a08… noreply 152 return destination
0981a08… noreply 153
0981a08… noreply 154 @staticmethod
0981a08… noreply 155 def _html_to_text(html: str) -> str:
0981a08… noreply 156 """Strip HTML tags and return plain text.
0981a08… noreply 157
0981a08… noreply 158 Apple Notes returns note bodies as HTML. This uses regex-based
0981a08… noreply 159 stripping similar to web_source._strip_html_tags.
0981a08… noreply 160 """
0981a08… noreply 161 if not html:
0981a08… noreply 162 return ""
0981a08… noreply 163 # Replace <br> variants with newlines
0981a08… noreply 164 text = re.sub(r"<br\s*/?>", "\n", html, flags=re.IGNORECASE)
0981a08… noreply 165 # Replace block-level closing tags with newlines
0981a08… noreply 166 text = re.sub(r"</(?:p|div|li|tr|h[1-6])>", "\n", text, flags=re.IGNORECASE)
0981a08… noreply 167 # Remove all remaining tags
0981a08… noreply 168 text = re.sub(r"<[^>]+>", "", text)
0981a08… noreply 169 # Decode common HTML entities
0981a08… noreply 170 text = text.replace("&amp;", "&")
0981a08… noreply 171 text = text.replace("&lt;", "<")
0981a08… noreply 172 text = text.replace("&gt;", ">")
0981a08… noreply 173 text = text.replace("&quot;", '"')
0981a08… noreply 174 text = text.replace("&#39;", "'")
0981a08… noreply 175 text = text.replace("&nbsp;", " ")
0981a08… noreply 176 # Collapse excessive blank lines
0981a08… noreply 177 text = re.sub(r"\n{3,}", "\n\n", text)
0981a08… noreply 178 return text.strip()

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button