Coverage for src/prosemark/app/materialize_node.py: 100%

48 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-09-30 23:09 +0000

1"""MaterializeNode use case for converting placeholders to actual nodes.""" 

2 

3from pathlib import Path 

4from typing import TYPE_CHECKING, NamedTuple 

5 

6from prosemark.domain.models import BinderItem, NodeId 

7from prosemark.exceptions import PlaceholderNotFoundError 

8 

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 

16 

17 

18class MaterializeResult(NamedTuple): 

19 """Result of a materialization operation.""" 

20 

21 node_id: NodeId 

22 was_already_materialized: bool 

23 

24 

25class MaterializeNode: 

26 """Convert placeholder items in the binder to actual content nodes. 

27 

28 Also creates missing notes files for already-materialized nodes. 

29 """ 

30 

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. 

42 

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. 

50 

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 

58 

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. 

66 

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. 

69 

70 Args: 

71 title: Title of the placeholder to materialize. 

72 project_path: Project directory path. 

73 

74 Returns: 

75 MaterializeResult containing the node ID and whether it was already materialized. 

76 

77 Raises: 

78 PlaceholderNotFoundError: If no placeholder with the given title is found. 

79 

80 """ 

81 project_path = project_path or Path.cwd() 

82 self.logger.info('Materializing placeholder: %s', title) 

83 

84 # Load existing binder 

85 binder = self.binder_repo.load() 

86 

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) 

92 

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) 

105 

106 # Generate new node ID 

107 node_id = self.id_generator.new() 

108 

109 # Create the node files 

110 self.node_repo.create(node_id, title, None) 

111 

112 # Update the item with the node ID 

113 item.node_id = node_id 

114 

115 # Save updated binder 

116 self.binder_repo.save(binder) 

117 

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) 

121 

122 return MaterializeResult(node_id, was_already_materialized=False) 

123 

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

126 

127 Args: 

128 items: List of binder items to search. 

129 title: Title to search for. 

130 

131 Returns: 

132 The item if found, None otherwise. 

133 

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