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

41 statements  

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

1"""AddNode use case for adding content nodes to the binder.""" 

2 

3from pathlib import Path 

4from typing import TYPE_CHECKING 

5 

6from prosemark.domain.models import BinderItem, NodeId 

7 

8if TYPE_CHECKING: # pragma: no cover 

9 from prosemark.ports.binder_repo import BinderRepo 

10 from prosemark.ports.clock import Clock 

11 from prosemark.ports.console_port import ConsolePort 

12 from prosemark.ports.id_generator import IdGenerator 

13 from prosemark.ports.logger import Logger 

14 from prosemark.ports.node_repo import NodeRepo 

15 

16 

17class AddNode: 

18 """Add a new content node to the binder hierarchy.""" 

19 

20 def __init__( 

21 self, 

22 *, 

23 binder_repo: 'BinderRepo', 

24 node_repo: 'NodeRepo', 

25 id_generator: 'IdGenerator', 

26 clock: 'Clock', 

27 console: 'ConsolePort', 

28 logger: 'Logger', 

29 ) -> None: 

30 """Initialize the AddNode use case. 

31 

32 Args: 

33 binder_repo: Repository for binder operations. 

34 node_repo: Repository for node operations. 

35 id_generator: Generator for unique node IDs. 

36 clock: Clock for timestamps. 

37 console: Console output port. 

38 logger: Logger port. 

39 

40 """ 

41 self.binder_repo = binder_repo 

42 self.node_repo = node_repo 

43 self.id_generator = id_generator 

44 self.clock = clock 

45 self.console = console 

46 self.logger = logger 

47 

48 def execute( 

49 self, 

50 *, 

51 title: str, 

52 parent_id: NodeId | None = None, 

53 position: int | None = None, 

54 project_path: Path | None = None, 

55 ) -> NodeId: 

56 """Add a new node to the binder. 

57 

58 Args: 

59 title: Title for the new node. 

60 parent_id: Optional parent node ID for nested placement. 

61 position: Optional position within parent's children. 

62 project_path: Project directory path. 

63 

64 Returns: 

65 The ID of the newly created node. 

66 

67 """ 

68 project_path = project_path or Path.cwd() 

69 self.logger.info('Adding node: %s', title) 

70 

71 # Load existing binder 

72 binder = self.binder_repo.load() 

73 

74 # Generate new node ID 

75 node_id = self.id_generator.new() 

76 

77 # Create the node files 

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

79 

80 # Create binder item 

81 new_item = BinderItem(display_title=title, node_id=node_id, children=[]) 

82 

83 # Add to binder hierarchy 

84 if parent_id: 

85 # Find parent and add as child 

86 parent_item = self._find_item(binder.roots, parent_id) 

87 if not parent_item: 

88 self.console.print_error(f'Parent node {parent_id.value} not found') 

89 return node_id 

90 

91 if position is not None and 0 <= position <= len(parent_item.children): 

92 parent_item.children.insert(position, new_item) 

93 else: 

94 parent_item.children.append(new_item) 

95 # Add as root item 

96 elif position is not None and 0 <= position <= len(binder.roots): 

97 binder.roots.insert(position, new_item) 

98 else: 

99 binder.roots.append(new_item) 

100 

101 # Save updated binder 

102 self.binder_repo.save(binder) 

103 

104 self.console.print_success(f'Added "{title}" ({node_id.value})') 

105 self.logger.info('Node added: %s', node_id.value) 

106 

107 return node_id 

108 

109 def _find_item(self, items: list[BinderItem], node_id: NodeId) -> BinderItem | None: 

110 """Find an item by node ID in the hierarchy. 

111 

112 Args: 

113 items: List of binder items to search. 

114 node_id: Node ID to find. 

115 

116 Returns: 

117 The binder item if found, None otherwise. 

118 

119 """ 

120 for item in items: 

121 if item.node_id and item.node_id.value == node_id.value: 

122 return item 

123 found = self._find_item(item.children, node_id) 

124 if found: 

125 return found 

126 return None