#
tokens: 11863/50000 1/15 files (page 2/2)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 of 2. Use http://codebase.md/philosolares/roam-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .dockerignore
├── .gitignore
├── Dockerfile
├── LICENSE
├── pyproject.toml
├── readme.md
├── requirements.txt
├── roam_mcp
│   ├── __init__.py
│   ├── api.py
│   ├── cli.py
│   ├── content_parsers.py
│   ├── content.py
│   ├── memory.py
│   ├── search.py
│   ├── server.py
│   └── utils.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/roam_mcp/search.py:
--------------------------------------------------------------------------------

```python
   1 | """Search operations for the Roam MCP server."""
   2 | 
   3 | from typing import Dict, List, Any, Optional, Union, Set
   4 | from datetime import datetime, timedelta
   5 | import re
   6 | import logging
   7 | 
   8 | from roam_mcp.api import (
   9 |     execute_query,
  10 |     get_session_and_headers,
  11 |     GRAPH_NAME,
  12 |     find_page_by_title,
  13 |     ValidationError,
  14 |     QueryError,
  15 |     PageNotFoundError,
  16 |     BlockNotFoundError
  17 | )
  18 | from roam_mcp.utils import (
  19 |     format_roam_date,
  20 |     resolve_block_references
  21 | )
  22 | 
  23 | # Set up logging
  24 | logger = logging.getLogger("roam-mcp.search")
  25 | 
  26 | 
  27 | def validate_search_params(text: Optional[str] = None, tag: Optional[str] = None, 
  28 |                           status: Optional[str] = None, page_title_uid: Optional[str] = None):
  29 |     """
  30 |     Validate common search parameters.
  31 |     
  32 |     Args:
  33 |         text: Optional text to search for
  34 |         tag: Optional tag to search for
  35 |         status: Optional status to search for
  36 |         page_title_uid: Optional page title or UID
  37 |         
  38 |     Raises:
  39 |         ValidationError: If parameters are invalid
  40 |     """
  41 |     if status and status not in ["TODO", "DONE"]:
  42 |         raise ValidationError("Status must be 'TODO' or 'DONE'", "status")
  43 | 
  44 | 
  45 | def search_by_text(text: str, page_title_uid: Optional[str] = None, case_sensitive: bool = True) -> Dict[str, Any]:
  46 |     """
  47 |     Search for blocks containing specific text.
  48 |     
  49 |     Args:
  50 |         text: Text to search for
  51 |         page_title_uid: Optional page title or UID to scope the search
  52 |         case_sensitive: Whether to perform case-sensitive search
  53 |         
  54 |     Returns:
  55 |         Search results
  56 |     """
  57 |     if not text:
  58 |         return {
  59 |             "success": False,
  60 |             "matches": [],
  61 |             "message": "Search text cannot be empty"
  62 |         }
  63 |     
  64 |     session, headers = get_session_and_headers()
  65 |     
  66 |     # Prepare the query
  67 |     if case_sensitive:
  68 |         text_condition = f'(clojure.string/includes? ?s "{text}")'
  69 |     else:
  70 |         text_condition = f'(clojure.string/includes? (clojure.string/lower-case ?s) "{text.lower()}")'
  71 |     
  72 |     try:
  73 |         if page_title_uid:
  74 |             # Try to find the page UID if a title was provided
  75 |             page_uid = find_page_by_title(session, headers, GRAPH_NAME, page_title_uid)
  76 |             
  77 |             if not page_uid:
  78 |                 return {
  79 |                     "success": False,
  80 |                     "matches": [],
  81 |                     "message": f"Page '{page_title_uid}' not found"
  82 |                 }
  83 |                 
  84 |             query = f"""[:find ?uid ?s ?order
  85 |                       :where
  86 |                       [?p :block/uid "{page_uid}"]
  87 |                       [?b :block/page ?p]
  88 |                       [?b :block/string ?s]
  89 |                       [?b :block/uid ?uid]
  90 |                       [?b :block/order ?order]
  91 |                       [{text_condition}]]"""
  92 |         else:
  93 |             query = f"""[:find ?uid ?s ?page-title
  94 |                       :where
  95 |                       [?b :block/string ?s]
  96 |                       [?b :block/uid ?uid]
  97 |                       [?b :block/page ?p]
  98 |                       [?p :node/title ?page-title]
  99 |                       [{text_condition}]]"""
 100 |         
 101 |         # Execute the query
 102 |         logger.debug(f"Executing text search for: {text}")
 103 |         results = execute_query(query)
 104 |         
 105 |         # Process the results
 106 |         matches = []
 107 |         if page_title_uid:
 108 |             # For page-specific search, results are [uid, content, order]
 109 |             for uid, content, order in results:
 110 |                 # Resolve references if present
 111 |                 resolved_content = resolve_block_references(session, headers, GRAPH_NAME, content)
 112 |                 
 113 |                 matches.append({
 114 |                     "block_uid": uid,
 115 |                     "content": resolved_content,
 116 |                     "page_title": page_title_uid
 117 |                 })
 118 |         else:
 119 |             # For global search, results are [uid, content, page_title]
 120 |             for uid, content, page_title in results:
 121 |                 # Resolve references if present
 122 |                 resolved_content = resolve_block_references(session, headers, GRAPH_NAME, content)
 123 |                 
 124 |                 matches.append({
 125 |                     "block_uid": uid,
 126 |                     "content": resolved_content,
 127 |                     "page_title": page_title
 128 |                 })
 129 |         
 130 |         return {
 131 |             "success": True,
 132 |             "matches": matches,
 133 |             "message": f"Found {len(matches)} block(s) containing \"{text}\""
 134 |         }
 135 |     except PageNotFoundError as e:
 136 |         return {
 137 |             "success": False,
 138 |             "matches": [],
 139 |             "message": str(e)
 140 |         }
 141 |     except QueryError as e:
 142 |         return {
 143 |             "success": False,
 144 |             "matches": [],
 145 |             "message": str(e)
 146 |         }
 147 |     except Exception as e:
 148 |         logger.error(f"Error searching by text: {str(e)}")
 149 |         return {
 150 |             "success": False,
 151 |             "matches": [],
 152 |             "message": f"Error searching by text: {str(e)}"
 153 |         }
 154 | 
 155 | 
 156 | def search_by_tag(tag: str, page_title_uid: Optional[str] = None, near_tag: Optional[str] = None) -> Dict[str, Any]:
 157 |     """
 158 |     Search for blocks containing a specific tag.
 159 |     
 160 |     Args:
 161 |         tag: Tag to search for (without # or [[ ]])
 162 |         page_title_uid: Optional page title or UID to scope the search
 163 |         near_tag: Optional second tag that must appear in the same block
 164 |         
 165 |     Returns:
 166 |         Search results
 167 |     """
 168 |     if not tag:
 169 |         return {
 170 |             "success": False,
 171 |             "matches": [],
 172 |             "message": "Tag cannot be empty"
 173 |         }
 174 |     
 175 |     session, headers = get_session_and_headers()
 176 |     
 177 |     # Format the tag for searching
 178 |     # Remove any existing formatting
 179 |     clean_tag = tag.replace('#', '').replace('[[', '').replace(']]', '')
 180 |     tag_variants = [f"#{clean_tag}", f"#[[{clean_tag}]]", f"[[{clean_tag}]]"]
 181 |     
 182 |     # Build tag conditions
 183 |     tag_conditions = []
 184 |     for variant in tag_variants:
 185 |         tag_conditions.append(f'(clojure.string/includes? ?s "{variant}")')
 186 |     
 187 |     tag_condition = f"(or {' '.join(tag_conditions)})"
 188 |     
 189 |     # Add near_tag condition if provided
 190 |     if near_tag:
 191 |         clean_near_tag = near_tag.replace('#', '').replace('[[', '').replace(']]', '')
 192 |         near_tag_variants = [f"#{clean_near_tag}", f"#[[{clean_near_tag}]]", f"[[{clean_near_tag}]]"]
 193 |         
 194 |         near_tag_conditions = []
 195 |         for variant in near_tag_variants:
 196 |             near_tag_conditions.append(f'(clojure.string/includes? ?s "{variant}")')
 197 |         
 198 |         near_tag_condition = f"(or {' '.join(near_tag_conditions)})"
 199 |         combined_condition = f"(and {tag_condition} {near_tag_condition})"
 200 |     else:
 201 |         combined_condition = tag_condition
 202 |     
 203 |     try:
 204 |         # Build query based on whether we're searching in a specific page
 205 |         if page_title_uid:
 206 |             # Try to find the page UID if a title was provided
 207 |             page_uid = find_page_by_title(session, headers, GRAPH_NAME, page_title_uid)
 208 |             
 209 |             if not page_uid:
 210 |                 return {
 211 |                     "success": False,
 212 |                     "matches": [],
 213 |                     "message": f"Page '{page_title_uid}' not found"
 214 |                 }
 215 |                 
 216 |             query = f"""[:find ?uid ?s
 217 |                       :where
 218 |                       [?p :block/uid "{page_uid}"]
 219 |                       [?b :block/page ?p]
 220 |                       [?b :block/string ?s]
 221 |                       [?b :block/uid ?uid]
 222 |                       [{combined_condition}]]"""
 223 |         else:
 224 |             query = f"""[:find ?uid ?s ?page-title
 225 |                       :where
 226 |                       [?b :block/string ?s]
 227 |                       [?b :block/uid ?uid]
 228 |                       [?b :block/page ?p]
 229 |                       [?p :node/title ?page-title]
 230 |                       [{combined_condition}]]"""
 231 |         
 232 |         # Execute the query
 233 |         logger.debug(f"Executing tag search for: {tag}")
 234 |         if near_tag:
 235 |             logger.debug(f"With near tag: {near_tag}")
 236 |             
 237 |         results = execute_query(query)
 238 |         
 239 |         # Process the results
 240 |         matches = []
 241 |         if page_title_uid:
 242 |             # For page-specific search, results are [uid, content]
 243 |             for uid, content in results:
 244 |                 # Resolve references if present
 245 |                 resolved_content = resolve_block_references(session, headers, GRAPH_NAME, content)
 246 |                 
 247 |                 matches.append({
 248 |                     "block_uid": uid,
 249 |                     "content": resolved_content,
 250 |                     "page_title": page_title_uid
 251 |                 })
 252 |         else:
 253 |             # For global search, results are [uid, content, page_title]
 254 |             for uid, content, page_title in results:
 255 |                 # Resolve references if present
 256 |                 resolved_content = resolve_block_references(session, headers, GRAPH_NAME, content)
 257 |                 
 258 |                 matches.append({
 259 |                     "block_uid": uid,
 260 |                     "content": resolved_content,
 261 |                     "page_title": page_title
 262 |                 })
 263 |         
 264 |         # Build message
 265 |         message = f"Found {len(matches)} block(s) with tag #{clean_tag}"
 266 |         if near_tag:
 267 |             message += f" near #{clean_near_tag}"
 268 |         
 269 |         return {
 270 |             "success": True,
 271 |             "matches": matches,
 272 |             "message": message
 273 |         }
 274 |     except PageNotFoundError as e:
 275 |         return {
 276 |             "success": False,
 277 |             "matches": [],
 278 |             "message": str(e)
 279 |         }
 280 |     except QueryError as e:
 281 |         return {
 282 |             "success": False,
 283 |             "matches": [],
 284 |             "message": str(e)
 285 |         }
 286 |     except Exception as e:
 287 |         logger.error(f"Error searching by tag: {str(e)}")
 288 |         return {
 289 |             "success": False,
 290 |             "matches": [],
 291 |             "message": f"Error searching by tag: {str(e)}"
 292 |         }
 293 | 
 294 | 
 295 | def search_by_status(status: str, page_title_uid: Optional[str] = None, include: Optional[str] = None, exclude: Optional[str] = None) -> Dict[str, Any]:
 296 |     """
 297 |     Search for blocks with a specific status (TODO/DONE).
 298 |     
 299 |     Args:
 300 |         status: Status to search for ("TODO" or "DONE")
 301 |         page_title_uid: Optional page title or UID to scope the search
 302 |         include: Optional comma-separated keywords to include
 303 |         exclude: Optional comma-separated keywords to exclude
 304 |         
 305 |     Returns:
 306 |         Search results
 307 |     """
 308 |     if status not in ["TODO", "DONE"]:
 309 |         return {
 310 |             "success": False,
 311 |             "matches": [],
 312 |             "message": "Status must be either 'TODO' or 'DONE'"
 313 |         }
 314 |     
 315 |     session, headers = get_session_and_headers()
 316 |     
 317 |     # Status pattern
 318 |     status_pattern = f"{{{{[[{status}]]}}}}"
 319 |     
 320 |     try:
 321 |         # Build query based on whether we're searching in a specific page
 322 |         if page_title_uid:
 323 |             # Try to find the page UID if a title was provided
 324 |             page_uid = find_page_by_title(session, headers, GRAPH_NAME, page_title_uid)
 325 |             
 326 |             if not page_uid:
 327 |                 return {
 328 |                     "success": False,
 329 |                     "matches": [],
 330 |                     "message": f"Page '{page_title_uid}' not found"
 331 |                 }
 332 |                 
 333 |             query = f"""[:find ?uid ?s
 334 |                       :where
 335 |                       [?p :block/uid "{page_uid}"]
 336 |                       [?b :block/page ?p]
 337 |                       [?b :block/string ?s]
 338 |                       [?b :block/uid ?uid]
 339 |                       [(clojure.string/includes? ?s "{status_pattern}")]]"""
 340 |         else:
 341 |             query = f"""[:find ?uid ?s ?page-title
 342 |                       :where
 343 |                       [?b :block/string ?s]
 344 |                       [?b :block/uid ?uid]
 345 |                       [?b :block/page ?p]
 346 |                       [?p :node/title ?page-title]
 347 |                       [(clojure.string/includes? ?s "{status_pattern}")]]"""
 348 |         
 349 |         # Execute the query
 350 |         logger.debug(f"Executing status search for: {status}")
 351 |         results = execute_query(query)
 352 |         
 353 |         # Process the results
 354 |         matches = []
 355 |         if page_title_uid:
 356 |             # For page-specific search, results are [uid, content]
 357 |             for uid, content in results:
 358 |                 # Resolve references if present
 359 |                 resolved_content = resolve_block_references(session, headers, GRAPH_NAME, content)
 360 |                 
 361 |                 # Apply include/exclude filters
 362 |                 if include:
 363 |                     include_terms = [term.strip().lower() for term in include.split(',')]
 364 |                     if not any(term in resolved_content.lower() for term in include_terms):
 365 |                         continue
 366 |                         
 367 |                 if exclude:
 368 |                     exclude_terms = [term.strip().lower() for term in exclude.split(',')]
 369 |                     if any(term in resolved_content.lower() for term in exclude_terms):
 370 |                         continue
 371 |                 
 372 |                 matches.append({
 373 |                     "block_uid": uid,
 374 |                     "content": resolved_content,
 375 |                     "page_title": page_title_uid
 376 |                 })
 377 |         else:
 378 |             # For global search, results are [uid, content, page_title]
 379 |             for uid, content, page_title in results:
 380 |                 # Resolve references if present
 381 |                 resolved_content = resolve_block_references(session, headers, GRAPH_NAME, content)
 382 |                 
 383 |                 # Apply include/exclude filters
 384 |                 if include:
 385 |                     include_terms = [term.strip().lower() for term in include.split(',')]
 386 |                     if not any(term in resolved_content.lower() for term in include_terms):
 387 |                         continue
 388 |                         
 389 |                 if exclude:
 390 |                     exclude_terms = [term.strip().lower() for term in exclude.split(',')]
 391 |                     if any(term in resolved_content.lower() for term in exclude_terms):
 392 |                         continue
 393 |                 
 394 |                 matches.append({
 395 |                     "block_uid": uid,
 396 |                     "content": resolved_content,
 397 |                     "page_title": page_title
 398 |                 })
 399 |         
 400 |         # Build message
 401 |         message = f"Found {len(matches)} block(s) with status {status}"
 402 |         if include:
 403 |             message += f" including '{include}'"
 404 |         if exclude:
 405 |             message += f" excluding '{exclude}'"
 406 |         
 407 |         return {
 408 |             "success": True,
 409 |             "matches": matches,
 410 |             "message": message
 411 |         }
 412 |     except PageNotFoundError as e:
 413 |         return {
 414 |             "success": False,
 415 |             "matches": [],
 416 |             "message": str(e)
 417 |         }
 418 |     except QueryError as e:
 419 |         return {
 420 |             "success": False,
 421 |             "matches": [],
 422 |             "message": str(e)
 423 |         }
 424 |     except Exception as e:
 425 |         logger.error(f"Error searching by status: {str(e)}")
 426 |         return {
 427 |             "success": False,
 428 |             "matches": [],
 429 |             "message": f"Error searching by status: {str(e)}"
 430 |         }
 431 | 
 432 | 
 433 | def search_block_refs(block_uid: Optional[str] = None, page_title_uid: Optional[str] = None) -> Dict[str, Any]:
 434 |     """
 435 |     Search for block references.
 436 |     
 437 |     Args:
 438 |         block_uid: Optional UID of the block to find references to
 439 |         page_title_uid: Optional page title or UID to scope the search
 440 |         
 441 |     Returns:
 442 |         Search results
 443 |     """
 444 |     session, headers = get_session_and_headers()
 445 |     
 446 |     # Determine what kind of search we're doing
 447 |     if block_uid:
 448 |         block_ref_pattern = f"(({block_uid}))"
 449 |         description = f"referencing block (({block_uid}))"
 450 |     else:
 451 |         block_ref_pattern = "\\(\\([^)]+\\)\\)"
 452 |         description = "containing block references"
 453 |     
 454 |     try:
 455 |         # Build query based on whether we're searching in a specific page
 456 |         if page_title_uid:
 457 |             # Try to find the page UID if a title was provided
 458 |             page_uid = find_page_by_title(session, headers, GRAPH_NAME, page_title_uid)
 459 |             
 460 |             if not page_uid:
 461 |                 return {
 462 |                     "success": False,
 463 |                     "matches": [],
 464 |                     "message": f"Page '{page_title_uid}' not found"
 465 |                 }
 466 |                 
 467 |             if block_uid:
 468 |                 query = f"""[:find ?uid ?s
 469 |                           :where
 470 |                           [?p :block/uid "{page_uid}"]
 471 |                           [?b :block/page ?p]
 472 |                           [?b :block/string ?s]
 473 |                           [?b :block/uid ?uid]
 474 |                           [(clojure.string/includes? ?s "{block_ref_pattern}")]]"""
 475 |             else:
 476 |                 query = f"""[:find ?uid ?s
 477 |                           :where
 478 |                           [?p :block/uid "{page_uid}"]
 479 |                           [?b :block/page ?p]
 480 |                           [?b :block/string ?s]
 481 |                           [?b :block/uid ?uid]
 482 |                           [(re-find #"\\(\\([^)]+\\)\\)" ?s)]]"""
 483 |         else:
 484 |             if block_uid:
 485 |                 query = f"""[:find ?uid ?s ?page-title
 486 |                           :where
 487 |                           [?b :block/string ?s]
 488 |                           [?b :block/uid ?uid]
 489 |                           [?b :block/page ?p]
 490 |                           [?p :node/title ?page-title]
 491 |                           [(clojure.string/includes? ?s "{block_ref_pattern}")]]"""
 492 |             else:
 493 |                 query = f"""[:find ?uid ?s ?page-title
 494 |                           :where
 495 |                           [?b :block/string ?s]
 496 |                           [?b :block/uid ?uid]
 497 |                           [?b :block/page ?p]
 498 |                           [?p :node/title ?page-title]
 499 |                           [(re-find #"\\(\\([^)]+\\)\\)" ?s)]]"""
 500 |         
 501 |         # Execute the query
 502 |         logger.debug(f"Executing block reference search")
 503 |         results = execute_query(query)
 504 |         
 505 |         # Process the results
 506 |         matches = []
 507 |         if page_title_uid:
 508 |             # For page-specific search, results are [uid, content]
 509 |             for uid, content in results:
 510 |                 # Resolve references if present
 511 |                 resolved_content = resolve_block_references(session, headers, GRAPH_NAME, content)
 512 |                 
 513 |                 matches.append({
 514 |                     "block_uid": uid,
 515 |                     "content": resolved_content,
 516 |                     "page_title": page_title_uid
 517 |                 })
 518 |         else:
 519 |             # For global search, results are [uid, content, page_title]
 520 |             for uid, content, page_title in results:
 521 |                 # Resolve references if present
 522 |                 resolved_content = resolve_block_references(session, headers, GRAPH_NAME, content)
 523 |                 
 524 |                 matches.append({
 525 |                     "block_uid": uid,
 526 |                     "content": resolved_content,
 527 |                     "page_title": page_title
 528 |                 })
 529 |         
 530 |         return {
 531 |             "success": True,
 532 |             "matches": matches,
 533 |             "message": f"Found {len(matches)} block(s) {description}"
 534 |         }
 535 |     except PageNotFoundError as e:
 536 |         return {
 537 |             "success": False,
 538 |             "matches": [],
 539 |             "message": str(e)
 540 |         }
 541 |     except QueryError as e:
 542 |         return {
 543 |             "success": False,
 544 |             "matches": [],
 545 |             "message": str(e)
 546 |         }
 547 |     except Exception as e:
 548 |         logger.error(f"Error searching block references: {str(e)}")
 549 |         return {
 550 |             "success": False,
 551 |             "matches": [],
 552 |             "message": f"Error searching block references: {str(e)}"
 553 |         }
 554 | 
 555 | 
 556 | def search_hierarchy(parent_uid: Optional[str] = None, child_uid: Optional[str] = None, 
 557 |                      page_title_uid: Optional[str] = None, max_depth: int = 1) -> Dict[str, Any]:
 558 |     """
 559 |     Search for parents or children in the block hierarchy.
 560 |     
 561 |     Args:
 562 |         parent_uid: Optional UID of the block to find children of
 563 |         child_uid: Optional UID of the block to find parents of
 564 |         page_title_uid: Optional page title or UID to scope the search
 565 |         max_depth: Maximum depth to search
 566 |         
 567 |     Returns:
 568 |         Search results
 569 |     """
 570 |     if not parent_uid and not child_uid:
 571 |         return {
 572 |             "success": False,
 573 |             "matches": [],
 574 |             "message": "Either parent_uid or child_uid must be provided"
 575 |         }
 576 |     
 577 |     if max_depth < 1:
 578 |         return {
 579 |             "success": False,
 580 |             "matches": [],
 581 |             "message": "max_depth must be at least 1"
 582 |         }
 583 |         
 584 |     if max_depth > 10:
 585 |         max_depth = 10
 586 |         logger.warning("max_depth limited to 10")
 587 |     
 588 |     session, headers = get_session_and_headers()
 589 |     
 590 |     # Define ancestor rule
 591 |     ancestor_rule = """[
 592 |         [(ancestor ?child ?parent ?depth)
 593 |             [?parent :block/children ?child]
 594 |             [(identity 1) ?depth]]
 595 |         [(ancestor ?child ?parent ?depth)
 596 |             [?mid :block/children ?child]
 597 |             (ancestor ?mid ?parent ?prev-depth)
 598 |             [(+ ?prev-depth 1) ?depth]]
 599 |     ]"""
 600 |     
 601 |     try:
 602 |         # Determine search type and build query
 603 |         if parent_uid:
 604 |             # Searching for children
 605 |             if page_title_uid:
 606 |                 # Try to find the page UID if a title was provided
 607 |                 page_uid = find_page_by_title(session, headers, GRAPH_NAME, page_title_uid)
 608 |                 
 609 |                 if not page_uid:
 610 |                     return {
 611 |                         "success": False,
 612 |                         "matches": [],
 613 |                         "message": f"Page '{page_title_uid}' not found"
 614 |                     }
 615 |                     
 616 |                 query = f"""[:find ?uid ?s ?depth
 617 |                           :in $ % ?parent-uid ?max-depth
 618 |                           :where
 619 |                           [?parent :block/uid ?parent-uid]
 620 |                           [?p :block/uid "{page_uid}"]
 621 |                           (ancestor ?b ?parent ?depth)
 622 |                           [?b :block/string ?s]
 623 |                           [?b :block/uid ?uid]
 624 |                           [?b :block/page ?p]
 625 |                           [(<= ?depth ?max-depth)]]"""
 626 |                 inputs = [ancestor_rule, parent_uid, max_depth]
 627 |             else:
 628 |                 query = f"""[:find ?uid ?s ?page-title ?depth
 629 |                           :in $ % ?parent-uid ?max-depth
 630 |                           :where
 631 |                           [?parent :block/uid ?parent-uid]
 632 |                           (ancestor ?b ?parent ?depth)
 633 |                           [?b :block/string ?s]
 634 |                           [?b :block/uid ?uid]
 635 |                           [?b :block/page ?p]
 636 |                           [?p :node/title ?page-title]
 637 |                           [(<= ?depth ?max-depth)]]"""
 638 |                 inputs = [ancestor_rule, parent_uid, max_depth]
 639 |             
 640 |             description = f"descendants of block {parent_uid}"
 641 |         else:
 642 |             # Searching for parents
 643 |             if page_title_uid:
 644 |                 # Try to find the page UID if a title was provided
 645 |                 page_uid = find_page_by_title(session, headers, GRAPH_NAME, page_title_uid)
 646 |                 
 647 |                 if not page_uid:
 648 |                     return {
 649 |                         "success": False,
 650 |                         "matches": [],
 651 |                         "message": f"Page '{page_title_uid}' not found"
 652 |                     }
 653 |                     
 654 |                 query = f"""[:find ?uid ?s ?depth
 655 |                           :in $ % ?child-uid ?max-depth
 656 |                           :where
 657 |                           [?child :block/uid ?child-uid]
 658 |                           [?p :block/uid "{page_uid}"]
 659 |                           (ancestor ?child ?b ?depth)
 660 |                           [?b :block/string ?s]
 661 |                           [?b :block/uid ?uid]
 662 |                           [?b :block/page ?p]
 663 |                           [(<= ?depth ?max-depth)]]"""
 664 |                 inputs = [ancestor_rule, child_uid, max_depth]
 665 |             else:
 666 |                 query = f"""[:find ?uid ?s ?page-title ?depth
 667 |                           :in $ % ?child-uid ?max-depth
 668 |                           :where
 669 |                           [?child :block/uid ?child-uid]
 670 |                           (ancestor ?child ?b ?depth)
 671 |                           [?b :block/string ?s]
 672 |                           [?b :block/uid ?uid]
 673 |                           [?b :block/page ?p]
 674 |                           [?p :node/title ?page-title]
 675 |                           [(<= ?depth ?max-depth)]]"""
 676 |                 inputs = [ancestor_rule, child_uid, max_depth]
 677 |             
 678 |             description = f"ancestors of block {child_uid}"
 679 |         
 680 |         # Execute the query
 681 |         logger.debug(f"Executing hierarchy search with max_depth: {max_depth}")
 682 |         results = execute_query(query, inputs)
 683 |         
 684 |         # Process the results
 685 |         matches = []
 686 |         if page_title_uid:
 687 |             # For page-specific search, results are [uid, content, depth]
 688 |             for uid, content, depth in results:
 689 |                 # Resolve references if present
 690 |                 resolved_content = resolve_block_references(session, headers, GRAPH_NAME, content)
 691 |                 
 692 |                 matches.append({
 693 |                     "block_uid": uid,
 694 |                     "content": resolved_content,
 695 |                     "depth": depth,
 696 |                     "page_title": page_title_uid
 697 |                 })
 698 |         else:
 699 |             # For global search, results are [uid, content, page_title, depth]
 700 |             for uid, content, page_title, depth in results:
 701 |                 # Resolve references if present
 702 |                 resolved_content = resolve_block_references(session, headers, GRAPH_NAME, content)
 703 |                 
 704 |                 matches.append({
 705 |                     "block_uid": uid,
 706 |                     "content": resolved_content,
 707 |                     "depth": depth,
 708 |                     "page_title": page_title
 709 |                 })
 710 |         
 711 |         return {
 712 |             "success": True,
 713 |             "matches": matches,
 714 |             "message": f"Found {len(matches)} block(s) as {description}"
 715 |         }
 716 |     except PageNotFoundError as e:
 717 |         return {
 718 |             "success": False,
 719 |             "matches": [],
 720 |             "message": str(e)
 721 |         }
 722 |     except BlockNotFoundError as e:
 723 |         return {
 724 |             "success": False,
 725 |             "matches": [],
 726 |             "message": str(e)
 727 |         }
 728 |     except QueryError as e:
 729 |         return {
 730 |             "success": False,
 731 |             "matches": [],
 732 |             "message": str(e)
 733 |         }
 734 |     except Exception as e:
 735 |         logger.error(f"Error searching hierarchy: {str(e)}")
 736 |         return {
 737 |             "success": False,
 738 |             "matches": [],
 739 |             "message": f"Error searching hierarchy: {str(e)}"
 740 |         }
 741 | 
 742 | 
 743 | def search_by_date(start_date: str, end_date: Optional[str] = None, 
 744 |                    type_filter: str = "created", scope: str = "blocks",
 745 |                    include_content: bool = True) -> Dict[str, Any]:
 746 |     """
 747 |     Search for blocks or pages based on creation or modification dates.
 748 |     
 749 |     Args:
 750 |         start_date: Start date in ISO format (YYYY-MM-DD)
 751 |         end_date: Optional end date in ISO format (YYYY-MM-DD)
 752 |         type_filter: Whether to search by "created", "modified", or "both"
 753 |         scope: Whether to search "blocks", "pages", or "both"
 754 |         include_content: Whether to include block/page content
 755 |         
 756 |     Returns:
 757 |         Search results
 758 |     """
 759 |     # Validate inputs
 760 |     if type_filter not in ["created", "modified", "both"]:
 761 |         return {
 762 |             "success": False,
 763 |             "matches": [],
 764 |             "message": "Type must be 'created', 'modified', or 'both'"
 765 |         }
 766 |     
 767 |     if scope not in ["blocks", "pages", "both"]:
 768 |         return {
 769 |             "success": False,
 770 |             "matches": [],
 771 |             "message": "Scope must be 'blocks', 'pages', or 'both'"
 772 |         }
 773 |     
 774 |     # Parse dates
 775 |     try:
 776 |         start_timestamp = int(datetime.strptime(start_date, "%Y-%m-%d").timestamp() * 1000)
 777 |         
 778 |         if end_date:
 779 |             # Set end_date to end of day
 780 |             end_dt = datetime.strptime(end_date, "%Y-%m-%d")
 781 |             end_dt = end_dt.replace(hour=23, minute=59, second=59)
 782 |             end_timestamp = int(end_dt.timestamp() * 1000)
 783 |         else:
 784 |             # Default to now if no end date
 785 |             end_timestamp = int(datetime.now().timestamp() * 1000)
 786 |     except ValueError:
 787 |         return {
 788 |             "success": False,
 789 |             "matches": [],
 790 |             "message": "Invalid date format. Dates should be in YYYY-MM-DD format."
 791 |         }
 792 |     
 793 |     session, headers = get_session_and_headers()
 794 |     
 795 |     # Track matches across all queries to handle sorting
 796 |     all_matches = []
 797 |     logger.debug(f"Executing date search: {start_date} to {end_date or 'now'}")
 798 |     
 799 |     try:
 800 |         # Build and execute queries based on scope and type
 801 |         # Block queries for creation time
 802 |         if scope in ["blocks", "both"] and type_filter in ["created", "both"]:
 803 |             logger.debug("Searching blocks by creation time")
 804 |             query = f"""[:find ?uid ?s ?page-title ?time
 805 |                       :where
 806 |                       [?b :block/string ?s]
 807 |                       [?b :block/uid ?uid]
 808 |                       [?b :block/page ?p]
 809 |                       [?p :node/title ?page-title]
 810 |                       [?b :create/time ?time]
 811 |                       [(>= ?time {start_timestamp})]
 812 |                       [(<= ?time {end_timestamp})]]
 813 |                       :limit 1000"""
 814 |             
 815 |             block_created_results = execute_query(query)
 816 |             
 817 |             for uid, content, page_title, time in block_created_results:
 818 |                 match_data = {
 819 |                     "uid": uid,
 820 |                     "type": "block",
 821 |                     "time": time,
 822 |                     "time_type": "created",
 823 |                     "page_title": page_title
 824 |                 }
 825 |                 
 826 |                 if include_content:
 827 |                     resolved_content = resolve_block_references(session, headers, GRAPH_NAME, content)
 828 |                     match_data["content"] = resolved_content
 829 |                 
 830 |                 all_matches.append(match_data)
 831 |         
 832 |         # Block queries for modification time
 833 |         if scope in ["blocks", "both"] and type_filter in ["modified", "both"]:
 834 |             logger.debug("Searching blocks by modification time")
 835 |             query = f"""[:find ?uid ?s ?page-title ?time
 836 |                       :where
 837 |                       [?b :block/string ?s]
 838 |                       [?b :block/uid ?uid]
 839 |                       [?b :block/page ?p]
 840 |                       [?p :node/title ?page-title]
 841 |                       [?b :edit/time ?time]
 842 |                       [(>= ?time {start_timestamp})]
 843 |                       [(<= ?time {end_timestamp})]]
 844 |                       :limit 1000"""
 845 |             
 846 |             block_modified_results = execute_query(query)
 847 |             
 848 |             for uid, content, page_title, time in block_modified_results:
 849 |                 match_data = {
 850 |                     "uid": uid,
 851 |                     "type": "block",
 852 |                     "time": time,
 853 |                     "time_type": "modified",
 854 |                     "page_title": page_title
 855 |                 }
 856 |                 
 857 |                 if include_content:
 858 |                     resolved_content = resolve_block_references(session, headers, GRAPH_NAME, content)
 859 |                     match_data["content"] = resolved_content
 860 |                 
 861 |                 all_matches.append(match_data)
 862 |         
 863 |         # Page queries for creation time
 864 |         if scope in ["pages", "both"] and type_filter in ["created", "both"]:
 865 |             logger.debug("Searching pages by creation time")
 866 |             query = f"""[:find ?uid ?title ?time
 867 |                       :where
 868 |                       [?p :node/title ?title]
 869 |                       [?p :block/uid ?uid]
 870 |                       [?p :create/time ?time]
 871 |                       [(>= ?time {start_timestamp})]
 872 |                       [(<= ?time {end_timestamp})]]
 873 |                       :limit 500"""
 874 |             
 875 |             page_created_results = execute_query(query)
 876 |             
 877 |             for uid, title, time in page_created_results:
 878 |                 match_data = {
 879 |                     "uid": uid,
 880 |                     "type": "page",
 881 |                     "time": time,
 882 |                     "time_type": "created",
 883 |                     "title": title
 884 |                 }
 885 |                 
 886 |                 if include_content:
 887 |                     # Get a sample of page content (first few blocks)
 888 |                     sample_query = f"""[:find ?s
 889 |                                     :where
 890 |                                     [?p :block/uid "{uid}"]
 891 |                                     [?b :block/page ?p]
 892 |                                     [?b :block/string ?s]
 893 |                                     [?b :block/order ?o]
 894 |                                     [(< ?o 3)]]
 895 |                                     :limit 3"""
 896 |                     
 897 |                     page_blocks = execute_query(sample_query)
 898 |                     page_sample = "\n".join([content[0] for content in page_blocks[:3]])
 899 |                     
 900 |                     if page_blocks:
 901 |                         match_data["content"] = f"# {title}\n{page_sample}"
 902 |                         if len(page_blocks) > 3:
 903 |                             match_data["content"] += "\n..."
 904 |                     else:
 905 |                         match_data["content"] = f"# {title}\n(No content)"
 906 |                 
 907 |                 all_matches.append(match_data)
 908 |         
 909 |         # Page queries for modification time
 910 |         if scope in ["pages", "both"] and type_filter in ["modified", "both"]:
 911 |             logger.debug("Searching pages by modification time")
 912 |             query = f"""[:find ?uid ?title ?time
 913 |                       :where
 914 |                       [?p :node/title ?title]
 915 |                       [?p :block/uid ?uid]
 916 |                       [?p :edit/time ?time]
 917 |                       [(>= ?time {start_timestamp})]
 918 |                       [(<= ?time {end_timestamp})]]
 919 |                       :limit 500"""
 920 |             
 921 |             page_modified_results = execute_query(query)
 922 |             
 923 |             for uid, title, time in page_modified_results:
 924 |                 match_data = {
 925 |                     "uid": uid,
 926 |                     "type": "page",
 927 |                     "time": time,
 928 |                     "time_type": "modified",
 929 |                     "title": title
 930 |                 }
 931 |                 
 932 |                 if include_content:
 933 |                     # Get a sample of page content (first few blocks)
 934 |                     sample_query = f"""[:find ?s
 935 |                                     :where
 936 |                                     [?p :block/uid "{uid}"]
 937 |                                     [?b :block/page ?p]
 938 |                                     [?b :block/string ?s]
 939 |                                     [?b :block/order ?o]
 940 |                                     [(< ?o 3)]]
 941 |                                     :limit 3"""
 942 |                     
 943 |                     page_blocks = execute_query(sample_query)
 944 |                     page_sample = "\n".join([content[0] for content in page_blocks[:3]])
 945 |                     
 946 |                     if page_blocks:
 947 |                         match_data["content"] = f"# {title}\n{page_sample}"
 948 |                         if len(page_blocks) > 3:
 949 |                             match_data["content"] += "\n..."
 950 |                     else:
 951 |                         match_data["content"] = f"# {title}\n(No content)"
 952 |                 
 953 |                 all_matches.append(match_data)
 954 |         
 955 |         # Sort by time (newest first)
 956 |         all_matches.sort(key=lambda x: x["time"], reverse=True)
 957 |         
 958 |         # Deduplicate by UID and time_type
 959 |         seen = set()
 960 |         unique_matches = []
 961 |         
 962 |         for match in all_matches:
 963 |             key = (match["uid"], match["time_type"])
 964 |             if key not in seen:
 965 |                 seen.add(key)
 966 |                 unique_matches.append(match)
 967 |         
 968 |         return {
 969 |             "success": True,
 970 |             "matches": unique_matches,
 971 |             "message": f"Found {len(unique_matches)} matches for the given date range and criteria"
 972 |         }
 973 |     except QueryError as e:
 974 |         return {
 975 |             "success": False,
 976 |             "matches": [],
 977 |             "message": str(e)
 978 |         }
 979 |     except Exception as e:
 980 |         logger.error(f"Error searching by date: {str(e)}")
 981 |         return {
 982 |             "success": False,
 983 |             "matches": [],
 984 |             "message": f"Error searching by date: {str(e)}"
 985 |         }
 986 | 
 987 | 
 988 | def find_pages_modified_today(max_num_pages: int = 50) -> Dict[str, Any]:
 989 |     """
 990 |     Find pages that have been modified today.
 991 |     
 992 |     Args:
 993 |         max_num_pages: Maximum number of pages to return
 994 |         
 995 |     Returns:
 996 |         List of modified pages
 997 |     """
 998 |     if max_num_pages < 1:
 999 |         return {
1000 |             "success": False,
1001 |             "pages": [],
1002 |             "message": "max_num_pages must be at least 1"
1003 |         }
1004 |     
1005 |     # Define ancestor rule
1006 |     ancestor_rule = """[
1007 |         [(ancestor ?b ?a)
1008 |           [?a :block/children ?b]]
1009 |         [(ancestor ?b ?a)
1010 |           [?parent :block/children ?b]
1011 |           (ancestor ?parent ?a)]
1012 |     ]"""
1013 |     
1014 |     # Get start of today
1015 |     today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
1016 |     start_timestamp = int(today.timestamp() * 1000)
1017 |     
1018 |     try:
1019 |         # Query for pages modified today
1020 |         logger.debug(f"Finding pages modified today (since {today.isoformat()})")
1021 |         query = f"""[:find ?title
1022 |                     :in $ ?start_timestamp %
1023 |                     :where
1024 |                     [?page :node/title ?title]
1025 |                     (ancestor ?block ?page)
1026 |                     [?block :edit/time ?time]
1027 |                     [(> ?time ?start_timestamp)]]
1028 |                     :limit {max_num_pages}"""
1029 |         
1030 |         results = execute_query(query, [start_timestamp, ancestor_rule])
1031 |         
1032 |         # Extract unique page titles
1033 |         unique_pages = list(set([title[0] for title in results]))[:max_num_pages]
1034 |         
1035 |         return {
1036 |             "success": True,
1037 |             "pages": unique_pages,
1038 |             "message": f"Found {len(unique_pages)} page(s) modified today"
1039 |         }
1040 |     except QueryError as e:
1041 |         return {
1042 |             "success": False,
1043 |             "pages": [],
1044 |             "message": str(e)
1045 |         }
1046 |     except Exception as e:
1047 |         logger.error(f"Error finding pages modified today: {str(e)}")
1048 |         return {
1049 |             "success": False,
1050 |             "pages": [],
1051 |             "message": f"Error finding pages modified today: {str(e)}"
1052 |         }
1053 | 
1054 | 
1055 | def execute_datomic_query(query: str, inputs: Optional[List[Any]] = None) -> Dict[str, Any]:
1056 |     """
1057 |     Execute a custom Datomic query.
1058 |     
1059 |     Args:
1060 |         query: The Datomic query
1061 |         inputs: Optional list of inputs
1062 |         
1063 |     Returns:
1064 |         Query results
1065 |     """
1066 |     if not query:
1067 |         return {
1068 |             "success": False,
1069 |             "matches": [],
1070 |             "message": "Query cannot be empty"
1071 |         }
1072 |     
1073 |     try:
1074 |         # Validate query format (basic check)
1075 |         if not query.strip().startswith('[:find'):
1076 |             logger.warning("Query doesn't start with [:find, may not be valid Datalog syntax")
1077 |         
1078 |         logger.debug(f"Executing custom Datomic query")
1079 |         results = execute_query(query, inputs or [])
1080 |         
1081 |         # Format results for display
1082 |         formatted_results = []
1083 |         for result in results:
1084 |             if isinstance(result, (list, tuple)):
1085 |                 formatted_result = " | ".join(str(item) for item in result)
1086 |             else:
1087 |                 formatted_result = str(result)
1088 |                 
1089 |             formatted_results.append({
1090 |                 "content": formatted_result,
1091 |                 "block_uid": "",
1092 |                 "page_title": ""
1093 |             })
1094 |         
1095 |         return {
1096 |             "success": True,
1097 |             "matches": formatted_results,
1098 |             "message": f"Query executed successfully. Found {len(formatted_results)} results."
1099 |         }
1100 |     except QueryError as e:
1101 |         return {
1102 |             "success": False,
1103 |             "matches": [],
1104 |             "message": str(e)
1105 |         }
1106 |     except Exception as e:
1107 |         logger.error(f"Error executing datomic query: {str(e)}")
1108 |         return {
1109 |             "success": False,
1110 |             "matches": [],
1111 |             "message": f"Failed to execute query: {str(e)}"
1112 |         }
```
Page 2/2FirstPrevNextLast