diff --git a/src/basic_memory/cli/main.py b/src/basic_memory/cli/main.py index b064bfa4..41a15fc7 100644 --- a/src/basic_memory/cli/main.py +++ b/src/basic_memory/cli/main.py @@ -14,7 +14,7 @@ __all__ = ["init", "status", "sync"] from basic_memory.config import config -def setup_logging(home_dir: str = config.home, log_file: str = "basic-memory-tools.log"): +def setup_logging(home_dir: str = config.home, log_file: str = "./basic-memory/basic-memory-tools.log"): """Configure logging for the application.""" # Remove default handler and any existing handlers diff --git a/src/basic_memory/mcp/tools/discussion.py b/src/basic_memory/mcp/tools/discussion.py index d47a7e31..f6c74ce6 100644 --- a/src/basic_memory/mcp/tools/discussion.py +++ b/src/basic_memory/mcp/tools/discussion.py @@ -10,7 +10,6 @@ from basic_memory.schemas.memory import GraphContext, MemoryUrl @mcp.tool( - name="Build Context", description="Build context from a memory:// URI to continue conversations naturally.", ) async def build_context( diff --git a/src/basic_memory/models/knowledge.py b/src/basic_memory/models/knowledge.py index 2d369478..91ba6c08 100644 --- a/src/basic_memory/models/knowledge.py +++ b/src/basic_memory/models/knowledge.py @@ -30,13 +30,16 @@ def generate_permalink(file_path: str) -> str: file_path: Original file path Returns: - Normalized permalink that matches validation rules + Normalized permalink that matches validation rules. Converts spaces and underscores + to hyphens for consistency. Examples: >>> generate_permalink("docs/My Feature.md") 'docs/my-feature' >>> generate_permalink("specs/API (v2).md") 'specs/api-v2' + >>> generate_permalink("design/unified_model_refactor.md") + 'design/unified-model-refactor' """ # Remove extension base = os.path.splitext(file_path)[0] @@ -47,8 +50,11 @@ def generate_permalink(file_path: str) -> str: # Convert to lowercase lower_text = ascii_text.lower() - # Replace spaces and invalid chars with hyphens - clean_text = re.sub(r'[^a-z0-9/\-_]', '-', lower_text) + # First replace underscores with hyphens + text_with_hyphens = lower_text.replace('_', '-') + + # Replace remaining invalid chars with hyphens + clean_text = re.sub(r'[^a-z0-9/\-]', '-', text_with_hyphens) # Collapse multiple hyphens clean_text = re.sub(r'-+', '-', clean_text) @@ -130,17 +136,17 @@ class Entity(Base): Requirements: 1. Must be valid URI path component - 2. Only lowercase letters, numbers, hyphens, and underscores + 2. Only lowercase letters, numbers, and hyphens (no underscores) 3. Path segments separated by forward slashes 4. No leading/trailing hyphens in segments """ if not value: raise ValueError("Permalink must not be None") - if not re.match(r'^[a-z0-9][a-z0-9\-_/]*[a-z0-9]$', value): + if not re.match(r'^[a-z0-9][a-z0-9\-/]*[a-z0-9]$', value): raise ValueError( f"Invalid permalink format: {value}. " - "Use only lowercase letters, numbers, hyphens, and underscores." + "Use only lowercase letters, numbers, and hyphens." ) return value diff --git a/src/basic_memory/repository/search_repository.py b/src/basic_memory/repository/search_repository.py index 0f74b61f..4fa6e8e5 100644 --- a/src/basic_memory/repository/search_repository.py +++ b/src/basic_memory/repository/search_repository.py @@ -74,10 +74,16 @@ class SearchRepository: await session.commit() def _quote_search_term(self, term: str) -> str: - """Add quotes if term contains special characters or /. - For FTS5, phrases with / need to be quoted to be treated as a single token. + """Add quotes if term contains special characters. + For FTS5, special characters and phrases need to be quoted to be treated as a single token. """ - if '/' in term or '*' in term or any(c in term for c in "-"): + # List of special characters that need quoting + special_chars = ['/', '*', '-', '.', ' ', '(', ')', '[', ']', '"', "'"] + + # Check if term contains any special characters + if any(c in term for c in special_chars): + # If the term already contains quotes, escape them + term = term.replace('"', '""') return f'"{term}"' return term @@ -229,4 +235,4 @@ class SearchRepository: else: result = await session.execute(query) logger.debug("Query executed successfully") - return result + return result \ No newline at end of file diff --git a/src/basic_memory/services/link_resolver.py b/src/basic_memory/services/link_resolver.py index 2047fd5d..5e71a248 100644 --- a/src/basic_memory/services/link_resolver.py +++ b/src/basic_memory/services/link_resolver.py @@ -123,5 +123,5 @@ class LinkResolver(): scored_results.append((score, result)) # Sort by score (lowest first) and return best - scored_results.sort() + scored_results.sort(key=lambda x: x[0], reverse=True) return scored_results[0][1] diff --git a/tests/edit_file_test.py b/tests/edit_file_test.py new file mode 100644 index 00000000..09f7721c --- /dev/null +++ b/tests/edit_file_test.py @@ -0,0 +1,17 @@ +"""Test file for experimenting with edit_file.""" + +def function_one(): + """First test function.""" + print("Hello from function one") + + # Some code here + x = 1 + 2 + return x + +def function_two(): + """Second test function.""" + print("Hello from function two") + + # Some more code + y = 3 * 4 + return y \ No newline at end of file diff --git a/tests/sync/test_sync_service.py b/tests/sync/test_sync_service.py index e067c393..3288b986 100644 --- a/tests/sync/test_sync_service.py +++ b/tests/sync/test_sync_service.py @@ -420,7 +420,44 @@ modified: 2024-01-01 assert doc is not None # File should have a checksum, even if it's from either version assert doc.checksum is not None + +@pytest.mark.asyncio +async def test_permalink_formatting(sync_service: SyncService, test_config: ProjectConfig, entity_service: EntityService): + """Test that permalinks are properly formatted during sync.""" + + # Test cases with different filename formats + test_files = { + # filename -> expected permalink + "my_awesome_feature.md": "my-awesome-feature", + "MIXED_CASE_NAME.md": "mixed-case-name", + "spaces and_underscores.md": "spaces-and-underscores", + "design/model_refactor.md": "design/model-refactor", + "test/multiple_word_directory/feature_name.md": "test/multiple-word-directory/feature-name", + } + + # Create test files + for filename, _ in test_files.items(): + content: str = """ +--- +type: knowledge +created: 2024-01-01 +modified: 2024-01-01 +--- +# Test File +Testing permalink generation. +""" + await create_test_file(test_config.home / filename, content) + + # Run sync + await sync_service.sync(test_config.home) + + # Verify permalinks + entities = await entity_service.repository.find_all() + for filename, expected_permalink in test_files.items(): + # Find entity for this file + entity = next(e for e in entities if e.file_path == filename) + assert entity.permalink == expected_permalink, f"File {filename} should have permalink {expected_permalink}" @pytest.mark.asyncio async def test_sync_null_checksum_cleanup( diff --git a/tests/test_permalink_formatting.py b/tests/test_permalink_formatting.py new file mode 100644 index 00000000..04b85547 --- /dev/null +++ b/tests/test_permalink_formatting.py @@ -0,0 +1,62 @@ +"""Test permalink formatting during sync.""" +import pytest +from pathlib import Path + +from basic_memory.config import ProjectConfig +from basic_memory.services import EntityService +from basic_memory.sync.sync_service import SyncService + + +async def create_test_file(path: Path, content: str = "test content") -> None: + """Create a test file with given content.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + +@pytest.mark.asyncio +async def test_permalink_formatting( + sync_service: SyncService, test_config: ProjectConfig, entity_service: EntityService +): + """Test that permalinks are properly formatted during sync. + + This ensures: + - Underscores are converted to hyphens + - Spaces are converted to hyphens + - Mixed case is lowercased + - Directory structure is preserved + - Multiple directories work correctly + """ + project_dir = test_config.home + + # Test cases with different filename formats + test_cases = [ + # filename -> expected permalink + ("my_awesome_feature.md", "my-awesome-feature"), + ("MIXED_CASE_NAME.md", "mixed-case-name"), + ("spaces and_underscores.md", "spaces-and-underscores"), + ("design/model_refactor.md", "design/model-refactor"), + ("test/multiple_word_directory/feature_name.md", "test/multiple-word-directory/feature-name"), + ] + + # Create test files + for filename, _ in test_cases: + content = f""" +--- +type: knowledge +created: 2024-01-01 +modified: 2024-01-01 +--- +# Test File + +Testing permalink generation. +""" + await create_test_file(project_dir / filename, content) + + # Run sync + await sync_service.sync(test_config.home) + + # Verify permalinks + for filename, expected_permalink in test_cases: + entity = await entity_service.repository.get_by_file_path(filename) + assert entity.permalink == expected_permalink, \ + f"File {filename} should have permalink {expected_permalink}" \ No newline at end of file