Skip to content

Commit 22f7bfa

Browse files
groksrcclaude[bot]phernandez
authored
fix: Update YAML frontmatter tag formatting for Obsidian compatibility (#280)
Signed-off-by: Drew Cain <groksrc@users.noreply.github.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com> Co-authored-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
1 parent cd7cee6 commit 22f7bfa

7 files changed

Lines changed: 449 additions & 5 deletions

File tree

src/basic_memory/file_utils.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Any, Dict, Union
77

88
import yaml
9+
import frontmatter
910
from loguru import logger
1011

1112
from basic_memory.utils import FilePath
@@ -236,6 +237,50 @@ async def update_frontmatter(path: FilePath, updates: Dict[str, Any]) -> str:
236237
raise FileError(f"Failed to update frontmatter: {e}")
237238

238239

240+
def dump_frontmatter(post: frontmatter.Post) -> str:
241+
"""
242+
Serialize frontmatter.Post to markdown with Obsidian-compatible YAML format.
243+
244+
This function ensures that tags are formatted as YAML lists instead of JSON arrays:
245+
246+
Good (Obsidian compatible):
247+
---
248+
tags:
249+
- system
250+
- overview
251+
- reference
252+
---
253+
254+
Bad (current behavior):
255+
---
256+
tags: ["system", "overview", "reference"]
257+
---
258+
259+
Args:
260+
post: frontmatter.Post object to serialize
261+
262+
Returns:
263+
String containing markdown with properly formatted YAML frontmatter
264+
"""
265+
if not post.metadata:
266+
# No frontmatter, just return content
267+
return post.content
268+
269+
# Serialize YAML with block style for lists
270+
yaml_str = yaml.dump(
271+
post.metadata,
272+
sort_keys=False,
273+
allow_unicode=True,
274+
default_flow_style=False
275+
)
276+
277+
# Construct the final markdown with frontmatter
278+
if post.content:
279+
return f"---\n{yaml_str}---\n\n{post.content}"
280+
else:
281+
return f"---\n{yaml_str}---\n"
282+
283+
239284
def sanitize_for_filename(text: str, replacement: str = "-") -> str:
240285
"""
241286
Sanitize string to be safe for use as a note title
@@ -252,3 +297,4 @@ def sanitize_for_filename(text: str, replacement: str = "-") -> str:
252297
text = re.sub(f"{re.escape(replacement)}+", replacement, text)
253298

254299
return text.strip(replacement)
300+

src/basic_memory/markdown/markdown_processor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from loguru import logger
88

99
from basic_memory import file_utils
10+
from basic_memory.file_utils import dump_frontmatter
1011
from basic_memory.markdown.entity_parser import EntityParser
1112
from basic_memory.markdown.schemas import EntityMarkdown, Observation, Relation
1213

@@ -115,7 +116,7 @@ async def write_file(
115116

116117
# Create Post object for frontmatter
117118
post = Post(content, **frontmatter_dict)
118-
final_content = frontmatter.dumps(post, sort_keys=False)
119+
final_content = dump_frontmatter(post)
119120

120121
logger.debug(f"writing file {path} with content:\n{final_content}")
121122

src/basic_memory/services/entity_service.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from sqlalchemy.exc import IntegrityError
1010

1111
from basic_memory.config import ProjectConfig, BasicMemoryConfig
12-
from basic_memory.file_utils import has_frontmatter, parse_frontmatter, remove_frontmatter
12+
from basic_memory.file_utils import has_frontmatter, parse_frontmatter, remove_frontmatter, dump_frontmatter
1313
from basic_memory.markdown import EntityMarkdown
1414
from basic_memory.markdown.entity_parser import EntityParser
1515
from basic_memory.markdown.utils import entity_model_from_markdown, schema_to_markdown
@@ -196,7 +196,7 @@ async def create_entity(self, schema: EntitySchema) -> EntityModel:
196196
post = await schema_to_markdown(schema)
197197

198198
# write file
199-
final_content = frontmatter.dumps(post, sort_keys=False)
199+
final_content = dump_frontmatter(post)
200200
checksum = await self.file_service.write_file(file_path, final_content)
201201

202202
# parse entity from file
@@ -273,7 +273,7 @@ async def update_entity(self, entity: EntityModel, schema: EntitySchema) -> Enti
273273
merged_post = frontmatter.Post(post.content, **existing_markdown.frontmatter.metadata)
274274

275275
# write file
276-
final_content = frontmatter.dumps(merged_post, sort_keys=False)
276+
final_content = dump_frontmatter(merged_post)
277277
checksum = await self.file_service.write_file(file_path, final_content)
278278

279279
# parse entity from file

src/basic_memory/utils.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,21 @@ def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
219219
# First strip whitespace, then strip leading '#' characters to prevent accumulation
220220
return [tag.strip().lstrip("#") for tag in tags if tag and tag.strip()]
221221

222-
# Process comma-separated string of tags
222+
# Process string input
223223
if isinstance(tags, str):
224+
# Check if it's a JSON array string (common issue from AI assistants)
225+
import json
226+
if tags.strip().startswith('[') and tags.strip().endswith(']'):
227+
try:
228+
# Try to parse as JSON array
229+
parsed_json = json.loads(tags)
230+
if isinstance(parsed_json, list):
231+
# Recursively parse the JSON array as a list
232+
return parse_tags(parsed_json)
233+
except json.JSONDecodeError:
234+
# Not valid JSON, fall through to comma-separated parsing
235+
pass
236+
224237
# Split by comma, strip whitespace, then strip leading '#' characters
225238
return [tag.strip().lstrip("#") for tag in tags.split(",") if tag and tag.strip()]
226239

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Integration tests for Obsidian-compatible YAML formatting in write_note tool."""
2+
3+
import pytest
4+
from pathlib import Path
5+
6+
from basic_memory.mcp.tools import write_note
7+
8+
9+
@pytest.mark.asyncio
10+
async def test_write_note_tags_yaml_format(app, project_config):
11+
"""Test that write_note creates files with proper YAML list format for tags."""
12+
# Create a note with tags using write_note
13+
result = await write_note.fn(
14+
title="YAML Format Test",
15+
folder="test",
16+
content="Testing YAML tag formatting",
17+
tags=["system", "overview", "reference"]
18+
)
19+
20+
# Verify the note was created successfully
21+
assert "Created note" in result
22+
assert "file_path: test/YAML Format Test.md" in result
23+
24+
# Read the file directly to check YAML formatting
25+
file_path = project_config.home / "test" / "YAML Format Test.md"
26+
content = file_path.read_text(encoding="utf-8")
27+
28+
# Should use YAML list format
29+
assert "tags:" in content
30+
assert "- system" in content
31+
assert "- overview" in content
32+
assert "- reference" in content
33+
34+
# Should NOT use JSON array format
35+
assert '["system"' not in content
36+
assert '"overview"' not in content
37+
assert '"reference"]' not in content
38+
39+
40+
@pytest.mark.asyncio
41+
async def test_write_note_stringified_json_tags(app, project_config):
42+
"""Test that stringified JSON arrays are handled correctly."""
43+
# This simulates the issue where AI assistants pass tags as stringified JSON
44+
result = await write_note.fn(
45+
title="Stringified JSON Test",
46+
folder="test",
47+
content="Testing stringified JSON tag input",
48+
tags='["python", "testing", "json"]' # Stringified JSON array
49+
)
50+
51+
# Verify the note was created successfully
52+
assert "Created note" in result
53+
54+
# Read the file to check formatting
55+
file_path = project_config.home / "test" / "Stringified JSON Test.md"
56+
content = file_path.read_text(encoding="utf-8")
57+
58+
# Should properly parse the JSON and format as YAML list
59+
assert "tags:" in content
60+
assert "- python" in content
61+
assert "- testing" in content
62+
assert "- json" in content
63+
64+
# Should NOT have the original stringified format issues
65+
assert '["python"' not in content
66+
assert '"testing"' not in content
67+
assert '"json"]' not in content
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_write_note_single_tag_yaml_format(app, project_config):
72+
"""Test that single tags are still formatted as YAML lists."""
73+
result = await write_note.fn(
74+
title="Single Tag Test",
75+
folder="test",
76+
content="Testing single tag formatting",
77+
tags=["solo-tag"]
78+
)
79+
80+
file_path = project_config.home / "test" / "Single Tag Test.md"
81+
content = file_path.read_text(encoding="utf-8")
82+
83+
# Single tag should still use list format
84+
assert "tags:" in content
85+
assert "- solo-tag" in content
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_write_note_no_tags(app, project_config):
90+
"""Test that notes without tags work normally."""
91+
result = await write_note.fn(
92+
title="No Tags Test",
93+
folder="test",
94+
content="Testing note without tags",
95+
tags=None
96+
)
97+
98+
file_path = project_config.home / "test" / "No Tags Test.md"
99+
content = file_path.read_text(encoding="utf-8")
100+
101+
# Should not have tags field in frontmatter
102+
assert "tags:" not in content
103+
assert "title: No Tags Test" in content
104+
105+
106+
@pytest.mark.asyncio
107+
async def test_write_note_empty_tags_list(app, project_config):
108+
"""Test that empty tag lists are handled properly."""
109+
result = await write_note.fn(
110+
title="Empty Tags Test",
111+
folder="test",
112+
content="Testing empty tag list",
113+
tags=[]
114+
)
115+
116+
file_path = project_config.home / "test" / "Empty Tags Test.md"
117+
content = file_path.read_text(encoding="utf-8")
118+
119+
# Should not add tags field to frontmatter for empty lists
120+
assert "tags:" not in content
121+
122+
123+
@pytest.mark.asyncio
124+
async def test_write_note_update_preserves_yaml_format(app, project_config):
125+
"""Test that updating a note preserves the YAML list format."""
126+
# First, create the note
127+
await write_note.fn(
128+
title="Update Format Test",
129+
folder="test",
130+
content="Initial content",
131+
tags=["initial", "tag"]
132+
)
133+
134+
# Then update it with new tags
135+
result = await write_note.fn(
136+
title="Update Format Test",
137+
folder="test",
138+
content="Updated content",
139+
tags=["updated", "new-tag", "format"]
140+
)
141+
142+
# Should be an update, not a new creation
143+
assert "Updated note" in result
144+
145+
# Check the file format
146+
file_path = project_config.home / "test" / "Update Format Test.md"
147+
content = file_path.read_text(encoding="utf-8")
148+
149+
# Should have proper YAML formatting for updated tags
150+
assert "tags:" in content
151+
assert "- updated" in content
152+
assert "- new-tag" in content
153+
assert "- format" in content
154+
155+
# Old tags should be gone
156+
assert "- initial" not in content
157+
assert "- tag" not in content
158+
159+
# Content should be updated
160+
assert "Updated content" in content
161+
assert "Initial content" not in content
162+
163+
164+
@pytest.mark.asyncio
165+
async def test_complex_tags_yaml_format(app, project_config):
166+
"""Test that complex tags with special characters format correctly."""
167+
result = await write_note.fn(
168+
title="Complex Tags Test",
169+
folder="test",
170+
content="Testing complex tag formats",
171+
tags=["python-3.9", "api_integration", "v2.0", "nested/category", "under_score"]
172+
)
173+
174+
file_path = project_config.home / "test" / "Complex Tags Test.md"
175+
content = file_path.read_text(encoding="utf-8")
176+
177+
# All complex tags should format correctly
178+
assert "- python-3.9" in content
179+
assert "- api_integration" in content
180+
assert "- v2.0" in content
181+
assert "- nested/category" in content
182+
assert "- under_score" in content

0 commit comments

Comments
 (0)