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,227 @@
1
+ """Parser for Apple Notes gzip-compressed protobuf content."""
2
+
3
+ import gzip
4
+ from dataclasses import dataclass, field
5
+ from enum import IntEnum
6
+
7
+ from .protobuf.notestore_pb2 import NoteStoreProto
8
+
9
+
10
+ class StyleType(IntEnum):
11
+ """Paragraph style types in Apple Notes."""
12
+
13
+ TITLE = 0
14
+ HEADING = 1
15
+ SUBHEADING = 2
16
+ MONOSPACED = 4
17
+ DOTTED_LIST = 100 # Bullet list
18
+ DASHED_LIST = 101
19
+ NUMBERED_LIST = 102
20
+ CHECKLIST = 103
21
+
22
+
23
+ @dataclass
24
+ class TextRun:
25
+ """A run of text with consistent formatting."""
26
+
27
+ text: str
28
+ bold: bool = False
29
+ italic: bool = False
30
+ underline: bool = False
31
+ strikethrough: bool = False
32
+ monospace: bool = False
33
+ link: str | None = None
34
+ attachment_id: str | None = None
35
+
36
+
37
+ @dataclass
38
+ class Paragraph:
39
+ """A paragraph with its style and text runs."""
40
+
41
+ runs: list[TextRun] = field(default_factory=list)
42
+ style: StyleType | None = None
43
+ indent: int = 0
44
+ is_checklist: bool = False
45
+ checklist_checked: bool = False
46
+ is_blockquote: bool = False
47
+ list_item_number: int | None = None # For numbered lists
48
+
49
+ @property
50
+ def plain_text(self) -> str:
51
+ """Get plain text content without formatting."""
52
+ return "".join(run.text for run in self.runs)
53
+
54
+
55
+ @dataclass
56
+ class ParsedNote:
57
+ """Parsed note content ready for conversion."""
58
+
59
+ title: str
60
+ paragraphs: list[Paragraph] = field(default_factory=list)
61
+ attachment_ids: list[str] = field(default_factory=list)
62
+
63
+ @property
64
+ def plain_text(self) -> str:
65
+ """Get full plain text content."""
66
+ return "\n".join(p.plain_text for p in self.paragraphs)
67
+
68
+
69
+ class NoteParser:
70
+ """Parser for Apple Notes protobuf content."""
71
+
72
+ def __init__(self):
73
+ """Initialize the parser."""
74
+ self._numbered_list_counters: dict[int, int] = {}
75
+
76
+ def parse(self, data: bytes, title: str = "Untitled") -> ParsedNote:
77
+ """
78
+ Parse gzip-compressed protobuf note data.
79
+
80
+ Args:
81
+ data: Gzip-compressed protobuf data
82
+ title: Note title (from database)
83
+
84
+ Returns:
85
+ ParsedNote with parsed content
86
+ """
87
+ if not data:
88
+ return ParsedNote(title=title)
89
+
90
+ # Decompress gzip data
91
+ try:
92
+ decompressed = gzip.decompress(data)
93
+ except gzip.BadGzipFile:
94
+ # Data might not be gzipped, try raw protobuf
95
+ decompressed = data
96
+
97
+ # Parse protobuf
98
+ try:
99
+ proto = NoteStoreProto()
100
+ proto.ParseFromString(decompressed)
101
+ except Exception:
102
+ # If parsing fails, return empty note
103
+ return ParsedNote(title=title)
104
+
105
+ return self._parse_proto(proto, title)
106
+
107
+ def _parse_proto(self, proto: NoteStoreProto, title: str) -> ParsedNote:
108
+ """Parse the protobuf message into structured content."""
109
+ note = ParsedNote(title=title)
110
+
111
+ if not proto.HasField("document") or not proto.document.HasField("note"):
112
+ return note
113
+
114
+ note_proto = proto.document.note
115
+ text = note_proto.note_text
116
+
117
+ # Reset numbered list counters for this note
118
+ self._numbered_list_counters = {}
119
+
120
+ # Process attribute runs
121
+ position = 0
122
+ current_paragraph = Paragraph()
123
+ paragraphs: list[Paragraph] = []
124
+
125
+ for attr_run in note_proto.attribute_run:
126
+ run_length = attr_run.length
127
+ run_text = text[position : position + run_length]
128
+ position += run_length
129
+
130
+ # Get paragraph style if present
131
+ style = None
132
+ indent = 0
133
+ is_checklist = False
134
+ checklist_checked = False
135
+ is_blockquote = False
136
+
137
+ if attr_run.HasField("paragraph_style"):
138
+ ps = attr_run.paragraph_style
139
+ if ps.HasField("style_type"):
140
+ try:
141
+ style = StyleType(ps.style_type)
142
+ except ValueError:
143
+ pass
144
+
145
+ if ps.HasField("indent_amount"):
146
+ indent = ps.indent_amount
147
+
148
+ if ps.HasField("checklist"):
149
+ is_checklist = True
150
+ if ps.checklist.HasField("done"):
151
+ checklist_checked = bool(ps.checklist.done)
152
+
153
+ if ps.HasField("block_quote"):
154
+ is_blockquote = bool(ps.block_quote)
155
+
156
+ # Get font hints for italic detection
157
+ is_italic = False
158
+ if attr_run.HasField("font"):
159
+ # Font hints: 1 = italic
160
+ if attr_run.font.HasField("font_hints"):
161
+ is_italic = (attr_run.font.font_hints & 1) != 0
162
+
163
+ # Create text run with formatting
164
+ text_run = TextRun(
165
+ text=run_text,
166
+ bold=attr_run.HasField("font_weight") and attr_run.font_weight > 0,
167
+ italic=is_italic,
168
+ underline=attr_run.HasField("underlined") and attr_run.underlined > 0,
169
+ strikethrough=attr_run.HasField("strikethrough") and attr_run.strikethrough > 0,
170
+ monospace=style == StyleType.MONOSPACED if style else False,
171
+ link=attr_run.link if attr_run.HasField("link") else None,
172
+ )
173
+
174
+ # Check for attachment
175
+ if attr_run.HasField("attachment_info"):
176
+ if attr_run.attachment_info.HasField("attachment_identifier"):
177
+ att_id = attr_run.attachment_info.attachment_identifier
178
+ text_run.attachment_id = att_id
179
+ note.attachment_ids.append(att_id)
180
+
181
+ # Split by newlines to create paragraphs
182
+ lines = run_text.split("\n")
183
+ for i, line in enumerate(lines):
184
+ if i > 0:
185
+ # New paragraph on newline
186
+ if current_paragraph.runs or current_paragraph.style:
187
+ paragraphs.append(current_paragraph)
188
+ current_paragraph = Paragraph()
189
+
190
+ if line or i == len(lines) - 1:
191
+ line_run = TextRun(
192
+ text=line,
193
+ bold=text_run.bold,
194
+ italic=text_run.italic,
195
+ underline=text_run.underline,
196
+ strikethrough=text_run.strikethrough,
197
+ monospace=text_run.monospace,
198
+ link=text_run.link,
199
+ attachment_id=text_run.attachment_id if i == 0 else None,
200
+ )
201
+ current_paragraph.runs.append(line_run)
202
+
203
+ # Apply paragraph style to first line of this run
204
+ if i == 0:
205
+ if style is not None:
206
+ current_paragraph.style = style
207
+ current_paragraph.indent = indent
208
+ current_paragraph.is_checklist = is_checklist
209
+ current_paragraph.checklist_checked = checklist_checked
210
+ current_paragraph.is_blockquote = is_blockquote
211
+
212
+ # Track numbered list items
213
+ if style == StyleType.NUMBERED_LIST:
214
+ counter_key = indent
215
+ self._numbered_list_counters[counter_key] = (
216
+ self._numbered_list_counters.get(counter_key, 0) + 1
217
+ )
218
+ current_paragraph.list_item_number = self._numbered_list_counters[
219
+ counter_key
220
+ ]
221
+
222
+ # Add final paragraph
223
+ if current_paragraph.runs:
224
+ paragraphs.append(current_paragraph)
225
+
226
+ note.paragraphs = paragraphs
227
+ return note
@@ -0,0 +1,5 @@
1
+ """Generated protobuf bindings for Apple Notes."""
2
+
3
+ from .notestore_pb2 import NoteStoreProto
4
+
5
+ __all__ = ["NoteStoreProto"]
@@ -0,0 +1,49 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # NO CHECKED-IN PROTOBUF GENCODE
4
+ # source: notestore.proto
5
+ # Protobuf Python Version: 6.33.4
6
+ """Generated protocol buffer code."""
7
+
8
+ from google.protobuf import descriptor as _descriptor
9
+ from google.protobuf import descriptor_pool as _descriptor_pool
10
+ from google.protobuf import runtime_version as _runtime_version
11
+ from google.protobuf import symbol_database as _symbol_database
12
+ from google.protobuf.internal import builder as _builder
13
+
14
+ _runtime_version.ValidateProtobufRuntimeVersion(
15
+ _runtime_version.Domain.PUBLIC, 6, 33, 4, "", "notestore.proto"
16
+ )
17
+ # @@protoc_insertion_point(imports)
18
+
19
+ _sym_db = _symbol_database.Default()
20
+
21
+
22
+ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
23
+ b'\n\x0fnotestore.proto\x12\tnotestore"7\n\x0eNoteStoreProto\x12%\n\x08\x64ocument\x18\x02 \x02(\x0b\x32\x13.notestore.Document")\n\x08\x44ocument\x12\x1d\n\x04note\x18\x03 \x02(\x0b\x32\x0f.notestore.Note"I\n\x04Note\x12\x11\n\tnote_text\x18\x02 \x02(\t\x12.\n\rattribute_run\x18\x05 \x03(\x0b\x32\x17.notestore.AttributeRun"\xa9\x02\n\x0c\x41ttributeRun\x12\x0e\n\x06length\x18\x01 \x02(\x05\x12\x32\n\x0fparagraph_style\x18\x02 \x01(\x0b\x32\x19.notestore.ParagraphStyle\x12\x1d\n\x04\x66ont\x18\x03 \x01(\x0b\x32\x0f.notestore.Font\x12\x13\n\x0b\x66ont_weight\x18\x05 \x01(\x05\x12\x12\n\nunderlined\x18\x06 \x01(\x05\x12\x15\n\rstrikethrough\x18\x07 \x01(\x05\x12\x13\n\x0bsuperscript\x18\x08 \x01(\x05\x12\x0c\n\x04link\x18\t \x01(\t\x12\x1f\n\x05\x63olor\x18\n \x01(\x0b\x32\x10.notestore.Color\x12\x32\n\x0f\x61ttachment_info\x18\x0c \x01(\x0b\x32\x19.notestore.AttachmentInfo"\x8c\x01\n\x0eParagraphStyle\x12\x12\n\nstyle_type\x18\x01 \x01(\x05\x12\x11\n\talignment\x18\x02 \x01(\x05\x12\x15\n\rindent_amount\x18\x04 \x01(\x05\x12\'\n\tchecklist\x18\x05 \x01(\x0b\x32\x14.notestore.Checklist\x12\x13\n\x0b\x62lock_quote\x18\x06 \x01(\x05"\'\n\tChecklist\x12\x0c\n\x04uuid\x18\x01 \x01(\x0c\x12\x0c\n\x04\x64one\x18\x02 \x01(\x05"A\n\x04\x46ont\x12\x11\n\tfont_name\x18\x01 \x01(\t\x12\x12\n\npoint_size\x18\x02 \x01(\x02\x12\x12\n\nfont_hints\x18\x03 \x01(\x05"@\n\x05\x43olor\x12\x0b\n\x03red\x18\x01 \x01(\x02\x12\r\n\x05green\x18\x02 \x01(\x02\x12\x0c\n\x04\x62lue\x18\x03 \x01(\x02\x12\r\n\x05\x61lpha\x18\x04 \x01(\x02"A\n\x0e\x41ttachmentInfo\x12\x1d\n\x15\x61ttachment_identifier\x18\x01 \x01(\t\x12\x10\n\x08type_uti\x18\x02 \x01(\t'
24
+ )
25
+
26
+ _globals = globals()
27
+ _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
28
+ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "notestore_pb2", _globals)
29
+ if not _descriptor._USE_C_DESCRIPTORS:
30
+ DESCRIPTOR._loaded_options = None
31
+ _globals["_NOTESTOREPROTO"]._serialized_start = 30
32
+ _globals["_NOTESTOREPROTO"]._serialized_end = 85
33
+ _globals["_DOCUMENT"]._serialized_start = 87
34
+ _globals["_DOCUMENT"]._serialized_end = 128
35
+ _globals["_NOTE"]._serialized_start = 130
36
+ _globals["_NOTE"]._serialized_end = 203
37
+ _globals["_ATTRIBUTERUN"]._serialized_start = 206
38
+ _globals["_ATTRIBUTERUN"]._serialized_end = 503
39
+ _globals["_PARAGRAPHSTYLE"]._serialized_start = 506
40
+ _globals["_PARAGRAPHSTYLE"]._serialized_end = 646
41
+ _globals["_CHECKLIST"]._serialized_start = 648
42
+ _globals["_CHECKLIST"]._serialized_end = 687
43
+ _globals["_FONT"]._serialized_start = 689
44
+ _globals["_FONT"]._serialized_end = 754
45
+ _globals["_COLOR"]._serialized_start = 756
46
+ _globals["_COLOR"]._serialized_end = 820
47
+ _globals["_ATTACHMENTINFO"]._serialized_start = 822
48
+ _globals["_ATTACHMENTINFO"]._serialized_end = 887
49
+ # @@protoc_insertion_point(module_scope)
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: notesctl
3
+ Version: 1.0.0
4
+ Summary: Export Apple iCloud Notes to Markdown files safely
5
+ Project-URL: Homepage, https://github.com/jwmoss/notesctl
6
+ Project-URL: Repository, https://github.com/jwmoss/notesctl
7
+ Project-URL: Documentation, https://github.com/jwmoss/notesctl#readme
8
+ Project-URL: Issues, https://github.com/jwmoss/notesctl/issues
9
+ Project-URL: Changelog, https://github.com/jwmoss/notesctl/blob/main/CHANGELOG.md
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: apple,export,icloud,markdown,notes
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: End Users/Desktop
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Utilities
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: pillow>=10.0.0
26
+ Requires-Dist: protobuf>=4.25.0
27
+ Requires-Dist: rich>=13.0.0
28
+ Requires-Dist: typer>=0.9.0
29
+ Provides-Extra: dev
30
+ Requires-Dist: grpcio-tools>=1.60.0; extra == 'dev'
31
+ Requires-Dist: mypy>=1.0.0; extra == 'dev'
32
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
33
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
34
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
35
+ Requires-Dist: types-pillow>=10.0.0; extra == 'dev'
36
+ Requires-Dist: types-protobuf>=4.24.0; extra == 'dev'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # notesctl
40
+
41
+ Export Apple iCloud Notes to Markdown files safely.
42
+
43
+ ## Features
44
+
45
+ - **Safe**: Copies database before reading, uses read-only mode
46
+ - **Complete**: Exports text, formatting, and attachments
47
+ - **Organized**: Groups notes by folder with YAML frontmatter
48
+
49
+ ## Installation
50
+
51
+ ```bash
52
+ # Using uv (recommended)
53
+ uv pip install .
54
+
55
+ # Or install in development mode
56
+ uv pip install -e ".[dev]"
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ ```bash
62
+ # Export all notes
63
+ uv run notesctl export -o ~/notes-backup
64
+
65
+ # Export specific folder
66
+ uv run notesctl export --folder "Work" -o ~/work-notes
67
+
68
+ # Preview without writing (safe)
69
+ uv run notesctl export --dry-run
70
+
71
+ # List notes
72
+ uv run notesctl list-notes
73
+
74
+ # List folders
75
+ uv run notesctl list-folders
76
+
77
+ # Show statistics
78
+ uv run notesctl stats
79
+ ```
80
+
81
+ ## Safety
82
+
83
+ This tool prioritizes safety:
84
+
85
+ 1. **Copy-first**: Database files are copied to temp directory before reading
86
+ 2. **Read-only**: SQLite connection uses URI mode=ro
87
+ 3. **SELECT only**: All queries are validated to be SELECT statements
88
+ 4. **Dry-run mode**: Preview exports without writing files
89
+
90
+ ## Development
91
+
92
+ ```bash
93
+ # Install dev dependencies
94
+ uv pip install -e ".[dev]"
95
+
96
+ # Run tests
97
+ uv run pytest
98
+
99
+ # Run linter
100
+ uv run ruff check src tests
101
+
102
+ # Run type checker
103
+ uv run mypy src
104
+
105
+ # Format code
106
+ uv run ruff format src tests
107
+ ```
108
+
109
+ ## Requirements
110
+
111
+ - macOS with Apple Notes
112
+ - Python 3.11+
113
+ - uv (recommended) or pip
@@ -0,0 +1,18 @@
1
+ notesctl/__init__.py,sha256=X-odR8GWwfgOrw-lzonDw9SG4nu-u6EaQuHBf8NROTc,64
2
+ notesctl/cli.py,sha256=qnpGGG8e5CzN2H24ter_IfxrfzlDOIHIiGz12VyHkrk,7838
3
+ notesctl/exporter.py,sha256=ll0jUZSg_oXoZ1csZ1ypVO7mHkxfez5Fx6DjVrjnFf0,7711
4
+ notesctl/converter/__init__.py,sha256=uXGQKdQXqyLHZmvXwKL6vXEAW6vJzLAbI34SzbPpNCM,109
5
+ notesctl/converter/markdown.py,sha256=zT5oRPBwx_P_rvNgoi_qXUe-p-D0bey9T1cyXFYkRe8,8032
6
+ notesctl/database/__init__.py,sha256=Ku774zaf2ikCCEgxBT1EWR3BSmdGMkyb3Cd3ldIYOzw,264
7
+ notesctl/database/connection.py,sha256=g6w93I1ln5YjHt0FMSGT8u5aBw3mIknLeLswKOHYEfQ,5154
8
+ notesctl/database/models.py,sha256=EknKwfBoTqg_UNo_EjGGOGH1c_svQZPuC870uDuvUqo,2216
9
+ notesctl/database/queries.py,sha256=PloMefQqlL3J1y5iLeDYfEO1x08nvrWDhAOeyL_p8PM,7796
10
+ notesctl/parser/__init__.py,sha256=NGCner72eO9JJcf5BqRYeB-vPvjDDTpfLhBtnbBWoe8,127
11
+ notesctl/parser/note_parser.py,sha256=Vw0VbYSUcYNq4a_1kT-xwkjYID6g_fgEgPDE20TE6es,7726
12
+ notesctl/parser/protobuf/__init__.py,sha256=m876LJ-X14pTvjQMtViUYOCcOXGSLd3HZVQ2iTi_pWw,124
13
+ notesctl/parser/protobuf/notestore_pb2.py,sha256=gdPQL7rlbwbiV6_9lhFxj8kHhRo1Jeay-bEG4B7S5Ps,3614
14
+ notesctl-1.0.0.dist-info/METADATA,sha256=nFiXSZJr0xEPxCm2BFqU3_t5YZBNppRV2bIRmSEYt5Y,2927
15
+ notesctl-1.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
16
+ notesctl-1.0.0.dist-info/entry_points.txt,sha256=1Ebiz6QJFgcSPZ06R477NxNbc5J_smznlV_jrqsTxpE,46
17
+ notesctl-1.0.0.dist-info/licenses/LICENSE,sha256=Pz2eACSxkhsGfW9_iN60pgy-enjnbGTj8df8O3ebnQQ,16726
18
+ notesctl-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ notesctl = notesctl.cli:app