mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix: move_note without file extension (#281)
Signed-off-by: Drew Cain <groksrc@gmail.com> Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com> Co-authored-by: phernandez <paul@basicmachines.co>
This commit is contained in:
@@ -14,5 +14,4 @@ __all__ = [
|
||||
"import_chatgpt",
|
||||
"tool",
|
||||
"project",
|
||||
"cloud",
|
||||
]
|
||||
|
||||
@@ -395,4 +395,4 @@ def unmount() -> None:
|
||||
@cloud_app.command("mount-status")
|
||||
def mount_status() -> None:
|
||||
"""Show current mount status."""
|
||||
show_mount_status()
|
||||
show_mount_status()
|
||||
|
||||
@@ -65,16 +65,16 @@ def _format_cross_project_error_response(
|
||||
"""Format error response for detected cross-project move attempts."""
|
||||
return dedent(f"""
|
||||
# Move Failed - Cross-Project Move Not Supported
|
||||
|
||||
|
||||
Cannot move '{identifier}' to '{destination_path}' because it appears to reference a different project ('{target_project}').
|
||||
|
||||
|
||||
**Current project:** {current_project}
|
||||
**Target project:** {target_project}
|
||||
|
||||
|
||||
## Cross-project moves are not supported directly
|
||||
|
||||
|
||||
Notes can only be moved within the same project. To move content between projects, use this workflow:
|
||||
|
||||
|
||||
### Recommended approach:
|
||||
```
|
||||
# 1. Read the note content from current project
|
||||
@@ -87,13 +87,13 @@ def _format_cross_project_error_response(
|
||||
delete_note("{identifier}", project="{current_project}")
|
||||
|
||||
```
|
||||
|
||||
|
||||
### Alternative: Stay in current project
|
||||
If you want to move the note within the **{current_project}** project only:
|
||||
```
|
||||
move_note("{identifier}", "new-folder/new-name.md")
|
||||
```
|
||||
|
||||
|
||||
## Available projects:
|
||||
Use `list_memory_projects()` to see all available projects.
|
||||
""").strip()
|
||||
@@ -429,6 +429,79 @@ move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in dest
|
||||
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
|
||||
return cross_project_error
|
||||
|
||||
# Get the source entity information for extension validation
|
||||
source_ext = "md" # Default to .md if we can't determine source extension
|
||||
try:
|
||||
# Fetch source entity information to get the current file extension
|
||||
url = f"{project_url}/knowledge/entities/{identifier}"
|
||||
response = await call_get(client, url)
|
||||
source_entity = EntityResponse.model_validate(response.json())
|
||||
if "." in source_entity.file_path:
|
||||
source_ext = source_entity.file_path.split(".")[-1]
|
||||
except Exception as e:
|
||||
# If we can't fetch the source entity, default to .md extension
|
||||
logger.debug(f"Could not fetch source entity for extension check: {e}")
|
||||
|
||||
# Validate that destination path includes a file extension
|
||||
if "." not in destination_path or not destination_path.split(".")[-1]:
|
||||
logger.warning(f"Move failed - no file extension provided: {destination_path}")
|
||||
return dedent(f"""
|
||||
# Move Failed - File Extension Required
|
||||
|
||||
The destination path '{destination_path}' must include a file extension (e.g., '.md').
|
||||
|
||||
## Valid examples:
|
||||
- `notes/my-note.md`
|
||||
- `projects/meeting-2025.txt`
|
||||
- `archive/old-program.sh`
|
||||
|
||||
## Try again with extension:
|
||||
```
|
||||
move_note("{identifier}", "{destination_path}.{source_ext}")
|
||||
```
|
||||
|
||||
All examples in Basic Memory expect file extensions to be explicitly provided.
|
||||
""").strip()
|
||||
|
||||
# Get the source entity to check its file extension
|
||||
try:
|
||||
# Fetch source entity information
|
||||
url = f"{project_url}/knowledge/entities/{identifier}"
|
||||
response = await call_get(client, url)
|
||||
source_entity = EntityResponse.model_validate(response.json())
|
||||
|
||||
# Extract file extensions
|
||||
source_ext = (
|
||||
source_entity.file_path.split(".")[-1] if "." in source_entity.file_path else ""
|
||||
)
|
||||
dest_ext = destination_path.split(".")[-1] if "." in destination_path else ""
|
||||
|
||||
# Check if extensions match
|
||||
if source_ext and dest_ext and source_ext.lower() != dest_ext.lower():
|
||||
logger.warning(
|
||||
f"Move failed - file extension mismatch: source={source_ext}, dest={dest_ext}"
|
||||
)
|
||||
return dedent(f"""
|
||||
# Move Failed - File Extension Mismatch
|
||||
|
||||
The destination file extension '.{dest_ext}' does not match the source file extension '.{source_ext}'.
|
||||
|
||||
To preserve file type consistency, the destination must have the same extension as the source.
|
||||
|
||||
## Source file:
|
||||
- Path: `{source_entity.file_path}`
|
||||
- Extension: `.{source_ext}`
|
||||
|
||||
## Try again with matching extension:
|
||||
```
|
||||
move_note("{identifier}", "{destination_path.rsplit(".", 1)[0]}.{source_ext}")
|
||||
```
|
||||
""").strip()
|
||||
except Exception as e:
|
||||
# If we can't fetch the source entity, log it but continue
|
||||
# This might happen if the identifier is not yet resolved
|
||||
logger.debug(f"Could not fetch source entity for extension check: {e}")
|
||||
|
||||
try:
|
||||
# Prepare move request
|
||||
move_data = {
|
||||
|
||||
@@ -205,6 +205,115 @@ async def test_move_note_invalid_destination_path(client, test_project):
|
||||
assert "/absolute/path.md" in result or "Invalid" in result or "path" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_missing_file_extension(client, test_project):
|
||||
"""Test moving note without file extension in destination path."""
|
||||
# Create initial note
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="ExtensionTest",
|
||||
folder="source",
|
||||
content="# Extension Test\nTesting extension validation.",
|
||||
)
|
||||
|
||||
# Test path without extension
|
||||
result = await move_note.fn(
|
||||
project=test_project.name,
|
||||
identifier="source/extension-test",
|
||||
destination_path="target/renamed-note",
|
||||
)
|
||||
|
||||
# Should return error about missing extension
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed - File Extension Required" in result
|
||||
assert "must include a file extension" in result
|
||||
assert ".md" in result
|
||||
assert "renamed-note.md" in result # Should suggest adding .md
|
||||
|
||||
# Test path with empty extension (edge case)
|
||||
result = await move_note.fn(
|
||||
project=test_project.name,
|
||||
identifier="source/extension-test",
|
||||
destination_path="target/renamed-note.",
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed - File Extension Required" in result
|
||||
assert "must include a file extension" in result
|
||||
|
||||
# Test that note still exists at original location
|
||||
content = await read_note.fn("source/extension-test", project=test_project.name)
|
||||
assert "# Extension Test" in content
|
||||
assert "Testing extension validation" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_file_extension_mismatch(client, test_project):
|
||||
"""Test that moving note with different extension is blocked."""
|
||||
# Create initial note with .md extension
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="MarkdownNote",
|
||||
folder="source",
|
||||
content="# Markdown Note\nThis is a markdown file.",
|
||||
)
|
||||
|
||||
# Try to move with .txt extension
|
||||
result = await move_note.fn(
|
||||
project=test_project.name,
|
||||
identifier="source/markdown-note",
|
||||
destination_path="target/renamed-note.txt",
|
||||
)
|
||||
|
||||
# Should return error about extension mismatch
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed - File Extension Mismatch" in result
|
||||
assert "does not match the source file extension" in result
|
||||
assert ".md" in result
|
||||
assert ".txt" in result
|
||||
assert "renamed-note.md" in result # Should suggest correct extension
|
||||
|
||||
# Test that note still exists at original location with original extension
|
||||
content = await read_note.fn("source/markdown-note", project=test_project.name)
|
||||
assert "# Markdown Note" in content
|
||||
assert "This is a markdown file" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_preserves_file_extension(client, test_project):
|
||||
"""Test that moving note with matching extension succeeds."""
|
||||
# Create initial note with .md extension
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="PreserveExtension",
|
||||
folder="source",
|
||||
content="# Preserve Extension\nTesting that extension is preserved.",
|
||||
)
|
||||
|
||||
# Move with same .md extension
|
||||
result = await move_note.fn(
|
||||
project=test_project.name,
|
||||
identifier="source/preserve-extension",
|
||||
destination_path="target/preserved-note.md",
|
||||
)
|
||||
|
||||
# Should succeed
|
||||
assert isinstance(result, str)
|
||||
assert "✅ Note moved successfully" in result
|
||||
|
||||
# Verify note exists at new location with same extension
|
||||
content = await read_note.fn("target/preserved-note", project=test_project.name)
|
||||
assert "# Preserve Extension" in content
|
||||
assert "Testing that extension is preserved" in content
|
||||
|
||||
# Verify old location no longer exists
|
||||
try:
|
||||
await read_note.fn("source/preserve-extension")
|
||||
assert False, "Original note should not exist after move"
|
||||
except Exception:
|
||||
pass # Expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_destination_exists(client, test_project):
|
||||
"""Test moving note to existing destination."""
|
||||
|
||||
Reference in New Issue
Block a user