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 +3 -0
- notesctl/cli.py +285 -0
- notesctl/converter/__init__.py +5 -0
- notesctl/converter/markdown.py +232 -0
- notesctl/database/__init__.py +7 -0
- notesctl/database/connection.py +156 -0
- notesctl/database/models.py +74 -0
- notesctl/database/queries.py +241 -0
- notesctl/exporter.py +245 -0
- notesctl/parser/__init__.py +5 -0
- notesctl/parser/note_parser.py +227 -0
- notesctl/parser/protobuf/__init__.py +5 -0
- notesctl/parser/protobuf/notestore_pb2.py +49 -0
- notesctl-1.0.0.dist-info/METADATA +113 -0
- notesctl-1.0.0.dist-info/RECORD +18 -0
- notesctl-1.0.0.dist-info/WHEEL +4 -0
- notesctl-1.0.0.dist-info/entry_points.txt +2 -0
- notesctl-1.0.0.dist-info/licenses/LICENSE +373 -0
notesctl/__init__.py
ADDED
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,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"")
|
|
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()
|