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
|
@@ -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
|
+
}
|