Coverage for src/prosemark/freewriting/adapters/freewrite_service_adapter.py: 100%
160 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-28 19:17 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-28 19:17 +0000
1"""Freewrite service adapter implementation.
3This module provides the concrete implementation of the FreewriteServicePort
4that orchestrates all freewriting operations.
5"""
7from __future__ import annotations
9from datetime import UTC, datetime
10from pathlib import Path
11from typing import TYPE_CHECKING, Any
12from uuid import uuid4
14from prosemark.freewriting.domain.exceptions import FileSystemError, ValidationError
15from prosemark.freewriting.domain.models import FreewriteSession, SessionConfig, SessionState
16from prosemark.freewriting.ports.freewrite_service import FreewriteServicePort
18if TYPE_CHECKING: # pragma: no cover
19 from prosemark.freewriting.ports.file_system import FileSystemPort
20 from prosemark.freewriting.ports.node_service import NodeServicePort
21from prosemark.freewriting.adapters.node_service_adapter import NodeServiceAdapter
24class FreewriteServiceAdapter(FreewriteServicePort):
25 """Concrete implementation of FreewriteServicePort.
27 This adapter orchestrates freewriting operations by coordinating
28 between the file system, node service, and domain models.
29 """
31 def __init__(
32 self,
33 file_system: FileSystemPort,
34 node_service: NodeServicePort,
35 ) -> None:
36 """Initialize the freewrite service adapter.
38 Args:
39 file_system: File system port for file operations.
40 node_service: Node service port for node operations.
42 """
43 self.file_system = file_system
44 self.node_service = node_service
46 def create_session(self, config: SessionConfig) -> FreewriteSession:
47 """Create a new freewriting session with given configuration.
49 Args:
50 config: Session configuration from CLI.
52 Returns:
53 Initialized FreewriteSession.
55 Raises:
56 ValidationError: If configuration is invalid.
57 FileSystemError: If target directory is not writable.
59 """
60 # Validate the configuration
61 self._validate_session_config(config)
63 # Generate session ID
64 session_id = str(uuid4())
66 # Determine output file path
67 output_file_path = self._determine_output_path(config)
69 # Ensure we can write to the target location
70 self._ensure_writable_target(output_file_path, config)
72 # Create and return the session
73 session = FreewriteSession(
74 session_id=session_id,
75 target_node=config.target_node,
76 title=config.title,
77 start_time=datetime.now(tz=UTC),
78 word_count_goal=config.word_count_goal,
79 time_limit=config.time_limit,
80 current_word_count=0,
81 elapsed_time=0,
82 output_file_path=output_file_path,
83 content_lines=[],
84 state=SessionState.INITIALIZING,
85 )
87 # Initialize the output file
88 self._initialize_output_file(session, config)
90 # Load existing content if file exists
91 session = self._load_existing_content(session, config)
93 return session.change_state(SessionState.ACTIVE)
95 def append_content(self, session: FreewriteSession, content: str) -> FreewriteSession:
96 """Append content line to the session and persist immediately.
98 Args:
99 session: Current session state.
100 content: Content line to append.
102 Returns:
103 Updated session with new content and word count.
105 Raises:
106 FileSystemError: If write operation fails.
107 ValidationError: If content is invalid.
109 """
110 if not content.strip():
111 # Allow empty lines - they're part of freewriting
112 pass
114 # Add content to session
115 updated_session = session.add_content_line(content)
117 try:
118 # Persist to file immediately
119 if session.target_node:
120 self._append_to_node_file(updated_session, content)
121 else:
122 self._append_to_daily_file(updated_session, content)
123 except Exception as e:
124 raise FileSystemError('append', session.output_file_path, str(e)) from e
126 return updated_session
128 @staticmethod
129 def validate_node_uuid(node_uuid: str) -> bool:
130 """Validate that a node UUID is properly formatted.
132 Args:
133 node_uuid: UUID string to validate.
135 Returns:
136 True if valid UUID format, False otherwise.
138 """
139 # Use the standard UUID validation logic directly
140 try:
141 from uuid import UUID
143 UUID(node_uuid)
144 except ValueError:
145 return False
146 return True
148 @staticmethod
149 def create_daily_filename(timestamp: datetime) -> str:
150 """Generate filename for daily freewrite file.
152 Args:
153 timestamp: When the session started.
155 Returns:
156 Filename in YYYY-MM-DD-HHmm.md format.
158 """
159 return timestamp.strftime('%Y-%m-%d-%H%M.md')
161 @staticmethod
162 def get_session_stats(session: FreewriteSession) -> dict[str, int | float | bool]:
163 """Calculate current session statistics.
165 Args:
166 session: Current session.
168 Returns:
169 Dictionary with word_count, elapsed_time, progress metrics.
171 """
172 stats: dict[str, int | float | bool] = {
173 'word_count': session.current_word_count,
174 'elapsed_time': session.elapsed_time,
175 'line_count': len(session.content_lines),
176 }
178 # Add goal progress if goals are set
179 goals_met = session.is_goal_reached()
180 if goals_met:
181 stats.update(goals_met)
183 # Calculate progress percentages
184 if session.word_count_goal:
185 word_progress = min(100.0, (session.current_word_count / session.word_count_goal) * 100)
186 stats['word_progress_percent'] = word_progress
188 if session.time_limit:
189 time_progress = min(100.0, (session.elapsed_time / session.time_limit) * 100)
190 stats['time_progress_percent'] = time_progress
191 stats['time_remaining'] = max(0, session.time_limit - session.elapsed_time)
193 return stats
195 def _validate_session_config(self, config: SessionConfig) -> None:
196 """Validate session configuration.
198 Args:
199 config: Configuration to validate.
201 Raises:
202 ValidationError: If configuration is invalid.
204 """
205 if config.target_node and not NodeServiceAdapter.validate_node_uuid(config.target_node):
206 msg = 'Invalid UUID format'
207 raise ValidationError('target_node', config.target_node, msg)
209 if not self.file_system.is_writable(config.current_directory):
210 msg = 'Directory is not writable'
211 raise ValidationError('current_directory', config.current_directory, msg)
213 def _determine_output_path(self, config: SessionConfig) -> str:
214 """Determine the output file path based on configuration.
216 Args:
217 config: Session configuration.
219 Returns:
220 Absolute path to output file.
222 """
223 if config.target_node:
224 # For node-targeted sessions, use node service to get path
225 return self.node_service.get_node_path(config.target_node)
226 # For daily files, create timestamped file in current directory
227 filename = FreewriteServiceAdapter.create_daily_filename(datetime.now(tz=UTC))
228 return self.file_system.get_absolute_path(self.file_system.join_paths(config.current_directory, filename))
230 def _ensure_writable_target(self, output_file_path: str, config: SessionConfig) -> None:
231 """Ensure we can write to the target location.
233 Args:
234 output_file_path: Path to output file.
235 config: Session configuration.
237 Raises:
238 FileSystemError: If target is not writable.
240 """
241 if config.target_node:
242 # For nodes, ensure the node exists or can be created
243 if not self.node_service.node_exists(config.target_node):
244 try:
245 self.node_service.create_node(config.target_node, config.title)
246 except Exception as e:
247 msg = f'Cannot create node: {e}'
248 raise FileSystemError('create_node', config.target_node, str(e)) from e
249 else:
250 # For daily files, ensure parent directory is writable
251 parent_dir = str(Path(output_file_path).parent)
252 if not self.file_system.is_writable(parent_dir):
253 msg = 'Parent directory is not writable'
254 raise FileSystemError('check_writable', parent_dir, msg)
256 def _initialize_output_file(self, session: FreewriteSession, config: SessionConfig) -> None:
257 """Initialize the output file with proper structure.
259 Args:
260 session: The session being initialized.
261 config: Session configuration.
263 Raises:
264 FileSystemError: If file initialization fails.
266 """
267 if config.target_node:
268 # For node files, we don't initialize - content gets appended
269 return
271 # Check if file already exists
272 if self.file_system.file_exists(session.output_file_path):
273 # File exists, no initialization needed - content will be loaded into session separately
274 return
276 # Create initial content for daily file
277 initial_content = FreewriteServiceAdapter._create_daily_file_initial_content(session)
279 # Write initial content
280 self.file_system.write_file(session.output_file_path, initial_content, append=False)
282 try:
283 # Verify file was written successfully
284 self._verify_file_created(session.output_file_path)
285 except Exception as e:
286 raise FileSystemError('initialize', session.output_file_path, str(e)) from e
288 @staticmethod
289 def _create_daily_file_initial_content(session: FreewriteSession) -> str:
290 """Create the initial content for a daily freewrite file.
292 Args:
293 session: The session being initialized.
295 Returns:
296 Initial file content with YAML frontmatter and header.
298 """
299 # For daily files, create with YAML frontmatter
300 frontmatter_data: dict[str, Any] = {
301 'type': 'freewrite',
302 'session_id': session.session_id,
303 'created': session.start_time.isoformat(),
304 }
306 if session.title:
307 frontmatter_data['title'] = session.title
309 if session.word_count_goal:
310 frontmatter_data['word_count_goal'] = session.word_count_goal
312 if session.time_limit:
313 frontmatter_data['time_limit'] = session.time_limit
315 # Create YAML frontmatter
316 frontmatter_lines = ['---']
317 for key, value in frontmatter_data.items():
318 if isinstance(value, str):
319 frontmatter_lines.append(f'{key}: "{value}"')
320 else:
321 frontmatter_lines.append(f'{key}: {value}')
322 frontmatter_lines.extend(['---', '', '# Freewrite Session', ''])
324 return '\n'.join(frontmatter_lines)
326 @staticmethod
327 def _verify_file_created(file_path: str) -> None:
328 """Verify that the file was written successfully.
330 Args:
331 file_path: Path to the file to verify.
333 Raises:
334 OSError: If file was not created.
336 """
337 if not Path(file_path).exists():
338 raise OSError('File not created')
340 def _load_existing_content(self, session: FreewriteSession, config: SessionConfig) -> FreewriteSession:
341 """Load existing content from file if it exists.
343 Args:
344 session: The session being initialized.
345 config: Session configuration.
347 Returns:
348 Updated session with existing content loaded.
350 Raises:
351 FileSystemError: If file reading fails.
353 """
354 # Check if file exists
355 if not self.file_system.file_exists(session.output_file_path):
356 # No existing file, return session as-is
357 return session
359 try:
360 # Read existing file content
361 existing_content = self.file_system.read_file(session.output_file_path)
363 # Split into lines and filter out empty lines at the end
364 content_lines = existing_content.splitlines()
366 # For daily files, skip YAML frontmatter and header if present
367 if not config.target_node:
368 content_lines = FreewriteServiceAdapter._filter_frontmatter_and_header(content_lines)
370 # Update session with existing content
371 updated_session = session
372 for line in content_lines:
373 updated_session = updated_session.add_content_line(line)
375 except Exception as e:
376 raise FileSystemError('read_existing', session.output_file_path, str(e)) from e
377 else:
378 return updated_session
380 @staticmethod
381 def _filter_frontmatter_and_header(content_lines: list[str]) -> list[str]:
382 """Filter out YAML frontmatter and header from daily freewrite files.
384 Args:
385 content_lines: Raw content lines from file.
387 Returns:
388 Content lines without frontmatter and header.
390 """
391 filtered_lines = []
392 in_frontmatter = False
393 frontmatter_closed = False
394 skip_empty_lines = True
396 for line in content_lines:
397 # Check for YAML frontmatter start/end
398 if line.strip() == '---':
399 if not in_frontmatter and not frontmatter_closed:
400 in_frontmatter = True
401 continue
402 if in_frontmatter:
403 in_frontmatter = False
404 frontmatter_closed = True
405 continue
407 # Skip frontmatter lines
408 if in_frontmatter:
409 continue
411 # Skip header line (starts with #)
412 if frontmatter_closed and line.strip().startswith('# '):
413 continue
415 # Skip leading empty lines after header
416 if skip_empty_lines and not line.strip():
417 continue
419 # Found content, stop skipping empty lines
420 skip_empty_lines = False
421 filtered_lines.append(line)
423 return filtered_lines
425 def _append_to_daily_file(self, session: FreewriteSession, content: str) -> None:
426 """Append content to daily freewrite file.
428 Args:
429 session: Current session.
430 content: Content line to append.
432 Raises:
433 FileSystemError: If append operation fails.
435 """
436 # Add newline and append to file
437 content_with_newline = content + '\n'
438 self.file_system.write_file(session.output_file_path, content_with_newline, append=True)
440 def _append_to_node_file(self, session: FreewriteSession, content: str) -> None:
441 """Append content to node file via node service.
443 Args:
444 session: Current session.
445 content: Content line to append.
447 Raises:
448 FileSystemError: If append operation fails.
450 """
451 if not session.target_node:
452 msg = 'No target node specified for node append operation'
453 raise FileSystemError('append_node', session.output_file_path, msg)
455 # Prepare session metadata
456 session_metadata = {
457 'timestamp': datetime.now(tz=UTC).strftime('%Y-%m-%d %H:%M'),
458 'word_count': str(session.current_word_count),
459 'session_id': session.session_id,
460 }
462 # Use node service to append content
463 self.node_service.append_to_node(session.target_node, [content], session_metadata)