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 | }
```