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.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/README +1 -0
- basic_memory/alembic/env.py +75 -0
- basic_memory/alembic/migrations.py +29 -0
- basic_memory/alembic/script.py.mako +26 -0
- basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
- basic_memory/api/__init__.py +2 -1
- basic_memory/api/app.py +26 -24
- basic_memory/api/routers/knowledge_router.py +28 -26
- basic_memory/api/routers/memory_router.py +17 -11
- basic_memory/api/routers/search_router.py +6 -12
- basic_memory/cli/__init__.py +1 -1
- basic_memory/cli/app.py +0 -1
- basic_memory/cli/commands/__init__.py +3 -3
- basic_memory/cli/commands/db.py +25 -0
- basic_memory/cli/commands/import_memory_json.py +35 -31
- basic_memory/cli/commands/mcp.py +20 -0
- basic_memory/cli/commands/status.py +10 -6
- basic_memory/cli/commands/sync.py +5 -56
- basic_memory/cli/main.py +5 -38
- basic_memory/config.py +3 -3
- basic_memory/db.py +15 -22
- basic_memory/deps.py +3 -4
- basic_memory/file_utils.py +36 -35
- basic_memory/markdown/entity_parser.py +13 -30
- basic_memory/markdown/markdown_processor.py +7 -7
- basic_memory/markdown/plugins.py +109 -123
- basic_memory/markdown/schemas.py +7 -8
- basic_memory/markdown/utils.py +70 -121
- basic_memory/mcp/__init__.py +1 -1
- basic_memory/mcp/async_client.py +0 -2
- basic_memory/mcp/server.py +3 -27
- basic_memory/mcp/tools/__init__.py +5 -3
- basic_memory/mcp/tools/knowledge.py +2 -2
- basic_memory/mcp/tools/memory.py +8 -4
- basic_memory/mcp/tools/search.py +2 -1
- basic_memory/mcp/tools/utils.py +1 -1
- basic_memory/models/__init__.py +1 -2
- basic_memory/models/base.py +3 -3
- basic_memory/models/knowledge.py +23 -60
- basic_memory/models/search.py +1 -1
- basic_memory/repository/__init__.py +5 -3
- basic_memory/repository/entity_repository.py +34 -98
- basic_memory/repository/relation_repository.py +0 -7
- basic_memory/repository/repository.py +2 -39
- basic_memory/repository/search_repository.py +20 -25
- basic_memory/schemas/__init__.py +4 -4
- basic_memory/schemas/base.py +21 -62
- basic_memory/schemas/delete.py +2 -3
- basic_memory/schemas/discovery.py +4 -1
- basic_memory/schemas/memory.py +12 -13
- basic_memory/schemas/request.py +4 -23
- basic_memory/schemas/response.py +10 -9
- basic_memory/schemas/search.py +4 -7
- basic_memory/services/__init__.py +2 -7
- basic_memory/services/context_service.py +116 -110
- basic_memory/services/entity_service.py +25 -62
- basic_memory/services/exceptions.py +1 -0
- basic_memory/services/file_service.py +73 -109
- basic_memory/services/link_resolver.py +9 -9
- basic_memory/services/search_service.py +22 -15
- basic_memory/services/service.py +3 -24
- basic_memory/sync/__init__.py +2 -2
- basic_memory/sync/file_change_scanner.py +3 -7
- basic_memory/sync/sync_service.py +35 -40
- basic_memory/sync/utils.py +6 -38
- basic_memory/sync/watch_service.py +26 -5
- basic_memory/utils.py +42 -33
- {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/METADATA +2 -7
- basic_memory-0.1.2.dist-info/RECORD +78 -0
- basic_memory/mcp/main.py +0 -21
- basic_memory/mcp/tools/ai_edit.py +0 -84
- basic_memory/services/database_service.py +0 -159
- basic_memory-0.1.1.dist-info/RECORD +0 -74
- {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/WHEEL +0 -0
- {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.1.1.dist-info → basic_memory-0.1.2.dist-info}/licenses/LICENSE +0 -0
basic_memory/file_utils.py
CHANGED
|
@@ -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
|
|
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
|
|
96
|
+
True if content has valid frontmatter markers (---), False otherwise
|
|
95
97
|
"""
|
|
96
98
|
content = content.strip()
|
|
97
|
-
|
|
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
|
|
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
|
|
158
|
+
ParseError: If content starts with frontmatter marker but is malformed
|
|
154
159
|
"""
|
|
155
|
-
|
|
156
|
-
if not has_frontmatter(content):
|
|
157
|
-
return content.strip()
|
|
160
|
+
content = content.strip()
|
|
158
161
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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"] =
|
|
94
|
-
frontmatter_dict["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:
|
basic_memory/markdown/plugins.py
CHANGED
|
@@ -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 !=
|
|
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(
|
|
19
|
+
if content.startswith("[ ]") or content.startswith("[x]") or content.startswith("[-]"):
|
|
20
20
|
return False
|
|
21
|
-
|
|
22
|
-
has_category = content.startswith(
|
|
23
|
-
has_tags =
|
|
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
|
-
|
|
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
|
|
45
|
+
context = content[start + 1 : -1].strip()
|
|
50
46
|
content = content[:start].strip()
|
|
51
|
-
|
|
52
|
-
#
|
|
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
|
|
54
|
+
if "#" in part[1:]:
|
|
61
55
|
# Split on # but keep non-empty tags
|
|
62
|
-
subtags = [t for t in part.split(
|
|
63
|
-
tags.
|
|
56
|
+
subtags = [t for t in part.split("#") if t]
|
|
57
|
+
tags.extend(subtags)
|
|
64
58
|
else:
|
|
65
|
-
tags.
|
|
66
|
-
|
|
67
|
-
content_parts.append(part)
|
|
68
|
-
|
|
59
|
+
tags.append(part[1:])
|
|
60
|
+
|
|
69
61
|
return {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 !=
|
|
72
|
+
if token.type != "inline": # pragma: no cover
|
|
81
73
|
return False
|
|
82
|
-
|
|
74
|
+
|
|
83
75
|
content = token.content.strip()
|
|
84
|
-
return
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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 ==
|
|
171
|
+
if token.type == "inline" and is_observation(token):
|
|
179
172
|
obs = parse_observation(token)
|
|
180
|
-
if obs[
|
|
181
|
-
token.meta[
|
|
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(
|
|
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
|
-
|
|
199
|
+
if token.type == "list_item_open":
|
|
214
200
|
in_list_item = True
|
|
215
|
-
elif token.type ==
|
|
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 ==
|
|
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[
|
|
228
|
-
|
|
213
|
+
token.meta["relations"] = [rel]
|
|
214
|
+
|
|
229
215
|
# Always check for inline links in any text
|
|
230
|
-
elif
|
|
216
|
+
elif "[[" in token.content:
|
|
231
217
|
rels = parse_inline_relations(token.content)
|
|
232
218
|
if rels:
|
|
233
|
-
token.meta[
|
|
234
|
-
|
|
219
|
+
token.meta["relations"] = token.meta.get("relations", []) + rels
|
|
220
|
+
|
|
235
221
|
# Add the rule after inline processing
|
|
236
|
-
md.core.ruler.after(
|
|
222
|
+
md.core.ruler.after("inline", "relations", relation_rule)
|