Coverage for src/prosemark/domain/entities.py: 100%

201 statements  

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

1"""Domain entities for prosemark - aliases and additional entity definitions.""" 

2 

3from dataclasses import dataclass 

4from datetime import UTC, datetime 

5from pathlib import Path 

6 

7# Import existing entities from models 

8from prosemark.domain.models import Binder, BinderItem, NodeId, NodeMetadata 

9from prosemark.exceptions import FreeformContentValidationError, NodeValidationError 

10 

11 

12@dataclass(frozen=True) 

13class Node: 

14 """Entity representing a content node with file references. 

15 

16 Node extends NodeMetadata with file path information to create 

17 a complete entity for content management. 

18 

19 Args: 

20 node_id: Unique identifier for the node (UUIDv7) 

21 title: Optional title of the node document 

22 synopsis: Optional synopsis/summary of the node content 

23 created: Creation timestamp as datetime object (accepts datetime or ISO string) 

24 updated: Last update timestamp as datetime object (accepts datetime or ISO string) 

25 draft_path: Path to the {node_id}.md file 

26 notes_path: Path to the {node_id}.notes.md file 

27 

28 """ 

29 

30 id: NodeId 

31 title: str | None 

32 synopsis: str | None 

33 created: datetime 

34 updated: datetime 

35 draft_path: Path 

36 notes_path: Path 

37 

38 def __init__( 

39 self, 

40 node_id: NodeId, 

41 title: str | None, 

42 synopsis: str | None, 

43 created: datetime | str, 

44 updated: datetime | str, 

45 draft_path: Path, 

46 notes_path: Path, 

47 ) -> None: 

48 # Validate required fields 

49 if node_id is None: 

50 msg = 'node_id cannot be None' 

51 raise NodeValidationError(msg) 

52 if draft_path is None: 

53 msg = 'draft_path cannot be None' 

54 raise NodeValidationError(msg) 

55 if notes_path is None: 

56 msg = 'notes_path cannot be None' 

57 raise NodeValidationError(msg) 

58 

59 # Convert string timestamps to datetime objects 

60 created_datetime = datetime.fromisoformat(created) if isinstance(created, str) else created 

61 updated_datetime = datetime.fromisoformat(updated) if isinstance(updated, str) else updated 

62 

63 # Validate timestamp ordering 

64 if updated_datetime < created_datetime: 

65 msg = 'Updated timestamp must be >= created timestamp' 

66 raise NodeValidationError(msg) 

67 

68 object.__setattr__(self, 'id', node_id) 

69 object.__setattr__(self, 'title', title) 

70 object.__setattr__(self, 'synopsis', synopsis) 

71 object.__setattr__(self, 'created', created_datetime) 

72 object.__setattr__(self, 'updated', updated_datetime) 

73 object.__setattr__(self, 'draft_path', draft_path) 

74 object.__setattr__(self, 'notes_path', notes_path) 

75 

76 def get_expected_draft_path(self) -> Path: 

77 """Return the expected draft file path based on node ID prefix.""" 

78 # Use first 8 characters of the UUID for filename 

79 prefix = str(self.id)[:8] 

80 return Path(f'{prefix}.md') 

81 

82 def get_expected_notes_path(self) -> Path: 

83 """Return the expected notes file path based on node ID prefix.""" 

84 # Use first 8 characters of the UUID for filename 

85 prefix = str(self.id)[:8] 

86 return Path(f'{prefix}.notes.md') 

87 

88 def touch(self, new_time: datetime | str | None = None) -> None: 

89 """Update the updated timestamp. 

90 

91 Args: 

92 new_time: Optional datetime or datetime string. Defaults to current time. 

93 

94 """ 

95 if new_time is None: 

96 new_time = datetime.now(UTC) 

97 

98 new_time_datetime = datetime.fromisoformat(new_time) if isinstance(new_time, str) else new_time 

99 object.__setattr__(self, 'updated', new_time_datetime) 

100 

