Coverage for src/prosemark/domain/entities.py: 100%
201 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +0000
1"""Domain entities for prosemark - aliases and additional entity definitions."""
3from dataclasses import dataclass
4from datetime import UTC, datetime
5from pathlib import Path
7# Import existing entities from models
8from prosemark.domain.models import Binder, BinderItem, NodeId, NodeMetadata
9from prosemark.exceptions import FreeformContentValidationError, NodeValidationError
12@dataclass(frozen=True)
13class Node:
14 """Entity representing a content node with file references.
16 Node extends NodeMetadata with file path information to create
17 a complete entity for content management.
19 Args:
20 node_id: Unique identifier for the node (UUIDv7)
21 title: Optional title of the node document
22 synopsis: Optional synopsis/summary of the node content
23 created: Creation timestamp as datetime object (accepts datetime or ISO string)
24 updated: Last update timestamp as datetime object (accepts datetime or ISO string)
25 draft_path: Path to the {node_id}.md file
26 notes_path: Path to the {node_id}.notes.md file
28 """
30 id: NodeId
31 title: str | None
32 synopsis: str | None
33 created: datetime
34 updated: datetime
35 draft_path: Path
36 notes_path: Path
38 def __init__(
39 self,
40 node_id: NodeId,
41 title: str | None,
42 synopsis: str | None,
43 created: datetime | str,
44 updated: datetime | str,
45 draft_path: Path,
46 notes_path: Path,
47 ) -> None:
48 # Validate required fields
49 if node_id is None:
50 msg = 'node_id cannot be None'
51 raise NodeValidationError(msg)
52 if draft_path is None:
53 msg = 'draft_path cannot be None'
54 raise NodeValidationError(msg)
55 if notes_path is None:
56 msg = 'notes_path cannot be None'
57 raise NodeValidationError(msg)
59 # Convert string timestamps to datetime objects
60 created_datetime = datetime.fromisoformat(created) if isinstance(created, str) else created
61 updated_datetime = datetime.fromisoformat(updated) if isinstance(updated, str) else updated
63 # Validate timestamp ordering
64 if updated_datetime < created_datetime:
65 msg = 'Updated timestamp must be >= created timestamp'
66 raise NodeValidationError(msg)
68 object.__setattr__(self, 'id', node_id)
69 object.__setattr__(self, 'title', title)
70 object.__setattr__(self, 'synopsis', synopsis)
71 object.__setattr__(self, 'created', created_datetime)
72 object.__setattr__(self, 'updated', updated_datetime)
73 object.__setattr__(self, 'draft_path', draft_path)
74 object.__setattr__(self, 'notes_path', notes_path)
76 def get_expected_draft_path(self) -> Path:
77 """Return the expected draft file path based on node ID prefix."""
78 # Use first 8 characters of the UUID for filename
79 prefix = str(self.id)[:8]
80 return Path(f'{prefix}.md')
82 def get_expected_notes_path(self) -> Path:
83 """Return the expected notes file path based on node ID prefix."""
84 # Use first 8 characters of the UUID for filename
85 prefix = str(self.id)[:8]
86 return Path(f'{prefix}.notes.md')
88 def touch(self, new_time: datetime | str | None = None) -> None:
89 """Update the updated timestamp.
91 Args:
92 new_time: Optional datetime or datetime string. Defaults to current time.
94 """
95 if new_time is None:
96 new_time = datetime.now(UTC)
98 new_time_datetime = datetime.fromisoformat(new_time) if isinstance(new_time, str) else new_time
99 object.__setattr__(self, 'updated', new_time_datetime)
101 def update_metadata(
102 self,
103 title: str | None = None,
104 synopsis: str | None = None,
105 updated: datetime | str | None = None,
106 ) -> None:
107 """Update the node's metadata and optionally its timestamp.
109 Args:
110 title: Optional new title
111 synopsis: Optional new synopsis
112 updated: Optional timestamp for update
114 """
115 if title is not None:
116 object.__setattr__(self, 'title', title)
118 if synopsis is not None:
119 object.__setattr__(self, 'synopsis', synopsis)
121 if updated is not None:
122 self.touch(updated)
124 @classmethod
125 def from_metadata(cls, metadata: NodeMetadata, project_root: Path) -> 'Node':
126 """Create Node from NodeMetadata and project root.
128 Args:
129 metadata: NodeMetadata with id, title, synopsis, timestamps
130 project_root: Root directory of the prosemark project
132 Returns:
133 Node entity with computed file paths
135 """
136 draft_path = project_root / f'{metadata.id}.md'
137 notes_path = project_root / f'{metadata.id}.notes.md'
139 return cls(
140 node_id=metadata.id,
141 title=metadata.title,
142 synopsis=metadata.synopsis,
143 created=metadata.created,
144 updated=metadata.updated,
145 draft_path=draft_path,
146 notes_path=notes_path,
147 )
149 def to_metadata(self) -> NodeMetadata:
150 """Convert Node to NodeMetadata.
152 Returns:
153 NodeMetadata with id, title, synopsis, and timestamps
155 """
156 return NodeMetadata(
157 id=self.id,
158 title=self.title,
159 synopsis=self.synopsis,
160 created=self.created,
161 updated=self.updated,
162 )
164 def __str__(self) -> str:
165 """Return a meaningful string representation of the Node.
167 If title is present, use it. Otherwise, use node ID.
168 """
169 display = self.title or str(self.id)
170 return f'Node({display})'
172 def __eq__(self, other: object) -> bool:
173 """Compare Node instances based on ID."""
174 if not isinstance(other, Node):
175 return False
176 return self.id == other.id
178 def __hash__(self) -> int:
179 """Return hash based on ID for use in sets and dicts."""
180 return hash(self.id)
183@dataclass(frozen=True)
184class FreeformContent:
185 """Entity representing timestamped freeform writing file.
187 FreeformContent represents files created for quick writing that
188 don't fit into the structured project hierarchy. Files follow
189 the naming pattern: YYYYMMDDTHHMM_{uuid7}.md
191 Args:
192 id: UUIDv7 identifier as string
193 title: Optional content title
194 created: Creation timestamp (must match filename timestamp)
195 file_path: Path to the timestamped file
197 """
199 # Constants for validation
200 EXPECTED_UUID_VERSION = 7
201 TIMESTAMP_LENGTH = 13
202 TIMESTAMP_T_POSITION = 8
203 MIN_MONTH = 1
204 MAX_MONTH = 12
205 MIN_DAY = 1
206 MAX_DAY = 31
207 MIN_HOUR = 0
208 MAX_HOUR = 23
209 MIN_MINUTE = 0
210 MAX_MINUTE = 59
212 id: str
213 title: str | None
214 created: str
215 file_path: Path
217 def __post_init__(self) -> None:
218 """Validate freeform content constraints."""
219 self._validate_required_fields()
220 self._validate_uuid_format()
221 self._validate_filename_pattern()
223 def _validate_required_fields(self) -> None:
224 """Validate that required fields are not None."""
225 if self.created is None:
226 msg = 'created timestamp cannot be None'
227 raise FreeformContentValidationError(msg)
228 if self.id is None:
229 msg = 'id cannot be None'
230 raise FreeformContentValidationError(msg)
232 def _validate_uuid_format(self) -> None:
233 """Validate that ID is a properly formatted UUIDv7."""
234 try:
235 import uuid
237 parsed_uuid = uuid.UUID(self.id)
238 if parsed_uuid.version != self.EXPECTED_UUID_VERSION:
239 FreeformContent._raise_uuid_version_error(parsed_uuid.version or 0)
240 except ValueError as exc:
241 from prosemark.exceptions import FreeformContentValidationError
243 msg = f'Invalid UUID format for FreeformContent ID: {self.id}'
244 raise FreeformContentValidationError(msg) from exc
246 def _validate_filename_pattern(self) -> None:
247 """Validate filename follows YYYYMMDDTHHMM_{uuid7}.md pattern."""
248 filename = self.file_path.name
249 if not filename.endswith('.md'):
250 msg = f'FreeformContent file must end with .md: {filename}'
251 raise FreeformContentValidationError(msg)
253 timestamp_part, uuid_part = FreeformContent._extract_filename_parts(filename)
254 self._validate_uuid_match(uuid_part)
255 self._validate_timestamp_format(timestamp_part)
256 self._validate_timestamp_consistency(timestamp_part)
258 @staticmethod
259 def _extract_filename_parts(filename: str) -> tuple[str, str]:
260 """Extract timestamp and UUID parts from filename."""
261 if '_' not in filename:
262 msg = f'FreeformContent filename must contain underscore: {filename}'
263 raise FreeformContentValidationError(msg)
265 timestamp_part, uuid_part = filename.rsplit('_', 1)
266 uuid_part = uuid_part.removesuffix('.md')
267 return timestamp_part, uuid_part
269 def _validate_uuid_match(self, uuid_part: str) -> None:
270 """Validate UUID in filename matches the id."""
271 if uuid_part != self.id:
272 msg = f"UUID in filename ({uuid_part}) doesn't match id ({self.id})"
273 raise FreeformContentValidationError(msg)
275 def _validate_timestamp_format(self, timestamp_part: str) -> None:
276 """Validate timestamp format YYYYMMDDTHHMM."""
277 if len(timestamp_part) != self.TIMESTAMP_LENGTH or timestamp_part[self.TIMESTAMP_T_POSITION] != 'T':
278 msg = f'Invalid timestamp format in filename: {timestamp_part}'
279 raise FreeformContentValidationError(msg)
281 self._validate_timestamp_components(timestamp_part)
283 def _validate_timestamp_components(self, timestamp_part: str) -> None:
284 """Validate individual timestamp components are valid."""
285 try:
286 int(timestamp_part[0:4])
287 month = int(timestamp_part[4:6])
288 day = int(timestamp_part[6:8])
289 hour = int(timestamp_part[9:11])
290 minute = int(timestamp_part[11:13])
292 self._validate_time_ranges(month, day, hour, minute)
294 except ValueError as exc:
295 msg = f'Invalid timestamp components in filename: {timestamp_part}'
296 raise FreeformContentValidationError(msg) from exc
298 def _validate_time_ranges(self, month: int, day: int, hour: int, minute: int) -> None:
299 """Validate time component ranges."""
300 if not (self.MIN_MONTH <= month <= self.MAX_MONTH):
301 msg = f'Invalid month in timestamp: {month}'
302 raise FreeformContentValidationError(msg)
303 if not (self.MIN_DAY <= day <= self.MAX_DAY):
304 msg = f'Invalid day in timestamp: {day}'
305 raise FreeformContentValidationError(msg)
306 if not (self.MIN_HOUR <= hour <= self.MAX_HOUR):
307 msg = f'Invalid hour in timestamp: {hour}'
308 raise FreeformContentValidationError(msg)
309 if not (self.MIN_MINUTE <= minute <= self.MAX_MINUTE):
310 msg = f'Invalid minute in timestamp: {minute}'
311 raise FreeformContentValidationError(msg)
313 def _validate_timestamp_consistency(self, timestamp_part: str) -> None:
314 """Validate that filename timestamp matches created timestamp."""
315 from datetime import datetime
317 # Parse the created timestamp
318 try:
319 created_dt = datetime.fromisoformat(self.created)
320 except ValueError as exc:
321 msg = f'Invalid created timestamp format: {self.created}'
322 raise FreeformContentValidationError(msg) from exc
324 # Format it as YYYYMMDDTHHMM
325 expected_timestamp = created_dt.strftime('%Y%m%dT%H%M')
327 if timestamp_part != expected_timestamp:
328 msg = f'Filename timestamp ({timestamp_part}) does not match created timestamp ({expected_timestamp})'
329 raise FreeformContentValidationError(msg)
331 @staticmethod
332 def _raise_uuid_version_error(version: int) -> None:
333 """Raise error for invalid UUID version."""
334 msg = f'FreeformContent ID must be UUIDv7, got version {version}'
335 raise FreeformContentValidationError(msg)
337 def get_expected_filename(self) -> str:
338 """Return the expected filename based on created timestamp and ID."""
339 # Parse the ISO timestamp and format it as YYYYMMDDTHHMM
340 from datetime import datetime
342 created_dt = datetime.fromisoformat(self.created)
343 timestamp_part = created_dt.strftime('%Y%m%dT%H%M')
344 return f'{timestamp_part}_{self.id}.md'
346 def parse_filename(self) -> dict[str, str]:
347 """Parse the current file's filename into components.
349 Returns:
350 Dictionary with 'timestamp', 'uuid', and 'extension' keys
352 """
353 filename = self.file_path.name
355 if not filename.endswith('.md'):
356 msg = f'Filename must end with .md: {filename}'
357 raise FreeformContentValidationError(msg)
359 base_name = filename.removesuffix('.md')
360 if '_' not in base_name:
361 msg = f'Filename must contain underscore: {filename}'
362 raise FreeformContentValidationError(msg)
364 timestamp_part, uuid_part = base_name.rsplit('_', 1)
365 return {'timestamp': timestamp_part, 'uuid': uuid_part, 'extension': '.md'}
367 def update_title(self, new_title: str | None) -> None:
368 """Update the title of the content.
370 Args:
371 new_title: New title or None to clear the title
373 """
374 object.__setattr__(self, 'title', new_title)
376 def __eq__(self, other: object) -> bool:
377 """Compare FreeformContent instances based on ID."""
378 if not isinstance(other, FreeformContent):
379 return False
380 return self.id == other.id
382 def __hash__(self) -> int:
383 """Return hash based on ID for use in sets and dicts."""
384 return hash(self.id)
386 @classmethod
387 def get_filename_pattern(cls) -> str:
388 """Return the regex pattern for validating freeform content filenames."""
389 return r'\d{8}T\d{4}_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.md'
392# Re-export all entities for convenient importing
393__all__ = [
394 'Binder',
395 'BinderItem',
396 'FreeformContent',
397 'Node',
398 'NodeId',
399 'NodeMetadata', # Include for backward compatibility
400]