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.
@@ -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
+ )