Coverage for src/prosemark/freewriting/domain/models.py: 100%
184 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 models for the freewriting feature.
3This module contains the core domain models that represent the business entities
4and concepts for the write-only freewriting interface.
5"""
7from __future__ import annotations
9from dataclasses import dataclass, field
10from datetime import UTC, datetime
11from enum import Enum
12from pathlib import Path
13from uuid import UUID
16class SessionState(Enum):
17 """Possible states of a freewriting session."""
19 INITIALIZING = 'initializing'
20 ACTIVE = 'active'
21 PAUSED = 'paused'
22 COMPLETED = 'completed'
23 ARCHIVED = 'archived'
26@dataclass(frozen=True)
27class FreewriteSession:
28 """Represents a single freewriting session with metadata and configuration.
30 This is the core domain model that tracks all aspects of a user's
31 freewriting session, from configuration to real-time progress.
32 """
34 session_id: str
35 target_node: str | None
36 title: str | None
37 start_time: datetime
38 word_count_goal: int | None
39 time_limit: int | None
40 current_word_count: int
41 elapsed_time: int
42 output_file_path: str
43 content_lines: list[str] = field(default_factory=list)
44 state: SessionState = SessionState.INITIALIZING
46 def __post_init__(self) -> None:
47 """Validate the session data after initialization."""
48 self._validate_session_id()
49 self._validate_target_node()
50 self._validate_start_time()
51 self._validate_goals()
52 self._validate_counters()
53 self._validate_file_path()
55 def _validate_session_id(self) -> None:
56 """Validate that session_id is a proper UUID."""
57 try:
58 UUID(self.session_id)
59 except ValueError as e:
60 msg = f'Invalid session_id format: {self.session_id}'
61 raise ValueError(msg) from e
63 def _validate_target_node(self) -> None:
64 """Validate target_node is a proper UUID format if provided."""
65 if self.target_node is not None:
66 try:
67 UUID(self.target_node)
68 except ValueError as e:
69 msg = f'Invalid target_node UUID format: {self.target_node}'
70 raise ValueError(msg) from e
72 def _validate_start_time(self) -> None:
73 """Validate start_time is not in the future."""
74 now = datetime.now(tz=self.start_time.tzinfo)
75 if self.start_time > now:
76 msg = f'start_time cannot be in the future: {self.start_time} > {now}'
77 raise ValueError(msg)
79 def _validate_goals(self) -> None:
80 """Validate word_count_goal and time_limit are positive if set."""
81 if self.word_count_goal is not None and self.word_count_goal <= 0:
82 msg = f'word_count_goal must be positive: {self.word_count_goal}'
83 raise ValueError(msg)
85 if self.time_limit is not None and self.time_limit <= 0:
86 msg = f'time_limit must be positive: {self.time_limit}'
87 raise ValueError(msg)
89 def _validate_counters(self) -> None:
90 """Validate current_word_count and elapsed_time are non-negative."""
91 if self.current_word_count < 0:
92 msg = f'current_word_count cannot be negative: {self.current_word_count}'
93 raise ValueError(msg)
95 if self.elapsed_time < 0:
96 msg = f'elapsed_time cannot be negative: {self.elapsed_time}'
97 raise ValueError(msg)
99 def _validate_file_path(self) -> None:
100 """Validate output_file_path is a valid file path."""
101 try:
102 Path(self.output_file_path)
103 except (OSError, ValueError) as e: # pragma: no cover
104 msg = f'Invalid output_file_path: {self.output_file_path}'
105 raise ValueError(msg) from e # pragma: no cover
107 def calculate_word_count(self) -> int:
108 """Calculate total word count from content lines.
110 Returns:
111 Total number of words across all content lines.
113 """
114 total_words = 0
115 for line in self.content_lines:
116 # Use simple whitespace splitting for word counting
117 words = line.split()
118 total_words += len(words)
119 return total_words
121 def add_content_line(self, content: str) -> FreewriteSession:
122 """Add a new content line and return updated session.
124 Args:
125 content: The content line to add (preserves exact input).
127 Returns:
128 New FreewriteSession with the added content line.
130 """
131 new_content_lines = list(self.content_lines)
132 new_content_lines.append(content)
134 # Calculate new word count
135 line_words = len(content.split())
136 new_word_count = self.current_word_count + line_words
138 # Return new immutable instance
139 return FreewriteSession(
140 session_id=self.session_id,
141 target_node=self.target_node,
142 title=self.title,
143 start_time=self.start_time,
144 word_count_goal=self.word_count_goal,
145 time_limit=self.time_limit,
146 current_word_count=new_word_count,
147 elapsed_time=self.elapsed_time,
148 output_file_path=self.output_file_path,
149 content_lines=new_content_lines,
150 state=self.state,
151 )
153 def update_elapsed_time(self, elapsed_seconds: int) -> FreewriteSession:
154 """Update elapsed time and return new session.
156 Args:
157 elapsed_seconds: New elapsed time in seconds.
159 Returns:
160 New FreewriteSession with updated elapsed time.
162 """
163 if elapsed_seconds < 0:
164 msg = f'elapsed_seconds cannot be negative: {elapsed_seconds}'
165 raise ValueError(msg)
167 return FreewriteSession(
168 session_id=self.session_id,
169 target_node=self.target_node,
170 title=self.title,
171 start_time=self.start_time,
172 word_count_goal=self.word_count_goal,
173 time_limit=self.time_limit,
174 current_word_count=self.current_word_count,
175 elapsed_time=elapsed_seconds,
176 output_file_path=self.output_file_path,
177 content_lines=self.content_lines,
178 state=self.state,
179 )
181 def change_state(self, new_state: SessionState) -> FreewriteSession:
182 """Change session state and return new session.
184 Args:
185 new_state: The new state to transition to.
187 Returns:
188 New FreewriteSession with updated state.
190 """
191 return FreewriteSession(
192 session_id=self.session_id,
193 target_node=self.target_node,
194 title=self.title,
195 start_time=self.start_time,
196 word_count_goal=self.word_count_goal,
197 time_limit=self.time_limit,
198 current_word_count=self.current_word_count,
199 elapsed_time=self.elapsed_time,
200 output_file_path=self.output_file_path,
201 content_lines=self.content_lines,
202 state=new_state,
203 )
205 def is_goal_reached(self) -> dict[str, bool]:
206 """Check if session goals have been reached.
208 Returns:
209 Dictionary indicating which goals have been reached.
211 """
212 result = {}
214 if self.word_count_goal is not None:
215 result['word_count'] = self.current_word_count >= self.word_count_goal
217 if self.time_limit is not None:
218 result['time_limit'] = self.elapsed_time >= self.time_limit
220 return result
223@dataclass(frozen=True)
224class SessionConfig:
225 """Configuration for a freewriting session.
227 This model represents the user's configuration choices that
228 determine how a freewriting session should behave.
229 """
231 target_node: str | None = None
232 title: str | None = None
233 word_count_goal: int | None = None
234 time_limit: int | None = None
235 theme: str = 'dark'
236 current_directory: str = '.'
238 def __post_init__(self) -> None:
239 """Validate the session configuration."""
240 self._validate_target_node()
241 self._validate_goals()
242 self._validate_directory()
244 def _validate_target_node(self) -> None:
245 """Validate target_node is a proper UUID format if provided."""
246 if self.target_node is not None:
247 try:
248 UUID(self.target_node)
249 except ValueError as e:
250 msg = f'Invalid target_node UUID format: {self.target_node}'
251 raise ValueError(msg) from e
253 def _validate_goals(self) -> None:
254 """Validate goals are positive if provided."""
255 if self.word_count_goal is not None and self.word_count_goal <= 0:
256 msg = f'word_count_goal must be positive: {self.word_count_goal}'
257 raise ValueError(msg)
259 if self.time_limit is not None and self.time_limit <= 0:
260 msg = f'time_limit must be positive: {self.time_limit}'
261 raise ValueError(msg)
263 def _validate_directory(self) -> None:
264 """Validate current_directory is a valid path."""
265 try:
266 Path(self.current_directory)
267 except (OSError, ValueError) as e: # pragma: no cover
268 msg = f'Invalid current_directory: {self.current_directory}'
269 raise ValueError(msg) from e # pragma: no cover
271 def has_goals(self) -> bool:
272 """Check if this configuration has any goals set.
274 Returns:
275 True if word count goal or time limit is set.
277 """
278 return self.word_count_goal is not None or self.time_limit is not None
280 def is_node_targeted(self) -> bool:
281 """Check if this configuration targets a specific node.
283 Returns:
284 True if target_node is specified.
286 """
287 return self.target_node is not None
290@dataclass(frozen=True)
291class FreewriteContent:
292 """Represents a single line of content entered by the user.
294 This model tracks individual content entries with their
295 metadata for detailed session analysis.
296 """
298 content: str
299 timestamp: datetime
300 line_number: int
301 word_count: int
303 def __post_init__(self) -> None:
304 """Validate the content data."""
305 self._validate_line_number()
306 self._validate_word_count()
308 def _validate_line_number(self) -> None:
309 """Validate line_number is positive."""
310 if self.line_number <= 0:
311 msg = f'line_number must be positive: {self.line_number}'
312 raise ValueError(msg)
314 def _validate_word_count(self) -> None:
315 """Validate word_count matches actual content."""
316 actual_word_count = len(self.content.split())
317 if self.word_count != actual_word_count:
318 msg = f'word_count mismatch: expected {actual_word_count}, got {self.word_count}'
319 raise ValueError(msg)
321 @classmethod
322 def from_content(cls, content: str, line_number: int, timestamp: datetime | None = None) -> FreewriteContent:
323 """Create FreewriteContent from content string.
325 Args:
326 content: The actual text content.
327 line_number: Sequential line number.
328 timestamp: When content was entered (defaults to now).
330 Returns:
331 New FreewriteContent instance.
333 """
334 if timestamp is None:
335 timestamp = datetime.now(tz=UTC)
337 word_count = len(content.split())
339 return cls(
340 content=content,
341 timestamp=timestamp,
342 line_number=line_number,
343 word_count=word_count,
344 )
347@dataclass(frozen=True)
348class FileTarget:
349 """Represents the destination file for freewriting content.
351 This model encapsulates the details about where content
352 should be written and how it should be formatted.
353 """
355 file_path: str
356 is_node: bool
357 node_uuid: str | None
358 created_timestamp: datetime
359 file_format: str = 'markdown'
361 def __post_init__(self) -> None:
362 """Validate the file target data."""
363 self._validate_file_path()
364 self._validate_node_consistency()
365 self._validate_file_format()
367 def _validate_file_path(self) -> None:
368 """Validate file_path is a valid path."""
369 try:
370 Path(self.file_path)
371 except (OSError, ValueError) as e: # pragma: no cover
372 msg = f'Invalid file_path: {self.file_path}'
373 raise ValueError(msg) from e # pragma: no cover
375 def _validate_node_consistency(self) -> None:
376 """Validate node_uuid is provided if is_node is True."""
377 if self.is_node and self.node_uuid is None:
378 msg = 'node_uuid is required when is_node is True'
379 raise ValueError(msg)
381 if self.is_node and self.node_uuid is not None:
382 try:
383 UUID(self.node_uuid)
384 except ValueError as e:
385 msg = f'Invalid node_uuid format: {self.node_uuid}'
386 raise ValueError(msg) from e
388 def _validate_file_format(self) -> None:
389 """Validate file_format is supported."""
390 if self.file_format != 'markdown':
391 msg = f'Unsupported file_format: {self.file_format}'
392 raise ValueError(msg)
394 @classmethod
395 def for_daily_file(cls, file_path: str) -> FileTarget:
396 """Create FileTarget for a daily freewrite file.
398 Args:
399 file_path: Path to the daily file.
401 Returns:
402 New FileTarget configured for daily file.
404 """
405 return cls(
406 file_path=file_path,
407 is_node=False,
408 node_uuid=None,
409 created_timestamp=datetime.now(tz=UTC),
410 file_format='markdown',
411 )
413 @classmethod
414 def for_node(cls, file_path: str, node_uuid: str) -> FileTarget:
415 """Create FileTarget for a node file.
417 Args:
418 file_path: Path to the node file.
419 node_uuid: UUID of the target node.
421 Returns:
422 New FileTarget configured for node file.
424 """
425 return cls(
426 file_path=file_path,
427 is_node=True,
428 node_uuid=node_uuid,
429 created_timestamp=datetime.now(tz=UTC),
430 file_format='markdown',
431 )