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

1"""Domain models for the freewriting feature. 

2 

3This module contains the core domain models that represent the business entities 

4and concepts for the write-only freewriting interface. 

5""" 

6 

7from __future__ import annotations 

8 

9from dataclasses import dataclass, field 

10from datetime import UTC, datetime 

11from enum import Enum 

12from pathlib import Path 

13from uuid import UUID 

14 

15 

16class SessionState(Enum): 

17 """Possible states of a freewriting session.""" 

18 

19 INITIALIZING = 'initializing' 

20 ACTIVE = 'active' 

21 PAUSED = 'paused' 

22 COMPLETED = 'completed' 

23 ARCHIVED = 'archived' 

24 

25 

26@dataclass(frozen=True) 

27class FreewriteSession: 

28 """Represents a single freewriting session with metadata and configuration. 

29 

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 """ 

33 

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 

45 

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() 

54 

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 

62 

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 

71 

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) 

78 

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) 

84 

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) 

88 

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) 

94 

95 if self.elapsed_time < 0: 

96 msg = f'elapsed_time cannot be negative: {self.elapsed_time}' 

97 raise ValueError(msg) 

98 

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 

106 

107 def calculate_word_count(self) -> int: 

108 """Calculate total word count from content lines. 

109 

110 Returns: 

111 Total number of words across all content lines. 

112 

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 

120 

121 def add_content_line(self, content: str) -> FreewriteSession: 

122 """Add a new content line and return updated session. 

123 

124 Args: 

125 content: The content line to add (preserves exact input). 

126 

127 Returns: 

128 New FreewriteSession with the added content line. 

129 

130 """ 

131 new_content_lines = list(self.content_lines) 

132 new_content_lines.append(content) 

133 

134 # Calculate new word count 

135 line_words = len(content.split()) 

136 new_word_count = self.current_word_count + line_words 

137 

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 ) 

152 

153 def update_elapsed_time(self, elapsed_seconds: int) -> FreewriteSession: 

154 """Update elapsed time and return new session. 

155 

156 Args: 

157 elapsed_seconds: New elapsed time in seconds. 

158 

159 Returns: 

160 New FreewriteSession with updated elapsed time. 

161 

162 """ 

163 if elapsed_seconds < 0: 

164 msg = f'elapsed_seconds cannot be negative: {elapsed_seconds}' 

165 raise ValueError(msg) 

166 

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 ) 

180 

181 def change_state(self, new_state: SessionState) -> FreewriteSession: 

182 """Change session state and return new session. 

183 

184 Args: 

185 new_state: The new state to transition to. 

186 

187 Returns: 

188 New FreewriteSession with updated state. 

189 

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 ) 

204 

205 def is_goal_reached(self) -> dict[str, bool]: 

206 """Check if session goals have been reached. 

207 

208 Returns: 

209 Dictionary indicating which goals have been reached. 

210 

211 """ 

212 result = {} 

213 

214 if self.word_count_goal is not None: 

215 result['word_count'] = self.current_word_count >= self.word_count_goal 

216 

217 if self.time_limit is not None: 

218 result['time_limit'] = self.elapsed_time >= self.time_limit 

219 

220 return result 

221 

222 

223@dataclass(frozen=True) 

224class SessionConfig: 

225 """Configuration for a freewriting session. 

226 

227 This model represents the user's configuration choices that 

228 determine how a freewriting session should behave. 

229 """ 

230 

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 = '.' 

237 

238 def __post_init__(self) -> None: 

239 """Validate the session configuration.""" 

240 self._validate_target_node() 

241 self._validate_goals() 

242 self._validate_directory() 

243 

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 

252 

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) 

258 

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) 

262 

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 

270 

271 def has_goals(self) -> bool: 

272 """Check if this configuration has any goals set. 

273 

274 Returns: 

275 True if word count goal or time limit is set. 

276 

277 """ 

278 return self.word_count_goal is not None or self.time_limit is not None 

279 

280 def is_node_targeted(self) -> bool: 

281 """Check if this configuration targets a specific node. 

282 

283 Returns: 

284 True if target_node is specified. 

285 

286 """ 

287 return self.target_node is not None 

288 

289 

290@dataclass(frozen=True) 

291class FreewriteContent: 

292 """Represents a single line of content entered by the user. 

293 

294 This model tracks individual content entries with their 

295 metadata for detailed session analysis. 

296 """ 

297 

298 content: str 

299 timestamp: datetime 

300 line_number: int 

301 word_count: int 

302 

303 def __post_init__(self) -> None: 

304 """Validate the content data.""" 

305 self._validate_line_number() 

306 self._validate_word_count() 

307 

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) 

313 

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) 

320 

321 @classmethod 

322 def from_content(cls, content: str, line_number: int, timestamp: datetime | None = None) -> FreewriteContent: 

323 """Create FreewriteContent from content string. 

324 

325 Args: 

326 content: The actual text content. 

327 line_number: Sequential line number. 

328 timestamp: When content was entered (defaults to now). 

329 

330 Returns: 

331 New FreewriteContent instance. 

332 

333 """ 

334 if timestamp is None: 

335 timestamp = datetime.now(tz=UTC) 

336 

337 word_count = len(content.split()) 

338 

339 return cls( 

340 content=content, 

341 timestamp=timestamp, 

342 line_number=line_number, 

343 word_count=word_count, 

344 ) 

345 

346 

347@dataclass(frozen=True) 

348class FileTarget: 

349 """Represents the destination file for freewriting content. 

350 

351 This model encapsulates the details about where content 

352 should be written and how it should be formatted. 

353 """ 

354 

355 file_path: str 

356 is_node: bool 

357 node_uuid: str | None 

358 created_timestamp: datetime 

359 file_format: str = 'markdown' 

360 

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() 

366 

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 

374 

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) 

380 

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 

387 

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) 

393 

394 @classmethod 

395 def for_daily_file(cls, file_path: str) -> FileTarget: 

396 """Create FileTarget for a daily freewrite file. 

397 

398 Args: 

399 file_path: Path to the daily file. 

400 

401 Returns: 

402 New FileTarget configured for daily file. 

403 

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 ) 

412 

413 @classmethod 

414 def for_node(cls, file_path: str, node_uuid: str) -> FileTarget: 

415 """Create FileTarget for a node file. 

416 

417 Args: 

418 file_path: Path to the node file. 

419 node_uuid: UUID of the target node. 

420 

421 Returns: 

422 New FileTarget configured for node file. 

423 

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 )