101 def update_metadata( 

102 self, 

103 title: str | None = None, 

104 synopsis: str | None = None, 

105 updated: datetime | str | None = None, 

106 ) -> None: 

107 """Update the node's metadata and optionally its timestamp. 

108 

109 Args: 

110 title: Optional new title 

111 synopsis: Optional new synopsis 

112 updated: Optional timestamp for update 

113 

114 """ 

115 if title is not None: 

116 object.__setattr__(self, 'title', title) 

117 

118 if synopsis is not None: 

119 object.__setattr__(self, 'synopsis', synopsis) 

120 

121 if updated is not None: 

122 self.touch(updated) 

123 

124 @classmethod 

125 def from_metadata(cls, metadata: NodeMetadata, project_root: Path) -> 'Node': 

126 """Create Node from NodeMetadata and project root. 

127 

128 Args: 

129 metadata: NodeMetadata with id, title, synopsis, timestamps 

130 project_root: Root directory of the prosemark project 

131 

132 Returns: 

133 Node entity with computed file paths 

134 

135 """ 

136 draft_path = project_root / f'{metadata.id}.md' 

137 notes_path = project_root / f'{metadata.id}.notes.md' 

138 

139 return cls( 

140 node_id=metadata.id, 

141 title=metadata.title, 

142 synopsis=metadata.synopsis, 

143 created=metadata.created, 

144 updated=metadata.updated, 

145 draft_path=draft_path, 

146 notes_path=notes_path, 

147 ) 

148 

149 def to_metadata(self) -> NodeMetadata: 

150 """Convert Node to NodeMetadata. 

151 

152 Returns: 

153 NodeMetadata with id, title, synopsis, and timestamps 

154 

155 """ 

156 return NodeMetadata( 

157 id=self.id, 

158 title=self.title, 

159 synopsis=self.synopsis, 

160 created=self.created, 

161 updated=self.updated, 

162 ) 

163 

164 def __str__(self) -> str: 

165 """Return a meaningful string representation of the Node. 

166 

167 If title is present, use it. Otherwise, use node ID. 

168 """ 

169 display = self.title or str(self.id) 

170 return f'Node({display})' 

171 

172 def __eq__(self, other: object) -> bool: 

173 """Compare Node instances based on ID.""" 

174 if not isinstance(other, Node): 

175 return False 

176 return self.id == other.id 

177 

178 def __hash__(self) -> int: 

179 """Return hash based on ID for use in sets and dicts.""" 

180 return hash(self.id) 

181 

182 

183@dataclass(frozen=True) 

184class FreeformContent: 

185 """Entity representing timestamped freeform writing file. 

186 

187 FreeformContent represents files created for quick writing that 

188 don't fit into the structured project hierarchy. Files follow 

189 the naming pattern: YYYYMMDDTHHMM_{uuid7}.md 

190 

191 Args: 

192 id: UUIDv7 identifier as string 

193 title: Optional content title 

194 created: Creation timestamp (must match filename timestamp) 

195 file_path: Path to the timestamped file 

196 

197 """ 

198 

199 # Constants for validation 

200 EXPECTED_UUID_VERSION = 7 

201 TIMESTAMP_LENGTH = 13 

202 TIMESTAMP_T_POSITION = 8 

203 MIN_MONTH = 1 

204 MAX_MONTH = 12 

205 MIN_DAY = 1 

206 MAX_DAY = 31 

207 MIN_HOUR = 0 

208 MAX_HOUR = 23 

209 MIN_MINUTE = 0 

210 MAX_MINUTE = 59 

211 

212 id: str 

213 title: str | None 

214 created: str 

215 file_path: Path 

216 

217 def __post_init__(self) -> None: 

218 """Validate freeform content constraints.""" 

219 self._validate_required_fields() 

220 self._validate_uuid_format() 

221 self._validate_filename_pattern() 

222 

223 def _validate_required_fields(self) -> None: 

224 """Validate that required fields are not None.""" 

