mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
set permalink in frontmatter, re-index file_path on move during sync
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
"""Writer for knowledge entity markdown files."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from basic_memory.markdown import EntityFrontmatter
|
||||
from basic_memory.models import Entity as EntityModel, Observation
|
||||
|
||||
|
||||
class KnowledgeWriter:
|
||||
"""Formats entities into markdown files.
|
||||
|
||||
|
||||
Content handling:
|
||||
1. If raw content is provided, use it directly
|
||||
2. If structured data exists (observations/relations), generate structured content
|
||||
@@ -18,7 +20,7 @@ class KnowledgeWriter:
|
||||
async def format_frontmatter(self, entity: EntityModel) -> dict:
|
||||
"""Generate frontmatter metadata for entity."""
|
||||
frontmatter = {
|
||||
"id": entity.permalink,
|
||||
"permalink": entity.permalink,
|
||||
"type": entity.entity_type,
|
||||
"created": entity.created_at.isoformat(),
|
||||
"modified": entity.updated_at.isoformat(),
|
||||
@@ -35,19 +37,19 @@ class KnowledgeWriter:
|
||||
if obs.tags:
|
||||
line += " " + " ".join(f"#{tag}" for tag in sorted(obs.tags))
|
||||
|
||||
# Add context if present
|
||||
# Add context if present
|
||||
if obs.context:
|
||||
line += f" ({obs.context})"
|
||||
|
||||
return line
|
||||
|
||||
|
||||
async def format_content(self, entity: EntityModel, content: Optional[str] = None) -> str:
|
||||
"""Format entity content as markdown.
|
||||
|
||||
|
||||
Args:
|
||||
entity: Entity to format
|
||||
content: Optional raw content to use instead of generating structured content
|
||||
|
||||
|
||||
Returns:
|
||||
Formatted markdown content
|
||||
"""
|
||||
@@ -58,25 +60,29 @@ class KnowledgeWriter:
|
||||
|
||||
# Otherwise, build structured content from entity data
|
||||
sections = []
|
||||
|
||||
|
||||
# Only add entity title if we don't have structured content
|
||||
# This prevents duplicate titles when raw content already has a title
|
||||
if not (entity.observations or entity.outgoing_relations):
|
||||
sections.extend([
|
||||
f"# {entity.title}",
|
||||
"", # Empty line after title
|
||||
])
|
||||
|
||||
sections.extend(
|
||||
[
|
||||
f"# {entity.title}",
|
||||
"", # Empty line after title
|
||||
]
|
||||
)
|
||||
|
||||
if entity.summary:
|
||||
sections.extend([entity.summary, ""])
|
||||
|
||||
# Add observations if present
|
||||
if entity.observations:
|
||||
sections.extend([
|
||||
"## Observations",
|
||||
"<!-- Format: - [category] Content text #tag1 #tag2 (optional context) -->",
|
||||
"",
|
||||
])
|
||||
sections.extend(
|
||||
[
|
||||
"## Observations",
|
||||
"<!-- Format: - [category] Content text #tag1 #tag2 (optional context) -->",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
for obs in entity.observations:
|
||||
sections.append(await self.format_observation(obs))
|
||||
@@ -84,12 +90,14 @@ class KnowledgeWriter:
|
||||
|
||||
# Add relations if present
|
||||
if entity.outgoing_relations:
|
||||
sections.extend([
|
||||
"## Relations",
|
||||
"<!-- Format: - relation_type [[Entity]] (context) -->",
|
||||
"", # Empty line after format comment
|
||||
])
|
||||
|
||||
sections.extend(
|
||||
[
|
||||
"## Relations",
|
||||
"<!-- Format: - relation_type [[Entity]] (context) -->",
|
||||
"", # Empty line after format comment
|
||||
]
|
||||
)
|
||||
|
||||
for rel in entity.outgoing_relations:
|
||||
line = f"- {rel.relation_type} [[{rel.to_entity.title}]]"
|
||||
if rel.context:
|
||||
|
||||
@@ -69,9 +69,11 @@ class SyncService:
|
||||
entity = await self.entity_repository.get_by_file_path(old_path)
|
||||
if entity:
|
||||
# Update file_path but keep the same permalink for link stability
|
||||
await self.entity_repository.update(
|
||||
updated = await self.entity_repository.update(
|
||||
entity.id, {"file_path": new_path, "checksum": changes.checksums[new_path]}
|
||||
)
|
||||
# update search index
|
||||
await self.search_service.index_entity(updated)
|
||||
|
||||
# Handle deletions next
|
||||
# remove rows from db for files no longer present
|
||||
|
||||
@@ -57,7 +57,7 @@ async def test_format_frontmatter_basic(knowledge_writer: KnowledgeWriter, sampl
|
||||
"""Test basic frontmatter formatting."""
|
||||
frontmatter = await knowledge_writer.format_frontmatter(sample_entity)
|
||||
|
||||
assert frontmatter["id"] == "knowledge/test-entity"
|
||||
assert frontmatter["permalink"] == "knowledge/test-entity"
|
||||
assert frontmatter["type"] == "test"
|
||||
assert frontmatter["created"] == "2025-01-01T00:00:00+00:00"
|
||||
assert frontmatter["modified"] == "2025-01-02T00:00:00+00:00"
|
||||
@@ -74,7 +74,7 @@ async def test_format_frontmatter_with_metadata(
|
||||
|
||||
assert frontmatter["status"] == "active"
|
||||
assert frontmatter["priority"] == "high"
|
||||
assert frontmatter["id"] == "knowledge/test-entity"
|
||||
assert frontmatter["permalink"] == "knowledge/test-entity"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -55,7 +55,7 @@ async def test_create_entity(entity_service: EntityService, file_service: FileSe
|
||||
metadata = yaml.safe_load(frontmatter)
|
||||
|
||||
# Verify frontmatter contents
|
||||
assert metadata["id"] == entity.permalink
|
||||
assert metadata["permalink"] == entity.permalink
|
||||
assert metadata["type"] == entity.entity_type
|
||||
assert "created" in metadata
|
||||
assert "modified" in metadata
|
||||
@@ -440,7 +440,7 @@ async def test_update_entity_name(entity_service: EntityService, file_service: F
|
||||
|
||||
_, frontmatter, _ = content.split("---", 2)
|
||||
metadata = yaml.safe_load(frontmatter)
|
||||
assert metadata["id"] == entity.permalink
|
||||
assert metadata["permalink"] == entity.permalink
|
||||
|
||||
# And verify content uses new name for title
|
||||
assert "# new-name" in content
|
||||
|
||||
@@ -544,6 +544,44 @@ Testing file timestamps
|
||||
assert abs((file_entity.created_at.timestamp() - file_stats.st_ctime)) < 1 # Allow 1s difference
|
||||
assert abs((file_entity.updated_at.timestamp() - file_stats.st_mtime)) < 1 # Allow 1s difference
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_move_updates_search_index(
|
||||
sync_service: SyncService,
|
||||
test_config: ProjectConfig,
|
||||
search_service: SearchService,
|
||||
):
|
||||
"""Test that moving a file updates its path in the search index."""
|
||||
project_dir = test_config.home
|
||||
|
||||
# Create initial file
|
||||
content = """
|
||||
---
|
||||
type: knowledge
|
||||
---
|
||||
# Test Move
|
||||
Content for move test
|
||||
"""
|
||||
old_path = project_dir / "old" / "test_move.md"
|
||||
old_path.parent.mkdir(parents=True)
|
||||
await create_test_file(old_path, content)
|
||||
|
||||
# Initial sync
|
||||
await sync_service.sync(test_config.home)
|
||||
|
||||
# Move the file
|
||||
new_path = project_dir / "new" / "moved_file.md"
|
||||
new_path.parent.mkdir(parents=True)
|
||||
old_path.rename(new_path)
|
||||
|
||||
# Sync again
|
||||
await sync_service.sync(test_config.home)
|
||||
|
||||
# Check search index has updated path
|
||||
results = await search_service.search(SearchQuery(text="Content for move test"))
|
||||
assert len(results) == 1
|
||||
assert results[0].file_path == str(new_path.relative_to(project_dir))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_null_checksum_cleanup(
|
||||
sync_service: SyncService, test_config: ProjectConfig, entity_service: EntityService
|
||||
|
||||
Reference in New Issue
Block a user