mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix: use relative file paths in importers for cloud storage compatibility
Importers now use relative file paths (based on permalink) instead of absolute paths. This enables proper S3 key generation in cloud environments. Changes: - write_entity() accepts str | Path for file_path parameter - ensure_folder_exists() uses relative paths directly - All importers pass relative paths to FileService - FileService handles base_path resolution internally This prevents S3 keys from including container filesystem paths like `/app/basic-memory/imports/...` and instead uses clean relative paths like `imports/20241010-file.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -55,7 +55,7 @@ class Importer[T: ImportResult]:
|
||||
"""
|
||||
pass # pragma: no cover
|
||||
|
||||
async def write_entity(self, entity: EntityMarkdown, file_path: Path) -> str:
|
||||
async def write_entity(self, entity: EntityMarkdown, file_path: str | Path) -> str:
|
||||
"""Write entity to file using FileService.
|
||||
|
||||
This method serializes the entity to markdown and writes it using
|
||||
@@ -64,7 +64,7 @@ class Importer[T: ImportResult]:
|
||||
|
||||
Args:
|
||||
entity: EntityMarkdown instance to write.
|
||||
file_path: Path to write the entity to.
|
||||
file_path: Relative path to write the entity to. FileService handles base_path.
|
||||
|
||||
Returns:
|
||||
Checksum of written file.
|
||||
@@ -73,21 +73,16 @@ class Importer[T: ImportResult]:
|
||||
# FileService.write_file handles directory creation and returns checksum
|
||||
return await self.file_service.write_file(file_path, content)
|
||||
|
||||
async def ensure_folder_exists(self, folder: str) -> Path:
|
||||
async def ensure_folder_exists(self, folder: str) -> None:
|
||||
"""Ensure folder exists using FileService.
|
||||
|
||||
For cloud storage (S3), this is essentially a no-op since S3 doesn't
|
||||
have actual folders - they're just key prefixes.
|
||||
|
||||
Args:
|
||||
folder: Folder name or path within the project.
|
||||
|
||||
Returns:
|
||||
Path to the folder.
|
||||
folder: Relative folder path within the project. FileService handles base_path.
|
||||
"""
|
||||
folder_path = self.base_path / folder
|
||||
await self.file_service.ensure_directory(folder_path)
|
||||
return folder_path
|
||||
await self.file_service.ensure_directory(folder)
|
||||
|
||||
@abstractmethod
|
||||
def handle_error(
|
||||
|
||||
@@ -41,8 +41,8 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
# Convert to entity
|
||||
entity = self._format_chat_content(destination_folder, chat)
|
||||
|
||||
# Write file
|
||||
file_path = self.base_path / f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
|
||||
# Count messages
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown
|
||||
@@ -31,7 +30,7 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
"""
|
||||
try:
|
||||
# Ensure the destination folder exists
|
||||
folder_path = await self.ensure_folder_exists(destination_folder)
|
||||
await self.ensure_folder_exists(destination_folder)
|
||||
|
||||
conversations = source_data
|
||||
|
||||
@@ -45,15 +44,15 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
|
||||
# Convert to entity
|
||||
entity = self._format_chat_content(
|
||||
base_path=folder_path,
|
||||
folder=destination_folder,
|
||||
name=chat_name,
|
||||
messages=chat["chat_messages"],
|
||||
created_at=chat["created_at"],
|
||||
modified_at=chat["updated_at"],
|
||||
)
|
||||
|
||||
# Write file
|
||||
file_path = self.base_path / Path(f"{entity.frontmatter.metadata['permalink']}.md")
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
|
||||
chats_imported += 1
|
||||
@@ -72,7 +71,7 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
|
||||
def _format_chat_content(
|
||||
self,
|
||||
base_path: Path,
|
||||
folder: str,
|
||||
name: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
created_at: str,
|
||||
@@ -81,7 +80,7 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
"""Convert chat messages to Basic Memory entity format.
|
||||
|
||||
Args:
|
||||
base_path: Base path for the entity.
|
||||
folder: Destination folder name (relative path).
|
||||
name: Chat name.
|
||||
messages: List of chat messages.
|
||||
created_at: Creation timestamp.
|
||||
@@ -90,10 +89,10 @@ class ClaudeConversationsImporter(Importer[ChatImportResult]):
|
||||
Returns:
|
||||
EntityMarkdown instance representing the conversation.
|
||||
"""
|
||||
# Generate permalink
|
||||
# Generate permalink using folder name (relative path)
|
||||
date_prefix = datetime.fromisoformat(created_at.replace("Z", "+00:00")).strftime("%Y%m%d")
|
||||
clean_title = clean_filename(name)
|
||||
permalink = f"{base_path.name}/{date_prefix}-{clean_title}"
|
||||
permalink = f"{folder}/{date_prefix}-{clean_title}"
|
||||
|
||||
# Format content
|
||||
content = self._format_chat_markdown(
|
||||
|
||||
@@ -29,9 +29,8 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
"""
|
||||
try:
|
||||
# Ensure the base folder exists
|
||||
base_path = self.base_path
|
||||
if destination_folder:
|
||||
base_path = await self.ensure_folder_exists(destination_folder)
|
||||
await self.ensure_folder_exists(destination_folder)
|
||||
|
||||
projects = source_data
|
||||
|
||||
@@ -42,20 +41,26 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
for project in projects:
|
||||
project_dir = clean_filename(project["name"])
|
||||
|
||||
# Create project directories using FileService
|
||||
docs_dir = base_path / project_dir / "docs"
|
||||
# Create project directories using FileService with relative path
|
||||
docs_dir = (
|
||||
f"{destination_folder}/{project_dir}/docs"
|
||||
if destination_folder
|
||||
else f"{project_dir}/docs"
|
||||
)
|
||||
await self.file_service.ensure_directory(docs_dir)
|
||||
|
||||
# Import prompt template if it exists
|
||||
if prompt_entity := self._format_prompt_markdown(project):
|
||||
file_path = base_path / f"{prompt_entity.frontmatter.metadata['permalink']}.md"
|
||||
if prompt_entity := self._format_prompt_markdown(project, destination_folder):
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{prompt_entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(prompt_entity, file_path)
|
||||
prompts_imported += 1
|
||||
|
||||
# Import project documents
|
||||
for doc in project.get("docs", []):
|
||||
entity = self._format_project_markdown(project, doc)
|
||||
file_path = base_path / f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
entity = self._format_project_markdown(project, doc, destination_folder)
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
docs_imported += 1
|
||||
|
||||
@@ -71,13 +76,14 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
return self.handle_error("Failed to import Claude projects", e) # pyright: ignore [reportReturnType]
|
||||
|
||||
def _format_project_markdown(
|
||||
self, project: Dict[str, Any], doc: Dict[str, Any]
|
||||
self, project: Dict[str, Any], doc: Dict[str, Any], destination_folder: str = ""
|
||||
) -> EntityMarkdown:
|
||||
"""Format a project document as a Basic Memory entity.
|
||||
|
||||
Args:
|
||||
project: Project data.
|
||||
doc: Document data.
|
||||
destination_folder: Optional destination folder prefix.
|
||||
|
||||
Returns:
|
||||
EntityMarkdown instance representing the document.
|
||||
@@ -90,6 +96,13 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
project_dir = clean_filename(project["name"])
|
||||
doc_file = clean_filename(doc["filename"])
|
||||
|
||||
# Build permalink with optional destination folder prefix
|
||||
permalink = (
|
||||
f"{destination_folder}/{project_dir}/docs/{doc_file}"
|
||||
if destination_folder
|
||||
else f"{project_dir}/docs/{doc_file}"
|
||||
)
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
@@ -98,7 +111,7 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
"title": doc["filename"],
|
||||
"created": created_at,
|
||||
"modified": modified_at,
|
||||
"permalink": f"{project_dir}/docs/{doc_file}",
|
||||
"permalink": permalink,
|
||||
"project_name": project["name"],
|
||||
"project_uuid": project["uuid"],
|
||||
"doc_uuid": doc["uuid"],
|
||||
@@ -109,11 +122,14 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
|
||||
return entity
|
||||
|
||||
def _format_prompt_markdown(self, project: Dict[str, Any]) -> Optional[EntityMarkdown]:
|
||||
def _format_prompt_markdown(
|
||||
self, project: Dict[str, Any], destination_folder: str = ""
|
||||
) -> Optional[EntityMarkdown]:
|
||||
"""Format project prompt template as a Basic Memory entity.
|
||||
|
||||
Args:
|
||||
project: Project data.
|
||||
destination_folder: Optional destination folder prefix.
|
||||
|
||||
Returns:
|
||||
EntityMarkdown instance representing the prompt template, or None if
|
||||
@@ -129,6 +145,13 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
# Generate clean project directory name
|
||||
project_dir = clean_filename(project["name"])
|
||||
|
||||
# Build permalink with optional destination folder prefix
|
||||
permalink = (
|
||||
f"{destination_folder}/{project_dir}/prompt-template"
|
||||
if destination_folder
|
||||
else f"{project_dir}/prompt-template"
|
||||
)
|
||||
|
||||
# Create entity
|
||||
entity = EntityMarkdown(
|
||||
frontmatter=EntityFrontmatter(
|
||||
@@ -137,7 +160,7 @@ class ClaudeProjectsImporter(Importer[ProjectImportResult]):
|
||||
"title": f"Prompt Template: {project['name']}",
|
||||
"created": created_at,
|
||||
"modified": modified_at,
|
||||
"permalink": f"{project_dir}/prompt-template",
|
||||
"permalink": permalink,
|
||||
"project_name": project["name"],
|
||||
"project_uuid": project["uuid"],
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from basic_memory.config import get_project_config
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter, EntityMarkdown, Observation, Relation
|
||||
from basic_memory.importers.base import Importer
|
||||
from basic_memory.schemas.importer import EntityImportResult
|
||||
@@ -27,17 +26,15 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
Returns:
|
||||
EntityImportResult containing statistics and status of the import.
|
||||
"""
|
||||
config = get_project_config()
|
||||
try:
|
||||
# First pass - collect all relations by source entity
|
||||
entity_relations: Dict[str, List[Relation]] = {}
|
||||
entities: Dict[str, Dict[str, Any]] = {}
|
||||
skipped_entities: int = 0
|
||||
|
||||
# Ensure the base path exists
|
||||
base_path = config.home # pragma: no cover
|
||||
# Ensure the destination folder exists if provided
|
||||
if destination_folder: # pragma: no cover
|
||||
base_path = await self.ensure_folder_exists(destination_folder)
|
||||
await self.ensure_folder_exists(destination_folder)
|
||||
|
||||
# First pass - collect entities and relations
|
||||
for line in source_data:
|
||||
@@ -68,8 +65,19 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
# Get entity type with fallback
|
||||
entity_type = entity_data.get("entityType") or entity_data.get("type") or "entity"
|
||||
|
||||
# Ensure entity type directory exists using FileService
|
||||
entity_type_dir = base_path / entity_type
|
||||
# Build permalink with optional destination folder prefix
|
||||
permalink = (
|
||||
f"{destination_folder}/{entity_type}/{name}"
|
||||
if destination_folder
|
||||
else f"{entity_type}/{name}"
|
||||
)
|
||||
|
||||
# Ensure entity type directory exists using FileService with relative path
|
||||
entity_type_dir = (
|
||||
f"{destination_folder}/{entity_type}"
|
||||
if destination_folder
|
||||
else entity_type
|
||||
)
|
||||
await self.file_service.ensure_directory(entity_type_dir)
|
||||
|
||||
# Get observations with fallback to empty list
|
||||
@@ -80,7 +88,7 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
metadata={
|
||||
"type": entity_type,
|
||||
"title": name,
|
||||
"permalink": f"{entity_type}/{name}",
|
||||
"permalink": permalink,
|
||||
}
|
||||
),
|
||||
content=f"# {name}\n",
|
||||
@@ -88,8 +96,8 @@ class MemoryJsonImporter(Importer[EntityImportResult]):
|
||||
relations=entity_relations.get(name, []),
|
||||
)
|
||||
|
||||
# Write entity file
|
||||
file_path = base_path / f"{entity_type}/{name}.md"
|
||||
# Write file using relative path - FileService handles base_path
|
||||
file_path = f"{entity.frontmatter.metadata['permalink']}.md"
|
||||
await self.write_entity(entity, file_path)
|
||||
entities_created += 1
|
||||
|
||||
|
||||
@@ -69,15 +69,15 @@ def test_importer(tmp_path, mock_markdown_processor, mock_file_service):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_data_success(test_importer, mock_file_service, tmp_path):
|
||||
async def test_import_data_success(test_importer, mock_file_service):
|
||||
"""Test successful import_data implementation."""
|
||||
result = await test_importer.import_data({}, "test_folder")
|
||||
assert result.success
|
||||
assert result.import_count == {"files": 1}
|
||||
assert result.error_message is None
|
||||
|
||||
# Verify file_service.ensure_directory was called
|
||||
mock_file_service.ensure_directory.assert_called_once_with(tmp_path / "test_folder")
|
||||
# Verify file_service.ensure_directory was called with relative path
|
||||
mock_file_service.ensure_directory.assert_called_once_with("test_folder")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -105,19 +105,15 @@ async def test_write_entity(test_importer, mock_markdown_processor, mock_file_se
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_folder_exists(test_importer, mock_file_service, tmp_path):
|
||||
async def test_ensure_folder_exists(test_importer, mock_file_service):
|
||||
"""Test ensure_folder_exists method."""
|
||||
# Test with simple folder
|
||||
folder_path = await test_importer.ensure_folder_exists("test_folder")
|
||||
assert folder_path == tmp_path / "test_folder"
|
||||
# Test with simple folder - now passes relative path to FileService
|
||||
await test_importer.ensure_folder_exists("test_folder")
|
||||
mock_file_service.ensure_directory.assert_called_with("test_folder")
|
||||
|
||||
# Verify file_service.ensure_directory was called
|
||||
mock_file_service.ensure_directory.assert_called_with(tmp_path / "test_folder")
|
||||
|
||||
# Test with nested folder
|
||||
nested_path = await test_importer.ensure_folder_exists("nested/folder/path")
|
||||
assert nested_path == tmp_path / "nested" / "folder" / "path"
|
||||
mock_file_service.ensure_directory.assert_called_with(tmp_path / "nested" / "folder" / "path")
|
||||
# Test with nested folder - FileService handles base_path resolution
|
||||
await test_importer.ensure_folder_exists("nested/folder/path")
|
||||
mock_file_service.ensure_directory.assert_called_with("nested/folder/path")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user