Coverage for src/prosemark/app/materialize_node.py: 100%
48 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"""MaterializeNode use case for converting placeholders to actual nodes."""
3from pathlib import Path
4from typing import TYPE_CHECKING, NamedTuple
6from prosemark.domain.models import BinderItem, NodeId
7from prosemark.exceptions import PlaceholderNotFoundError
9if TYPE_CHECKING: # pragma: no cover
10 from prosemark.ports.binder_repo import BinderRepo
11 from prosemark.ports.clock import Clock
12 from prosemark.ports.console_port import ConsolePort
13 from prosemark.ports.id_generator import IdGenerator
14 from prosemark.ports.logger import Logger
15 from prosemark.ports.node_repo import NodeRepo
18class MaterializeResult(NamedTuple):
19 """Result of a materialization operation."""
21 node_id: NodeId
22 was_already_materialized: bool
25class MaterializeNode:
26 """Convert placeholder items in the binder to actual content nodes.
28 Also creates missing notes files for already-materialized nodes.
29 """
31 def __init__(
32 self,
33 *,
34 binder_repo: 'BinderRepo',
35 node_repo: 'NodeRepo',
36 id_generator: 'IdGenerator',
37 clock: 'Clock',
38 console: 'ConsolePort',
39 logger: 'Logger',
40 ) -> None:
41 """Initialize the MaterializeNode use case.
43 Args:
44 binder_repo: Repository for binder operations.
45 node_repo: Repository for node operations.
46 id_generator: Generator for unique node IDs.
47 clock: Clock for timestamps.
48 console: Console output port.
49 logger: Logger port.
51 """
52 self.binder_repo = binder_repo
53 self.node_repo = node_repo
54 self.id_generator = id_generator
55 self.clock = clock
56 self.console = console
57 self.logger = logger
59 def execute(
60 self,
61 *,
62 title: str,
63 project_path: Path | None = None,
64 ) -> MaterializeResult:
65 """Materialize a placeholder into a real node.
67 If the placeholder is already materialized but is missing a notes file,
68 this method will create the missing notes file with an obsidian-style link.
70 Args:
71 title: Title of the placeholder to materialize.
72 project_path: Project directory path.
74 Returns:
75 MaterializeResult containing the node ID and whether it was already materialized.
77 Raises:
78 PlaceholderNotFoundError: If no placeholder with the given title is found.
80 """
81 project_path = project_path or Path.cwd()
82 self.logger.info('Materializing placeholder: %s', title)
84 # Load existing binder
85 binder = self.binder_repo.load()
87 # Find the item by title (placeholder or materialized)
88 item = self._find_item_by_title(binder.roots, title)
89 if not item:
90 msg = f"Item '{title}' not found"
91 raise PlaceholderNotFoundError(msg)
93 # Check if already materialized
94 if item.node_id:
95 # Node is already materialized, but check if notes file is missing
96 existing_node_id = item.node_id
97 if not self.node_repo.file_exists(existing_node_id, 'notes'):
98 self.logger.info('Creating missing notes file for: %s', existing_node_id.value)
99 self.node_repo.create_notes_file(existing_node_id)
100 self.console.print_success(f'Created missing notes file for "{title}" ({existing_node_id.value})')
101 self.console.print_info(f'Created file: {existing_node_id.value}.notes.md')
102 else:
103 self.console.print_warning(f"'{title}' is already materialized")
104 return MaterializeResult(existing_node_id, was_already_materialized=True)
106 # Generate new node ID
107 node_id = self.id_generator.new()
109 # Create the node files
110 self.node_repo.create(node_id, title, None)
112 # Update the item with the node ID
113 item.node_id = node_id
115 # Save updated binder
116 self.binder_repo.save(binder)
118 self.console.print_success(f'Materialized "{title}" ({node_id.value})')
119 self.console.print_info(f'Created files: {node_id.value}.md, {node_id.value}.notes.md')
120 self.logger.info('Placeholder materialized: %s -> %s', title, node_id.value)
122 return MaterializeResult(node_id, was_already_materialized=False)
124 def _find_item_by_title(self, items: list[BinderItem], title: str) -> BinderItem | None:
125 """Find an item by title in the hierarchy (placeholder or materialized).
127 Args:
128 items: List of binder items to search.
129 title: Title to search for.
131 Returns:
132 The item if found, None otherwise.
134 """
135 for item in items:
136 if item.display_title == title:
137 return item
138 found = self._find_item_by_title(item.children, title)
139 if found:
140 return found
141 return None