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.
notesctl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """iCloud Notes to Markdown Exporter."""
2
+
3
+ __version__ = "1.0.0"
notesctl/cli.py ADDED
@@ -0,0 +1,285 @@
1
+ """Command-line interface for notesctl."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.progress import Progress, SpinnerColumn, TextColumn
9
+ from rich.table import Table
10
+
11
+ from .exporter import ExportOptions, NotesExporter
12
+
13
+ app = typer.Typer(
14
+ name="notesctl",
15
+ help="Export Apple iCloud Notes to Markdown files.",
16
+ no_args_is_help=True,
17
+ )
18
+ console = Console()
19
+
20
+
21
+ @app.command()
22
+ def export(
23
+ output: Annotated[
24
+ Path,
25
+ typer.Option(
26
+ "-o",
27
+ "--output",
28
+ help="Output directory for exported notes",
29
+ ),
30
+ ] = Path("./notes-export"),
31
+ folder: Annotated[
32
+ str | None,
33
+ typer.Option(
34
+ "--folder",
35
+ "-f",
36
+ help="Only export notes from this folder",
37
+ ),
38
+ ] = None,
39
+ include_trashed: Annotated[
40
+ bool,
41
+ typer.Option(
42
+ "--include-trashed",
43
+ help="Include notes in trash",
44
+ ),
45
+ ] = False,
46
+ no_frontmatter: Annotated[
47
+ bool,
48
+ typer.Option(
49
+ "--no-frontmatter",
50
+ help="Don't include YAML frontmatter",
51
+ ),
52
+ ] = False,
53
+ flat: Annotated[
54
+ bool,
55
+ typer.Option(
56
+ "--flat",
57
+ help="Don't organize by folder",
58
+ ),
59
+ ] = False,
60
+ dry_run: Annotated[
61
+ bool,
62
+ typer.Option(
63
+ "--dry-run",
64
+ "-n",
65
+ help="Preview what would be exported without writing files",
66
+ ),
67
+ ] = False,
68
+ db_path: Annotated[
69
+ Path | None,
70
+ typer.Option(
71
+ "--db",
72
+ help="Path to NoteStore.sqlite (uses default if not specified)",
73
+ hidden=True,
74
+ ),
75
+ ] = None,
76
+ ) -> None:
77
+ """Export Apple Notes to Markdown files."""
78
+ exporter = NotesExporter(db_path)
79
+
80
+ options = ExportOptions(
81
+ output_dir=output.expanduser().resolve(),
82
+ folder_name=folder,
83
+ include_trashed=include_trashed,
84
+ include_frontmatter=not no_frontmatter,
85
+ dry_run=dry_run,
86
+ organize_by_folder=not flat,
87
+ )
88
+
89
+ if dry_run:
90
+ console.print("[yellow]DRY RUN[/yellow] - No files will be written\n")
91
+
92
+ try:
93
+ with Progress(
94
+ SpinnerColumn(),
95
+ TextColumn("[progress.description]{task.description}"),
96
+ console=console,
97
+ ) as progress:
98
+ result = exporter.export(options, progress)
99
+ except FileNotFoundError as e:
100
+ console.print(f"[red]Error:[/red] {e}")
101
+ raise typer.Exit(1) from None
102
+ except Exception as e:
103
+ console.print(f"[red]Error:[/red] {e}")
104
+ raise typer.Exit(1) from None
105
+
106
+ # Print results
107
+ console.print()
108
+ if dry_run:
109
+ console.print(f"[bold]Would export {result.exported_notes} notes[/bold]")
110
+ if result.exported_files:
111
+ console.print("\nFiles that would be created:")
112
+ for f in result.exported_files[:20]:
113
+ console.print(f" {f}")
114
+ if len(result.exported_files) > 20:
115
+ console.print(f" ... and {len(result.exported_files) - 20} more")
116
+ else:
117
+ console.print(f"[green]Exported {result.exported_notes} notes[/green]")
118
+ console.print(f" Output: {result.output_path}")
119
+ console.print(f" Attachments copied: {result.attachments_copied}")
120
+
121
+ if result.skipped_encrypted:
122
+ console.print(f"[yellow]Skipped {result.skipped_encrypted} encrypted notes[/yellow]")
123
+
124
+ if result.skipped_errors:
125
+ console.print(f"[red]Failed to export {result.skipped_errors} notes[/red]")
126
+
127
+ if result.errors:
128
+ console.print("\n[red]Errors:[/red]")
129
+ for error in result.errors[:10]:
130
+ console.print(f" {error}")
131
+
132
+
133
+ @app.command("list-notes")
134
+ def list_notes(
135
+ folder: Annotated[
136
+ str | None,
137
+ typer.Option(
138
+ "--folder",
139
+ "-f",
140
+ help="Only list notes from this folder",
141
+ ),
142
+ ] = None,
143
+ include_trashed: Annotated[
144
+ bool,
145
+ typer.Option(
146
+ "--include-trashed",
147
+ help="Include notes in trash",
148
+ ),
149
+ ] = False,
150
+ limit: Annotated[
151
+ int,
152
+ typer.Option(
153
+ "--limit",
154
+ "-l",
155
+ help="Maximum number of notes to show",
156
+ ),
157
+ ] = 50,
158
+ db_path: Annotated[
159
+ Path | None,
160
+ typer.Option(
161
+ "--db",
162
+ help="Path to NoteStore.sqlite",
163
+ hidden=True,
164
+ ),
165
+ ] = None,
166
+ ) -> None:
167
+ """List notes in the database."""
168
+ exporter = NotesExporter(db_path)
169
+
170
+ try:
171
+ notes = exporter.list_notes(folder, include_trashed)
172
+ except FileNotFoundError as e:
173
+ console.print(f"[red]Error:[/red] {e}")
174
+ raise typer.Exit(1) from None
175
+ except Exception as e:
176
+ console.print(f"[red]Error:[/red] {e}")
177
+ raise typer.Exit(1) from None
178
+
179
+ if not notes:
180
+ console.print("No notes found.")
181
+ return
182
+
183
+ table = Table(title=f"Notes ({len(notes)} total)")
184
+ table.add_column("Title", style="cyan", no_wrap=True, max_width=50)
185
+ table.add_column("Folder", style="green")
186
+ table.add_column("Modified", style="yellow")
187
+ table.add_column("Status", style="dim")
188
+
189
+ for note in notes[:limit]:
190
+ status_parts = []
191
+ if note.is_encrypted:
192
+ status_parts.append("🔒")
193
+ if note.is_pinned:
194
+ status_parts.append("📌")
195
+ if note.is_trashed:
196
+ status_parts.append("🗑️")
197
+
198
+ table.add_row(
199
+ note.title[:50],
200
+ note.folder,
201
+ note.modified.strftime("%Y-%m-%d %H:%M"),
202
+ " ".join(status_parts),
203
+ )
204
+
205
+ console.print(table)
206
+
207
+ if len(notes) > limit:
208
+ console.print(
209
+ f"\n[dim]Showing {limit} of {len(notes)} notes. Use --limit to see more.[/dim]"
210
+ )
211
+
212
+
213
+ @app.command("list-folders")
214
+ def list_folders(
215
+ db_path: Annotated[
216
+ Path | None,
217
+ typer.Option(
218
+ "--db",
219
+ help="Path to NoteStore.sqlite",
220
+ hidden=True,
221
+ ),
222
+ ] = None,
223
+ ) -> None:
224
+ """List all folders in Notes."""
225
+ exporter = NotesExporter(db_path)
226
+
227
+ try:
228
+ folders = exporter.list_folders()
229
+ except FileNotFoundError as e:
230
+ console.print(f"[red]Error:[/red] {e}")
231
+ raise typer.Exit(1) from None
232
+ except Exception as e:
233
+ console.print(f"[red]Error:[/red] {e}")
234
+ raise typer.Exit(1) from None
235
+
236
+ if not folders:
237
+ console.print("No folders found.")
238
+ return
239
+
240
+ table = Table(title="Notes Folders")
241
+ table.add_column("Folder", style="cyan")
242
+ table.add_column("Notes", style="green", justify="right")
243
+
244
+ for name, count in sorted(folders, key=lambda x: x[0].lower()):
245
+ table.add_row(name, str(count))
246
+
247
+ console.print(table)
248
+
249
+
250
+ @app.command("stats")
251
+ def stats(
252
+ db_path: Annotated[
253
+ Path | None,
254
+ typer.Option(
255
+ "--db",
256
+ help="Path to NoteStore.sqlite",
257
+ hidden=True,
258
+ ),
259
+ ] = None,
260
+ ) -> None:
261
+ """Show statistics about your Notes database."""
262
+ exporter = NotesExporter(db_path)
263
+
264
+ try:
265
+ db_stats = exporter.get_stats()
266
+ except FileNotFoundError as e:
267
+ console.print(f"[red]Error:[/red] {e}")
268
+ raise typer.Exit(1) from None
269
+ except Exception as e:
270
+ console.print(f"[red]Error:[/red] {e}")
271
+ raise typer.Exit(1) from None
272
+
273
+ console.print("[bold]Notes Database Statistics[/bold]\n")
274
+ console.print(f" Total notes: {db_stats['total_notes']}")
275
+ console.print(f" Encrypted notes: {db_stats['encrypted_notes']}")
276
+ console.print(f" Exportable: {db_stats['total_notes'] - db_stats['encrypted_notes']}")
277
+
278
+
279
+ def main() -> None:
280
+ """Entry point for the CLI."""
281
+ app()
282
+
283
+
284
+ if __name__ == "__main__":
285
+ main()
@@ -0,0 +1,5 @@
1
+ """Converters for note content."""
2
+
3
+ from .markdown import MarkdownConverter
4
+
5
+ __all__ = ["MarkdownConverter"]
@@ -0,0 +1,232 @@
1
+ """Convert parsed notes to Markdown format."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+
6
+ from ..database.models import Attachment, Note
7
+ from ..parser.note_parser import Paragraph, ParsedNote, StyleType, TextRun
8
+
9
+
10
+ @dataclass
11
+ class MarkdownOptions:
12
+ """Options for Markdown conversion."""
13
+
14
+ include_frontmatter: bool = True
15
+ include_title_heading: bool = True
16
+ attachment_folder: str = "attachments"
17
+
18
+
19
+ class MarkdownConverter:
20
+ """Convert parsed notes to Markdown format."""
21
+
22
+ def __init__(self, options: MarkdownOptions | None = None):
23
+ """Initialize converter with options."""
24
+ self.options = options or MarkdownOptions()
25
+
26
+ def convert(
27
+ self,
28
+ note: Note,
29
+ parsed: ParsedNote,
30
+ attachments: list[Attachment] | None = None,
31
+ ) -> str:
32
+ """
33
+ Convert a parsed note to Markdown.
34
+
35
+ Args:
36
+ note: Note metadata from database
37
+ parsed: Parsed note content
38
+ attachments: Optional list of attachments
39
+
40
+ Returns:
41
+ Markdown string
42
+ """
43
+ parts: list[str] = []
44
+
45
+ # YAML frontmatter
46
+ if self.options.include_frontmatter:
47
+ parts.append(self._generate_frontmatter(note))
48
+ parts.append("") # Blank line after frontmatter
49
+
50
+ # Title as heading
51
+ if self.options.include_title_heading:
52
+ parts.append(f"# {note.title}")
53
+ parts.append("")
54
+
55
+ # Convert paragraphs
56
+ attachment_map = {a.identifier: a for a in (attachments or [])}
57
+ converted_paragraphs = self._convert_paragraphs(parsed.paragraphs, attachment_map)
58
+ parts.extend(converted_paragraphs)
59
+
60
+ return "\n".join(parts)
61
+
62
+ def _generate_frontmatter(self, note: Note) -> str:
63
+ """Generate YAML frontmatter."""
64
+ lines = ["---"]
65
+ lines.append(f'title: "{self._escape_yaml_string(note.title)}"')
66
+ lines.append(f"created: {self._format_datetime(note.created)}")
67
+ lines.append(f"modified: {self._format_datetime(note.modified)}")
68
+ lines.append(f'folder: "{self._escape_yaml_string(note.folder)}"')
69
+
70
+ if note.is_pinned:
71
+ lines.append("pinned: true")
72
+
73
+ lines.append("---")
74
+ return "\n".join(lines)
75
+
76
+ def _escape_yaml_string(self, s: str) -> str:
77
+ """Escape a string for YAML."""
78
+ return s.replace("\\", "\\\\").replace('"', '\\"')
79
+
80
+ def _format_datetime(self, dt: datetime) -> str:
81
+ """Format datetime for frontmatter."""
82
+ return dt.strftime("%Y-%m-%dT%H:%M:%S")
83
+
84
+ def _convert_paragraphs(
85
+ self,
86
+ paragraphs: list[Paragraph],
87
+ attachment_map: dict[str, Attachment],
88
+ ) -> list[str]:
89
+ """Convert paragraphs to Markdown lines."""
90
+ lines: list[str] = []
91
+ prev_style: StyleType | None = None
92
+ in_list = False
93
+
94
+ for para in paragraphs:
95
+ # Handle list transitions - add blank line before new list
96
+ is_list = (
97
+ para.style
98
+ in (
99
+ StyleType.DOTTED_LIST,
100
+ StyleType.DASHED_LIST,
101
+ StyleType.NUMBERED_LIST,
102
+ StyleType.CHECKLIST,
103
+ )
104
+ or para.is_checklist
105
+ )
106
+
107
+ should_add_blank = (is_list and not in_list and lines and lines[-1]) or (
108
+ not is_list and in_list and lines and lines[-1]
109
+ )
110
+ if should_add_blank:
111
+ lines.append("")
112
+
113
+ in_list = is_list
114
+
115
+ # Skip empty paragraphs but preserve blank lines
116
+ # But don't skip paragraphs with attachments
117
+ has_attachment = any(run.attachment_id for run in para.runs)
118
+ if not para.runs or (not para.plain_text.strip() and not has_attachment):
119
+ if lines and lines[-1] != "":
120
+ lines.append("")
121
+ continue
122
+
123
+ line = self._convert_paragraph(para, attachment_map, prev_style)
124
+ lines.append(line)
125
+ prev_style = para.style
126
+
127
+ # Clean up trailing empty lines
128
+ while lines and lines[-1] == "":
129
+ lines.pop()
130
+
131
+ return lines
132
+
133
+ def _convert_paragraph(
134
+ self,
135
+ para: Paragraph,
136
+ attachment_map: dict[str, Attachment],
137
+ prev_style: StyleType | None, # noqa: ARG002
138
+ ) -> str:
139
+ """Convert a single paragraph to Markdown."""
140
+ # Get formatted text content
141
+ text = self._format_runs(para.runs, attachment_map)
142
+
143
+ # Apply paragraph-level formatting
144
+ if para.style == StyleType.TITLE:
145
+ return f"# {text}"
146
+ if para.style == StyleType.HEADING:
147
+ return f"## {text}"
148
+ if para.style == StyleType.SUBHEADING:
149
+ return f"### {text}"
150
+ if para.style == StyleType.MONOSPACED:
151
+ return f"`{text}`"
152
+ if para.style in {StyleType.DOTTED_LIST, StyleType.DASHED_LIST}:
153
+ indent = " " * para.indent
154
+ return f"{indent}- {text}"
155
+ if para.style == StyleType.NUMBERED_LIST:
156
+ indent = " " * para.indent
157
+ num = para.list_item_number or 1
158
+ return f"{indent}{num}. {text}"
159
+ if para.is_checklist or para.style == StyleType.CHECKLIST:
160
+ indent = " " * para.indent
161
+ checkbox = "[x]" if para.checklist_checked else "[ ]"
162
+ return f"{indent}- {checkbox} {text}"
163
+ if para.is_blockquote:
164
+ return f"> {text}"
165
+ return text
166
+
167
+ def _format_runs(
168
+ self,
169
+ runs: list[TextRun],
170
+ attachment_map: dict[str, Attachment],
171
+ ) -> str:
172
+ """Format text runs with inline Markdown."""
173
+ parts: list[str] = []
174
+
175
+ for run in runs:
176
+ text = run.text
177
+
178
+ # Handle attachment
179
+ if run.attachment_id:
180
+ attachment = attachment_map.get(run.attachment_id)
181
+ if attachment and attachment.is_image:
182
+ # Use relative path for images
183
+ filename = attachment.filename or f"{run.attachment_id}.jpg"
184
+ img_path = f"{self.options.attachment_folder}/{run.attachment_id}/{filename}"
185
+ parts.append(f"![{filename}]({img_path})")
186
+ continue
187
+ if attachment:
188
+ # Non-image attachment
189
+ filename = attachment.filename or run.attachment_id
190
+ att_path = f"{self.options.attachment_folder}/{run.attachment_id}/{filename}"
191
+ parts.append(f"[{filename}]({att_path})")
192
+ continue
193
+
194
+ # Skip empty text
195
+ if not text:
196
+ continue
197
+
198
+ # Apply inline formatting (order matters for nesting)
199
+ formatted = text
200
+
201
+ # Escape Markdown special characters in regular text
202
+ if not run.monospace:
203
+ formatted = self._escape_markdown(formatted)
204
+
205
+ # Apply formatting
206
+ if run.monospace:
207
+ formatted = f"`{formatted}`"
208
+ if run.bold:
209
+ formatted = f"**{formatted}**"
210
+ if run.italic:
211
+ formatted = f"*{formatted}*"
212
+ if run.strikethrough:
213
+ formatted = f"~~{formatted}~~"
214
+ if run.underline:
215
+ # Markdown doesn't have native underline, use HTML
216
+ formatted = f"<u>{formatted}</u>"
217
+ if run.link:
218
+ formatted = f"[{formatted}]({run.link})"
219
+
220
+ parts.append(formatted)
221
+
222
+ return "".join(parts)
223
+
224
+ def _escape_markdown(self, text: str) -> str:
225
+ """Escape Markdown special characters."""
226
+ # Only escape characters that would be interpreted as Markdown
227
+ # Be conservative to avoid over-escaping
228
+ chars_to_escape = ["\\", "`", "*", "_", "[", "]", "#"]
229
+ result = text
230
+ for char in chars_to_escape:
231
+ result = result.replace(char, f"\\{char}")
232
+ return result
@@ -0,0 +1,7 @@
1
+ """Database access layer for Apple Notes SQLite database."""
2
+
3
+ from .connection import SafeNotesDatabase
4
+ from .models import Attachment, Folder, Note
5
+ from .queries import NotesQueries
6
+
7
+ __all__ = ["Attachment", "Folder", "Note", "NotesQueries", "SafeNotesDatabase"]
@@ -0,0 +1,156 @@
1
+ """Safe read-only database connection for Apple Notes."""
2
+
3
+ import shutil
4
+ import sqlite3
5
+ import tempfile
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+
12
+ console = Console()
13
+
14
+ # Default location of the Apple Notes database
15
+ DEFAULT_NOTES_DB = Path.home() / "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite"
16
+
17
+
18
+ class SafeNotesDatabase:
19
+ """
20
+ Safe read-only access to Apple Notes SQLite database.
21
+
22
+ Safety measures:
23
+ 1. Copies database + WAL + SHM to temp directory before reading
24
+ 2. Opens database in read-only mode using SQLite URI
25
+ 3. All queries are SELECT-only (enforced by this class)
26
+ """
27
+
28
+ def __init__(self, db_path: Path | None = None):
29
+ """
30
+ Initialize the database connection wrapper.
31
+
32
+ Args:
33
+ db_path: Path to the NoteStore.sqlite file. Uses default if not provided.
34
+ """
35
+ self.source_path = db_path or DEFAULT_NOTES_DB
36
+ self._temp_dir: tempfile.TemporaryDirectory[str] | None = None
37
+ self._temp_db_path: Path | None = None
38
+ self._connection: sqlite3.Connection | None = None
39
+
40
+ def _verify_source_exists(self) -> None:
41
+ """Verify the source database exists."""
42
+ if not self.source_path.exists():
43
+ raise FileNotFoundError(
44
+ f"Notes database not found at {self.source_path}. "
45
+ "Make sure you have Apple Notes installed and have created at least one note."
46
+ )
47
+
48
+ def _copy_database_files(self) -> Path:
49
+ """
50
+ Copy database and associated files to temp directory.
51
+
52
+ Returns:
53
+ Path to the copied database file.
54
+ """
55
+ self._temp_dir = tempfile.TemporaryDirectory(prefix="notes_export_")
56
+ temp_path = Path(self._temp_dir.name)
57
+
58
+ # Copy main database file
59
+ db_name = self.source_path.name
60
+ dest_db = temp_path / db_name
61
+ shutil.copy2(self.source_path, dest_db)
62
+
63
+ # Copy WAL and SHM files if they exist (important for transaction consistency)
64
+ for suffix in ["-wal", "-shm"]:
65
+ wal_file = self.source_path.parent / f"{db_name}{suffix}"
66
+ if wal_file.exists():
67
+ shutil.copy2(wal_file, temp_path / f"{db_name}{suffix}")
68
+
69
+ return dest_db
70
+
71
+ def connect(self) -> None:
72
+ """
73
+ Establish a safe read-only connection to the database.
74
+
75
+ This copies the database to a temp directory and opens it read-only.
76
+ """
77
+ self._verify_source_exists()
78
+ self._temp_db_path = self._copy_database_files()
79
+
80
+ # Open in read-only mode using SQLite URI
81
+ # The mode=ro parameter ensures SQLite won't write to the file
82
+ uri = f"file:{self._temp_db_path}?mode=ro"
83
+ self._connection = sqlite3.connect(uri, uri=True)
84
+ self._connection.row_factory = sqlite3.Row
85
+
86
+ def close(self) -> None:
87
+ """Close the database connection and clean up temp files."""
88
+ if self._connection:
89
+ self._connection.close()
90
+ self._connection = None
91
+ if self._temp_dir:
92
+ self._temp_dir.cleanup()
93
+ self._temp_dir = None
94
+ self._temp_db_path = None
95
+
96
+ @property
97
+ def connection(self) -> sqlite3.Connection:
98
+ """Get the active database connection."""
99
+ if self._connection is None:
100
+ raise RuntimeError("Database not connected. Call connect() first.")
101
+ return self._connection
102
+
103
+ def execute(self, query: str, params: tuple = ()) -> sqlite3.Cursor:
104
+ """
105
+ Execute a SELECT query safely.
106
+
107
+ Args:
108
+ query: SQL query (must be SELECT only)
109
+ params: Query parameters
110
+
111
+ Returns:
112
+ Cursor with query results
113
+
114
+ Raises:
115
+ ValueError: If query is not a SELECT statement
116
+ """
117
+ # Safety check: only allow SELECT queries
118
+ normalized = query.strip().upper()
119
+ if not normalized.startswith("SELECT"):
120
+ raise ValueError("Only SELECT queries are allowed for safety")
121
+
122
+ return self.connection.execute(query, params)
123
+
124
+ def fetchall(self, query: str, params: tuple = ()) -> list[sqlite3.Row]:
125
+ """Execute a query and fetch all results."""
126
+ return self.execute(query, params).fetchall()
127
+
128
+ def fetchone(self, query: str, params: tuple = ()) -> sqlite3.Row | None:
129
+ """Execute a query and fetch one result."""
130
+ return self.execute(query, params).fetchone()
131
+
132
+ def __enter__(self) -> "SafeNotesDatabase":
133
+ """Context manager entry."""
134
+ self.connect()
135
+ return self
136
+
137
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
138
+ """Context manager exit."""
139
+ self.close()
140
+
141
+
142
+ @contextmanager
143
+ def get_notes_database(db_path: Path | None = None) -> Iterator[SafeNotesDatabase]:
144
+ """
145
+ Context manager for safe database access.
146
+
147
+ Usage:
148
+ with get_notes_database() as db:
149
+ notes = db.fetchall("SELECT * FROM notes")
150
+ """
151
+ db = SafeNotesDatabase(db_path)
152
+ try:
153
+ db.connect()
154
+ yield db
155
+ finally:
156
+ db.close()