225 if self.created is None: 

226 msg = 'created timestamp cannot be None' 

227 raise FreeformContentValidationError(msg) 

228 if self.id is None: 

229 msg = 'id cannot be None' 

230 raise FreeformContentValidationError(msg) 

231 

232 def _validate_uuid_format(self) -> None: 

233 """Validate that ID is a properly formatted UUIDv7.""" 

234 try: 

235 import uuid 

236 

237 parsed_uuid = uuid.UUID(self.id) 

238 if parsed_uuid.version != self.EXPECTED_UUID_VERSION: 

239 FreeformContent._raise_uuid_version_error(parsed_uuid.version or 0) 

240 except ValueError as exc: 

241 from prosemark.exceptions import FreeformContentValidationError 

242 

243 msg = f'Invalid UUID format for FreeformContent ID: {self.id}' 

244 raise FreeformContentValidationError(msg) from exc 

245 

246 def _validate_filename_pattern(self) -> None: 

247 """Validate filename follows YYYYMMDDTHHMM_{uuid7}.md pattern.""" 

248 filename = self.file_path.name 

249 if not filename.endswith('.md'): 

250 msg = f'FreeformContent file must end with .md: {filename}' 

251 raise FreeformContentValidationError(msg) 

252 

253 timestamp_part, uuid_part = FreeformContent._extract_filename_parts(filename) 

254 self._validate_uuid_match(uuid_part) 

255 self._validate_timestamp_format(timestamp_part) 

256 self._validate_timestamp_consistency(timestamp_part) 

257 

258 @staticmethod 

259 def _extract_filename_parts(filename: str) -> tuple[str, str]: 

260 """Extract timestamp and UUID parts from filename.""" 

261 if '_' not in filename: 

262 msg = f'FreeformContent filename must contain underscore: {filename}' 

263 raise FreeformContentValidationError(msg) 

264 

265 timestamp_part, uuid_part = filename.rsplit('_', 1) 

266 uuid_part = uuid_part.removesuffix('.md') 

267 return timestamp_part, uuid_part 

268 

269 def _validate_uuid_match(self, uuid_part: str) -> None: 

270 """Validate UUID in filename matches the id.""" 

271 if uuid_part != self.id: 

272 msg = f"UUID in filename ({uuid_part}) doesn't match id ({self.id})" 

273 raise FreeformContentValidationError(msg) 

274 

275 def _validate_timestamp_format(self, timestamp_part: str) -> None: 

276 """Validate timestamp format YYYYMMDDTHHMM.""" 

277 if len(timestamp_part) != self.TIMESTAMP_LENGTH or timestamp_part[self.TIMESTAMP_T_POSITION] != 'T': 

278 msg = f'Invalid timestamp format in filename: {timestamp_part}' 

279 raise FreeformContentValidationError(msg) 

280 

281 self._validate_timestamp_components(timestamp_part) 

282 

283 def _validate_timestamp_components(self, timestamp_part: str) -> None: 

284 """Validate individual timestamp components are valid.""" 

285 try: 

286 int(timestamp_part[0:4]) 

287 month = int(timestamp_part[4:6]) 

288 day = int(timestamp_part[6:8]) 

289 hour = int(timestamp_part[9:11]) 

290 minute = int(timestamp_part[11:13]) 

291 

292 self._validate_time_ranges(month, day, hour, minute) 

293 

294 except ValueError as exc: 

295 msg = f'Invalid timestamp components in filename: {timestamp_part}' 

296 raise FreeformContentValidationError(msg) from exc 

297 

298 def _validate_time_ranges(self, month: int, day: int, hour: int, minute: int) -> None: 

299 """Validate time component ranges.""" 

300 if not (self.MIN_MONTH <= month <= self.MAX_MONTH): 

301 msg = f'Invalid month in timestamp: {month}' 

302 raise FreeformContentValidationError(msg) 

303 if not (self.MIN_DAY <= day <= self.MAX_DAY): 

