cloudnoteslib 0.1.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.
- cloudnoteslib/__init__.py +128 -0
- cloudnoteslib/analyzers/__init__.py +14 -0
- cloudnoteslib/analyzers/content_analyzer.py +180 -0
- cloudnoteslib/analyzers/search.py +143 -0
- cloudnoteslib/analyzers/statistics.py +88 -0
- cloudnoteslib/config.py +28 -0
- cloudnoteslib/exceptions.py +19 -0
- cloudnoteslib/exporters/__init__.py +11 -0
- cloudnoteslib/exporters/base.py +31 -0
- cloudnoteslib/exporters/json_exporter.py +19 -0
- cloudnoteslib/exporters/markdown_exporter.py +28 -0
- cloudnoteslib/models/__init__.py +18 -0
- cloudnoteslib/models/note.py +323 -0
- cloudnoteslib/models/note_collection.py +233 -0
- cloudnoteslib/models/tag.py +129 -0
- cloudnoteslib/processors/__init__.py +36 -0
- cloudnoteslib/processors/base.py +157 -0
- cloudnoteslib/processors/markdown_processor.py +157 -0
- cloudnoteslib/processors/plaintext_processor.py +103 -0
- cloudnoteslib/processors/richtext_processor.py +122 -0
- cloudnoteslib/security/__init__.py +12 -0
- cloudnoteslib/security/encryptor.py +81 -0
- cloudnoteslib/security/sanitizer.py +56 -0
- cloudnoteslib-0.1.0.dist-info/METADATA +37 -0
- cloudnoteslib-0.1.0.dist-info/RECORD +27 -0
- cloudnoteslib-0.1.0.dist-info/WHEEL +5 -0
- cloudnoteslib-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cloudnoteslib.exporters.markdown_exporter — Markdown Exporter.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base import NoteExporter
|
|
6
|
+
from ..models.note_collection import NoteCollection
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MarkdownExporter(NoteExporter):
|
|
10
|
+
"""Exports NoteCollection to a single Markdown document."""
|
|
11
|
+
|
|
12
|
+
def export(self, collection: NoteCollection) -> str:
|
|
13
|
+
lines = ["# Exported Notes", ""]
|
|
14
|
+
|
|
15
|
+
for note in collection:
|
|
16
|
+
lines.append(f"## {note.title}")
|
|
17
|
+
lines.append(f"**Date:** {note.created_at.strftime('%Y-%m-%d %H:%M')}")
|
|
18
|
+
if note.tags:
|
|
19
|
+
lines.append(f"**Tags:** {', '.join(note.tags)}")
|
|
20
|
+
lines.append("")
|
|
21
|
+
lines.append(note.content)
|
|
22
|
+
lines.append("\n---\n")
|
|
23
|
+
|
|
24
|
+
return "\n".join(lines)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def extension(self) -> str:
|
|
28
|
+
return "md"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cloudnoteslib.models — Core data models for the Cloud Notes library.
|
|
3
|
+
|
|
4
|
+
This package contains the fundamental data structures used throughout
|
|
5
|
+
the library. Each model demonstrates OOP Encapsulation through private
|
|
6
|
+
attributes and controlled @property accessors.
|
|
7
|
+
|
|
8
|
+
Exports:
|
|
9
|
+
Note: Immutable note with content analysis properties.
|
|
10
|
+
Tag: Lightweight tag model for categorization.
|
|
11
|
+
NoteCollection: Iterable container with aggregate operations.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .note import Note
|
|
15
|
+
from .tag import Tag
|
|
16
|
+
from .note_collection import NoteCollection
|
|
17
|
+
|
|
18
|
+
__all__ = ["Note", "Tag", "NoteCollection"]
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cloudnoteslib.models.note — Note Data Model.
|
|
3
|
+
|
|
4
|
+
Demonstrates OOP ENCAPSULATION:
|
|
5
|
+
- All internal attributes are private (prefixed with _)
|
|
6
|
+
- Controlled access through @property decorators
|
|
7
|
+
- Computed properties (reading_time, word_count) derive from internal state
|
|
8
|
+
- Immutable fields (created_at) cannot be modified after construction
|
|
9
|
+
|
|
10
|
+
This model represents a single note in the system. It is framework-agnostic
|
|
11
|
+
and can be used with any backend (FastAPI, Flask, Django).
|
|
12
|
+
|
|
13
|
+
Example:
|
|
14
|
+
>>> note = Note(title="Meeting Notes", content="Discussed project timeline...")
|
|
15
|
+
>>> note.word_count
|
|
16
|
+
3
|
|
17
|
+
>>> note.reading_time
|
|
18
|
+
0.0
|
|
19
|
+
>>> note.to_dict()
|
|
20
|
+
{'title': 'Meeting Notes', 'content': 'Discussed project timeline...', ...}
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from typing import List, Optional
|
|
25
|
+
import re
|
|
26
|
+
import hashlib
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Note:
|
|
30
|
+
"""
|
|
31
|
+
Core Note model with encapsulated attributes and computed properties.
|
|
32
|
+
|
|
33
|
+
OOP Principles:
|
|
34
|
+
- ENCAPSULATION: Private attributes (_title, _content, _tags) with
|
|
35
|
+
@property accessors. External code cannot directly mutate internal
|
|
36
|
+
state without going through setters that enforce validation.
|
|
37
|
+
- COMPUTED PROPERTIES: word_count, reading_time, char_count, and
|
|
38
|
+
summary are derived automatically from the content.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
title (str): The note title (1-200 characters).
|
|
42
|
+
content (str): The note body text.
|
|
43
|
+
tags (list): List of tag strings for categorization.
|
|
44
|
+
is_pinned (bool): Whether the note is pinned/starred.
|
|
45
|
+
is_archived (bool): Whether the note is archived.
|
|
46
|
+
created_at (datetime): Immutable creation timestamp.
|
|
47
|
+
updated_at (datetime): Last modification timestamp.
|
|
48
|
+
note_id (int, optional): Database ID for persistence mapping.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
>>> note = Note("Shopping List", "Milk, Eggs, Bread", tags=["personal"])
|
|
52
|
+
>>> note.title
|
|
53
|
+
'Shopping List'
|
|
54
|
+
>>> note.word_count
|
|
55
|
+
3
|
|
56
|
+
>>> note.is_pinned
|
|
57
|
+
False
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# Class-level constants
|
|
61
|
+
MAX_TITLE_LENGTH = 200
|
|
62
|
+
MAX_CONTENT_LENGTH = 50000
|
|
63
|
+
AVG_READING_SPEED_WPM = 200 # Words per minute
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
title: str,
|
|
68
|
+
content: str,
|
|
69
|
+
tags: Optional[List[str]] = None,
|
|
70
|
+
is_pinned: bool = False,
|
|
71
|
+
is_archived: bool = False,
|
|
72
|
+
note_id: Optional[int] = None,
|
|
73
|
+
created_at: Optional[datetime] = None,
|
|
74
|
+
updated_at: Optional[datetime] = None,
|
|
75
|
+
):
|
|
76
|
+
"""
|
|
77
|
+
Initialize a Note instance.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
title: Note title (must be 1-200 characters).
|
|
81
|
+
content: Note body text (max 50,000 characters).
|
|
82
|
+
tags: Optional list of tag strings.
|
|
83
|
+
is_pinned: Whether the note is pinned. Defaults to False.
|
|
84
|
+
is_archived: Whether the note is archived. Defaults to False.
|
|
85
|
+
note_id: Optional database ID.
|
|
86
|
+
created_at: Optional creation timestamp (auto-set if None).
|
|
87
|
+
updated_at: Optional update timestamp (auto-set if None).
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ValueError: If title is empty or exceeds MAX_TITLE_LENGTH.
|
|
91
|
+
ValueError: If content exceeds MAX_CONTENT_LENGTH.
|
|
92
|
+
"""
|
|
93
|
+
# Validate inputs
|
|
94
|
+
if not title or not title.strip():
|
|
95
|
+
raise ValueError("Note title cannot be empty.")
|
|
96
|
+
if len(title) > self.MAX_TITLE_LENGTH:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Title exceeds maximum length of {self.MAX_TITLE_LENGTH} characters."
|
|
99
|
+
)
|
|
100
|
+
if len(content) > self.MAX_CONTENT_LENGTH:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"Content exceeds maximum length of {self.MAX_CONTENT_LENGTH} characters."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Private attributes (ENCAPSULATION)
|
|
106
|
+
self._note_id = note_id
|
|
107
|
+
self._title = title.strip()
|
|
108
|
+
self._content = content
|
|
109
|
+
self._tags = [t.strip().lower() for t in (tags or []) if t.strip()]
|
|
110
|
+
self._is_pinned = is_pinned
|
|
111
|
+
self._is_archived = is_archived
|
|
112
|
+
self._created_at = created_at or datetime.utcnow()
|
|
113
|
+
self._updated_at = updated_at or datetime.utcnow()
|
|
114
|
+
|
|
115
|
+
# ─── Read-Only Properties (ENCAPSULATION) ───
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def note_id(self) -> Optional[int]:
|
|
119
|
+
"""Database identifier. Read-only after construction."""
|
|
120
|
+
return self._note_id
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def title(self) -> str:
|
|
124
|
+
"""Note title. Validated on set to ensure non-empty and within length."""
|
|
125
|
+
return self._title
|
|
126
|
+
|
|
127
|
+
@title.setter
|
|
128
|
+
def title(self, value: str):
|
|
129
|
+
"""Set note title with validation."""
|
|
130
|
+
if not value or not value.strip():
|
|
131
|
+
raise ValueError("Note title cannot be empty.")
|
|
132
|
+
if len(value) > self.MAX_TITLE_LENGTH:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"Title exceeds maximum length of {self.MAX_TITLE_LENGTH} characters."
|
|
135
|
+
)
|
|
136
|
+
self._title = value.strip()
|
|
137
|
+
self._updated_at = datetime.utcnow()
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def content(self) -> str:
|
|
141
|
+
"""Note body text. Updates the modified timestamp on change."""
|
|
142
|
+
return self._content
|
|
143
|
+
|
|
144
|
+
@content.setter
|
|
145
|
+
def content(self, value: str):
|
|
146
|
+
"""Set note content with validation and timestamp update."""
|
|
147
|
+
if len(value) > self.MAX_CONTENT_LENGTH:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"Content exceeds maximum length of {self.MAX_CONTENT_LENGTH} characters."
|
|
150
|
+
)
|
|
151
|
+
self._content = value
|
|
152
|
+
self._updated_at = datetime.utcnow()
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def tags(self) -> List[str]:
|
|
156
|
+
"""List of tags. Returns a copy to prevent external mutation."""
|
|
157
|
+
return self._tags.copy()
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def is_pinned(self) -> bool:
|
|
161
|
+
"""Whether this note is pinned/starred."""
|
|
162
|
+
return self._is_pinned
|
|
163
|
+
|
|
164
|
+
@is_pinned.setter
|
|
165
|
+
def is_pinned(self, value: bool):
|
|
166
|
+
self._is_pinned = bool(value)
|
|
167
|
+
self._updated_at = datetime.utcnow()
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def is_archived(self) -> bool:
|
|
171
|
+
"""Whether this note is archived."""
|
|
172
|
+
return self._is_archived
|
|
173
|
+
|
|
174
|
+
@is_archived.setter
|
|
175
|
+
def is_archived(self, value: bool):
|
|
176
|
+
self._is_archived = bool(value)
|
|
177
|
+
self._updated_at = datetime.utcnow()
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def created_at(self) -> datetime:
|
|
181
|
+
"""Immutable creation timestamp. Cannot be modified."""
|
|
182
|
+
return self._created_at
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def updated_at(self) -> datetime:
|
|
186
|
+
"""Last modification timestamp. Auto-updates on changes."""
|
|
187
|
+
return self._updated_at
|
|
188
|
+
|
|
189
|
+
# ─── Computed Properties (derived from internal state) ───
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def word_count(self) -> int:
|
|
193
|
+
"""Count of words in the note content."""
|
|
194
|
+
if not self._content.strip():
|
|
195
|
+
return 0
|
|
196
|
+
return len(self._content.split())
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def char_count(self) -> int:
|
|
200
|
+
"""Count of characters in the note content (excluding whitespace)."""
|
|
201
|
+
return len(self._content.replace(" ", "").replace("\n", ""))
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def sentence_count(self) -> int:
|
|
205
|
+
"""Approximate count of sentences (split by . ! ? endings)."""
|
|
206
|
+
if not self._content.strip():
|
|
207
|
+
return 0
|
|
208
|
+
sentences = re.split(r'[.!?]+', self._content.strip())
|
|
209
|
+
return len([s for s in sentences if s.strip()])
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def reading_time(self) -> float:
|
|
213
|
+
"""Estimated reading time in minutes (based on 200 WPM average)."""
|
|
214
|
+
return round(self.word_count / self.AVG_READING_SPEED_WPM, 1)
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def summary(self) -> str:
|
|
218
|
+
"""Auto-generated summary: first 150 characters of content."""
|
|
219
|
+
if len(self._content) <= 150:
|
|
220
|
+
return self._content
|
|
221
|
+
return self._content[:150].rsplit(" ", 1)[0] + "..."
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def content_hash(self) -> str:
|
|
225
|
+
"""SHA-256 hash of the content for integrity verification."""
|
|
226
|
+
return hashlib.sha256(self._content.encode("utf-8")).hexdigest()
|
|
227
|
+
|
|
228
|
+
# ─── Public Methods ───
|
|
229
|
+
|
|
230
|
+
def add_tag(self, tag: str) -> None:
|
|
231
|
+
"""
|
|
232
|
+
Add a tag to this note (case-insensitive, no duplicates).
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
tag: Tag string to add.
|
|
236
|
+
"""
|
|
237
|
+
normalized = tag.strip().lower()
|
|
238
|
+
if normalized and normalized not in self._tags:
|
|
239
|
+
self._tags.append(normalized)
|
|
240
|
+
self._updated_at = datetime.utcnow()
|
|
241
|
+
|
|
242
|
+
def remove_tag(self, tag: str) -> bool:
|
|
243
|
+
"""
|
|
244
|
+
Remove a tag from this note.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
tag: Tag string to remove.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
True if the tag was removed, False if it wasn't found.
|
|
251
|
+
"""
|
|
252
|
+
normalized = tag.strip().lower()
|
|
253
|
+
if normalized in self._tags:
|
|
254
|
+
self._tags.remove(normalized)
|
|
255
|
+
self._updated_at = datetime.utcnow()
|
|
256
|
+
return True
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
def has_tag(self, tag: str) -> bool:
|
|
260
|
+
"""Check if the note has a specific tag."""
|
|
261
|
+
return tag.strip().lower() in self._tags
|
|
262
|
+
|
|
263
|
+
def to_dict(self) -> dict:
|
|
264
|
+
"""
|
|
265
|
+
Serialize the note to a dictionary for API responses.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
dict: Complete note data including computed properties.
|
|
269
|
+
"""
|
|
270
|
+
return {
|
|
271
|
+
"note_id": self._note_id,
|
|
272
|
+
"title": self._title,
|
|
273
|
+
"content": self._content,
|
|
274
|
+
"tags": self._tags.copy(),
|
|
275
|
+
"is_pinned": self._is_pinned,
|
|
276
|
+
"is_archived": self._is_archived,
|
|
277
|
+
"word_count": self.word_count,
|
|
278
|
+
"char_count": self.char_count,
|
|
279
|
+
"sentence_count": self.sentence_count,
|
|
280
|
+
"reading_time": self.reading_time,
|
|
281
|
+
"summary": self.summary,
|
|
282
|
+
"created_at": self._created_at.isoformat(),
|
|
283
|
+
"updated_at": self._updated_at.isoformat(),
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@classmethod
|
|
287
|
+
def from_dict(cls, data: dict) -> "Note":
|
|
288
|
+
"""
|
|
289
|
+
Factory constructor: create a Note from a dictionary.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
data: Dictionary with note fields.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Note: New Note instance.
|
|
296
|
+
"""
|
|
297
|
+
return cls(
|
|
298
|
+
title=data.get("title", ""),
|
|
299
|
+
content=data.get("content", ""),
|
|
300
|
+
tags=data.get("tags", []),
|
|
301
|
+
is_pinned=data.get("is_pinned", False),
|
|
302
|
+
is_archived=data.get("is_archived", False),
|
|
303
|
+
note_id=data.get("note_id") or data.get("id"),
|
|
304
|
+
created_at=data.get("created_at"),
|
|
305
|
+
updated_at=data.get("updated_at"),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def __repr__(self) -> str:
|
|
309
|
+
return (
|
|
310
|
+
f"Note(title='{self._title}', "
|
|
311
|
+
f"words={self.word_count}, "
|
|
312
|
+
f"tags={self._tags}, "
|
|
313
|
+
f"pinned={self._is_pinned})"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def __eq__(self, other) -> bool:
|
|
317
|
+
if not isinstance(other, Note):
|
|
318
|
+
return False
|
|
319
|
+
return self._title == other._title and self._content == other._content
|
|
320
|
+
|
|
321
|
+
def __len__(self) -> int:
|
|
322
|
+
"""Returns the word count of the note."""
|
|
323
|
+
return self.word_count
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cloudnoteslib.models.note_collection — NoteCollection Container.
|
|
3
|
+
|
|
4
|
+
An iterable container that holds multiple Note objects and provides
|
|
5
|
+
aggregate operations such as filtering, sorting, and statistics.
|
|
6
|
+
|
|
7
|
+
Demonstrates:
|
|
8
|
+
- ENCAPSULATION: Internal list is private, access via methods
|
|
9
|
+
- Python magic methods: __iter__, __len__, __getitem__, __contains__
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
>>> collection = NoteCollection([note1, note2, note3])
|
|
13
|
+
>>> len(collection)
|
|
14
|
+
3
|
|
15
|
+
>>> for note in collection:
|
|
16
|
+
... print(note.title)
|
|
17
|
+
>>> pinned = collection.get_pinned()
|
|
18
|
+
>>> tagged = collection.filter_by_tag("work")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from typing import List, Optional, Iterator
|
|
22
|
+
from .note import Note
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NoteCollection:
|
|
26
|
+
"""
|
|
27
|
+
Iterable container for Note objects with aggregate operations.
|
|
28
|
+
|
|
29
|
+
Provides a clean interface for common note operations such as
|
|
30
|
+
filtering by tag, searching by keyword, sorting, and computing
|
|
31
|
+
aggregate statistics over the entire collection.
|
|
32
|
+
|
|
33
|
+
This class implements Python's iterator protocol (__iter__),
|
|
34
|
+
container protocol (__len__, __contains__, __getitem__), and
|
|
35
|
+
provides domain-specific filter/sort methods.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
notes (list): Internal list of Note objects (private).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, notes: Optional[List[Note]] = None):
|
|
42
|
+
"""
|
|
43
|
+
Initialize a NoteCollection.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
notes: Optional list of Note objects to populate the collection.
|
|
47
|
+
"""
|
|
48
|
+
self._notes: List[Note] = list(notes) if notes else []
|
|
49
|
+
|
|
50
|
+
# ─── Iterator Protocol ───
|
|
51
|
+
|
|
52
|
+
def __iter__(self) -> Iterator[Note]:
|
|
53
|
+
"""Iterate over all notes in the collection."""
|
|
54
|
+
return iter(self._notes)
|
|
55
|
+
|
|
56
|
+
def __len__(self) -> int:
|
|
57
|
+
"""Return the number of notes in the collection."""
|
|
58
|
+
return len(self._notes)
|
|
59
|
+
|
|
60
|
+
def __getitem__(self, index: int) -> Note:
|
|
61
|
+
"""Access a note by index."""
|
|
62
|
+
return self._notes[index]
|
|
63
|
+
|
|
64
|
+
def __contains__(self, note: Note) -> bool:
|
|
65
|
+
"""Check if a note is in the collection."""
|
|
66
|
+
return note in self._notes
|
|
67
|
+
|
|
68
|
+
# ─── Mutation Methods ───
|
|
69
|
+
|
|
70
|
+
def add(self, note: Note) -> None:
|
|
71
|
+
"""Add a note to the collection."""
|
|
72
|
+
if not isinstance(note, Note):
|
|
73
|
+
raise TypeError("Only Note objects can be added to NoteCollection.")
|
|
74
|
+
self._notes.append(note)
|
|
75
|
+
|
|
76
|
+
def remove(self, note: Note) -> bool:
|
|
77
|
+
"""
|
|
78
|
+
Remove a note from the collection.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
True if the note was found and removed, False otherwise.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
self._notes.remove(note)
|
|
85
|
+
return True
|
|
86
|
+
except ValueError:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
def clear(self) -> None:
|
|
90
|
+
"""Remove all notes from the collection."""
|
|
91
|
+
self._notes.clear()
|
|
92
|
+
|
|
93
|
+
# ─── Filter Methods ───
|
|
94
|
+
|
|
95
|
+
def get_pinned(self) -> "NoteCollection":
|
|
96
|
+
"""Return a new NoteCollection containing only pinned notes."""
|
|
97
|
+
return NoteCollection([n for n in self._notes if n.is_pinned])
|
|
98
|
+
|
|
99
|
+
def get_archived(self) -> "NoteCollection":
|
|
100
|
+
"""Return a new NoteCollection containing only archived notes."""
|
|
101
|
+
return NoteCollection([n for n in self._notes if n.is_archived])
|
|
102
|
+
|
|
103
|
+
def get_active(self) -> "NoteCollection":
|
|
104
|
+
"""Return a new NoteCollection containing only non-archived notes."""
|
|
105
|
+
return NoteCollection([n for n in self._notes if not n.is_archived])
|
|
106
|
+
|
|
107
|
+
def filter_by_tag(self, tag: str) -> "NoteCollection":
|
|
108
|
+
"""
|
|
109
|
+
Filter notes that contain a specific tag.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
tag: Tag name to filter by (case-insensitive).
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
New NoteCollection with matching notes.
|
|
116
|
+
"""
|
|
117
|
+
normalized = tag.strip().lower()
|
|
118
|
+
return NoteCollection([n for n in self._notes if n.has_tag(normalized)])
|
|
119
|
+
|
|
120
|
+
def search(self, query: str) -> "NoteCollection":
|
|
121
|
+
"""
|
|
122
|
+
Simple keyword search across note titles and content.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
query: Search keyword (case-insensitive).
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
New NoteCollection with matching notes.
|
|
129
|
+
"""
|
|
130
|
+
q = query.strip().lower()
|
|
131
|
+
if not q:
|
|
132
|
+
return NoteCollection(self._notes.copy())
|
|
133
|
+
return NoteCollection([
|
|
134
|
+
n for n in self._notes
|
|
135
|
+
if q in n.title.lower() or q in n.content.lower()
|
|
136
|
+
])
|
|
137
|
+
|
|
138
|
+
# ─── Sort Methods ───
|
|
139
|
+
|
|
140
|
+
def sort_by_date(self, newest_first: bool = True) -> "NoteCollection":
|
|
141
|
+
"""Sort notes by creation date."""
|
|
142
|
+
sorted_notes = sorted(
|
|
143
|
+
self._notes,
|
|
144
|
+
key=lambda n: n.created_at,
|
|
145
|
+
reverse=newest_first,
|
|
146
|
+
)
|
|
147
|
+
return NoteCollection(sorted_notes)
|
|
148
|
+
|
|
149
|
+
def sort_by_word_count(self, descending: bool = True) -> "NoteCollection":
|
|
150
|
+
"""Sort notes by word count."""
|
|
151
|
+
sorted_notes = sorted(
|
|
152
|
+
self._notes,
|
|
153
|
+
key=lambda n: n.word_count,
|
|
154
|
+
reverse=descending,
|
|
155
|
+
)
|
|
156
|
+
return NoteCollection(sorted_notes)
|
|
157
|
+
|
|
158
|
+
def sort_by_title(self, reverse: bool = False) -> "NoteCollection":
|
|
159
|
+
"""Sort notes alphabetically by title."""
|
|
160
|
+
sorted_notes = sorted(
|
|
161
|
+
self._notes,
|
|
162
|
+
key=lambda n: n.title.lower(),
|
|
163
|
+
reverse=reverse,
|
|
164
|
+
)
|
|
165
|
+
return NoteCollection(sorted_notes)
|
|
166
|
+
|
|
167
|
+
# ─── Aggregate Properties ───
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def total_words(self) -> int:
|
|
171
|
+
"""Total word count across all notes."""
|
|
172
|
+
return sum(n.word_count for n in self._notes)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def total_reading_time(self) -> float:
|
|
176
|
+
"""Total estimated reading time across all notes (minutes)."""
|
|
177
|
+
return round(sum(n.reading_time for n in self._notes), 1)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def average_word_count(self) -> float:
|
|
181
|
+
"""Average word count per note."""
|
|
182
|
+
if not self._notes:
|
|
183
|
+
return 0.0
|
|
184
|
+
return round(self.total_words / len(self._notes), 1)
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def all_tags(self) -> List[str]:
|
|
188
|
+
"""Unique list of all tags across all notes."""
|
|
189
|
+
tags = set()
|
|
190
|
+
for note in self._notes:
|
|
191
|
+
tags.update(note.tags)
|
|
192
|
+
return sorted(tags)
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def tag_counts(self) -> dict:
|
|
196
|
+
"""Count of notes per tag."""
|
|
197
|
+
counts = {}
|
|
198
|
+
for note in self._notes:
|
|
199
|
+
for tag in note.tags:
|
|
200
|
+
counts[tag] = counts.get(tag, 0) + 1
|
|
201
|
+
return dict(sorted(counts.items(), key=lambda x: x[1], reverse=True))
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def longest_note(self) -> Optional[Note]:
|
|
205
|
+
"""The note with the most words. None if collection is empty."""
|
|
206
|
+
if not self._notes:
|
|
207
|
+
return None
|
|
208
|
+
return max(self._notes, key=lambda n: n.word_count)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def shortest_note(self) -> Optional[Note]:
|
|
212
|
+
"""The note with the fewest words. None if collection is empty."""
|
|
213
|
+
if not self._notes:
|
|
214
|
+
return None
|
|
215
|
+
return min(self._notes, key=lambda n: n.word_count)
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def most_recent(self) -> Optional[Note]:
|
|
219
|
+
"""The most recently created note. None if collection is empty."""
|
|
220
|
+
if not self._notes:
|
|
221
|
+
return None
|
|
222
|
+
return max(self._notes, key=lambda n: n.created_at)
|
|
223
|
+
|
|
224
|
+
def to_list(self) -> List[dict]:
|
|
225
|
+
"""Serialize all notes to a list of dictionaries."""
|
|
226
|
+
return [n.to_dict() for n in self._notes]
|
|
227
|
+
|
|
228
|
+
def __repr__(self) -> str:
|
|
229
|
+
return (
|
|
230
|
+
f"NoteCollection(count={len(self._notes)}, "
|
|
231
|
+
f"total_words={self.total_words}, "
|
|
232
|
+
f"tags={self.all_tags})"
|
|
233
|
+
)
|