notesctl 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.
@@ -0,0 +1,74 @@
1
+ """Data models for Apple Notes database entities."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass
9
+ class Attachment:
10
+ """Represents a media attachment in a note."""
11
+
12
+ identifier: str
13
+ filename: str | None
14
+ mime_type: str | None
15
+ media_path: Path | None
16
+ note_id: int
17
+
18
+ @property
19
+ def is_image(self) -> bool:
20
+ """Check if attachment is an image."""
21
+ if self.mime_type:
22
+ # Handle both MIME types and UTIs
23
+ # UTIs: public.jpeg, public.png, public.heic, public.image, etc.
24
+ # MIME: image/jpeg, image/png, etc.
25
+ uti_lower = self.mime_type.lower()
26
+ if uti_lower.startswith("image/"):
27
+ return True
28
+ if uti_lower.startswith("public."):
29
+ image_utis = {"jpeg", "png", "gif", "heic", "heif", "tiff", "bmp", "image"}
30
+ uti_type = uti_lower.replace("public.", "")
31
+ return uti_type in image_utis
32
+ if self.filename:
33
+ return self.filename.lower().endswith((".jpg", ".jpeg", ".png", ".gif", ".heic"))
34
+ return False
35
+
36
+
37
+ @dataclass
38
+ class Folder:
39
+ """Represents a Notes folder."""
40
+
41
+ id: int
42
+ name: str
43
+ parent_id: int | None = None
44
+ identifier: str | None = None
45
+
46
+
47
+ @dataclass
48
+ class Note:
49
+ """Represents an Apple Note with its content and metadata."""
50
+
51
+ id: int
52
+ title: str
53
+ created: datetime
54
+ modified: datetime
55
+ folder: str
56
+ folder_id: int | None = None
57
+ content_data: bytes | None = None
58
+ is_encrypted: bool = False
59
+ is_pinned: bool = False
60
+ is_trashed: bool = False
61
+ identifier: str | None = None
62
+ attachments: list[Attachment] = field(default_factory=list)
63
+
64
+ @property
65
+ def safe_filename(self) -> str:
66
+ """Generate a safe filename from the note title."""
67
+ # Remove or replace characters that are problematic in filenames
68
+ unsafe_chars = '<>:"/\\|?*'
69
+ safe_title = self.title
70
+ for char in unsafe_chars:
71
+ safe_title = safe_title.replace(char, "_")
72
+ # Limit length and strip whitespace
73
+ safe_title = safe_title.strip()[:100]
74
+ return safe_title or "untitled"
@@ -0,0 +1,241 @@
1
+ """SQL queries for reading Apple Notes data (SELECT only)."""
2
+
3
+ from datetime import UTC, datetime
4
+ from pathlib import Path
5
+
6
+ from .connection import SafeNotesDatabase
7
+ from .models import Attachment, Folder, Note
8
+
9
+ # Apple's Core Data timestamp epoch (2001-01-01 00:00:00 UTC)
10
+ APPLE_EPOCH = datetime(2001, 1, 1, tzinfo=UTC)
11
+
12
+
13
+ def apple_timestamp_to_datetime(timestamp: float | None) -> datetime:
14
+ """Convert Apple Core Data timestamp to Python datetime."""
15
+ if timestamp is None:
16
+ return datetime.now(tz=UTC)
17
+ # Apple timestamps are seconds since 2001-01-01
18
+ return datetime.fromtimestamp(timestamp + APPLE_EPOCH.timestamp(), tz=UTC)
19
+
20
+
21
+ class NotesQueries:
22
+ """Query class for extracting notes data from Apple Notes database."""
23
+
24
+ # Path to media attachments relative to Notes container
25
+ MEDIA_BASE = Path.home() / "Library/Group Containers/group.com.apple.notes"
26
+
27
+ def __init__(self, db: SafeNotesDatabase):
28
+ """Initialize with database connection."""
29
+ self.db = db
30
+
31
+ def get_folders(self) -> list[Folder]:
32
+ """
33
+ Get all non-system folders.
34
+
35
+ Returns:
36
+ List of Folder objects
37
+ """
38
+ query = """
39
+ SELECT
40
+ Z_PK as id,
41
+ ZTITLE2 as name,
42
+ ZPARENT as parent_id,
43
+ ZIDENTIFIER as identifier
44
+ FROM ZICCLOUDSYNCINGOBJECT
45
+ WHERE ZTITLE2 IS NOT NULL
46
+ AND ZMARKEDFORDELETION != 1
47
+ ORDER BY ZTITLE2
48
+ """
49
+ rows = self.db.fetchall(query)
50
+ return [
51
+ Folder(
52
+ id=row["id"],
53
+ name=row["name"],
54
+ parent_id=row["parent_id"],
55
+ identifier=row["identifier"],
56
+ )
57
+ for row in rows
58
+ ]
59
+
60
+ def get_folder_by_name(self, name: str) -> Folder | None:
61
+ """Get a folder by its name."""
62
+ query = """
63
+ SELECT
64
+ Z_PK as id,
65
+ ZTITLE2 as name,
66
+ ZPARENT as parent_id,
67
+ ZIDENTIFIER as identifier
68
+ FROM ZICCLOUDSYNCINGOBJECT
69
+ WHERE ZTITLE2 = ?
70
+ AND ZMARKEDFORDELETION != 1
71
+ LIMIT 1
72
+ """
73
+ row = self.db.fetchone(query, (name,))
74
+ if row:
75
+ return Folder(
76
+ id=row["id"],
77
+ name=row["name"],
78
+ parent_id=row["parent_id"],
79
+ identifier=row["identifier"],
80
+ )
81
+ return None
82
+
83
+ def get_notes(
84
+ self,
85
+ folder_id: int | None = None,
86
+ include_trashed: bool = False,
87
+ ) -> list[Note]:
88
+ """
89
+ Get all notes, optionally filtered by folder.
90
+
91
+ Args:
92
+ folder_id: Optional folder ID to filter by
93
+ include_trashed: Whether to include trashed notes
94
+
95
+ Returns:
96
+ List of Note objects with basic metadata (content loaded separately)
97
+ """
98
+ query = """
99
+ SELECT
100
+ n.Z_PK as id,
101
+ n.ZTITLE1 as title,
102
+ n.ZCREATIONDATE3 as created,
103
+ n.ZMODIFICATIONDATE1 as modified,
104
+ n.ZFOLDER as folder_id,
105
+ n.ZIDENTIFIER as identifier,
106
+ n.ZISPASSWORDPROTECTED as is_encrypted,
107
+ n.ZISPINNED as is_pinned,
108
+ n.ZMARKEDFORDELETION as is_trashed,
109
+ f.ZTITLE2 as folder_name,
110
+ d.ZDATA as content_data
111
+ FROM ZICCLOUDSYNCINGOBJECT n
112
+ LEFT JOIN ZICCLOUDSYNCINGOBJECT f ON n.ZFOLDER = f.Z_PK
113
+ LEFT JOIN ZICNOTEDATA d ON n.ZNOTEDATA = d.Z_PK
114
+ WHERE n.ZTITLE1 IS NOT NULL
115
+ """
116
+
117
+ params: list = []
118
+
119
+ if folder_id is not None:
120
+ query += " AND n.ZFOLDER = ?"
121
+ params.append(folder_id)
122
+
123
+ if not include_trashed:
124
+ query += " AND (n.ZMARKEDFORDELETION IS NULL OR n.ZMARKEDFORDELETION != 1)"
125
+
126
+ query += " ORDER BY n.ZMODIFICATIONDATE1 DESC"
127
+
128
+ rows = self.db.fetchall(query, tuple(params))
129
+
130
+ notes = []
131
+ for row in rows:
132
+ note = Note(
133
+ id=row["id"],
134
+ title=row["title"] or "Untitled",
135
+ created=apple_timestamp_to_datetime(row["created"]),
136
+ modified=apple_timestamp_to_datetime(row["modified"]),
137
+ folder=row["folder_name"] or "Notes",
138
+ folder_id=row["folder_id"],
139
+ content_data=row["content_data"],
140
+ is_encrypted=bool(row["is_encrypted"]),
141
+ is_pinned=bool(row["is_pinned"]),
142
+ is_trashed=bool(row["is_trashed"]),
143
+ identifier=row["identifier"],
144
+ )
145
+ notes.append(note)
146
+
147
+ return notes
148
+
149
+ def get_note_attachments(self, note_id: int) -> list[Attachment]:
150
+ """
151
+ Get attachments for a specific note.
152
+
153
+ Args:
154
+ note_id: The note's primary key
155
+
156
+ Returns:
157
+ List of Attachment objects
158
+ """
159
+ query = """
160
+ SELECT
161
+ a.Z_PK as id,
162
+ a.ZIDENTIFIER as identifier,
163
+ a.ZFILENAME as filename,
164
+ a.ZTYPEUTI as type_uti,
165
+ a.ZMEDIA as media_id,
166
+ m.ZFILENAME as media_filename,
167
+ m.ZIDENTIFIER as media_identifier
168
+ FROM ZICCLOUDSYNCINGOBJECT a
169
+ LEFT JOIN ZICCLOUDSYNCINGOBJECT m ON a.ZMEDIA = m.Z_PK
170
+ WHERE a.ZNOTE = ?
171
+ AND a.ZTYPEUTI IS NOT NULL
172
+ """
173
+ rows = self.db.fetchall(query, (note_id,))
174
+
175
+ attachments = []
176
+ for row in rows:
177
+ # Try to find the media file path
178
+ media_path = None
179
+ if row["media_identifier"]:
180
+ # Look in the Media folder structure
181
+ media_path = self._find_media_file(row["media_identifier"])
182
+
183
+ attachment = Attachment(
184
+ identifier=row["identifier"] or str(row["id"]),
185
+ filename=row["filename"] or row["media_filename"],
186
+ mime_type=row["type_uti"], # ZTYPEUTI stores the UTI (e.g., public.jpeg)
187
+ media_path=media_path,
188
+ note_id=note_id,
189
+ )
190
+ attachments.append(attachment)
191
+
192
+ return attachments
193
+
194
+ def _find_media_file(self, identifier: str) -> Path | None:
195
+ """
196
+ Find the actual media file on disk.
197
+
198
+ Args:
199
+ identifier: The media identifier
200
+
201
+ Returns:
202
+ Path to the media file if found
203
+ """
204
+ # Media files are stored in Accounts/<UUID>/Media/<identifier>/
205
+ accounts_path = self.MEDIA_BASE / "Accounts"
206
+ if not accounts_path.exists():
207
+ return None
208
+
209
+ for account_dir in accounts_path.iterdir():
210
+ if not account_dir.is_dir():
211
+ continue
212
+ media_dir = account_dir / "Media" / identifier
213
+ if media_dir.exists():
214
+ # Return the first file found in the directory
215
+ for file in media_dir.iterdir():
216
+ if file.is_file() and not file.name.startswith("."):
217
+ return file
218
+ return None
219
+
220
+ def get_note_count(self) -> int:
221
+ """Get total number of non-trashed notes."""
222
+ query = """
223
+ SELECT COUNT(*) as count
224
+ FROM ZICCLOUDSYNCINGOBJECT
225
+ WHERE ZTITLE1 IS NOT NULL
226
+ AND (ZMARKEDFORDELETION IS NULL OR ZMARKEDFORDELETION != 1)
227
+ """
228
+ row = self.db.fetchone(query)
229
+ return row["count"] if row else 0
230
+
231
+ def get_encrypted_note_count(self) -> int:
232
+ """Get number of encrypted notes."""
233
+ query = """
234
+ SELECT COUNT(*) as count
235
+ FROM ZICCLOUDSYNCINGOBJECT
236
+ WHERE ZTITLE1 IS NOT NULL
237
+ AND ZISPASSWORDPROTECTED = 1
238
+ AND (ZMARKEDFORDELETION IS NULL OR ZMARKEDFORDELETION != 1)
239
+ """
240
+ row = self.db.fetchone(query)
241
+ return row["count"] if row else 0
notesctl/exporter.py ADDED
@@ -0,0 +1,245 @@
1
+ """Note exporter that orchestrates the export process."""
2
+
3
+ import shutil
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+ from rich.progress import Progress, TaskID
9
+
10
+ from .converter.markdown import MarkdownConverter, MarkdownOptions
11
+ from .database import Note, NotesQueries, SafeNotesDatabase
12
+ from .parser import NoteParser, ParsedNote
13
+
14
+ console = Console()
15
+
16
+
17
+ @dataclass
18
+ class ExportResult:
19
+ """Result of an export operation."""
20
+
21
+ total_notes: int
22
+ exported_notes: int
23
+ skipped_encrypted: int
24
+ skipped_errors: int
25
+ attachments_copied: int
26
+ output_path: Path
27
+ exported_files: list[Path]
28
+ errors: list[str]
29
+
30
+
31
+ @dataclass
32
+ class ExportOptions:
33
+ """Options for export operation."""
34
+
35
+ output_dir: Path
36
+ folder_name: str | None = None
37
+ include_trashed: bool = False
38
+ include_frontmatter: bool = True
39
+ dry_run: bool = False
40
+ organize_by_folder: bool = True
41
+
42
+
43
+ class NotesExporter:
44
+ """Exports Apple Notes to Markdown files."""
45
+
46
+ def __init__(self, db_path: Path | None = None):
47
+ """Initialize exporter with optional custom database path."""
48
+ self.db_path = db_path
49
+ self.parser = NoteParser()
50
+
51
+ def export(
52
+ self,
53
+ options: ExportOptions,
54
+ progress: Progress | None = None,
55
+ ) -> ExportResult:
56
+ """
57
+ Export notes to Markdown files.
58
+
59
+ Args:
60
+ options: Export options
61
+ progress: Optional Rich progress bar
62
+
63
+ Returns:
64
+ ExportResult with statistics
65
+ """
66
+ result = ExportResult(
67
+ total_notes=0,
68
+ exported_notes=0,
69
+ skipped_encrypted=0,
70
+ skipped_errors=0,
71
+ attachments_copied=0,
72
+ output_path=options.output_dir,
73
+ exported_files=[],
74
+ errors=[],
75
+ )
76
+
77
+ with SafeNotesDatabase(self.db_path) as db:
78
+ queries = NotesQueries(db)
79
+
80
+ # Get folder filter if specified
81
+ folder_id = None
82
+ if options.folder_name:
83
+ folder = queries.get_folder_by_name(options.folder_name)
84
+ if folder:
85
+ folder_id = folder.id
86
+ else:
87
+ result.errors.append(f"Folder '{options.folder_name}' not found")
88
+ return result
89
+
90
+ # Get notes
91
+ notes = queries.get_notes(
92
+ folder_id=folder_id,
93
+ include_trashed=options.include_trashed,
94
+ )
95
+ result.total_notes = len(notes)
96
+
97
+ if not notes:
98
+ return result
99
+
100
+ # Create output directory (unless dry run)
101
+ if not options.dry_run:
102
+ options.output_dir.mkdir(parents=True, exist_ok=True)
103
+
104
+ # Set up progress tracking
105
+ task: TaskID | None = None
106
+ if progress:
107
+ task = progress.add_task("Exporting notes...", total=len(notes))
108
+
109
+ # Export each note
110
+ converter = MarkdownConverter(
111
+ MarkdownOptions(include_frontmatter=options.include_frontmatter)
112
+ )
113
+
114
+ for note in notes:
115
+ try:
116
+ self._export_note(
117
+ note=note,
118
+ queries=queries,
119
+ converter=converter,
120
+ options=options,
121
+ result=result,
122
+ )
123
+ except Exception as e:
124
+ result.skipped_errors += 1
125
+ result.errors.append(f"Error exporting '{note.title}': {e}")
126
+
127
+ if progress and task is not None:
128
+ progress.update(task, advance=1)
129
+
130
+ return result
131
+
132
+ def _export_note(
133
+ self,
134
+ note: Note,
135
+ queries: NotesQueries,
136
+ converter: MarkdownConverter,
137
+ options: ExportOptions,
138
+ result: ExportResult,
139
+ ) -> None:
140
+ """Export a single note."""
141
+ # Skip encrypted notes
142
+ if note.is_encrypted:
143
+ result.skipped_encrypted += 1
144
+ return
145
+
146
+ # Parse note content
147
+ parsed: ParsedNote
148
+ if note.content_data:
149
+ parsed = self.parser.parse(note.content_data, note.title)
150
+ else:
151
+ parsed = ParsedNote(title=note.title)
152
+
153
+ # Get attachments
154
+ attachments = queries.get_note_attachments(note.id)
155
+ note.attachments = attachments
156
+
157
+ # Convert to Markdown
158
+ markdown = converter.convert(note, parsed, attachments)
159
+
160
+ # Determine output path
161
+ if options.organize_by_folder:
162
+ folder_dir = options.output_dir / self._safe_folder_name(note.folder)
163
+ else:
164
+ folder_dir = options.output_dir
165
+
166
+ filename = f"{note.safe_filename}.md"
167
+ output_file = folder_dir / filename
168
+
169
+ # Handle duplicate filenames
170
+ counter = 1
171
+ while output_file.exists():
172
+ output_file = folder_dir / f"{note.safe_filename}_{counter}.md"
173
+ counter += 1
174
+
175
+ if options.dry_run:
176
+ result.exported_files.append(output_file)
177
+ result.exported_notes += 1
178
+ return
179
+
180
+ # Create folder and write file
181
+ folder_dir.mkdir(parents=True, exist_ok=True)
182
+ output_file.write_text(markdown, encoding="utf-8")
183
+ result.exported_files.append(output_file)
184
+ result.exported_notes += 1
185
+
186
+ # Copy attachments
187
+ for attachment in attachments:
188
+ if attachment.media_path and attachment.media_path.exists():
189
+ att_dir = folder_dir / "attachments" / attachment.identifier
190
+ att_dir.mkdir(parents=True, exist_ok=True)
191
+ dest = att_dir / (attachment.filename or attachment.media_path.name)
192
+ if not dest.exists():
193
+ shutil.copy2(attachment.media_path, dest)
194
+ result.attachments_copied += 1
195
+
196
+ def _safe_folder_name(self, name: str) -> str:
197
+ """Create a safe folder name."""
198
+ unsafe_chars = '<>:"/\\|?*'
199
+ safe_name = name
200
+ for char in unsafe_chars:
201
+ safe_name = safe_name.replace(char, "_")
202
+ return safe_name.strip() or "Notes"
203
+
204
+ def list_notes(
205
+ self,
206
+ folder_name: str | None = None,
207
+ include_trashed: bool = False,
208
+ ) -> list[Note]:
209
+ """List notes with optional folder filter."""
210
+ with SafeNotesDatabase(self.db_path) as db:
211
+ queries = NotesQueries(db)
212
+
213
+ folder_id = None
214
+ if folder_name:
215
+ folder = queries.get_folder_by_name(folder_name)
216
+ if folder:
217
+ folder_id = folder.id
218
+
219
+ return queries.get_notes(
220
+ folder_id=folder_id,
221
+ include_trashed=include_trashed,
222
+ )
223
+
224
+ def list_folders(self) -> list[tuple[str, int]]:
225
+ """List all folders with note counts."""
226
+ with SafeNotesDatabase(self.db_path) as db:
227
+ queries = NotesQueries(db)
228
+ folders = queries.get_folders()
229
+
230
+ # Count notes per folder
231
+ folder_counts: dict[str, int] = {}
232
+ notes = queries.get_notes()
233
+ for note in notes:
234
+ folder_counts[note.folder] = folder_counts.get(note.folder, 0) + 1
235
+
236
+ return [(f.name, folder_counts.get(f.name, 0)) for f in folders]
237
+
238
+ def get_stats(self) -> dict[str, int]:
239
+ """Get database statistics."""
240
+ with SafeNotesDatabase(self.db_path) as db:
241
+ queries = NotesQueries(db)
242
+ return {
243
+ "total_notes": queries.get_note_count(),
244
+ "encrypted_notes": queries.get_encrypted_note_count(),
245
+ }
@@ -0,0 +1,5 @@
1
+ """Parser for Apple Notes content."""
2
+
3
+ from .note_parser import NoteParser, ParsedNote
4
+
5
+ __all__ = ["NoteParser", "ParsedNote"]