From 903591384dffeba9996a18463818bdb8b28ca03e Mon Sep 17 00:00:00 2001 From: Paul Hernandez <60959+phernandez@users.noreply.github.com> Date: Fri, 3 Oct 2025 22:11:47 -0500 Subject: [PATCH] feat: Add disable_permalinks config flag (#313) Signed-off-by: phernandez Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Paul Hernandez Co-authored-by: Claude --- .../cli/commands/cloud/api_client.py | 5 +- src/basic_memory/config.py | 11 + src/basic_memory/deps.py | 2 + src/basic_memory/schemas/base.py | 6 +- src/basic_memory/services/entity_service.py | 43 ++-- src/basic_memory/sync/sync_service.py | 10 +- src/basic_memory/sync/watch_service.py | 4 +- .../test_disable_permalinks_integration.py | 141 +++++++++++ tests/cli/test_cloud_authentication.py | 4 +- tests/conftest.py | 2 + .../test_entity_service_disable_permalinks.py | 220 ++++++++++++++++++ tests/test_config.py | 10 + 12 files changed, 432 insertions(+), 26 deletions(-) create mode 100644 test-int/test_disable_permalinks_integration.py create mode 100644 tests/services/test_entity_service_disable_permalinks.py diff --git a/src/basic_memory/cli/commands/cloud/api_client.py b/src/basic_memory/cli/commands/cloud/api_client.py index e0f5623b..2f48e424 100644 --- a/src/basic_memory/cli/commands/cloud/api_client.py +++ b/src/basic_memory/cli/commands/cloud/api_client.py @@ -90,7 +90,10 @@ async def make_api_request( # Handle both FastAPI HTTPException format (nested under "detail") # and direct format detail_obj = error_detail.get("detail", error_detail) - if isinstance(detail_obj, dict) and detail_obj.get("error") == "subscription_required": + if ( + isinstance(detail_obj, dict) + and detail_obj.get("error") == "subscription_required" + ): message = detail_obj.get("message", "Active subscription required") subscribe_url = detail_obj.get( "subscribe_url", "https://basicmemory.com/subscribe" diff --git a/src/basic_memory/config.py b/src/basic_memory/config.py index cfd73e3f..4a638875 100644 --- a/src/basic_memory/config.py +++ b/src/basic_memory/config.py @@ -93,11 +93,22 @@ class BasicMemoryConfig(BaseSettings): description="Format for generated filenames. False preserves spaces and special chars, True converts them to hyphens for consistency with permalinks", ) + disable_permalinks: bool = Field( + default=False, + description="Disable automatic permalink generation in frontmatter. When enabled, new notes won't have permalinks added and sync won't update permalinks. Existing permalinks will still work for reading.", + ) + skip_initialization_sync: bool = Field( default=False, description="Skip expensive initialization synchronization. Useful for cloud/stateless deployments where project reconciliation is not needed.", ) + # API connection configuration + api_url: Optional[str] = Field( + default=None, + description="URL of remote Basic Memory API. If set, MCP will connect to this API instead of using local ASGI transport.", + ) + # Cloud configuration cloud_client_id: str = Field( default="client_01K6KWQPW6J1M8VV7R3TZP5A6M", diff --git a/src/basic_memory/deps.py b/src/basic_memory/deps.py index 86fe4a7c..c023c6ac 100644 --- a/src/basic_memory/deps.py +++ b/src/basic_memory/deps.py @@ -260,6 +260,7 @@ async def get_entity_service( entity_parser: EntityParserDep, file_service: FileServiceDep, link_resolver: "LinkResolverDep", + app_config: AppConfigDep, ) -> EntityService: """Create EntityService with repository.""" return EntityService( @@ -269,6 +270,7 @@ async def get_entity_service( entity_parser=entity_parser, file_service=file_service, link_resolver=link_resolver, + app_config=app_config, ) diff --git a/src/basic_memory/schemas/base.py b/src/basic_memory/schemas/base.py index 3da860a4..142a23ab 100644 --- a/src/basic_memory/schemas/base.py +++ b/src/basic_memory/schemas/base.py @@ -197,6 +197,7 @@ class Entity(BaseModel): """ # private field to override permalink + # Use empty string "" as sentinel to indicate permalinks are explicitly disabled _permalink: Optional[str] = None title: str @@ -247,8 +248,11 @@ class Entity(BaseModel): return os.path.join(self.folder, safe_title) if self.folder else safe_title @property - def permalink(self) -> Permalink: + def permalink(self) -> Optional[Permalink]: """Get a url friendly path}.""" + # Empty string is a sentinel value indicating permalinks are disabled + if self._permalink == "": + return None return self._permalink or generate_permalink(self.file_path) @model_validator(mode="after") diff --git a/src/basic_memory/services/entity_service.py b/src/basic_memory/services/entity_service.py index 371072e8..92e7bbb3 100644 --- a/src/basic_memory/services/entity_service.py +++ b/src/basic_memory/services/entity_service.py @@ -42,6 +42,7 @@ class EntityService(BaseService[EntityModel]): relation_repository: RelationRepository, file_service: FileService, link_resolver: LinkResolver, + app_config: Optional[BasicMemoryConfig] = None, ): super().__init__(entity_repository) self.observation_repository = observation_repository @@ -49,6 +50,7 @@ class EntityService(BaseService[EntityModel]): self.entity_parser = entity_parser self.file_service = file_service self.link_resolver = link_resolver + self.app_config = app_config async def detect_file_path_conflicts(self, file_path: str) -> List[Entity]: """Detect potential file path conflicts for a given file path. @@ -145,9 +147,9 @@ class EntityService(BaseService[EntityModel]): ) # Try to find existing entity using smart resolution - existing = await self.link_resolver.resolve_link( - schema.file_path - ) or await self.link_resolver.resolve_link(schema.permalink) + existing = await self.link_resolver.resolve_link(schema.file_path) + if not existing and schema.permalink: + existing = await self.link_resolver.resolve_link(schema.permalink) if existing: logger.debug(f"Found existing entity: {existing.file_path}") @@ -194,9 +196,15 @@ class EntityService(BaseService[EntityModel]): relations=[], ) - # Get unique permalink (prioritizing content frontmatter) - permalink = await self.resolve_permalink(file_path, content_markdown) - schema._permalink = permalink + # Get unique permalink (prioritizing content frontmatter) unless disabled + if self.app_config and self.app_config.disable_permalinks: + # Use empty string as sentinel to indicate permalinks are disabled + # The permalink property will return None when it sees empty string + schema._permalink = "" + else: + # Generate and set permalink + permalink = await self.resolve_permalink(file_path, content_markdown) + schema._permalink = permalink post = await schema_to_markdown(schema) @@ -254,15 +262,16 @@ class EntityService(BaseService[EntityModel]): relations=[], ) - # Check if we need to update the permalink based on content frontmatter + # Check if we need to update the permalink based on content frontmatter (unless disabled) new_permalink = entity.permalink # Default to existing - if content_markdown and content_markdown.frontmatter.permalink: - # Resolve permalink with the new content frontmatter - resolved_permalink = await self.resolve_permalink(file_path, content_markdown) - if resolved_permalink != entity.permalink: - new_permalink = resolved_permalink - # Update the schema to use the new permalink - schema._permalink = new_permalink + if self.app_config and not self.app_config.disable_permalinks: + if content_markdown and content_markdown.frontmatter.permalink: + # Resolve permalink with the new content frontmatter + resolved_permalink = await self.resolve_permalink(file_path, content_markdown) + if resolved_permalink != entity.permalink: + new_permalink = resolved_permalink + # Update the schema to use the new permalink + schema._permalink = new_permalink # Create post with new content from schema post = await schema_to_markdown(schema) @@ -746,8 +755,10 @@ class EntityService(BaseService[EntityModel]): # 6. Prepare database updates updates = {"file_path": destination_path} - # 7. Update permalink if configured or if entity has null permalink - if app_config.update_permalinks_on_move or old_permalink is None: + # 7. Update permalink if configured or if entity has null permalink (unless disabled) + if not app_config.disable_permalinks and ( + app_config.update_permalinks_on_move or old_permalink is None + ): # Generate new permalink from destination path new_permalink = await self.resolve_permalink(destination_path) diff --git a/src/basic_memory/sync/sync_service.py b/src/basic_memory/sync/sync_service.py index 1e7f1983..8b17c9e8 100644 --- a/src/basic_memory/sync/sync_service.py +++ b/src/basic_memory/sync/sync_service.py @@ -338,8 +338,8 @@ class SyncService: # entity markdown will always contain front matter, so it can be used up create/update the entity entity_markdown = await self.entity_parser.parse_file(path) - # if the file contains frontmatter, resolve a permalink - if file_contains_frontmatter: + # if the file contains frontmatter, resolve a permalink (unless disabled) + if file_contains_frontmatter and not self.app_config.disable_permalinks: # Resolve permalink - this handles all the cases including conflicts permalink = await self.entity_service.resolve_permalink(path, markdown=entity_markdown) @@ -530,8 +530,10 @@ class SyncService: updates = {"file_path": new_path} # If configured, also update permalink to match new path - if self.app_config.update_permalinks_on_move and self.file_service.is_markdown( - new_path + if ( + self.app_config.update_permalinks_on_move + and not self.app_config.disable_permalinks + and self.file_service.is_markdown(new_path) ): # generate new permalink value new_permalink = await self.entity_service.resolve_permalink(new_path) diff --git a/src/basic_memory/sync/watch_service.py b/src/basic_memory/sync/watch_service.py index dc9c2a4a..04b0310f 100644 --- a/src/basic_memory/sync/watch_service.py +++ b/src/basic_memory/sync/watch_service.py @@ -121,7 +121,9 @@ class WatchService: ignore_patterns = self._get_ignore_patterns(project_path) if should_ignore_path(file_path, project_path, ignore_patterns): - logger.trace(f"Ignoring watched file change: {file_path.relative_to(project_path)}") + logger.trace( + f"Ignoring watched file change: {file_path.relative_to(project_path)}" + ) continue project_changes[project].append((change, path)) diff --git a/test-int/test_disable_permalinks_integration.py b/test-int/test_disable_permalinks_integration.py new file mode 100644 index 00000000..379b989a --- /dev/null +++ b/test-int/test_disable_permalinks_integration.py @@ -0,0 +1,141 @@ +"""Integration tests for the disable_permalinks configuration.""" + +import pytest +from pathlib import Path +from textwrap import dedent + +from basic_memory.config import BasicMemoryConfig +from basic_memory.markdown import EntityParser, MarkdownProcessor +from basic_memory.models import Project +from basic_memory.repository import ( + EntityRepository, + ObservationRepository, + RelationRepository, +) +from basic_memory.repository.search_repository import SearchRepository +from basic_memory.schemas import Entity as EntitySchema +from basic_memory.services import FileService +from basic_memory.services.entity_service import EntityService +from basic_memory.services.link_resolver import LinkResolver +from basic_memory.services.search_service import SearchService +from basic_memory.sync.sync_service import SyncService + + +@pytest.mark.asyncio +async def test_disable_permalinks_create_entity(tmp_path, engine_factory): + """Test that entities created with disable_permalinks=True don't have permalinks.""" + engine, session_maker = engine_factory + + # Create app config with disable_permalinks=True + app_config = BasicMemoryConfig(disable_permalinks=True) + + # Setup repositories + entity_repository = EntityRepository(session_maker, project_id=1) + observation_repository = ObservationRepository(session_maker, project_id=1) + relation_repository = RelationRepository(session_maker, project_id=1) + search_repository = SearchRepository(session_maker, project_id=1) + + # Setup services + entity_parser = EntityParser(tmp_path) + markdown_processor = MarkdownProcessor(entity_parser) + file_service = FileService(tmp_path, markdown_processor) + search_service = SearchService(search_repository, entity_repository, file_service) + await search_service.init_search_index() + link_resolver = LinkResolver(entity_repository, search_service) + + entity_service = EntityService( + entity_parser=entity_parser, + entity_repository=entity_repository, + observation_repository=observation_repository, + relation_repository=relation_repository, + file_service=file_service, + link_resolver=link_resolver, + app_config=app_config, + ) + + # Create entity via API + entity_data = EntitySchema( + title="Test Note", + folder="test", + entity_type="note", + content="Test content", + ) + + created = await entity_service.create_entity(entity_data) + + # Verify entity has no permalink + assert created.permalink is None + + # Verify file has no permalink in frontmatter + file_path = tmp_path / "test" / "Test Note.md" + assert file_path.exists() + content = file_path.read_text() + assert "permalink:" not in content + assert "Test content" in content + + +@pytest.mark.asyncio +async def test_disable_permalinks_sync_workflow(tmp_path, engine_factory): + """Test full sync workflow with disable_permalinks enabled.""" + engine, session_maker = engine_factory + + # Create app config with disable_permalinks=True + app_config = BasicMemoryConfig(disable_permalinks=True) + + # Create a test markdown file without frontmatter + test_file = tmp_path / "test_note.md" + test_file.write_text("# Test Note\nThis is test content.") + + # Setup repositories + entity_repository = EntityRepository(session_maker, project_id=1) + observation_repository = ObservationRepository(session_maker, project_id=1) + relation_repository = RelationRepository(session_maker, project_id=1) + search_repository = SearchRepository(session_maker, project_id=1) + + # Setup services + entity_parser = EntityParser(tmp_path) + markdown_processor = MarkdownProcessor(entity_parser) + file_service = FileService(tmp_path, markdown_processor) + search_service = SearchService(search_repository, entity_repository, file_service) + await search_service.init_search_index() + link_resolver = LinkResolver(entity_repository, search_service) + + entity_service = EntityService( + entity_parser=entity_parser, + entity_repository=entity_repository, + observation_repository=observation_repository, + relation_repository=relation_repository, + file_service=file_service, + link_resolver=link_resolver, + app_config=app_config, + ) + + sync_service = SyncService( + app_config=app_config, + entity_service=entity_service, + entity_parser=entity_parser, + entity_repository=entity_repository, + relation_repository=relation_repository, + search_service=search_service, + file_service=file_service, + ) + + # Run sync + report = await sync_service.scan(tmp_path) + # Note: scan may pick up database files too, so just check our file is there + assert "test_note.md" in report.new + + # Sync the file + await sync_service.sync_file("test_note.md", new=True) + + # Verify file has no permalink added + content = test_file.read_text() + assert "permalink:" not in content + assert "# Test Note" in content + + # Verify entity in database has no permalink + entities = await entity_repository.find_all() + assert len(entities) == 1 + assert entities[0].permalink is None + # Title is extracted from filename when no frontmatter, or from frontmatter when present + assert entities[0].title in ("test_note", "Test Note") diff --git a/tests/cli/test_cloud_authentication.py b/tests/cli/test_cloud_authentication.py index e3b80f29..0076bea0 100644 --- a/tests/cli/test_cloud_authentication.py +++ b/tests/cli/test_cloud_authentication.py @@ -70,9 +70,7 @@ class TestAPIClientErrorHandling: mock_response.headers = {} # Create HTTPStatusError with the mock response - http_error = httpx.HTTPStatusError( - "403 Forbidden", request=Mock(), response=mock_response - ) + http_error = httpx.HTTPStatusError("403 Forbidden", request=Mock(), response=mock_response) # Mock httpx client to raise the error with patch("basic_memory.cli.commands.cloud.api_client.httpx.AsyncClient") as mock_client: diff --git a/tests/conftest.py b/tests/conftest.py index f6a0476a..9ab50d47 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -200,6 +200,7 @@ async def entity_service( entity_parser: EntityParser, file_service: FileService, link_resolver: LinkResolver, + app_config: BasicMemoryConfig, ) -> EntityService: """Create EntityService.""" return EntityService( @@ -209,6 +210,7 @@ async def entity_service( relation_repository=relation_repository, file_service=file_service, link_resolver=link_resolver, + app_config=app_config, ) diff --git a/tests/services/test_entity_service_disable_permalinks.py b/tests/services/test_entity_service_disable_permalinks.py new file mode 100644 index 00000000..9d32b310 --- /dev/null +++ b/tests/services/test_entity_service_disable_permalinks.py @@ -0,0 +1,220 @@ +"""Tests for EntityService with disable_permalinks flag.""" + +from textwrap import dedent +import pytest +import yaml + +from basic_memory.config import BasicMemoryConfig +from basic_memory.schemas import Entity as EntitySchema +from basic_memory.services import FileService +from basic_memory.services.entity_service import EntityService + + +@pytest.mark.asyncio +async def test_create_entity_with_permalinks_disabled( + entity_repository, + observation_repository, + relation_repository, + entity_parser, + file_service: FileService, + link_resolver, +): + """Test that entities created with disable_permalinks=True don't have permalinks.""" + # Create entity service with permalinks disabled + app_config = BasicMemoryConfig(disable_permalinks=True) + entity_service = EntityService( + entity_parser=entity_parser, + entity_repository=entity_repository, + observation_repository=observation_repository, + relation_repository=relation_repository, + file_service=file_service, + link_resolver=link_resolver, + app_config=app_config, + ) + + entity_data = EntitySchema( + title="Test Entity", + folder="test", + entity_type="note", + content="Test content", + ) + + # Create entity + entity = await entity_service.create_entity(entity_data) + + # Assert entity has no permalink + assert entity.permalink is None + + # Verify file frontmatter doesn't contain permalink + file_path = file_service.get_entity_path(entity) + file_content, _ = await file_service.read_file(file_path) + _, frontmatter, doc_content = file_content.split("---", 2) + metadata = yaml.safe_load(frontmatter) + + assert "permalink" not in metadata + assert metadata["title"] == "Test Entity" + assert metadata["type"] == "note" + + +@pytest.mark.asyncio +async def test_update_entity_with_permalinks_disabled( + entity_repository, + observation_repository, + relation_repository, + entity_parser, + file_service: FileService, + link_resolver, +): + """Test that entities updated with disable_permalinks=True don't get permalinks added.""" + # First create with permalinks enabled + app_config_enabled = BasicMemoryConfig(disable_permalinks=False) + entity_service_enabled = EntityService( + entity_parser=entity_parser, + entity_repository=entity_repository, + observation_repository=observation_repository, + relation_repository=relation_repository, + file_service=file_service, + link_resolver=link_resolver, + app_config=app_config_enabled, + ) + + entity_data = EntitySchema( + title="Test Entity", + folder="test", + entity_type="note", + content="Original content", + ) + + # Create entity with permalinks enabled + entity = await entity_service_enabled.create_entity(entity_data) + assert entity.permalink is not None + original_permalink = entity.permalink + + # Now create service with permalinks disabled + app_config_disabled = BasicMemoryConfig(disable_permalinks=True) + entity_service_disabled = EntityService( + entity_parser=entity_parser, + entity_repository=entity_repository, + observation_repository=observation_repository, + relation_repository=relation_repository, + file_service=file_service, + link_resolver=link_resolver, + app_config=app_config_disabled, + ) + + # Update entity with permalinks disabled + entity_data.content = "Updated content" + updated = await entity_service_disabled.update_entity(entity, entity_data) + + # Permalink should remain unchanged (not removed, just not updated) + assert updated.permalink == original_permalink + + # Verify file still has the original permalink + file_path = file_service.get_entity_path(updated) + file_content, _ = await file_service.read_file(file_path) + assert "Updated content" in file_content + assert f"permalink: {original_permalink}" in file_content + + +@pytest.mark.asyncio +async def test_create_entity_with_content_frontmatter_permalinks_disabled( + entity_repository, + observation_repository, + relation_repository, + entity_parser, + file_service: FileService, + link_resolver, +): + """Test that content frontmatter permalinks are ignored when disabled.""" + # Create entity service with permalinks disabled + app_config = BasicMemoryConfig(disable_permalinks=True) + entity_service = EntityService( + entity_parser=entity_parser, + entity_repository=entity_repository, + observation_repository=observation_repository, + relation_repository=relation_repository, + file_service=file_service, + link_resolver=link_resolver, + app_config=app_config, + ) + + # Content with frontmatter containing permalink + content = dedent( + """ + --- + permalink: custom-permalink + --- + # Test Content + """ + ).strip() + + entity_data = EntitySchema( + title="Test Entity", + folder="test", + entity_type="note", + content=content, + ) + + # Create entity + entity = await entity_service.create_entity(entity_data) + + # Entity should not have a permalink set + assert entity.permalink is None + + # Verify file doesn't have permalink in frontmatter + file_path = file_service.get_entity_path(entity) + file_content, _ = await file_service.read_file(file_path) + _, frontmatter, doc_content = file_content.split("---", 2) + metadata = yaml.safe_load(frontmatter) + + # The permalink from content frontmatter should not be present + assert "permalink" not in metadata + + +@pytest.mark.asyncio +async def test_move_entity_with_permalinks_disabled( + entity_repository, + observation_repository, + relation_repository, + entity_parser, + file_service: FileService, + link_resolver, + project_config, +): + """Test that moving an entity with disable_permalinks=True doesn't update permalinks.""" + # First create with permalinks enabled + app_config = BasicMemoryConfig(disable_permalinks=False, update_permalinks_on_move=True) + entity_service = EntityService( + entity_parser=entity_parser, + entity_repository=entity_repository, + observation_repository=observation_repository, + relation_repository=relation_repository, + file_service=file_service, + link_resolver=link_resolver, + app_config=app_config, + ) + + entity_data = EntitySchema( + title="Test Entity", + folder="test", + entity_type="note", + content="Test content", + ) + + # Create entity + entity = await entity_service.create_entity(entity_data) + original_permalink = entity.permalink + + # Now disable permalinks + app_config_disabled = BasicMemoryConfig(disable_permalinks=True, update_permalinks_on_move=True) + + # Move entity + moved = await entity_service.move_entity( + identifier=entity.permalink, + destination_path="new_folder/test_entity.md", + project_config=project_config, + app_config=app_config_disabled, + ) + + # Permalink should remain unchanged even though update_permalinks_on_move is True + assert moved.permalink == original_permalink diff --git a/tests/test_config.py b/tests/test_config.py index d4cd268f..65ffe564 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -169,3 +169,13 @@ class TestConfigManager: with pytest.raises(ValueError, match="Project 'nonexistent' not found"): config_manager.set_default_project("nonexistent") + + def test_disable_permalinks_flag_default(self): + """Test that disable_permalinks flag defaults to False.""" + config = BasicMemoryConfig() + assert config.disable_permalinks is False + + def test_disable_permalinks_flag_can_be_enabled(self): + """Test that disable_permalinks flag can be set to True.""" + config = BasicMemoryConfig(disable_permalinks=True) + assert config.disable_permalinks is True