PlanOpticon

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

Keyboard Shortcuts

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