basic-memory 0.1.1__py3-none-any.whl → 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of basic-memory might be problematic. Click here for more details.

Files changed (77) hide show
  1. basic_memory/__init__.py +1 -1
  2. basic_memory/alembic/README +1 -0
  3. basic_memory/alembic/env.py +75 -0
  4. basic_memory/alembic/migrations.py +29 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  7. basic_memory/api/__init__.py +2 -1
  8. basic_memory/api/app.py +26 -24
  9. basic_memory/api/routers/knowledge_router.py +28 -26
  10. basic_memory/api/routers/memory_router.py +17 -11
  11. basic_memory/api/routers/search_router.py +6 -12
  12. basic_memory/cli/__init__.py +1 -1
  13. basic_memory/cli/app.py +0 -1
  14. basic_memory/cli/commands/__init__.py +3 -3
  15. basic_memory/cli/commands/db.py +25 -0
  16. basic_memory/cli/commands/import_memory_json.py +35 -31
  17. basic_memory/cli/commands/mcp.py +20 -0
  18. basic_memory/cli/commands/status.py +10 -6
  19. basic_memory/cli/commands/sync.py +5 -56
  20. basic_memory/cli/main.py +5 -38
  21. basic_memory/config.py +3 -3
  22. basic_memory/db.py +15 -22
  23. basic_memory/deps.py +3 -4
  24. basic_memory/file_utils.py +36 -35
  25. basic_memory/markdown/entity_parser.py +13 -30
  26. basic_memory/markdown/markdown_processor.py +7 -7
  27. basic_memory/markdown/plugins.py +109 -123
  28. basic_memory/markdown/schemas.py +7 -8
  29. basic_memory/markdown/utils.py +70 -121
  30. basic_memory/mcp/__init__.py +1 -1
  31. basic_memory/mcp/async_client.py +0 -2
  32. basic_memory/mcp/server.py +3 -27
  33. basic_memory/mcp/tools/__init__.py +5 -3
  34. basic_memory/mcp/tools/knowledge.py +2 -2
  35. basic_memory/mcp/tools/memory.py +8 -4
  36. basic_memory/mcp/tools/search.py +2 -1
  37. basic_memory/mcp/tools/utils.py +1 -1
  38. basic_memory/models/__init__.py +1 -2
  39. basic_memory/models/base.py +3 -3
  40. basic_memory/models/knowledge.py +23 -60
  41. basic_memory/models/search.py +1 -1
  42. basic_memory/repository/__init__.py +5 -3
  43. basic_memory/repository/entity_repository.py +34 -98
  44. basic_memory/repository/relation_repository.py +0 -7
  45. basic_memory/repository/repository.py +2 -39
  46. basic_memory/repository/search_repository.py +20 -25
  47. basic_memory/schemas/__init__.py +4 -4
  48. basic_memory/schemas/base.py +21 -62
  49. basic_memory/schemas/delete.py +2 -3
  50. basic_memory/schemas/discovery.py +4 -1
  51. basic_memory/schemas/memory.py +12 -13
  52. basic_memory/schemas/request.py +4 -23
  53. basic_memory/schemas/response.py +10 -9
  54. basic_memory/schemas/search.py +4 -7
  55. basic_memory/services/__init__.py +2 -7
  56. basic_memory/services/context_service.py +116 -110
  57. basic_memory/services/entity_service.py +25 -62
  58. basic_memory/services/exceptions.py +1 -0
  59. basic_memory/services/file_service.py +73 -109
  60. basic_memory/services/link_resolver.py +9 -9
  61. basic_memory/services/search_service.py +22 -15
  62. basic_memory/services/service.py +3 -24
  63. basic_memory/sync/__init__.py +2 -2
  64. basic_memory/sync/file_change_scanner.py +3 -7
  65. basic_memory/sync/sync_service.py +35 -40
  66. basic_memory/sync/utils.py +6 -38
  67. basic_memory/sync/watch_service.py +26 -5
  68. basic_memory/utils.py +42 -33
  69. {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/METADATA +2 -7
  70. basic_memory-0.1.2.dist-info/RECORD +78 -0
  71. basic_memory/mcp/main.py +0 -21
  72. basic_memory/mcp/tools/ai_edit.py +0 -84
  73. basic_memory/services/database_service.py +0 -159
  74. basic_memory-0.1.1.dist-info/RECORD +0 -74
  75. {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/WHEEL +0 -0
  76. {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/entry_points.txt +0 -0
  77. {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,8 @@
1
1
  """Utilities for file operations."""
2
+
2
3
  import hashlib
3
4
  from pathlib import Path
4
- from typing import Dict, Any, Tuple
5
+ from typing import Dict, Any
5
6
 
6
7
  import yaml
7
8
  from loguru import logger
@@ -9,35 +10,38 @@ from loguru import logger
9
10
 
10
11
  class FileError(Exception):
11
12
  """Base exception for file operations."""
13
+
12
14
  pass
13
15
 
14
16
 
15
17
  class FileWriteError(FileError):
16
18
  """Raised when file operations fail."""
19
+
17
20
  pass
18
21
 
19
22
 
20
23
  class ParseError(FileError):
21
24
  """Raised when parsing file content fails."""
25
+
22
26
  pass
23
27
 
24
28
 
25
29
  async def compute_checksum(content: str) -> str:
26
30
  """
27
31
  Compute SHA-256 checksum of content.
28
-
32
+
29
33
  Args:
30
34
  content: Text content to hash
31
-
35
+
32
36
  Returns:
33
37
  SHA-256 hex digest
34
-
38
+
35
39
  Raises:
36
40
  FileError: If checksum computation fails
37
41
  """
38
42
  try:
39
43
  return hashlib.sha256(content.encode()).hexdigest()
40
- except Exception as e:
44
+ except Exception as e: # pragma: no cover
41
45
  logger.error(f"Failed to compute checksum: {e}")
42
46
  raise FileError(f"Failed to compute checksum: {e}")
43
47
 
@@ -45,16 +49,16 @@ async def compute_checksum(content: str) -> str:
45
49
  async def ensure_directory(path: Path) -> None:
46
50
  """
47
51
  Ensure directory exists, creating if necessary.
48
-
52
+
49
53
  Args:
50
54
  path: Directory path to ensure
51
-
55
+
52
56
  Raises:
53
57
  FileWriteError: If directory creation fails
54
58
  """
55
59
  try:
56
60
  path.mkdir(parents=True, exist_ok=True)
57
- except Exception as e:
61
+ except Exception as e: # pragma: no cover
58
62
  logger.error(f"Failed to create directory: {path}: {e}")
59
63
  raise FileWriteError(f"Failed to create directory {path}: {e}")
60
64
 
@@ -62,22 +66,20 @@ async def ensure_directory(path: Path) -> None:
62
66
  async def write_file_atomic(path: Path, content: str) -> None:
63
67
  """
64
68
  Write file with atomic operation using temporary file.
65
-
69
+
66
70
  Args:
67
71
  path: Target file path
68
72
  content: Content to write
69
-
73
+
70
74
  Raises:
71
75
  FileWriteError: If write operation fails
72
76
  """
73
77
  temp_path = path.with_suffix(".tmp")
74
78
  try:
75
79
  temp_path.write_text(content)
76
-
77
- # TODO check for path.exists()
78
80
  temp_path.replace(path)
79
81
  logger.debug(f"wrote file: {path}")
80
- except Exception as e:
82
+ except Exception as e: # pragma: no cover
81
83
  temp_path.unlink(missing_ok=True)
82
84
  logger.error(f"Failed to write file: {path}: {e}")
83
85
  raise FileWriteError(f"Failed to write file {path}: {e}")
@@ -85,16 +87,19 @@ async def write_file_atomic(path: Path, content: str) -> None:
85
87
 
86
88
  def has_frontmatter(content: str) -> bool:
87
89
  """
88
- Check if content contains YAML frontmatter.
90
+ Check if content contains valid YAML frontmatter.
89
91
 
90
92
  Args:
91
93
  content: Content to check
92
94
 
93
95
  Returns:
94
- True if content has frontmatter delimiter (---), False otherwise
96
+ True if content has valid frontmatter markers (---), False otherwise
95
97
  """
96
98
  content = content.strip()
97
- return content.startswith("---") and "---" in content[3:]
99
+ if not content.startswith("---"):
100
+ return False
101
+
102
+ return "---" in content[3:]
98
103
 
99
104
 
100
105
  def parse_frontmatter(content: str) -> Dict[str, Any]:
@@ -111,7 +116,7 @@ def parse_frontmatter(content: str) -> Dict[str, Any]:
111
116
  ParseError: If frontmatter is invalid or parsing fails
112
117
  """
113
118
  try:
114
- if not has_frontmatter(content):
119
+ if not content.strip().startswith("---"):
115
120
  raise ParseError("Content has no frontmatter")
116
121
 
117
122
  # Split on first two occurrences of ---
@@ -132,7 +137,7 @@ def parse_frontmatter(content: str) -> Dict[str, Any]:
132
137
  except yaml.YAMLError as e:
133
138
  raise ParseError(f"Invalid YAML in frontmatter: {e}")
134
139
 
135
- except Exception as e:
140
+ except Exception as e: # pragma: no cover
136
141
  if not isinstance(e, ParseError):
137
142
  logger.error(f"Failed to parse frontmatter: {e}")
138
143
  raise ParseError(f"Failed to parse frontmatter: {e}")
@@ -147,27 +152,23 @@ def remove_frontmatter(content: str) -> str:
147
152
  content: Content with frontmatter
148
153
 
149
154
  Returns:
150
- Content with frontmatter removed
155
+ Content with frontmatter removed, or original content if no frontmatter
151
156
 
152
157
  Raises:
153
- ParseError: If frontmatter format is invalid
158
+ ParseError: If content starts with frontmatter marker but is malformed
154
159
  """
155
- try:
156
- if not has_frontmatter(content):
157
- return content.strip()
160
+ content = content.strip()
158
161
 
159
- # Split on first two occurrences of ---
160
- parts = content.split("---", 2)
161
- if len(parts) < 3:
162
- raise ParseError("Invalid frontmatter format")
162
+ # Return as-is if no frontmatter marker
163
+ if not content.startswith("---"):
164
+ return content
163
165
 
164
- return parts[2].strip()
166
+ # Split on first two occurrences of ---
167
+ parts = content.split("---", 2)
168
+ if len(parts) < 3:
169
+ raise ParseError("Invalid frontmatter format")
165
170
 
166
- except Exception as e:
167
- if not isinstance(e, ParseError):
168
- logger.error(f"Failed to remove frontmatter: {e}")
169
- raise ParseError(f"Failed to remove frontmatter: {e}")
170
- raise
171
+ return parts[2].strip()
171
172
 
172
173
 
173
174
  async def update_frontmatter(path: Path, updates: Dict[str, Any]) -> str:
@@ -208,6 +209,6 @@ async def update_frontmatter(path: Path, updates: Dict[str, Any]) -> str:
208
209
  await write_file_atomic(path, final_content)
209
210
  return await compute_checksum(final_content)
210
211
 
211
- except Exception as e:
212
+ except Exception as e: # pragma: no cover
212
213
  logger.error(f"Failed to update frontmatter in {path}: {e}")
213
- raise FileError(f"Failed to update frontmatter: {e}")
214
+ raise FileError(f"Failed to update frontmatter: {e}")
@@ -2,6 +2,7 @@
2
2
 
3
3
  Uses markdown-it with plugins to parse structured data from markdown content.
4
4
  """
5
+
5
6
  from dataclasses import dataclass, field
6
7
  from pathlib import Path
7
8
  from datetime import datetime
@@ -21,11 +22,12 @@ from basic_memory.markdown.schemas import (
21
22
 
22
23
  md = MarkdownIt().use(observation_plugin).use(relation_plugin)
23
24
 
25
+
24
26
  @dataclass
25
27
  class EntityContent:
26
- content: str
27
- observations: list[Observation] = field(default_factory=list)
28
- relations: list[Relation] = field(default_factory=list)
28
+ content: str
29
+ observations: list[Observation] = field(default_factory=list)
30
+ relations: list[Relation] = field(default_factory=list)
29
31
 
30
32
 
31
33
  def parse(content: str) -> EntityContent:
@@ -53,13 +55,13 @@ def parse(content: str) -> EntityContent:
53
55
  relations=relations,
54
56
  )
55
57
 
58
+
56
59
  def parse_tags(tags: Any) -> list[str]:
57
60
  """Parse tags into list of strings."""
58
- if isinstance(tags, str):
59
- return [t.strip() for t in tags.split(",") if t.strip()]
60
61
  if isinstance(tags, (list, tuple)):
61
62
  return [str(t).strip() for t in tags if str(t).strip()]
62
- return []
63
+ return [t.strip() for t in tags.split(",") if t.strip()]
64
+
63
65
 
64
66
  class EntityParser:
65
67
  """Parser for markdown files into Entity objects."""
@@ -68,21 +70,6 @@ class EntityParser:
68
70
  """Initialize parser with base path for relative permalink generation."""
69
71
  self.base_path = base_path.resolve()
70
72
 
71
-
72
- def relative_path(self, file_path: Path) -> str:
73
- """Get file path relative to base_path.
74
-
75
- Example:
76
- base_path: /project/root
77
- file_path: /project/root/design/models/data.md
78
- returns: "design/models/data"
79
- """
80
- # Get relative path and remove .md extension
81
- rel_path = file_path.resolve().relative_to(self.base_path)
82
- if rel_path.suffix.lower() == ".md":
83
- return str(rel_path.with_suffix(""))
84
- return str(rel_path)
85
-
86
73
  def parse_date(self, value: Any) -> Optional[datetime]:
87
74
  """Parse date strings using dateparser for maximum flexibility.
88
75
 
@@ -96,21 +83,18 @@ class EntityParser:
96
83
  if isinstance(value, datetime):
97
84
  return value
98
85
  if isinstance(value, str):
99
- try:
100
- parsed = dateparser.parse(value)
101
- if parsed:
102
- return parsed
103
- except Exception:
104
- pass
86
+ parsed = dateparser.parse(value)
87
+ if parsed:
88
+ return parsed
105
89
  return None
106
90
 
107
91
  async def parse_file(self, file_path: Path) -> EntityMarkdown:
108
92
  """Parse markdown file into EntityMarkdown."""
109
-
93
+
110
94
  absolute_path = self.base_path / file_path
111
95
  # Parse frontmatter and content using python-frontmatter
112
96
  post = frontmatter.load(str(absolute_path))
113
-
97
+
114
98
  # Extract file stat info
115
99
  file_stats = absolute_path.stat()
116
100
 
@@ -134,4 +118,3 @@ class EntityParser:
134
118
  created=datetime.fromtimestamp(file_stats.st_ctime),
135
119
  modified=datetime.fromtimestamp(file_stats.st_mtime),
136
120
  )
137
-
@@ -19,7 +19,7 @@ class DirtyFileError(Exception):
19
19
 
20
20
  class MarkdownProcessor:
21
21
  """Process markdown files while preserving content and structure.
22
-
22
+
23
23
  used only for import
24
24
 
25
25
  This class handles the file I/O aspects of our markdown processing. It:
@@ -90,14 +90,14 @@ class MarkdownProcessor:
90
90
 
91
91
  # Convert frontmatter to dict
92
92
  frontmatter_dict = OrderedDict()
93
- frontmatter_dict["title"] = markdown.frontmatter.title
94
- frontmatter_dict["type"] = markdown.frontmatter.type
93
+ frontmatter_dict["title"] = markdown.frontmatter.title
94
+ frontmatter_dict["type"] = markdown.frontmatter.type
95
95
  frontmatter_dict["permalink"] = markdown.frontmatter.permalink
96
-
96
+
97
97
  metadata = markdown.frontmatter.metadata or {}
98
- for k,v in metadata.items():
98
+ for k, v in metadata.items():
99
99
  frontmatter_dict[k] = v
100
-
100
+
101
101
  # Start with user content (or minimal title for new files)
102
102
  content = markdown.content or f"# {markdown.frontmatter.title}\n"
103
103
 
@@ -107,7 +107,7 @@ class MarkdownProcessor:
107
107
  # add a blank line if we have semantic content
108
108
  if markdown.observations or markdown.relations:
109
109
  content += "\n"
110
-
110
+
111
111
  if markdown.observations:
112
112
  content += self.format_observations(markdown.observations)
113
113
  if markdown.relations:
@@ -8,19 +8,19 @@ from markdown_it.token import Token
8
8
  # Observation handling functions
9
9
  def is_observation(token: Token) -> bool:
10
10
  """Check if token looks like our observation format."""
11
- if token.type != 'inline':
11
+ if token.type != "inline": # pragma: no cover
12
12
  return False
13
-
13
+
14
14
  content = token.content.strip()
15
- if not content:
15
+ if not content: # pragma: no cover
16
16
  return False
17
-
17
+
18
18
  # if it's a markdown_task, return false
19
- if content.startswith('[ ]') or content.startswith('[x]') or content.startswith('[-]'):
19
+ if content.startswith("[ ]") or content.startswith("[x]") or content.startswith("[-]"):
20
20
  return False
21
-
22
- has_category = content.startswith('[') and ']' in content
23
- has_tags = '#' in content
21
+
22
+ has_category = content.startswith("[") and "]" in content
23
+ has_tags = "#" in content
24
24
  return has_category or has_tags
25
25
 
26
26
 
@@ -28,119 +28,126 @@ def parse_observation(token: Token) -> Dict[str, Any]:
28
28
  """Extract observation parts from token."""
29
29
  # Strip bullet point if present
30
30
  content = token.content.strip()
31
- if content.startswith('- '):
32
- content = content[2:].strip()
33
- elif content.startswith('-'):
34
- content = content[1:].strip()
35
-
31
+
36
32
  # Parse [category]
37
33
  category = None
38
- if content.startswith('['):
39
- end = content.find(']')
34
+ if content.startswith("["):
35
+ end = content.find("]")
40
36
  if end != -1:
41
37
  category = content[1:end].strip() or None # Convert empty to None
42
- content = content[end + 1:].strip()
43
-
38
+ content = content[end + 1 :].strip()
39
+
44
40
  # Parse (context)
45
41
  context = None
46
- if content.endswith(')'):
47
- start = content.rfind('(')
42
+ if content.endswith(")"):
43
+ start = content.rfind("(")
48
44
  if start != -1:
49
- context = content[start + 1:-1].strip()
45
+ context = content[start + 1 : -1].strip()
50
46
  content = content[:start].strip()
51
-
52
- # Parse #tags and content
47
+
48
+ # Extract tags and keep original content
49
+ tags = []
53
50
  parts = content.split()
54
- content_parts = []
55
- tags = set() # Use set to avoid duplicates
56
-
57
51
  for part in parts:
58
- if part.startswith('#'):
52
+ if part.startswith("#"):
59
53
  # Handle multiple #tags stuck together
60
- if '#' in part[1:]:
54
+ if "#" in part[1:]:
61
55
  # Split on # but keep non-empty tags
62
- subtags = [t for t in part.split('#') if t]
63
- tags.update(subtags)
56
+ subtags = [t for t in part.split("#") if t]
57
+ tags.extend(subtags)
64
58
  else:
65
- tags.add(part[1:])
66
- else:
67
- content_parts.append(part)
68
-
59
+ tags.append(part[1:])
60
+
69
61
  return {
70
- 'category': category,
71
- 'content': content,
72
- 'tags': list(tags) if tags else None,
73
- 'context': context
62
+ "category": category,
63
+ "content": content,
64
+ "tags": tags if tags else None,
65
+ "context": context,
74
66
  }
75
67
 
76
68
 
77
69
  # Relation handling functions
78
70
  def is_explicit_relation(token: Token) -> bool:
79
71
  """Check if token looks like our relation format."""
80
- if token.type != 'inline':
72
+ if token.type != "inline": # pragma: no cover
81
73
  return False
82
-
74
+
83
75
  content = token.content.strip()
84
- return '[[' in content and ']]' in content
76
+ return "[[" in content and "]]" in content
85
77
 
86
78
 
87
- def parse_relation(token: Token) -> Dict[str, Any]:
79
+ def parse_relation(token: Token) -> Dict[str, Any] | None:
88
80
  """Extract relation parts from token."""
89
81
  # Remove bullet point if present
90
82
  content = token.content.strip()
91
- if content.startswith('- '):
92
- content = content[2:].strip()
93
- elif content.startswith('-'):
94
- content = content[1:].strip()
95
-
83
+
96
84
  # Extract [[target]]
97
85
  target = None
98
- rel_type = 'relates_to' # default
86
+ rel_type = "relates_to" # default
99
87
  context = None
100
-
101
- start = content.find('[[')
102
- end = content.find(']]')
103
-
88
+
89
+ start = content.find("[[")
90
+ end = content.find("]]")
91
+
104
92
  if start != -1 and end != -1:
105
93
  # Get text before link as relation type
106
94
  before = content[:start].strip()
107
95
  if before:
108
96
  rel_type = before
109
-
97
+
110
98
  # Get target
111
- target = content[start + 2:end].strip()
112
-
99
+ target = content[start + 2 : end].strip()
100
+
113
101
  # Look for context after
114
- after = content[end + 2:].strip()
115
- if after.startswith('(') and after.endswith(')'):
102
+ after = content[end + 2 :].strip()
103
+ if after.startswith("(") and after.endswith(")"):
116
104
  context = after[1:-1].strip() or None
117
-
118
- if not target:
105
+
106
+ if not target: # pragma: no cover
119
107
  return None
120
-
121
- return {
122
- 'type': rel_type,
123
- 'target': target,
124
- 'context': context
125
- }
108
+
109
+ return {"type": rel_type, "target": target, "context": context}
126
110
 
127
111
 
128
112
  def parse_inline_relations(content: str) -> List[Dict[str, Any]]:
129
113
  """Find wiki-style links in regular content."""
130
114
  relations = []
131
-
132
- import re
133
- pattern = r'\[\[([^\]]+)\]\]'
134
-
135
- for match in re.finditer(pattern, content):
136
- target = match.group(1).strip()
137
- if target and not target.startswith('[['): # Avoid nested matches
138
- relations.append({
139
- 'type': 'links to',
140
- 'target': target,
141
- 'context': None
142
- })
143
-
115
+ start = 0
116
+
117
+ while True:
118
+ # Find next outer-most [[
119
+ start = content.find("[[", start)
120
+ if start == -1: # pragma: no cover
121
+ break
122
+
123
+ # Find matching ]]
124
+ depth = 1
125
+ pos = start + 2
126
+ end = -1
127
+
128
+ while pos < len(content):
129
+ if content[pos : pos + 2] == "[[":
130
+ depth += 1
131
+ pos += 2
132
+ elif content[pos : pos + 2] == "]]":
133
+ depth -= 1
134
+ if depth == 0:
135
+ end = pos
136
+ break
137
+ pos += 2
138
+ else:
139
+ pos += 1
140
+
141
+ if end == -1:
142
+ # No matching ]] found
143
+ break
144
+
145
+ target = content[start + 2 : end].strip()
146
+ if target:
147
+ relations.append({"type": "links to", "target": target, "context": None})
148
+
149
+ start = end + 2
150
+
144
151
  return relations
145
152
 
146
153
 
@@ -149,88 +156,67 @@ def observation_plugin(md: MarkdownIt) -> None:
149
156
  - [category] Content text #tag1 #tag2 (context)
150
157
  - Content text #tag1 (context) # No category is also valid
151
158
  """
152
-
159
+
153
160
  def observation_rule(state: Any) -> None:
154
161
  """Process observations in token stream."""
155
162
  tokens = state.tokens
156
- current_section = None
157
- in_list_item = False
158
-
163
+
159
164
  for idx in range(len(tokens)):
160
165
  token = tokens[idx]
161
-
162
- # Track current section by headings
163
- if token.type == 'heading_open':
164
- next_token = tokens[idx + 1] if idx + 1 < len(tokens) else None
165
- if next_token and next_token.type == 'inline':
166
- current_section = next_token.content.lower()
167
-
168
- # Track list nesting
169
- elif token.type == 'list_item_open':
170
- in_list_item = True
171
- elif token.type == 'list_item_close':
172
- in_list_item = False
173
-
166
+
174
167
  # Initialize meta for all tokens
175
168
  token.meta = token.meta or {}
176
-
169
+
177
170
  # Parse observations in list items
178
- if token.type == 'inline' and is_observation(token):
171
+ if token.type == "inline" and is_observation(token):
179
172
  obs = parse_observation(token)
180
- if obs['content']: # Only store if we have content
181
- token.meta['observation'] = obs
182
-
173
+ if obs["content"]: # Only store if we have content
174
+ token.meta["observation"] = obs
175
+
183
176
  # Add the rule after inline processing
184
- md.core.ruler.after('inline', 'observations', observation_rule)
177
+ md.core.ruler.after("inline", "observations", observation_rule)
185
178
 
186
179
 
187
180
  def relation_plugin(md: MarkdownIt) -> None:
188
181
  """Plugin for parsing relation formats:
189
-
182
+
190
183
  Explicit relations:
191
184
  - relation_type [[target]] (context)
192
-
185
+
193
186
  Implicit relations (links in content):
194
187
  Some text with [[target]] reference
195
188
  """
196
-
189
+
197
190
  def relation_rule(state: Any) -> None:
198
191
  """Process relations in token stream."""
199
192
  tokens = state.tokens
200
- current_section = None
201
193
  in_list_item = False
202
-
194
+
203
195
  for idx in range(len(tokens)):
204
196
  token = tokens[idx]
205
-
206
- # Track current section by headings
207
- if token.type == 'heading_open':
208
- next_token = tokens[idx + 1] if idx + 1 < len(tokens) else None
209
- if next_token and next_token.type == 'inline':
210
- current_section = next_token.content.lower()
211
-
197
+
212
198
  # Track list nesting
213
- elif token.type == 'list_item_open':
199
+ if token.type == "list_item_open":
214
200
  in_list_item = True
215
- elif token.type == 'list_item_close':
201
+ elif token.type == "list_item_close":
216
202
  in_list_item = False
217
-
203
+
218
204
  # Initialize meta for all tokens
219
205
  token.meta = token.meta or {}
220
-
206
+
221
207
  # Only process inline tokens
222
- if token.type == 'inline':
208
+ if token.type == "inline":
223
209
  # Check for explicit relations in list items
224
210
  if in_list_item and is_explicit_relation(token):
225
211
  rel = parse_relation(token)
226
212
  if rel:
227
- token.meta['relations'] = [rel]
228
-
213
+ token.meta["relations"] = [rel]
214
+
229
215
  # Always check for inline links in any text
230
- elif '[[' in token.content:
216
+ elif "[[" in token.content:
231
217
  rels = parse_inline_relations(token.content)
232
218
  if rels:
233
- token.meta['relations'] = token.meta.get('relations', []) + rels
234
-
219
+ token.meta["relations"] = token.meta.get("relations", []) + rels
220
+
235
221
  # Add the rule after inline processing
236
- md.core.ruler.after('inline', 'relations', relation_rule)
222
+ md.core.ruler.after("inline", "relations", relation_rule)