diff --git a/pyproject.toml b/pyproject.toml index 981b56bb..b484a21d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "python-frontmatter>=1.1.0", "fastmcp>=0.4.1", "rich>=13.9.4", + "unidecode>=1.3.8", ] [project.optional-dependencies] diff --git a/src/basic_memory/models/knowledge.py b/src/basic_memory/models/knowledge.py index b7c1adc0..29dba812 100644 --- a/src/basic_memory/models/knowledge.py +++ b/src/basic_memory/models/knowledge.py @@ -1,7 +1,10 @@ """Knowledge graph models.""" +import re +import os from datetime import datetime from typing import Optional +from unidecode import unidecode from sqlalchemy import ( Integer, @@ -14,12 +17,49 @@ from sqlalchemy import ( Index, JSON, ) -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from basic_memory.models.base import Base from enum import Enum +def generate_permalink(file_path: str) -> str: + """Generate a stable permalink from a file path. + + Args: + file_path: Original file path + + Returns: + Normalized permalink that matches validation rules + + Examples: + >>> generate_permalink("docs/My Feature.md") + 'docs/my-feature' + >>> generate_permalink("specs/API (v2).md") + 'specs/api-v2' + """ + # Remove extension + base = os.path.splitext(file_path)[0] + + # Transliterate unicode to ascii + ascii_text = unidecode(base) + + # 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) + + # Collapse multiple hyphens + clean_text = re.sub(r'-+', '-', clean_text) + + # Clean each path segment + segments = clean_text.split('/') + clean_segments = [s.strip('-') for s in segments] + + return '/'.join(clean_segments) + + class Entity(Base): """ Core entity in the knowledge graph. @@ -35,6 +75,7 @@ class Entity(Base): __table_args__ = ( UniqueConstraint("permalink", name="uix_entity_permalink"), # Make permalink unique Index("ix_entity_type", "entity_type"), + Index("ix_entity_title", "title"), Index("ix_entity_created_at", "created_at"), # For timeline queries Index("ix_entity_updated_at", "updated_at"), # For timeline queries ) @@ -83,6 +124,23 @@ class Entity(Base): def relations(self): return self.incoming_relations + self.outgoing_relations + @validates('permalink') + def validate_permalink(self, key, value): + """Validate permalink format. + + Requirements: + 1. Must be valid URI path component + 2. Only lowercase letters, numbers, hyphens, and underscores + 3. Path segments separated by forward slashes + 4. No leading/trailing hyphens in segments + """ + 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." + ) + return value + def __repr__(self) -> str: return f"Entity(id={self.id}, name='{self.title}', type='{self.entity_type}', summary='{self.summary}')" @@ -169,4 +227,4 @@ class Relation(Base): to_entity = relationship("Entity", foreign_keys=[to_id], back_populates="incoming_relations") def __repr__(self) -> str: - return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, type='{self.relation_type}')" + return f"Relation(id={self.id}, from_id={self.from_id}, to_id={self.to_id}, type='{self.relation_type}')" \ No newline at end of file diff --git a/src/basic_memory/repository/entity_repository.py b/src/basic_memory/repository/entity_repository.py index 15e9d04e..e546219f 100644 --- a/src/basic_memory/repository/entity_repository.py +++ b/src/basic_memory/repository/entity_repository.py @@ -23,6 +23,11 @@ class EntityRepository(Repository[Entity]): query = self.select().where(Entity.permalink == permalink).options(*self.get_load_options()) return await self.find_one(query) + async def get_by_title(self, title: str) -> Optional[Entity]: + """Get entity by title.""" + query = self.select().where(Entity.title == title).options(*self.get_load_options()) + return await self.find_one(query) + async def list_entities( self, entity_type: Optional[str] = None, diff --git a/src/basic_memory/services/link_resolver.py b/src/basic_memory/services/link_resolver.py new file mode 100644 index 00000000..13bd5e3b --- /dev/null +++ b/src/basic_memory/services/link_resolver.py @@ -0,0 +1,84 @@ +"""Service for resolving markdown links to permalinks.""" + +from typing import Optional, List + +from loguru import logger + +from basic_memory.services.service import BaseService +from basic_memory.repository.entity_repository import EntityRepository +from basic_memory.models import Entity +from basic_memory.services.exceptions import EntityNotFoundError + + +class LinkResolver(BaseService[Entity]): + """Service for resolving markdown links to permalinks. + + Handles both exact and fuzzy link resolution using a combination of + direct permalink lookup and search-based matching. + """ + + def __init__(self, entity_repository: EntityRepository): + """Initialize with repositories.""" + super().__init__(entity_repository) + + async def resolve_link( + self, + link_text: str, + source_permalink: Optional[str] = None + ) -> str: + """Resolve a markdown link to a permalink. + + Args: + link_text: The text content of the link (without brackets) + source_permalink: Optional permalink of the source document for context + + Returns: + Resolved permalink, or original link text if no match found + """ + logger.debug(f"Resolving link: {link_text} from source: {source_permalink}") + + # Clean link text + clean_text = self._normalize_link_text(link_text) + + try: + # Try exact permalink match first + entity = await self.repository.get_by_permalink(clean_text) + if entity: + logger.debug(f"Found exact permalink match: {entity.permalink}") + return entity.permalink + + # Fall back to title match if needed + entity = await self.repository.get_by_title(clean_text) + if entity: + logger.debug(f"Found title match: {entity.permalink}") + return entity.permalink + + # No match found - will be created + logger.debug(f"No match found for link: {link_text}") + return clean_text + + except Exception as e: + logger.error(f"Error resolving link {link_text}: {e}") + return clean_text + + def _normalize_link_text(self, link_text: str) -> str: + """Normalize link text for matching. + + Args: + link_text: Raw link text from markdown + + Returns: + Normalized form for matching + """ + # Strip whitespace + text = link_text.strip() + + # Remove enclosing brackets if present + if text.startswith('[[') and text.endswith(']]'): + text = text[2:-2] + + # Handle Obsidian-style aliases + if '|' in text: + text = text.split('|')[0] + + return text \ No newline at end of file diff --git a/tests/repository/test_entity_repository.py b/tests/repository/test_entity_repository.py index 6b44a925..3f9ab463 100644 --- a/tests/repository/test_entity_repository.py +++ b/tests/repository/test_entity_repository.py @@ -8,6 +8,7 @@ from sqlalchemy import select from basic_memory import db from basic_memory.models import Entity, Observation, Relation +from basic_memory.models.knowledge import generate_permalink from basic_memory.repository.entity_repository import EntityRepository @@ -447,3 +448,81 @@ async def test_list_entities_with_related(entity_repository: EntityRepository, s core_service = next(e for e in services_and_related if e.title == "core_service") assert len(core_service.outgoing_relations) > 0 # Has incoming relation from config assert len(core_service.incoming_relations) > 0 # Has outgoing relation to db + + +@pytest.mark.asyncio +async def test_create_entity_with_invalid_permalink(entity_repository: EntityRepository): + """Test that creating an entity with invalid permalink raises error.""" + with pytest.raises(ValueError, match="Invalid permalink format"): + await entity_repository.create({ + "title": "Test", + "entity_type": "test", + "permalink": "Test/Invalid!!", # Invalid permalink + "file_path": "test/test.md", + "content_type": "text/markdown", + }) + + +@pytest.mark.asyncio +async def test_generate_permalink_from_file_path(): + """Test permalink generation from different file paths.""" + test_cases = [ + ("docs/My Feature.md", "docs/my-feature"), + ("specs/API (v2).md", "specs/api-v2"), + ("notes/2024/Q1 Planning!!!.md", "notes/2024/q1-planning"), + ("test/Über File.md", "test/uber-file"), + ("docs/my_feature_name.md", "docs/my_feature_name"), + ("specs/multiple--dashes.md", "specs/multiple-dashes"), + ("notes/trailing/space/ file.md", "notes/trailing/space/file"), + ] + + for input_path, expected in test_cases: + result = generate_permalink(input_path) + assert result == expected, f"Failed for {input_path}" + # Verify the result passes validation + Entity( + title="test", + entity_type="test", + permalink=result, + file_path=input_path, + content_type="text/markdown" + ) # This will raise ValueError if invalid + + +@pytest.mark.asyncio +async def test_get_by_title(entity_repository: EntityRepository, session_maker): + """Test getting an entity by title.""" + # Create test entities + async with db.scoped_session(session_maker) as session: + entities = [ + Entity( + title="Unique Title", + entity_type="test", + permalink="test/unique-title", + file_path="test/unique-title.md", + content_type="text/markdown", + ), + Entity( + title="Another Title", + entity_type="test", + permalink="test/another-title", + file_path="test/another-title.md", + content_type="text/markdown", + ), + ] + session.add_all(entities) + await session.flush() + + # Test getting by exact title + found = await entity_repository.get_by_title("Unique Title") + assert found is not None + assert found.title == "Unique Title" + + # Test case sensitivity + found = await entity_repository.get_by_title("unique title") + assert found is None # Should be case-sensitive + + # Test non-existent title + found = await entity_repository.get_by_title("Non Existent") + assert found is None + diff --git a/tests/services/test_link_resolver.py b/tests/services/test_link_resolver.py new file mode 100644 index 00000000..e5eb85d4 --- /dev/null +++ b/tests/services/test_link_resolver.py @@ -0,0 +1,107 @@ +"""Tests for link resolution service.""" + +import pytest +import pytest_asyncio + +from basic_memory.models.knowledge import Entity +from basic_memory.services.link_resolver import LinkResolver + + +@pytest_asyncio.fixture +async def link_resolver(entity_repository): + """Create LinkResolver instance.""" + return LinkResolver(entity_repository) + + +@pytest.mark.asyncio +async def test_exact_permalink_match( + link_resolver, entity_repository +): + """Test resolving a link that exactly matches a permalink.""" + # Create test entity + entity = Entity( + title="Test Entity", + entity_type="test", + summary="A test entity", + permalink="specs/test-entity", + file_path="specs/test-entity.md", + content_type="text/markdown" + ) + await entity_repository.add(entity) + + # Test exact permalink match + result = await link_resolver.resolve_link("specs/test-entity") + assert result == "specs/test-entity" + + +@pytest.mark.asyncio +async def test_normalize_link_text(link_resolver): + """Test link text normalization.""" + assert link_resolver._normalize_link_text("[[Test Entity]]") == "Test Entity" + assert link_resolver._normalize_link_text("Test Entity|Alias") == "Test Entity" + assert link_resolver._normalize_link_text(" Test Entity ") == "Test Entity" + assert link_resolver._normalize_link_text("specs/test-entity") == "specs/test-entity" + + +@pytest.mark.asyncio +async def test_title_match( + link_resolver, entity_repository +): + """Test resolving a link that matches an entity title.""" + # Create test entity + entity = Entity( + title="Test Entity", + entity_type="test", + summary="A test entity", + permalink="specs/test-entity", + file_path="specs/test-entity.md", + content_type="text/markdown" + ) + await entity_repository.add(entity) + + # Test title match + result = await link_resolver.resolve_link("Test Entity") + assert result == "specs/test-entity" + + +@pytest.mark.asyncio +async def test_no_match_returns_original(link_resolver): + """Test that unmatched links return original text.""" + result = await link_resolver.resolve_link("Non Existent Entity") + assert result == "Non Existent Entity" + + +@pytest.mark.asyncio +async def test_obsidian_style_links( + link_resolver, entity_repository +): + """Test handling Obsidian-style links with aliases.""" + # Create test entity + entity = Entity( + title="Original Title", + entity_type="test", + summary="A test entity", + permalink="test/original-title", + file_path="test/original-title.md", + content_type="text/markdown" + ) + await entity_repository.add(entity) + + # Test with Obsidian link formats + result = await link_resolver.resolve_link("[[Original Title|Display Text]]") + assert result == "test/original-title" + + +@pytest.mark.asyncio +async def test_error_handling( + link_resolver, entity_repository, monkeypatch +): + """Test error handling during link resolution.""" + # Mock repository to raise an exception + async def mock_get_by_permalink(*args, **kwargs): + raise Exception("Test error") + monkeypatch.setattr(entity_repository, "get_by_permalink", mock_get_by_permalink) + + # Should return original text on error + result = await link_resolver.resolve_link("Test Entity") + assert result == "Test Entity" \ No newline at end of file diff --git a/uv.lock b/uv.lock index ed176042..580b279d 100644 --- a/uv.lock +++ b/uv.lock @@ -197,6 +197,7 @@ dependencies = [ { name = "rich" }, { name = "sqlalchemy" }, { name = "typer" }, + { name = "unidecode" }, ] [package.optional-dependencies] @@ -236,6 +237,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.6" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "typer", specifier = ">=0.9.0" }, + { name = "unidecode", specifier = ">=1.3.8" }, ] [package.metadata.requires-dev] @@ -1130,6 +1132,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/62/51/2007ea29e605957a1 wheels = [ { url = "https://files.pythonhosted.org/packages/3d/16/4623fad6076448df21c1a870c93a9774ad8a7b4dd1660223b59082dd8fec/psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067", size = 1025113 }, { url = "https://files.pythonhosted.org/packages/66/de/baed128ae0fc07460d9399d82e631ea31a1f171c0c4ae18f9808ac6759e3/psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e", size = 1163951 }, + { url = "https://files.pythonhosted.org/packages/ae/49/a6cfc94a9c483b1fa401fbcb23aca7892f60c7269c5ffa2ac408364f80dc/psycopg2-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:91fd603a2155da8d0cfcdbf8ab24a2d54bca72795b90d2a3ed2b6da8d979dee2", size = 2569060 }, ] [[package]] @@ -1590,6 +1593,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 }, ] +[[package]] +name = "unidecode" +version = "1.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/89/19151076a006b9ac0dd37b1354e031f5297891ee507eb624755e58e10d3e/Unidecode-1.3.8.tar.gz", hash = "sha256:cfdb349d46ed3873ece4586b96aa75258726e2fa8ec21d6f00a591d98806c2f4", size = 192701 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/b7/6ec57841fb67c98f52fc8e4a2d96df60059637cba077edc569a302a8ffc7/Unidecode-1.3.8-py3-none-any.whl", hash = "sha256:d130a61ce6696f8148a3bd8fe779c99adeb4b870584eeb9526584e9aa091fd39", size = 235494 }, +] + [[package]] name = "urllib3" version = "2.2.3"