mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
@@ -51,7 +51,7 @@ async def update_project(
|
||||
# Validate that path is absolute if provided
|
||||
if path and not os.path.isabs(path):
|
||||
raise HTTPException(status_code=400, detail="Path must be absolute")
|
||||
|
||||
|
||||
# Get original project info for the response
|
||||
old_project_info = ProjectItem(
|
||||
name=name,
|
||||
|
||||
@@ -157,29 +157,33 @@ def move_project(
|
||||
"""Move a project to a new location."""
|
||||
# Resolve to absolute path
|
||||
resolved_path = os.path.abspath(os.path.expanduser(new_path))
|
||||
|
||||
|
||||
try:
|
||||
data = {"path": resolved_path}
|
||||
project_name = generate_permalink(name)
|
||||
|
||||
|
||||
current_project = session.get_current_project()
|
||||
response = asyncio.run(call_patch(client, f"/{current_project}/project/{project_name}", json=data))
|
||||
response = asyncio.run(
|
||||
call_patch(client, f"/{current_project}/project/{project_name}", json=data)
|
||||
)
|
||||
result = ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
|
||||
|
||||
# Show important file movement reminder
|
||||
console.print() # Empty line for spacing
|
||||
console.print(Panel(
|
||||
"[bold red]IMPORTANT:[/bold red] Project configuration updated successfully.\n\n"
|
||||
"[yellow]You must manually move your project files from the old location to:[/yellow]\n"
|
||||
f"[cyan]{resolved_path}[/cyan]\n\n"
|
||||
"[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
|
||||
title="⚠️ Manual File Movement Required",
|
||||
border_style="yellow",
|
||||
expand=False
|
||||
))
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold red]IMPORTANT:[/bold red] Project configuration updated successfully.\n\n"
|
||||
"[yellow]You must manually move your project files from the old location to:[/yellow]\n"
|
||||
f"[cyan]{resolved_path}[/cyan]\n\n"
|
||||
"[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
|
||||
title="⚠️ Manual File Movement Required",
|
||||
border_style="yellow",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error moving project: {str(e)}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -209,24 +209,24 @@ class ChatGPTImporter(Importer[ChatImportResult]):
|
||||
|
||||
# Use iterative approach with stack to avoid recursion depth issues
|
||||
stack = [root_id]
|
||||
|
||||
|
||||
while stack:
|
||||
node_id = stack.pop()
|
||||
if not node_id:
|
||||
continue
|
||||
|
||||
|
||||
node = mapping.get(node_id)
|
||||
if not node:
|
||||
continue
|
||||
|
||||
|
||||
# Process current node if it has a message and hasn't been seen
|
||||
if node["id"] not in seen and node.get("message"):
|
||||
seen.add(node["id"])
|
||||
messages.append(node["message"])
|
||||
|
||||
|
||||
# Add children to stack in reverse order to maintain conversation flow
|
||||
children = node.get("children", [])
|
||||
for child_id in reversed(children):
|
||||
stack.append(child_id)
|
||||
|
||||
|
||||
return messages
|
||||
|
||||
@@ -86,11 +86,11 @@ class ProjectRepository(Repository[Project]):
|
||||
|
||||
async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
|
||||
"""Update project path.
|
||||
|
||||
|
||||
Args:
|
||||
project_id: ID of the project to update
|
||||
new_path: New filesystem path for the project
|
||||
|
||||
|
||||
Returns:
|
||||
The updated project if found, None otherwise
|
||||
"""
|
||||
|
||||
@@ -47,35 +47,35 @@ class EntityService(BaseService[EntityModel]):
|
||||
|
||||
async def detect_file_path_conflicts(self, file_path: str) -> List[Entity]:
|
||||
"""Detect potential file path conflicts for a given file path.
|
||||
|
||||
|
||||
This checks for entities with similar file paths that might cause conflicts:
|
||||
- Case sensitivity differences (Finance/file.md vs finance/file.md)
|
||||
- Character encoding differences
|
||||
- Hyphen vs space differences
|
||||
- Unicode normalization differences
|
||||
|
||||
|
||||
Args:
|
||||
file_path: The file path to check for conflicts
|
||||
|
||||
|
||||
Returns:
|
||||
List of entities that might conflict with the given file path
|
||||
"""
|
||||
from basic_memory.utils import detect_potential_file_conflicts
|
||||
|
||||
|
||||
conflicts = []
|
||||
|
||||
|
||||
# Get all existing file paths
|
||||
all_entities = await self.repository.find_all()
|
||||
existing_paths = [entity.file_path for entity in all_entities]
|
||||
|
||||
|
||||
# Use the enhanced conflict detection utility
|
||||
conflicting_paths = detect_potential_file_conflicts(file_path, existing_paths)
|
||||
|
||||
|
||||
# Find the entities corresponding to conflicting paths
|
||||
for entity in all_entities:
|
||||
if entity.file_path in conflicting_paths:
|
||||
conflicts.append(entity)
|
||||
|
||||
|
||||
return conflicts
|
||||
|
||||
async def resolve_permalink(
|
||||
@@ -88,11 +88,11 @@ class EntityService(BaseService[EntityModel]):
|
||||
2. If markdown has permalink but it's used by another file -> make unique
|
||||
3. For existing files, keep current permalink from db
|
||||
4. Generate new unique permalink from file path
|
||||
|
||||
|
||||
Enhanced to detect and handle character-related conflicts.
|
||||
"""
|
||||
file_path_str = str(file_path)
|
||||
|
||||
|
||||
# Check for potential file path conflicts before resolving permalink
|
||||
conflicts = await self.detect_file_path_conflicts(file_path_str)
|
||||
if conflicts:
|
||||
@@ -100,7 +100,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
f"Detected potential file path conflicts for '{file_path_str}': "
|
||||
f"{[entity.file_path for entity in conflicts]}"
|
||||
)
|
||||
|
||||
|
||||
# If markdown has explicit permalink, try to validate it
|
||||
if markdown and markdown.frontmatter.permalink:
|
||||
desired_permalink = markdown.frontmatter.permalink
|
||||
|
||||
@@ -311,33 +311,33 @@ class ProjectService:
|
||||
|
||||
async def move_project(self, name: str, new_path: str) -> None:
|
||||
"""Move a project to a new location.
|
||||
|
||||
|
||||
Args:
|
||||
name: The name of the project to move
|
||||
new_path: The new absolute path for the project
|
||||
|
||||
|
||||
Raises:
|
||||
ValueError: If the project doesn't exist or repository isn't initialized
|
||||
"""
|
||||
if not self.repository:
|
||||
raise ValueError("Repository is required for move_project")
|
||||
|
||||
|
||||
# Resolve to absolute path
|
||||
resolved_path = os.path.abspath(os.path.expanduser(new_path))
|
||||
|
||||
|
||||
# Validate project exists in config
|
||||
if name not in self.config_manager.projects:
|
||||
raise ValueError(f"Project '{name}' not found in configuration")
|
||||
|
||||
|
||||
# Create the new directory if it doesn't exist
|
||||
Path(resolved_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# Update in configuration
|
||||
config = self.config_manager.load_config()
|
||||
old_path = config.projects[name]
|
||||
config.projects[name] = resolved_path
|
||||
self.config_manager.save_config(config)
|
||||
|
||||
|
||||
# Update in database
|
||||
project = await self.repository.get_by_name(name)
|
||||
if project:
|
||||
|
||||
@@ -462,7 +462,7 @@ class SyncService:
|
||||
f"entity_id={entity.id} trying to move from '{old_path}' to '{new_path}', "
|
||||
f"but entity_id={existing_at_destination.id} already occupies '{new_path}'"
|
||||
)
|
||||
|
||||
|
||||
# Check if this is a file swap (the destination entity is being moved to our old path)
|
||||
# This would indicate a simultaneous move operation
|
||||
old_path_after_swap = await self.entity_repository.get_by_file_path(old_path)
|
||||
|
||||
+19
-19
@@ -183,74 +183,74 @@ def setup_logging(
|
||||
|
||||
def normalize_file_path_for_comparison(file_path: str) -> str:
|
||||
"""Normalize a file path for conflict detection.
|
||||
|
||||
|
||||
This function normalizes file paths to help detect potential conflicts:
|
||||
- Converts to lowercase for case-insensitive comparison
|
||||
- Normalizes Unicode characters
|
||||
- Handles path separators consistently
|
||||
|
||||
|
||||
Args:
|
||||
file_path: The file path to normalize
|
||||
|
||||
|
||||
Returns:
|
||||
Normalized file path for comparison purposes
|
||||
"""
|
||||
import unicodedata
|
||||
|
||||
|
||||
# Convert to lowercase for case-insensitive comparison
|
||||
normalized = file_path.lower()
|
||||
|
||||
|
||||
# Normalize Unicode characters (NFD normalization)
|
||||
normalized = unicodedata.normalize('NFD', normalized)
|
||||
|
||||
normalized = unicodedata.normalize("NFD", normalized)
|
||||
|
||||
# Replace path separators with forward slashes
|
||||
normalized = normalized.replace('\\', '/')
|
||||
|
||||
normalized = normalized.replace("\\", "/")
|
||||
|
||||
# Remove multiple slashes
|
||||
normalized = re.sub(r'/+', '/', normalized)
|
||||
|
||||
normalized = re.sub(r"/+", "/", normalized)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def detect_potential_file_conflicts(file_path: str, existing_paths: List[str]) -> List[str]:
|
||||
"""Detect potential conflicts between a file path and existing paths.
|
||||
|
||||
|
||||
This function checks for various types of conflicts:
|
||||
- Case sensitivity differences
|
||||
- Unicode normalization differences
|
||||
- Path separator differences
|
||||
- Permalink generation conflicts
|
||||
|
||||
|
||||
Args:
|
||||
file_path: The file path to check
|
||||
existing_paths: List of existing file paths to check against
|
||||
|
||||
|
||||
Returns:
|
||||
List of existing paths that might conflict with the given file path
|
||||
"""
|
||||
conflicts = []
|
||||
|
||||
|
||||
# Normalize the input file path
|
||||
normalized_input = normalize_file_path_for_comparison(file_path)
|
||||
input_permalink = generate_permalink(file_path)
|
||||
|
||||
|
||||
for existing_path in existing_paths:
|
||||
# Skip identical paths
|
||||
if existing_path == file_path:
|
||||
continue
|
||||
|
||||
|
||||
# Check for case-insensitive path conflicts
|
||||
normalized_existing = normalize_file_path_for_comparison(existing_path)
|
||||
if normalized_input == normalized_existing:
|
||||
conflicts.append(existing_path)
|
||||
continue
|
||||
|
||||
|
||||
# Check for permalink conflicts
|
||||
existing_permalink = generate_permalink(existing_path)
|
||||
if input_permalink == existing_permalink:
|
||||
conflicts.append(existing_path)
|
||||
continue
|
||||
|
||||
|
||||
return conflicts
|
||||
|
||||
|
||||
|
||||
@@ -162,51 +162,52 @@ async def test_set_default_project_endpoint(test_config, client, project_service
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_path_endpoint(test_config, client, project_service, project_url, tmp_path):
|
||||
async def test_update_project_path_endpoint(
|
||||
test_config, client, project_service, project_url, tmp_path
|
||||
):
|
||||
"""Test the update project endpoint for changing project path."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-project"
|
||||
old_path = str(tmp_path / "old-location")
|
||||
new_path = str(tmp_path / "new-location")
|
||||
|
||||
|
||||
await project_service.add_project(test_project_name, old_path)
|
||||
|
||||
|
||||
try:
|
||||
# Verify initial state
|
||||
project = await project_service.get_project(test_project_name)
|
||||
assert project is not None
|
||||
assert project.path == old_path
|
||||
|
||||
|
||||
# Update the project path
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}",
|
||||
json={"path": new_path}
|
||||
f"{project_url}/project/{test_project_name}", json={"path": new_path}
|
||||
)
|
||||
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
|
||||
# Check response structure
|
||||
assert "message" in data
|
||||
assert "status" in data
|
||||
assert data["status"] == "success"
|
||||
assert "old_project" in data
|
||||
assert "new_project" in data
|
||||
|
||||
|
||||
# Check old project data
|
||||
assert data["old_project"]["name"] == test_project_name
|
||||
assert data["old_project"]["path"] == old_path
|
||||
|
||||
|
||||
# Check new project data
|
||||
assert data["new_project"]["name"] == test_project_name
|
||||
assert data["new_project"]["path"] == new_path
|
||||
|
||||
|
||||
# Verify project was actually updated in database
|
||||
updated_project = await project_service.get_project(test_project_name)
|
||||
assert updated_project is not None
|
||||
assert updated_project.path == new_path
|
||||
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
@@ -221,26 +222,25 @@ async def test_update_project_is_active_endpoint(test_config, client, project_se
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-active-project"
|
||||
test_path = "/tmp/test-update-active"
|
||||
|
||||
|
||||
await project_service.add_project(test_project_name, test_path)
|
||||
|
||||
|
||||
try:
|
||||
# Update the project is_active status
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}",
|
||||
json={"is_active": False}
|
||||
f"{project_url}/project/{test_project_name}", json={"is_active": False}
|
||||
)
|
||||
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
|
||||
# Check response structure
|
||||
assert "message" in data
|
||||
assert "status" in data
|
||||
assert data["status"] == "success"
|
||||
assert f"Project '{test_project_name}' updated successfully" == data["message"]
|
||||
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
@@ -250,34 +250,36 @@ async def test_update_project_is_active_endpoint(test_config, client, project_se
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_both_params_endpoint(test_config, client, project_service, project_url, tmp_path):
|
||||
async def test_update_project_both_params_endpoint(
|
||||
test_config, client, project_service, project_url, tmp_path
|
||||
):
|
||||
"""Test the update project endpoint with both path and is_active parameters."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-both-project"
|
||||
old_path = str(tmp_path / "old-location")
|
||||
new_path = str(tmp_path / "new-location")
|
||||
|
||||
|
||||
await project_service.add_project(test_project_name, old_path)
|
||||
|
||||
|
||||
try:
|
||||
# Update both path and is_active (path should take precedence)
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}",
|
||||
json={"path": new_path, "is_active": False}
|
||||
json={"path": new_path, "is_active": False},
|
||||
)
|
||||
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
|
||||
# Check that path update was performed (takes precedence)
|
||||
assert data["new_project"]["path"] == new_path
|
||||
|
||||
|
||||
# Verify project was actually updated in database
|
||||
updated_project = await project_service.get_project(test_project_name)
|
||||
assert updated_project is not None
|
||||
assert updated_project.path == new_path
|
||||
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
@@ -291,10 +293,9 @@ async def test_update_project_nonexistent_endpoint(client, project_url):
|
||||
"""Test the update project endpoint with a nonexistent project."""
|
||||
# Try to update a project that doesn't exist
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/nonexistent-project",
|
||||
json={"path": "/tmp/new-path"}
|
||||
f"{project_url}/project/nonexistent-project", json={"path": "/tmp/new-path"}
|
||||
)
|
||||
|
||||
|
||||
# Should return 400 error
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
@@ -303,27 +304,28 @@ async def test_update_project_nonexistent_endpoint(client, project_url):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_relative_path_error_endpoint(test_config, client, project_service, project_url):
|
||||
async def test_update_project_relative_path_error_endpoint(
|
||||
test_config, client, project_service, project_url
|
||||
):
|
||||
"""Test the update project endpoint with relative path (should fail)."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-relative-project"
|
||||
test_path = "/tmp/test-update-relative"
|
||||
|
||||
|
||||
await project_service.add_project(test_project_name, test_path)
|
||||
|
||||
|
||||
try:
|
||||
# Try to update with relative path
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}",
|
||||
json={"path": "./relative-path"}
|
||||
f"{project_url}/project/{test_project_name}", json={"path": "./relative-path"}
|
||||
)
|
||||
|
||||
|
||||
# Should return 400 error
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "detail" in data
|
||||
assert "Path must be absolute" in data["detail"]
|
||||
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
@@ -338,25 +340,22 @@ async def test_update_project_no_params_endpoint(test_config, client, project_se
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-no-params-project"
|
||||
test_path = "/tmp/test-update-no-params"
|
||||
|
||||
|
||||
await project_service.add_project(test_project_name, test_path)
|
||||
proj_info = await project_service.get_project(test_project_name)
|
||||
assert proj_info.name == test_project_name
|
||||
assert proj_info.path == test_path
|
||||
|
||||
|
||||
try:
|
||||
# Try to update with no parameters
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}",
|
||||
json={}
|
||||
)
|
||||
|
||||
response = await client.patch(f"{project_url}/project/{test_project_name}", json={})
|
||||
|
||||
# Should return 200 (no-op)
|
||||
assert response.status_code == 200
|
||||
proj_info = await project_service.get_project(test_project_name)
|
||||
assert proj_info.name == test_project_name
|
||||
assert proj_info.path == test_path
|
||||
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
@@ -366,26 +365,27 @@ async def test_update_project_no_params_endpoint(test_config, client, project_se
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_project_empty_path_endpoint(test_config, client, project_service, project_url):
|
||||
async def test_update_project_empty_path_endpoint(
|
||||
test_config, client, project_service, project_url
|
||||
):
|
||||
"""Test the update project endpoint with empty path parameter."""
|
||||
# Create a test project to update
|
||||
test_project_name = "test-update-empty-path-project"
|
||||
test_path = "/tmp/test-update-empty-path"
|
||||
|
||||
|
||||
await project_service.add_project(test_project_name, test_path)
|
||||
|
||||
|
||||
try:
|
||||
# Try to update with empty/null path - should be treated as no path update
|
||||
response = await client.patch(
|
||||
f"{project_url}/project/{test_project_name}",
|
||||
json={"path": None, "is_active": True}
|
||||
f"{project_url}/project/{test_project_name}", json={"path": None, "is_active": True}
|
||||
)
|
||||
|
||||
|
||||
# Should succeed and perform is_active update
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "success"
|
||||
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
try:
|
||||
|
||||
@@ -273,16 +273,16 @@ async def test_delete_nonexistent_project(project_repository: ProjectRepository)
|
||||
async def test_update_path(project_repository: ProjectRepository, sample_project: Project):
|
||||
"""Test updating a project's path."""
|
||||
new_path = "/new/project/path"
|
||||
|
||||
|
||||
# Update the project path
|
||||
updated_project = await project_repository.update_path(sample_project.id, new_path)
|
||||
|
||||
|
||||
# Verify returned object
|
||||
assert updated_project is not None
|
||||
assert updated_project.id == sample_project.id
|
||||
assert updated_project.path == new_path
|
||||
assert updated_project.name == sample_project.name # Other fields unchanged
|
||||
|
||||
|
||||
# Verify in database
|
||||
found = await project_repository.find_by_id(sample_project.id)
|
||||
assert found is not None
|
||||
|
||||
@@ -541,36 +541,36 @@ async def test_move_project(project_service: ProjectService, tmp_path):
|
||||
test_project_name = f"test-move-project-{os.urandom(4).hex()}"
|
||||
old_path = str(tmp_path / "old-location")
|
||||
new_path = str(tmp_path / "new-location")
|
||||
|
||||
|
||||
# Create old directory
|
||||
os.makedirs(old_path, exist_ok=True)
|
||||
|
||||
|
||||
try:
|
||||
# Add project with initial path
|
||||
await project_service.add_project(test_project_name, old_path)
|
||||
|
||||
|
||||
# Verify initial state
|
||||
assert test_project_name in project_service.projects
|
||||
assert project_service.projects[test_project_name] == old_path
|
||||
|
||||
|
||||
project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert project is not None
|
||||
assert project.path == old_path
|
||||
|
||||
|
||||
# Move project to new location
|
||||
await project_service.move_project(test_project_name, new_path)
|
||||
|
||||
|
||||
# Verify config was updated
|
||||
assert project_service.projects[test_project_name] == new_path
|
||||
|
||||
|
||||
# Verify database was updated
|
||||
updated_project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert updated_project is not None
|
||||
assert updated_project.path == new_path
|
||||
|
||||
|
||||
# Verify new directory was created
|
||||
assert os.path.exists(new_path)
|
||||
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
if test_project_name in project_service.projects:
|
||||
@@ -581,7 +581,7 @@ async def test_move_project(project_service: ProjectService, tmp_path):
|
||||
async def test_move_project_nonexistent(project_service: ProjectService, tmp_path):
|
||||
"""Test moving a project that doesn't exist."""
|
||||
new_path = str(tmp_path / "new-location")
|
||||
|
||||
|
||||
with pytest.raises(ValueError, match="not found in configuration"):
|
||||
await project_service.move_project("nonexistent-project", new_path)
|
||||
|
||||
@@ -592,28 +592,28 @@ async def test_move_project_db_mismatch(project_service: ProjectService, tmp_pat
|
||||
test_project_name = f"test-move-mismatch-{os.urandom(4).hex()}"
|
||||
old_path = str(tmp_path / "old-location")
|
||||
new_path = str(tmp_path / "new-location")
|
||||
|
||||
|
||||
# Create directories
|
||||
os.makedirs(old_path, exist_ok=True)
|
||||
|
||||
|
||||
config_manager = project_service.config_manager
|
||||
|
||||
|
||||
try:
|
||||
# Add project to config only (not to database)
|
||||
config_manager.add_project(test_project_name, old_path)
|
||||
|
||||
|
||||
# Verify it's in config but not in database
|
||||
assert test_project_name in project_service.projects
|
||||
db_project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert db_project is None
|
||||
|
||||
|
||||
# Try to move project - should fail and restore config
|
||||
with pytest.raises(ValueError, match="not found in database"):
|
||||
await project_service.move_project(test_project_name, new_path)
|
||||
|
||||
|
||||
# Verify config was restored to original path
|
||||
assert project_service.projects[test_project_name] == old_path
|
||||
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
if test_project_name in project_service.projects:
|
||||
@@ -625,28 +625,28 @@ async def test_move_project_expands_path(project_service: ProjectService, tmp_pa
|
||||
"""Test that move_project expands ~ and relative paths."""
|
||||
test_project_name = f"test-move-expand-{os.urandom(4).hex()}"
|
||||
old_path = str(tmp_path / "old-location")
|
||||
|
||||
|
||||
# Create old directory
|
||||
os.makedirs(old_path, exist_ok=True)
|
||||
|
||||
|
||||
try:
|
||||
# Add project with initial path
|
||||
await project_service.add_project(test_project_name, old_path)
|
||||
|
||||
|
||||
# Use a relative path for the move
|
||||
relative_new_path = "./new-location"
|
||||
expected_absolute_path = os.path.abspath(relative_new_path)
|
||||
|
||||
|
||||
# Move project using relative path
|
||||
await project_service.move_project(test_project_name, relative_new_path)
|
||||
|
||||
|
||||
# Verify the path was expanded to absolute
|
||||
assert project_service.projects[test_project_name] == expected_absolute_path
|
||||
|
||||
|
||||
updated_project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert updated_project is not None
|
||||
assert updated_project.path == expected_absolute_path
|
||||
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
if test_project_name in project_service.projects:
|
||||
@@ -713,5 +713,3 @@ async def test_synchronize_projects_handles_case_sensitivity_bug(
|
||||
db_project = await project_service.repository.get_by_name(name)
|
||||
if db_project:
|
||||
await project_service.repository.delete(db_project.id)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Test character-related sync conflicts and permalink generation."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
@@ -8,14 +7,12 @@ import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from basic_memory.config import ProjectConfig
|
||||
from basic_memory.models import Entity
|
||||
from basic_memory.repository import EntityRepository
|
||||
from basic_memory.services import EntityService
|
||||
from basic_memory.sync.sync_service import SyncService
|
||||
from basic_memory.utils import (
|
||||
generate_permalink,
|
||||
generate_permalink,
|
||||
normalize_file_path_for_comparison,
|
||||
detect_potential_file_conflicts
|
||||
detect_potential_file_conflicts,
|
||||
)
|
||||
|
||||
|
||||
@@ -31,29 +28,37 @@ class TestUtilityFunctions:
|
||||
def test_normalize_file_path_for_comparison(self):
|
||||
"""Test file path normalization for conflict detection."""
|
||||
# Case sensitivity normalization
|
||||
assert normalize_file_path_for_comparison("Finance/Investment.md") == "finance/investment.md"
|
||||
assert normalize_file_path_for_comparison("FINANCE/INVESTMENT.MD") == "finance/investment.md"
|
||||
|
||||
assert (
|
||||
normalize_file_path_for_comparison("Finance/Investment.md") == "finance/investment.md"
|
||||
)
|
||||
assert (
|
||||
normalize_file_path_for_comparison("FINANCE/INVESTMENT.MD") == "finance/investment.md"
|
||||
)
|
||||
|
||||
# Path separator normalization
|
||||
assert normalize_file_path_for_comparison("Finance\\Investment.md") == "finance/investment.md"
|
||||
|
||||
assert (
|
||||
normalize_file_path_for_comparison("Finance\\Investment.md") == "finance/investment.md"
|
||||
)
|
||||
|
||||
# Multiple slash handling
|
||||
assert normalize_file_path_for_comparison("Finance//Investment.md") == "finance/investment.md"
|
||||
assert (
|
||||
normalize_file_path_for_comparison("Finance//Investment.md") == "finance/investment.md"
|
||||
)
|
||||
|
||||
def test_detect_potential_file_conflicts(self):
|
||||
"""Test the enhanced conflict detection function."""
|
||||
existing_paths = [
|
||||
"Finance/Investment.md",
|
||||
"finance/Investment.md",
|
||||
"finance/Investment.md",
|
||||
"docs/my-feature.md",
|
||||
"docs/my feature.md"
|
||||
"docs/my feature.md",
|
||||
]
|
||||
|
||||
|
||||
# Case sensitivity conflict
|
||||
conflicts = detect_potential_file_conflicts("FINANCE/INVESTMENT.md", existing_paths)
|
||||
assert "Finance/Investment.md" in conflicts
|
||||
assert "finance/Investment.md" in conflicts
|
||||
|
||||
|
||||
# Permalink conflict (space vs hyphen)
|
||||
conflicts = detect_potential_file_conflicts("docs/my_feature.md", existing_paths)
|
||||
assert "docs/my-feature.md" in conflicts
|
||||
@@ -68,10 +73,10 @@ class TestPermalinkGeneration:
|
||||
# File with existing hyphens
|
||||
assert generate_permalink("docs/my-feature.md") == "docs/my-feature"
|
||||
assert generate_permalink("docs/basic-memory bug.md") == "docs/basic-memory-bug"
|
||||
|
||||
|
||||
# File with spaces that become hyphens
|
||||
assert generate_permalink("docs/my feature.md") == "docs/my-feature"
|
||||
|
||||
|
||||
# Mixed scenarios
|
||||
assert generate_permalink("docs/my-old feature.md") == "docs/my-old-feature"
|
||||
|
||||
@@ -79,7 +84,7 @@ class TestPermalinkGeneration:
|
||||
"""Test that forward slashes are handled properly."""
|
||||
# Normal directory structure
|
||||
assert generate_permalink("Finance/Investment.md") == "finance/investment"
|
||||
|
||||
|
||||
# Path with spaces in directory names
|
||||
assert generate_permalink("My Finance/Investment.md") == "my-finance/investment"
|
||||
|
||||
@@ -93,11 +98,14 @@ class TestPermalinkGeneration:
|
||||
def test_unicode_character_handling(self):
|
||||
"""Test that international characters are handled properly."""
|
||||
# Italian characters as mentioned in user feedback
|
||||
assert generate_permalink("Finance/Punti Chiave di Peter Lynch.md") == "finance/punti-chiave-di-peter-lynch"
|
||||
|
||||
assert (
|
||||
generate_permalink("Finance/Punti Chiave di Peter Lynch.md")
|
||||
== "finance/punti-chiave-di-peter-lynch"
|
||||
)
|
||||
|
||||
# Chinese characters (should be preserved)
|
||||
assert generate_permalink("中文/测试文档.md") == "中文/测试文档"
|
||||
|
||||
|
||||
# Mixed international characters
|
||||
assert generate_permalink("docs/Café München.md") == "docs/cafe-munchen"
|
||||
|
||||
@@ -105,7 +113,7 @@ class TestPermalinkGeneration:
|
||||
"""Test handling of special punctuation characters."""
|
||||
# Apostrophes should be removed
|
||||
assert generate_permalink("Peter's Guide.md") == "peters-guide"
|
||||
|
||||
|
||||
# Other punctuation should become hyphens
|
||||
assert generate_permalink("Q&A Session.md") == "q-a-session"
|
||||
|
||||
@@ -122,7 +130,7 @@ class TestSyncConflictHandling:
|
||||
):
|
||||
"""Test that file path conflicts are detected during move operations."""
|
||||
project_dir = project_config.home
|
||||
|
||||
|
||||
# Create two files
|
||||
content1 = dedent("""
|
||||
---
|
||||
@@ -131,7 +139,7 @@ class TestSyncConflictHandling:
|
||||
# Document One
|
||||
This is the first document.
|
||||
""")
|
||||
|
||||
|
||||
content2 = dedent("""
|
||||
---
|
||||
type: knowledge
|
||||
@@ -139,44 +147,44 @@ class TestSyncConflictHandling:
|
||||
# Document Two
|
||||
This is the second document.
|
||||
""")
|
||||
|
||||
|
||||
await create_test_file(project_dir / "doc1.md", content1)
|
||||
await create_test_file(project_dir / "doc2.md", content2)
|
||||
|
||||
|
||||
# Initial sync
|
||||
await sync_service.sync(project_config.home)
|
||||
|
||||
|
||||
# Verify both entities exist
|
||||
entities = await entity_repository.find_all()
|
||||
assert len(entities) == 2
|
||||
|
||||
|
||||
# Now simulate a move where doc1.md tries to move to doc2.md's location
|
||||
# This should be handled gracefully, not throw an IntegrityError
|
||||
|
||||
|
||||
# First, get the entities
|
||||
entity1 = await entity_repository.get_by_file_path("doc1.md")
|
||||
entity2 = await entity_repository.get_by_file_path("doc2.md")
|
||||
|
||||
|
||||
assert entity1 is not None
|
||||
assert entity2 is not None
|
||||
|
||||
|
||||
# Simulate the conflict scenario
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
# This should detect the conflict and handle it gracefully
|
||||
await sync_service.handle_move("doc1.md", "doc2.md")
|
||||
|
||||
|
||||
# The exception should be a meaningful error, not an IntegrityError
|
||||
assert not isinstance(exc_info.value, IntegrityError)
|
||||
|
||||
async def test_hyphen_filename_conflict(
|
||||
self,
|
||||
sync_service: SyncService,
|
||||
sync_service: SyncService,
|
||||
project_config: ProjectConfig,
|
||||
entity_repository: EntityRepository,
|
||||
):
|
||||
"""Test conflict when filename with hyphens conflicts with generated permalink."""
|
||||
project_dir = project_config.home
|
||||
|
||||
|
||||
# Create file with spaces (will generate permalink with hyphens)
|
||||
content1 = dedent("""
|
||||
---
|
||||
@@ -185,7 +193,7 @@ class TestSyncConflictHandling:
|
||||
# Basic Memory Bug
|
||||
This file has spaces in the name.
|
||||
""")
|
||||
|
||||
|
||||
# Create file with hyphens (already has hyphens in filename)
|
||||
content2 = dedent("""
|
||||
---
|
||||
@@ -194,17 +202,17 @@ class TestSyncConflictHandling:
|
||||
# Basic Memory Bug Report
|
||||
This file has hyphens in the name.
|
||||
""")
|
||||
|
||||
|
||||
await create_test_file(project_dir / "basic memory bug.md", content1)
|
||||
await create_test_file(project_dir / "basic-memory-bug.md", content2)
|
||||
|
||||
|
||||
# Sync should handle this without conflict
|
||||
await sync_service.sync(project_config.home)
|
||||
|
||||
|
||||
# Verify both entities were created with unique permalinks
|
||||
entities = await entity_repository.find_all()
|
||||
assert len(entities) == 2
|
||||
|
||||
|
||||
# Check that permalinks are unique
|
||||
permalinks = [entity.permalink for entity in entities if entity.permalink]
|
||||
assert len(set(permalinks)) == len(permalinks), "Permalinks should be unique"
|
||||
@@ -212,16 +220,16 @@ class TestSyncConflictHandling:
|
||||
async def test_case_sensitivity_conflict(
|
||||
self,
|
||||
sync_service: SyncService,
|
||||
project_config: ProjectConfig,
|
||||
project_config: ProjectConfig,
|
||||
entity_repository: EntityRepository,
|
||||
):
|
||||
"""Test conflict handling when case differences cause issues."""
|
||||
project_dir = project_config.home
|
||||
|
||||
|
||||
# Create directory structure that might cause case conflicts
|
||||
(project_dir / "Finance").mkdir(parents=True, exist_ok=True)
|
||||
(project_dir / "finance").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
content1 = dedent("""
|
||||
---
|
||||
type: knowledge
|
||||
@@ -229,7 +237,7 @@ class TestSyncConflictHandling:
|
||||
# Investment Guide
|
||||
Upper case directory.
|
||||
""")
|
||||
|
||||
|
||||
content2 = dedent("""
|
||||
---
|
||||
type: knowledge
|
||||
@@ -237,17 +245,17 @@ class TestSyncConflictHandling:
|
||||
# Investment Tips
|
||||
Lower case directory.
|
||||
""")
|
||||
|
||||
|
||||
await create_test_file(project_dir / "Finance" / "investment.md", content1)
|
||||
await create_test_file(project_dir / "finance" / "investment.md", content2)
|
||||
|
||||
|
||||
# Sync should handle case differences properly
|
||||
await sync_service.sync(project_config.home)
|
||||
|
||||
|
||||
# Verify entities were created
|
||||
entities = await entity_repository.find_all()
|
||||
assert len(entities) >= 2 # Allow for potential other test files
|
||||
|
||||
|
||||
# Check that file paths are preserved correctly
|
||||
file_paths = [entity.file_path for entity in entities]
|
||||
assert "Finance/investment.md" in file_paths
|
||||
@@ -261,44 +269,44 @@ class TestSyncConflictHandling:
|
||||
):
|
||||
"""Test that move conflicts are resolved with proper error handling."""
|
||||
project_dir = project_config.home
|
||||
|
||||
|
||||
# Create three files in a scenario that could cause move conflicts
|
||||
await create_test_file(project_dir / "file-a.md", "# File A")
|
||||
await create_test_file(project_dir / "file-b.md", "# File B")
|
||||
await create_test_file(project_dir / "temp.md", "# Temp File")
|
||||
|
||||
|
||||
# Initial sync
|
||||
await sync_service.sync(project_config.home)
|
||||
|
||||
|
||||
# Simulate a complex move scenario where files swap locations
|
||||
# This is the kind of scenario that caused the original bug
|
||||
|
||||
|
||||
# Get the entities
|
||||
entity_a = await entity_repository.get_by_file_path("file-a.md")
|
||||
entity_b = await entity_repository.get_by_file_path("file-b.md")
|
||||
entity_b = await entity_repository.get_by_file_path("file-b.md")
|
||||
entity_temp = await entity_repository.get_by_file_path("temp.md")
|
||||
|
||||
|
||||
assert all([entity_a, entity_b, entity_temp])
|
||||
|
||||
|
||||
# Try to move file-a to file-b's location (should detect conflict)
|
||||
try:
|
||||
await sync_service.handle_move("file-a.md", "file-b.md")
|
||||
# If this doesn't raise an exception, the conflict was resolved
|
||||
|
||||
|
||||
# Verify the state is consistent
|
||||
updated_entities = await entity_repository.find_all()
|
||||
file_paths = [entity.file_path for entity in updated_entities]
|
||||
|
||||
|
||||
# Should not have duplicate file paths
|
||||
assert len(file_paths) == len(set(file_paths)), "File paths should be unique"
|
||||
|
||||
|
||||
except Exception as e:
|
||||
# If an exception is raised, it should be a meaningful error
|
||||
assert "conflict" in str(e).lower() or "already exists" in str(e).lower()
|
||||
assert not isinstance(e, IntegrityError), "Should not be a raw IntegrityError"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio
|
||||
class TestEnhancedErrorMessages:
|
||||
"""Test that error messages provide helpful guidance for character conflicts."""
|
||||
|
||||
@@ -316,6 +324,6 @@ class TestEnhancedErrorMessages:
|
||||
sync_service: SyncService,
|
||||
project_config: ProjectConfig,
|
||||
):
|
||||
"""Test that case sensitivity conflicts generate helpful error messages."""
|
||||
"""Test that case sensitivity conflicts generate helpful error messages."""
|
||||
# This test will be implemented after we enhance the error handling
|
||||
pass
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user