304 msg = f'Invalid day in timestamp: {day}' 

305 raise FreeformContentValidationError(msg) 

306 if not (self.MIN_HOUR <= hour <= self.MAX_HOUR): 

307 msg = f'Invalid hour in timestamp: {hour}' 

308 raise FreeformContentValidationError(msg) 

309 if not (self.MIN_MINUTE <= minute <= self.MAX_MINUTE): 

310 msg = f'Invalid minute in timestamp: {minute}' 

311 raise FreeformContentValidationError(msg) 

312 

313 def _validate_timestamp_consistency(self, timestamp_part: str) -> None: 

314 """Validate that filename timestamp matches created timestamp.""" 

315 from datetime import datetime 

316 

317 # Parse the created timestamp 

318 try: 

319 created_dt = datetime.fromisoformat(self.created) 

320 except ValueError as exc: 

321 msg = f'Invalid created timestamp format: {self.created}' 

322 raise FreeformContentValidationError(msg) from exc 

323 

324 # Format it as YYYYMMDDTHHMM 

325 expected_timestamp = created_dt.strftime('%Y%m%dT%H%M') 

326 

327 if timestamp_part != expected_timestamp: 

328 msg = f'Filename timestamp ({timestamp_part}) does not match created timestamp ({expected_timestamp})' 

329 raise FreeformContentValidationError(msg) 

330 

331 @staticmethod 

332 def _raise_uuid_version_error(version: int) -> None: 

333 """Raise error for invalid UUID version.""" 

334 msg = f'FreeformContent ID must be UUIDv7, got version {version}' 

335 raise FreeformContentValidationError(msg) 

336 

337 def get_expected_filename(self) -> str: 

338 """Return the expected filename based on created timestamp and ID.""" 

339 # Parse the ISO timestamp and format it as YYYYMMDDTHHMM 

340 from datetime import datetime 

341 

342 created_dt = datetime.fromisoformat(self.created) 

343 timestamp_part = created_dt.strftime('%Y%m%dT%H%M') 

344 return f'{timestamp_part}_{self.id}.md' 

345 

346 def parse_filename(self) -> dict[str, str]: 

347 """Parse the current file's filename into components. 

348 

349 Returns: 

350 Dictionary with 'timestamp', 'uuid', and 'extension' keys 

351 

352 """ 

353 filename = self.file_path.name 

354 

355 if not filename.endswith('.md'): 

356 msg = f'Filename must end with .md: {filename}' 

357 raise FreeformContentValidationError(msg) 

358 

359 base_name = filename.removesuffix('.md') 

360 if '_' not in base_name: 

361 msg = f'Filename must contain underscore: {filename}' 

362 raise FreeformContentValidationError(msg) 

363 

364 timestamp_part, uuid_part = base_name.rsplit('_', 1) 

365 return {'timestamp': timestamp_part, 'uuid': uuid_part, 'extension': '.md'} 

366 

367 def update_title(self, new_title: str | None) -> None: 

368 """Update the title of the content. 

369 

370 Args: 

371 new_title: New title or None to clear the title 

372 

373 """ 

374 object.__setattr__(self, 'title', new_title) 

375 

376 def __eq__(self, other: object) -> bool: 

377 """Compare FreeformContent instances based on ID.""" 

378 if not isinstance(other, FreeformContent): 

379 return False 

380 return self.id == other.id 

381 

382 def __hash__(self) -> int: 

383 """Return hash based on ID for use in sets and dicts.""" 

384 return hash(self.id) 

385 

386 @classmethod 

387 def get_filename_pattern(cls) -> str: 

388 """Return the regex pattern for validating freeform content filenames.""" 

389 return r'\d{8}T\d{4}_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.md' 

390 

391 

392# Re-export all entities for convenient importing 

393__all__ = [ 

394 'Binder', 

395 'BinderItem', 

396 'FreeformContent', 

397 'Node', 

398 'NodeId', 

399 'NodeMetadata', # Include for backward compatibility 

400]