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:
Drew Cain
2025-09-26 11:41:45 -05:00
committed by GitHub
parent 1091e11322
commit 3e168b98f3
4 changed files with 190 additions and 9 deletions
@@ -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()
+80 -7
View File
@@ -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 = {
+109
View File
@@ -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."""