Coverage for src/prosemark/app/materialize_all_placeholders.py: 100%
89 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"""MaterializeAllPlaceholders use case for bulk conversion of placeholders to nodes."""
3import time
4from collections.abc import Callable
5from pathlib import Path
6from typing import TYPE_CHECKING
8from prosemark.domain.batch_materialize_result import BatchMaterializeResult
9from prosemark.domain.materialize_failure import MaterializeFailure
10from prosemark.domain.materialize_result import MaterializeResult
11from prosemark.domain.models import Binder, BinderItem
12from prosemark.domain.placeholder_summary import PlaceholderSummary
14if TYPE_CHECKING: # pragma: no cover
15 from prosemark.app.materialize_node import MaterializeNode
16 from prosemark.ports.binder_repo import BinderRepo
17 from prosemark.ports.clock import Clock
18 from prosemark.ports.id_generator import IdGenerator
19 from prosemark.ports.logger import Logger
20 from prosemark.ports.node_repo import NodeRepo
23class MaterializeAllPlaceholders:
24 """Materialize all placeholder items in a binder to actual content nodes."""
26 def __init__(
27 self,
28 *,
29 materialize_node_use_case: 'MaterializeNode',
30 binder_repo: 'BinderRepo',
31 node_repo: 'NodeRepo',
32 id_generator: 'IdGenerator',
33 clock: 'Clock',
34 logger: 'Logger',
35 ) -> None:
36 """Initialize the MaterializeAllPlaceholders use case.
38 Args:
39 materialize_node_use_case: Use case for individual node materialization
40 binder_repo: Repository for binder operations
41 node_repo: Repository for node operations
42 id_generator: Generator for unique node IDs
43 clock: Clock for timestamps
44 logger: Logger port
46 """
47 self.materialize_node_use_case = materialize_node_use_case
48 self.binder_repo = binder_repo
49 self.node_repo = node_repo
50 self.id_generator = id_generator
51 self.clock = clock
52 self.logger = logger
54 def execute(
55 self,
56 *,
57 binder: Binder | None = None,
58 project_path: Path | None = None,
59 progress_callback: Callable[[str], None] | None = None,
60 ) -> BatchMaterializeResult:
61 """Materialize all placeholders in the binder.
63 Args:
64 binder: Optional binder to process (if not provided, loads from repo)
65 project_path: Project directory path
66 progress_callback: Optional callback for progress reporting
68 Returns:
69 BatchMaterializeResult containing success/failure information
71 """
72 start_time = time.time()
73 project_path = project_path or Path.cwd()
75 # Load binder if not provided
76 if binder is None:
77 binder = self.binder_repo.load()
79 self.logger.info('Starting batch materialization of all placeholders')
81 # Discover all placeholders
82 placeholders = self._discover_placeholders(binder)
83 total_placeholders = len(placeholders)
85 self.logger.info('Found %d placeholders to materialize', total_placeholders)
86 if progress_callback:
87 progress_callback(f'Found {total_placeholders} placeholders to materialize...')
89 # If no placeholders, return early
90 if total_placeholders == 0:
91 execution_time = time.time() - start_time
92 return BatchMaterializeResult(
93 total_placeholders=0,
94 successful_materializations=[],
95 failed_materializations=[],
96 execution_time=execution_time,
97 )
99 # Process each placeholder
100 successful_materializations: list[MaterializeResult] = []
101 failed_materializations: list[MaterializeFailure] = []
103 for i, placeholder in enumerate(placeholders, 1):
104 try:
105 # Attempt materialization using existing use case
106 result = self._materialize_single_placeholder(placeholder=placeholder, project_path=project_path)
107 successful_materializations.append(result)
109 # Report progress
110 if progress_callback:
111 progress_callback(f"✓ Materialized '{result.display_title}' → {result.node_id.value}")
113 self.logger.info(
114 'Successfully materialized placeholder %d/%d: %s',
115 i,
116 total_placeholders,
117 placeholder.display_title,
118 )
120 except Exception as e:
121 # Create failure record
122 failure = MaterializeAllPlaceholders._create_failure_record(placeholder, e)
123 failed_materializations.append(failure)
125 # Report progress
126 if progress_callback:
127 progress_callback(f"✗ Failed to materialize '{placeholder.display_title}': {failure.error_message}")
129 self.logger.exception(
130 'Failed to materialize placeholder %d/%d: %s',
131 i,
132 total_placeholders,
133 placeholder.display_title,
134 )
136 # Check if we should stop the batch on critical errors
137 if failure.should_stop_batch:
138 self.logger.exception('Critical error encountered, stopping batch operation')
139 break
141 execution_time = time.time() - start_time
143 # Create final result
144 batch_result = BatchMaterializeResult(
145 total_placeholders=total_placeholders,
146 successful_materializations=successful_materializations,
147 failed_materializations=failed_materializations,
148 execution_time=execution_time,
149 )
151 self.logger.info(
152 'Batch materialization complete: %d successes, %d failures in %.2f seconds',
153 len(successful_materializations),
154 len(failed_materializations),
155 execution_time,
156 )
158 return batch_result
160 def _discover_placeholders(self, binder: Binder) -> list[PlaceholderSummary]:
161 """Discover all placeholders in the binder hierarchy.
163 Args:
164 binder: Binder to scan for placeholders
166 Returns:
167 List of placeholder summaries in hierarchical order
169 """
170 placeholders: list[PlaceholderSummary] = []
171 self._collect_placeholders_recursive(binder.roots, placeholders, parent_title=None, depth=0, position_path=[])
172 return placeholders
174 def _collect_placeholders_recursive(
175 self,
176 items: list[BinderItem],
177 placeholders: list[PlaceholderSummary],
178 parent_title: str | None,
179 depth: int,
180 position_path: list[int] | None = None,
181 ) -> None:
182 """Recursively collect placeholders from binder hierarchy.
184 Args:
185 items: Current level items to process
186 placeholders: List to append discovered placeholders to
187 parent_title: Title of parent item (if any)
188 depth: Current nesting depth
189 position_path: Hierarchical position path from root
191 """
192 for i, item in enumerate(items):
193 # Create position string based on index
194 position_path = position_path or []
195 position = '[' + ']['.join(str(idx) for idx in [*position_path, i]) + ']'
197 if item.node_id is None: # This is a placeholder
198 placeholder = PlaceholderSummary(
199 display_title=item.display_title,
200 position=position,
201 parent_title=parent_title,
202 depth=depth,
203 )
204 placeholders.append(placeholder)
206 # Recursively process children
207 if item.children:
208 self._collect_placeholders_recursive(
209 items=item.children,
210 placeholders=placeholders,
211 parent_title=item.display_title,
212 depth=depth + 1,
213 position_path=[*position_path, i],
214 )
216 def _materialize_single_placeholder(
217 self, *, placeholder: PlaceholderSummary, project_path: Path
218 ) -> MaterializeResult:
219 """Materialize a single placeholder using the existing MaterializeNode use case.
221 Args:
222 placeholder: Placeholder to materialize
223 project_path: Project directory path
225 Returns:
226 MaterializeResult with the outcome
228 Raises:
229 Various exceptions from the underlying materialization process
231 """
232 # Use the existing MaterializeNode use case
233 node_id = self.materialize_node_use_case.execute(title=placeholder.display_title, project_path=project_path)
235 # Create file paths
236 file_paths = [f'{node_id.value}.md', f'{node_id.value}.notes.md']
238 return MaterializeResult(
239 display_title=placeholder.display_title,
240 node_id=node_id,
241 file_paths=file_paths,
242 position=placeholder.position,
243 )
245 @staticmethod
246 def _create_failure_record(placeholder: PlaceholderSummary, error: Exception) -> MaterializeFailure:
247 """Create a MaterializeFailure record from an exception.
249 Args:
250 placeholder: Placeholder that failed to materialize
251 error: Exception that occurred during materialization
253 Returns:
254 MaterializeFailure record with categorized error information
256 """
257 # Categorize the error type based on exception
258 error_type = MaterializeAllPlaceholders._categorize_error(error)
259 error_message = str(error)
261 return MaterializeFailure(
262 display_title=placeholder.display_title,
263 error_type=error_type,
264 error_message=error_message,
265 position=placeholder.position,
266 )
268 @staticmethod
269 def _categorize_error(error: Exception) -> str:
270 """Categorize an exception into a known error type.
272 Args:
273 error: Exception to categorize
275 Returns:
276 Error type string from MaterializeFailure.VALID_ERROR_TYPES
278 """
279 error_type_name = type(error).__name__
281 # Map common exception types to our error categories
282 if 'FileSystem' in error_type_name or 'Permission' in error_type_name or 'OSError' in error_type_name:
283 return 'filesystem'
284 if 'Validation' in error_type_name or 'ValueError' in error_type_name:
285 return 'validation'
286 if 'AlreadyMaterialized' in error_type_name:
287 return 'already_materialized'
288 if 'Binder' in error_type_name or 'Integrity' in error_type_name:
289 return 'binder_integrity'
290 if 'UUID' in error_type_name or 'Id' in error_type_name:
291 return 'id_generation'
292 # Default to filesystem for unknown errors
293 return 'filesystem'