mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f962fdd87 | |||
| fbb497f6dc | |||
| 0023e736ab | |||
| 0b2080114b | |||
| 8730067f3a | |||
| e14ba92631 | |||
| 9d98892570 | |||
| 3be4495723 | |||
| 17c0e0a29b |
@@ -1,5 +1,45 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v0.18.0 (2026-01-28)
|
||||
|
||||
### Features
|
||||
|
||||
- **#527**: Add context-aware wiki link resolution with source_path support
|
||||
([`0023e73`](https://github.com/basicmachines-co/basic-memory/commit/0023e73))
|
||||
- Add `source_path` parameter to `resolve_link()` for context-aware resolution
|
||||
- Relative path resolution: `[[nested/note]]` from `folder/file.md` → `folder/nested/note.md`
|
||||
- Proximity-based resolution for duplicate titles (prefers notes in same folder)
|
||||
- Strict mode to disable fuzzy search fallback for wiki links
|
||||
|
||||
- **#518**: Add directory support to move_note and delete_note tools
|
||||
([`0b20801`](https://github.com/basicmachines-co/basic-memory/commit/0b20801))
|
||||
- Add `is_directory` parameter to `move_note` and `delete_note` MCP tools
|
||||
- New `POST /move-directory` and delete directory API endpoints
|
||||
- Rename `folder` → `directory` parameter across codebase for consistency
|
||||
|
||||
- **#522**: Local MCP cloud mode routing
|
||||
([`8730067`](https://github.com/basicmachines-co/basic-memory/commit/8730067))
|
||||
- Add `--local` and `--cloud` CLI routing flags
|
||||
- Local MCP server (`basic-memory mcp`) automatically uses local routing
|
||||
- Enables simultaneous use of local Claude Desktop and cloud-based clients
|
||||
- Environment variable `BASIC_MEMORY_FORCE_LOCAL=true` for global override
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#524**: Fix MCP prompt rendering errors
|
||||
([`e14ba92`](https://github.com/basicmachines-co/basic-memory/commit/e14ba92))
|
||||
- Fix "Error rendering prompt recent_activity" error
|
||||
- Change `TimeFrame` to `str` in prompt type annotations for FastMCP compatibility
|
||||
|
||||
## v0.17.9 (2026-01-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **#523**: Fix `remove_project()` checking stale config in cloud mode
|
||||
([`17c0e0a`](https://github.com/basicmachines-co/basic-memory/commit/17c0e0a))
|
||||
- In cloud mode, only check database `is_default` field (source of truth)
|
||||
- Config file can become stale when users set default project via v2 API
|
||||
|
||||
## v0.17.8 (2026-01-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -248,13 +248,13 @@ See SPEC-16 for full context manager refactor details.
|
||||
- Basic Memory exposes these MCP tools to LLMs:
|
||||
|
||||
**Content Management:**
|
||||
- `write_note(title, content, folder, tags)` - Create/update markdown notes with semantic observations and relations
|
||||
- `write_note(title, content, directory, tags)` - Create/update markdown notes with semantic observations and relations
|
||||
- `read_note(identifier, page, page_size)` - Read notes by title, permalink, or memory:// URL with knowledge graph awareness
|
||||
- `read_content(path)` - Read raw file content (text, images, binaries) without knowledge graph processing
|
||||
- `view_note(identifier, page, page_size)` - View notes as formatted artifacts for better readability
|
||||
- `edit_note(identifier, operation, content)` - Edit notes incrementally (append, prepend, find/replace, replace_section)
|
||||
- `move_note(identifier, destination_path)` - Move notes to new locations, updating database and maintaining links
|
||||
- `delete_note(identifier)` - Delete notes from the knowledge base
|
||||
- `move_note(identifier, destination_path, is_directory)` - Move notes or directories to new locations, updating database and maintaining links
|
||||
- `delete_note(identifier, is_directory)` - Delete notes or directories from the knowledge base
|
||||
|
||||
**Knowledge Graph Navigation:**
|
||||
- `build_context(url, depth, timeframe)` - Navigate the knowledge graph via memory:// URLs for conversation continuity
|
||||
@@ -270,7 +270,7 @@ See SPEC-16 for full context manager refactor details.
|
||||
- `delete_project(project_name)` - Delete a project from configuration
|
||||
|
||||
**Visualization:**
|
||||
- `canvas(nodes, edges, title, folder)` - Generate Obsidian canvas files for knowledge graph visualization
|
||||
- `canvas(nodes, edges, title, directory)` - Generate Obsidian canvas files for knowledge graph visualization
|
||||
|
||||
**ChatGPT-Compatible Tools:**
|
||||
- `search(query)` - Search across knowledge base (OpenAI actions compatible)
|
||||
@@ -310,6 +310,26 @@ Basic Memory now supports cloud synchronization and storage (requires active sub
|
||||
- Background relation resolution (non-blocking startup)
|
||||
- API performance optimizations (SPEC-11)
|
||||
|
||||
**CLI Routing Flags:**
|
||||
|
||||
When cloud mode is enabled, CLI commands route to the cloud API by default. Use `--local` and `--cloud` flags to override:
|
||||
|
||||
```bash
|
||||
# Force local routing (ignore cloud mode)
|
||||
basic-memory status --local
|
||||
basic-memory project list --local
|
||||
|
||||
# Force cloud routing (when cloud mode is disabled)
|
||||
basic-memory status --cloud
|
||||
basic-memory project info my-project --cloud
|
||||
```
|
||||
|
||||
Key behaviors:
|
||||
- The local MCP server (`basic-memory mcp`) automatically uses local routing
|
||||
- This allows simultaneous use of local Claude Desktop and cloud-based clients
|
||||
- Some commands (like `project default`, `project sync-config`, `project move`) require `--local` in cloud mode since they modify local configuration
|
||||
- Environment variable `BASIC_MEMORY_FORCE_LOCAL=true` forces local routing globally
|
||||
|
||||
## AI-Human Collaborative Development
|
||||
|
||||
Basic Memory emerged from and enables a new kind of development process that combines human and AI capabilities. Instead
|
||||
|
||||
@@ -357,6 +357,22 @@ basic-memory cloud check
|
||||
basic-memory cloud mount
|
||||
```
|
||||
|
||||
**Routing Flags** (for users with cloud subscriptions):
|
||||
|
||||
When cloud mode is enabled, CLI commands communicate with the cloud API by default. Use routing flags to override this:
|
||||
|
||||
```bash
|
||||
# Force local routing (useful for local MCP server while cloud mode is enabled)
|
||||
basic-memory status --local
|
||||
basic-memory project list --local
|
||||
|
||||
# Force cloud routing (when cloud mode is disabled but you want cloud access)
|
||||
basic-memory status --cloud
|
||||
basic-memory project info my-project --cloud
|
||||
```
|
||||
|
||||
The local MCP server (`basic-memory mcp`) automatically uses local routing, so you can use both local Claude Desktop and cloud-based clients simultaneously.
|
||||
|
||||
4. In Claude Desktop, the LLM can now use these tools:
|
||||
|
||||
**Content Management:**
|
||||
@@ -433,6 +449,7 @@ Basic Memory uses [Loguru](https://github.com/Delgan/loguru) for logging. The lo
|
||||
|----------|---------|-------------|
|
||||
| `BASIC_MEMORY_LOG_LEVEL` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
||||
| `BASIC_MEMORY_CLOUD_MODE` | `false` | When `true`, API logs to stdout with structured context |
|
||||
| `BASIC_MEMORY_FORCE_LOCAL` | `false` | When `true`, forces local API routing (ignores cloud mode) |
|
||||
| `BASIC_MEMORY_ENV` | `dev` | Set to `test` for test mode (stderr only) |
|
||||
|
||||
### Examples
|
||||
|
||||
+2
-2
@@ -6,12 +6,12 @@
|
||||
"url": "https://github.com/basicmachines-co/basic-memory.git",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "0.17.8",
|
||||
"version": "0.18.0",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "pypi",
|
||||
"identifier": "basic-memory",
|
||||
"version": "0.17.8",
|
||||
"version": "0.18.0",
|
||||
"runtimeHint": "uvx",
|
||||
"runtimeArguments": [
|
||||
{"type": "positional", "value": "basic-memory"},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
|
||||
|
||||
# Package version - updated by release automation
|
||||
__version__ = "0.17.8"
|
||||
__version__ = "0.18.0"
|
||||
|
||||
# API version for FastAPI - independent of package version
|
||||
__api_version__ = "v0"
|
||||
|
||||
@@ -27,13 +27,13 @@ router = APIRouter(prefix="/import", tags=["import"])
|
||||
async def import_chatgpt(
|
||||
importer: ChatGPTImporterDep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
directory: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from ChatGPT JSON export.
|
||||
|
||||
Args:
|
||||
file: The ChatGPT conversations.json file.
|
||||
folder: The folder to place the files in.
|
||||
directory: The directory to place the files in.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
|
||||
Returns:
|
||||
@@ -42,20 +42,20 @@ async def import_chatgpt(
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
return await import_file(importer, file, folder)
|
||||
return await import_file(importer, file, directory)
|
||||
|
||||
|
||||
@router.post("/claude/conversations", response_model=ChatImportResult)
|
||||
async def import_claude_conversations(
|
||||
importer: ClaudeConversationsImporterDep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
directory: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from Claude conversations.json export.
|
||||
|
||||
Args:
|
||||
file: The Claude conversations.json file.
|
||||
folder: The folder to place the files in.
|
||||
directory: The directory to place the files in.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
|
||||
Returns:
|
||||
@@ -64,20 +64,20 @@ async def import_claude_conversations(
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
return await import_file(importer, file, folder)
|
||||
return await import_file(importer, file, directory)
|
||||
|
||||
|
||||
@router.post("/claude/projects", response_model=ProjectImportResult)
|
||||
async def import_claude_projects(
|
||||
importer: ClaudeProjectsImporterDep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("projects"),
|
||||
directory: str = Form("projects"),
|
||||
) -> ProjectImportResult:
|
||||
"""Import projects from Claude projects.json export.
|
||||
|
||||
Args:
|
||||
file: The Claude projects.json file.
|
||||
base_folder: The base folder to place the files in.
|
||||
directory: The directory to place the files in.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
|
||||
Returns:
|
||||
@@ -86,20 +86,20 @@ async def import_claude_projects(
|
||||
Raises:
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
return await import_file(importer, file, folder)
|
||||
return await import_file(importer, file, directory)
|
||||
|
||||
|
||||
@router.post("/memory-json", response_model=EntityImportResult)
|
||||
async def import_memory_json(
|
||||
importer: MemoryJsonImporterDep,
|
||||
file: UploadFile,
|
||||
folder: str = Form("conversations"),
|
||||
directory: str = Form("conversations"),
|
||||
) -> EntityImportResult:
|
||||
"""Import entities and relations from a memory.json file.
|
||||
|
||||
Args:
|
||||
file: The memory.json file.
|
||||
destination_folder: Optional destination folder within the project.
|
||||
directory: Optional destination directory within the project.
|
||||
markdown_processor: MarkdownProcessor instance.
|
||||
|
||||
Returns:
|
||||
@@ -116,7 +116,7 @@ async def import_memory_json(
|
||||
json_data = json.loads(line)
|
||||
file_data.append(json_data)
|
||||
|
||||
result = await importer.import_data(file_data, folder)
|
||||
result = await importer.import_data(file_data, directory)
|
||||
if not result.success: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -29,7 +29,8 @@ from basic_memory.schemas import (
|
||||
DeleteEntitiesResponse,
|
||||
DeleteEntitiesRequest,
|
||||
)
|
||||
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest
|
||||
from basic_memory.schemas.request import EditEntityRequest, MoveEntityRequest, MoveDirectoryRequest
|
||||
from basic_memory.schemas.response import DirectoryMoveResult
|
||||
from basic_memory.schemas.base import Permalink, Entity
|
||||
|
||||
router = APIRouter(
|
||||
@@ -231,6 +232,50 @@ async def move_entity(
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/move-directory")
|
||||
async def move_directory(
|
||||
data: MoveDirectoryRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
entity_service: EntityServiceDep,
|
||||
project_config: ProjectConfigDep,
|
||||
app_config: AppConfigDep,
|
||||
search_service: SearchServiceDep,
|
||||
) -> DirectoryMoveResult:
|
||||
"""Move all entities in a directory to a new location.
|
||||
|
||||
This endpoint moves all files within a source directory to a destination
|
||||
directory, updating database records and optionally updating permalinks.
|
||||
"""
|
||||
logger.info(
|
||||
f"API request: endpoint='move_directory', source='{data.source_directory}', destination='{data.destination_directory}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# Move the directory using the service
|
||||
result = await entity_service.move_directory(
|
||||
source_directory=data.source_directory,
|
||||
destination_directory=data.destination_directory,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
# Reindex moved entities
|
||||
for file_path in result.moved_files:
|
||||
entity = await entity_service.link_resolver.resolve_link(file_path)
|
||||
if entity:
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
logger.info(
|
||||
f"API response: endpoint='move_directory', "
|
||||
f"total={result.total_files}, success={result.successful_moves}, failed={result.failed_moves}"
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving directory: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
## Read endpoints
|
||||
|
||||
|
||||
|
||||
@@ -32,14 +32,14 @@ async def import_chatgpt(
|
||||
importer: ChatGPTImporterV2ExternalDep,
|
||||
file: UploadFile,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
folder: str = Form("conversations"),
|
||||
directory: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from ChatGPT JSON export.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
file: The ChatGPT conversations.json file.
|
||||
folder: The folder to place the files in.
|
||||
directory: The directory to place the files in.
|
||||
importer: ChatGPT importer instance.
|
||||
|
||||
Returns:
|
||||
@@ -49,7 +49,7 @@ async def import_chatgpt(
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
logger.info(f"V2 Importing ChatGPT conversations for project {project_id}")
|
||||
return await import_file(importer, file, folder)
|
||||
return await import_file(importer, file, directory)
|
||||
|
||||
|
||||
@router.post("/claude/conversations", response_model=ChatImportResult)
|
||||
@@ -57,14 +57,14 @@ async def import_claude_conversations(
|
||||
importer: ClaudeConversationsImporterV2ExternalDep,
|
||||
file: UploadFile,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
folder: str = Form("conversations"),
|
||||
directory: str = Form("conversations"),
|
||||
) -> ChatImportResult:
|
||||
"""Import conversations from Claude conversations.json export.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
file: The Claude conversations.json file.
|
||||
folder: The folder to place the files in.
|
||||
directory: The directory to place the files in.
|
||||
importer: Claude conversations importer instance.
|
||||
|
||||
Returns:
|
||||
@@ -74,7 +74,7 @@ async def import_claude_conversations(
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
logger.info(f"V2 Importing Claude conversations for project {project_id}")
|
||||
return await import_file(importer, file, folder)
|
||||
return await import_file(importer, file, directory)
|
||||
|
||||
|
||||
@router.post("/claude/projects", response_model=ProjectImportResult)
|
||||
@@ -82,14 +82,14 @@ async def import_claude_projects(
|
||||
importer: ClaudeProjectsImporterV2ExternalDep,
|
||||
file: UploadFile,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
folder: str = Form("projects"),
|
||||
directory: str = Form("projects"),
|
||||
) -> ProjectImportResult:
|
||||
"""Import projects from Claude projects.json export.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
file: The Claude projects.json file.
|
||||
folder: The base folder to place the files in.
|
||||
directory: The base directory to place the files in.
|
||||
importer: Claude projects importer instance.
|
||||
|
||||
Returns:
|
||||
@@ -99,7 +99,7 @@ async def import_claude_projects(
|
||||
HTTPException: If import fails.
|
||||
"""
|
||||
logger.info(f"V2 Importing Claude projects for project {project_id}")
|
||||
return await import_file(importer, file, folder)
|
||||
return await import_file(importer, file, directory)
|
||||
|
||||
|
||||
@router.post("/memory-json", response_model=EntityImportResult)
|
||||
@@ -107,14 +107,14 @@ async def import_memory_json(
|
||||
importer: MemoryJsonImporterV2ExternalDep,
|
||||
file: UploadFile,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
folder: str = Form("conversations"),
|
||||
directory: str = Form("conversations"),
|
||||
) -> EntityImportResult:
|
||||
"""Import entities and relations from a memory.json file.
|
||||
|
||||
Args:
|
||||
project_id: Project external UUID from URL path
|
||||
file: The memory.json file.
|
||||
folder: Optional destination folder within the project.
|
||||
directory: Optional destination directory within the project.
|
||||
importer: Memory JSON importer instance.
|
||||
|
||||
Returns:
|
||||
@@ -132,7 +132,7 @@ async def import_memory_json(
|
||||
json_data = json.loads(line)
|
||||
file_data.append(json_data)
|
||||
|
||||
result = await importer.import_data(file_data, folder)
|
||||
result = await importer.import_data(file_data, directory)
|
||||
if not result.success: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -147,13 +147,13 @@ async def import_memory_json(
|
||||
return result
|
||||
|
||||
|
||||
async def import_file(importer: Importer, file: UploadFile, destination_folder: str):
|
||||
async def import_file(importer: Importer, file: UploadFile, destination_directory: str):
|
||||
"""Helper function to import a file using an importer instance.
|
||||
|
||||
Args:
|
||||
importer: The importer instance to use
|
||||
file: The file to import
|
||||
destination_folder: Destination folder for imported content
|
||||
destination_directory: Destination directory for imported content
|
||||
|
||||
Returns:
|
||||
Import result from the importer
|
||||
@@ -164,7 +164,7 @@ async def import_file(importer: Importer, file: UploadFile, destination_folder:
|
||||
try:
|
||||
# Process file
|
||||
json_data = json.load(file.file)
|
||||
result = await importer.import_data(json_data, destination_folder)
|
||||
result = await importer.import_data(json_data, destination_directory)
|
||||
if not result.success: # pragma: no cover
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
||||
@@ -31,7 +31,10 @@ from basic_memory.schemas.v2 import (
|
||||
EntityResolveResponse,
|
||||
EntityResponseV2,
|
||||
MoveEntityRequestV2,
|
||||
MoveDirectoryRequestV2,
|
||||
DeleteDirectoryRequestV2,
|
||||
)
|
||||
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
|
||||
|
||||
router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"])
|
||||
|
||||
@@ -100,8 +103,12 @@ async def resolve_identifier(
|
||||
resolution_method = "external_id" if entity else "search"
|
||||
|
||||
# If not found by external_id, try other resolution methods
|
||||
# Pass source_path for context-aware resolution (prefers notes closer to source)
|
||||
# Pass strict to control fuzzy search fallback (default False allows fuzzy matching)
|
||||
if not entity:
|
||||
entity = await link_resolver.resolve_link(data.identifier)
|
||||
entity = await link_resolver.resolve_link(
|
||||
data.identifier, source_path=data.source_path, strict=data.strict
|
||||
)
|
||||
if entity:
|
||||
# Determine resolution method
|
||||
if entity.permalink == data.identifier:
|
||||
@@ -423,3 +430,100 @@ async def move_entity(
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving entity: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
## Move directory endpoint
|
||||
|
||||
|
||||
@router.post("/move-directory", response_model=DirectoryMoveResult)
|
||||
async def move_directory(
|
||||
data: MoveDirectoryRequestV2,
|
||||
background_tasks: BackgroundTasks,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
project_config: ProjectConfigV2ExternalDep,
|
||||
app_config: AppConfigDep,
|
||||
search_service: SearchServiceV2ExternalDep,
|
||||
) -> DirectoryMoveResult:
|
||||
"""Move all entities in a directory to a new location.
|
||||
|
||||
V2 API uses project external_id in the URL path for stable references.
|
||||
Moves all files within a source directory to a destination directory,
|
||||
updating database records and optionally updating permalinks.
|
||||
|
||||
Args:
|
||||
project_id: Project external ID from URL path
|
||||
data: Move request with source and destination directories
|
||||
|
||||
Returns:
|
||||
DirectoryMoveResult with counts and details of moved files
|
||||
"""
|
||||
logger.info(
|
||||
f"API v2 request: move_directory source='{data.source_directory}', destination='{data.destination_directory}'"
|
||||
)
|
||||
|
||||
try:
|
||||
# Move the directory using the service
|
||||
result = await entity_service.move_directory(
|
||||
source_directory=data.source_directory,
|
||||
destination_directory=data.destination_directory,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
# Reindex moved entities
|
||||
for file_path in result.moved_files:
|
||||
entity = await entity_service.link_resolver.resolve_link(file_path)
|
||||
if entity:
|
||||
await search_service.index_entity(entity, background_tasks=background_tasks)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: move_directory "
|
||||
f"total={result.total_files}, success={result.successful_moves}, failed={result.failed_moves}"
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving directory: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
## Delete directory endpoint
|
||||
|
||||
|
||||
@router.post("/delete-directory", response_model=DirectoryDeleteResult)
|
||||
async def delete_directory(
|
||||
data: DeleteDirectoryRequestV2,
|
||||
project_id: ProjectExternalIdPathDep,
|
||||
entity_service: EntityServiceV2ExternalDep,
|
||||
) -> DirectoryDeleteResult:
|
||||
"""Delete all entities in a directory.
|
||||
|
||||
V2 API uses project external_id in the URL path for stable references.
|
||||
Deletes all files within a directory, updating database records and
|
||||
removing files from the filesystem.
|
||||
|
||||
Args:
|
||||
project_id: Project external ID from URL path
|
||||
data: Delete request with directory path
|
||||
|
||||
Returns:
|
||||
DirectoryDeleteResult with counts and details of deleted files
|
||||
"""
|
||||
logger.info(f"API v2 request: delete_directory directory='{data.directory}'")
|
||||
|
||||
try:
|
||||
# Delete the directory using the service
|
||||
result = await entity_service.delete_directory(
|
||||
directory=data.directory,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"API v2 response: delete_directory "
|
||||
f"total={result.total_files}, success={result.successful_deletes}, failed={result.failed_deletes}"
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting directory: {e}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -17,60 +17,64 @@ import basic_memory.mcp.tools # noqa: F401 # pragma: no cover
|
||||
import basic_memory.mcp.prompts # noqa: F401 # pragma: no cover
|
||||
from loguru import logger
|
||||
|
||||
config = ConfigManager().config
|
||||
|
||||
if not config.cloud_mode_enabled:
|
||||
@app.command()
|
||||
def mcp(
|
||||
transport: str = typer.Option("stdio", help="Transport type: stdio, streamable-http, or sse"),
|
||||
host: str = typer.Option(
|
||||
"0.0.0.0", help="Host for HTTP transports (use 0.0.0.0 to allow external connections)"
|
||||
),
|
||||
port: int = typer.Option(8000, help="Port for HTTP transports"),
|
||||
path: str = typer.Option("/mcp", help="Path prefix for streamable-http transport"),
|
||||
project: Optional[str] = typer.Option(None, help="Restrict MCP server to single project"),
|
||||
): # pragma: no cover
|
||||
"""Run the MCP server with configurable transport options.
|
||||
|
||||
@app.command()
|
||||
def mcp(
|
||||
transport: str = typer.Option(
|
||||
"stdio", help="Transport type: stdio, streamable-http, or sse"
|
||||
),
|
||||
host: str = typer.Option(
|
||||
"0.0.0.0", help="Host for HTTP transports (use 0.0.0.0 to allow external connections)"
|
||||
),
|
||||
port: int = typer.Option(8000, help="Port for HTTP transports"),
|
||||
path: str = typer.Option("/mcp", help="Path prefix for streamable-http transport"),
|
||||
project: Optional[str] = typer.Option(None, help="Restrict MCP server to single project"),
|
||||
): # pragma: no cover
|
||||
"""Run the MCP server with configurable transport options.
|
||||
This command starts an MCP server using one of three transport options:
|
||||
|
||||
This command starts an MCP server using one of three transport options:
|
||||
- stdio: Standard I/O (good for local usage)
|
||||
- streamable-http: Recommended for web deployments (default)
|
||||
- sse: Server-Sent Events (for compatibility with existing clients)
|
||||
|
||||
- stdio: Standard I/O (good for local usage)
|
||||
- streamable-http: Recommended for web deployments (default)
|
||||
- sse: Server-Sent Events (for compatibility with existing clients)
|
||||
Initialization, file sync, and cleanup are handled by the MCP server's lifespan.
|
||||
|
||||
Initialization, file sync, and cleanup are handled by the MCP server's lifespan.
|
||||
"""
|
||||
# Initialize logging for MCP (file only, stdout breaks protocol)
|
||||
init_mcp_logging()
|
||||
Note: This command is available regardless of cloud mode setting.
|
||||
Users who have cloud mode enabled can still use local MCP for Claude Code
|
||||
and Claude Desktop while using cloud MCP for web and mobile access.
|
||||
"""
|
||||
# Force local routing for local MCP server
|
||||
# Why: The local MCP server should always talk to the local API, not the cloud proxy.
|
||||
# Even when cloud_mode_enabled is True, stdio MCP runs locally and needs local API access.
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
|
||||
|
||||
# Validate and set project constraint if specified
|
||||
if project:
|
||||
config_manager = ConfigManager()
|
||||
project_name, _ = config_manager.get_project(project)
|
||||
if not project_name:
|
||||
typer.echo(f"No project found named: {project}", err=True)
|
||||
raise typer.Exit(1)
|
||||
# Initialize logging for MCP (file only, stdout breaks protocol)
|
||||
init_mcp_logging()
|
||||
|
||||
# Set env var with validated project name
|
||||
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
|
||||
logger.info(f"MCP server constrained to project: {project_name}")
|
||||
# Validate and set project constraint if specified
|
||||
if project:
|
||||
config_manager = ConfigManager()
|
||||
project_name, _ = config_manager.get_project(project)
|
||||
if not project_name:
|
||||
typer.echo(f"No project found named: {project}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Run the MCP server (blocks)
|
||||
# Lifespan handles: initialization, migrations, file sync, cleanup
|
||||
logger.info(f"Starting MCP server with {transport.upper()} transport")
|
||||
# Set env var with validated project name
|
||||
os.environ["BASIC_MEMORY_MCP_PROJECT"] = project_name
|
||||
logger.info(f"MCP server constrained to project: {project_name}")
|
||||
|
||||
if transport == "stdio":
|
||||
mcp_server.run(
|
||||
transport=transport,
|
||||
)
|
||||
elif transport == "streamable-http" or transport == "sse":
|
||||
mcp_server.run(
|
||||
transport=transport,
|
||||
host=host,
|
||||
port=port,
|
||||
path=path,
|
||||
log_level="INFO",
|
||||
)
|
||||
# Run the MCP server (blocks)
|
||||
# Lifespan handles: initialization, migrations, file sync, cleanup
|
||||
logger.info(f"Starting MCP server with {transport.upper()} transport")
|
||||
|
||||
if transport == "stdio":
|
||||
mcp_server.run(
|
||||
transport=transport,
|
||||
)
|
||||
elif transport == "streamable-http" or transport == "sse":
|
||||
mcp_server.run(
|
||||
transport=transport,
|
||||
host=host,
|
||||
port=port,
|
||||
path=path,
|
||||
log_level="INFO",
|
||||
)
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
"""Command module for basic-memory project management."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands.command_utils import get_project_info, run_with_cleanup
|
||||
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
|
||||
from basic_memory.config import ConfigManager
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from rich.panel import Panel
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.tools.utils import call_get, call_post, call_delete, call_put, call_patch
|
||||
from basic_memory.mcp.tools.utils import call_delete, call_get, call_patch, call_post, call_put
|
||||
from basic_memory.schemas.project_info import ProjectList, ProjectStatusResponse
|
||||
from basic_memory.utils import generate_permalink, normalize_project_path
|
||||
|
||||
# Import rclone commands for project sync
|
||||
from basic_memory.cli.commands.cloud.rclone_commands import (
|
||||
SyncProject,
|
||||
RcloneError,
|
||||
project_sync,
|
||||
SyncProject,
|
||||
project_bisync,
|
||||
project_check,
|
||||
project_ls,
|
||||
project_sync,
|
||||
)
|
||||
from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
|
||||
|
||||
@@ -46,8 +46,22 @@ def format_path(path: str) -> str:
|
||||
|
||||
|
||||
@project_app.command("list")
|
||||
def list_projects() -> None:
|
||||
"""List all Basic Memory projects."""
|
||||
def list_projects(
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
) -> None:
|
||||
"""List all Basic Memory projects.
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
async def _list_projects():
|
||||
async with get_client() as client:
|
||||
@@ -55,19 +69,20 @@ def list_projects() -> None:
|
||||
return ProjectList.model_validate(response.json())
|
||||
|
||||
try:
|
||||
result = run_with_cleanup(_list_projects())
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(_list_projects())
|
||||
config = ConfigManager().config
|
||||
|
||||
table = Table(title="Basic Memory Projects")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Path", style="green")
|
||||
|
||||
# Add Local Path column if in cloud mode
|
||||
if config.cloud_mode_enabled:
|
||||
# Add Local Path column if in cloud mode and not forcing local
|
||||
if config.cloud_mode_enabled and not local:
|
||||
table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold")
|
||||
|
||||
# Show Default column in local mode or if default_project_mode is enabled in cloud mode
|
||||
show_default_column = not config.cloud_mode_enabled or config.default_project_mode
|
||||
show_default_column = local or not config.cloud_mode_enabled or config.default_project_mode
|
||||
if show_default_column:
|
||||
table.add_column("Default", style="magenta")
|
||||
|
||||
@@ -78,8 +93,8 @@ def list_projects() -> None:
|
||||
# Build row based on mode
|
||||
row = [project.name, format_path(normalized_path)]
|
||||
|
||||
# Add local path if in cloud mode
|
||||
if config.cloud_mode_enabled:
|
||||
# Add local path if in cloud mode and not forcing local
|
||||
if config.cloud_mode_enabled and not local:
|
||||
local_path = ""
|
||||
if project.name in config.cloud_projects:
|
||||
local_path = config.cloud_projects[project.name].local_path or ""
|
||||
@@ -108,9 +123,16 @@ def add_project(
|
||||
None, "--local-path", help="Local sync path for cloud mode (optional)"
|
||||
),
|
||||
set_default: bool = typer.Option(False, "--default", help="Set as default project"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
) -> None:
|
||||
"""Add a new project.
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
|
||||
Cloud mode examples:\n
|
||||
bm project add research # No local sync\n
|
||||
bm project add research --local-path ~/docs # With local sync\n
|
||||
@@ -118,14 +140,23 @@ def add_project(
|
||||
Local mode example:\n
|
||||
bm project add research ~/Documents/research
|
||||
"""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
config = ConfigManager().config
|
||||
|
||||
# Determine effective mode: local flag forces local mode behavior
|
||||
effective_cloud_mode = config.cloud_mode_enabled and not local
|
||||
|
||||
# Resolve local sync path early (needed for both cloud and local mode)
|
||||
local_sync_path: str | None = None
|
||||
if local_path:
|
||||
local_sync_path = Path(os.path.abspath(os.path.expanduser(local_path))).as_posix()
|
||||
|
||||
if config.cloud_mode_enabled:
|
||||
if effective_cloud_mode:
|
||||
# Cloud mode: path auto-generated from name, local sync is optional
|
||||
|
||||
async def _add_project():
|
||||
@@ -154,11 +185,12 @@ def add_project(
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
result = run_with_cleanup(_add_project())
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(_add_project())
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
|
||||
# Save local sync path to config if in cloud mode
|
||||
if config.cloud_mode_enabled and local_sync_path:
|
||||
if effective_cloud_mode and local_sync_path:
|
||||
from basic_memory.config import CloudProjectConfig
|
||||
|
||||
# Create local directory if it doesn't exist
|
||||
@@ -243,8 +275,21 @@ def remove_project(
|
||||
delete_notes: bool = typer.Option(
|
||||
False, "--delete-notes", help="Delete project files from disk"
|
||||
),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
) -> None:
|
||||
"""Remove a project."""
|
||||
"""Remove a project.
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
async def _remove_project():
|
||||
async with get_client() as client:
|
||||
@@ -265,11 +310,11 @@ def remove_project(
|
||||
try:
|
||||
# Get config to check for local sync path and bisync state
|
||||
config = ConfigManager().config
|
||||
local_path = None
|
||||
local_path_config = None
|
||||
has_bisync_state = False
|
||||
|
||||
if config.cloud_mode_enabled and name in config.cloud_projects:
|
||||
local_path = config.cloud_projects[name].local_path
|
||||
if config.cloud_mode_enabled and not local and name in config.cloud_projects:
|
||||
local_path_config = config.cloud_projects[name].local_path
|
||||
|
||||
# Check for bisync state
|
||||
from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
|
||||
@@ -278,17 +323,18 @@ def remove_project(
|
||||
has_bisync_state = bisync_state_path.exists()
|
||||
|
||||
# Remove project from cloud/API
|
||||
result = run_with_cleanup(_remove_project())
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(_remove_project())
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
|
||||
# Clean up local sync directory if it exists and delete_notes is True
|
||||
if delete_notes and local_path:
|
||||
local_dir = Path(local_path)
|
||||
if delete_notes and local_path_config:
|
||||
local_dir = Path(local_path_config)
|
||||
if local_dir.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(local_dir)
|
||||
console.print(f"[green]Removed local sync directory: {local_path}[/green]")
|
||||
console.print(f"[green]Removed local sync directory: {local_path_config}[/green]")
|
||||
|
||||
# Clean up bisync state if it exists
|
||||
if has_bisync_state:
|
||||
@@ -301,14 +347,14 @@ def remove_project(
|
||||
console.print("[green]Removed bisync state[/green]")
|
||||
|
||||
# Clean up cloud_projects config entry
|
||||
if config.cloud_mode_enabled and name in config.cloud_projects:
|
||||
if config.cloud_mode_enabled and not local and name in config.cloud_projects:
|
||||
del config.cloud_projects[name]
|
||||
ConfigManager().save_config(config)
|
||||
|
||||
# Show informative message if files were not deleted
|
||||
if not delete_notes:
|
||||
if local_path:
|
||||
console.print(f"[yellow]Note: Local files remain at {local_path}[/yellow]")
|
||||
if local_path_config:
|
||||
console.print(f"[yellow]Note: Local files remain at {local_path_config}[/yellow]")
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error removing project: {str(e)}[/red]")
|
||||
@@ -318,15 +364,24 @@ def remove_project(
|
||||
@project_app.command("default")
|
||||
def set_default_project(
|
||||
name: str = typer.Argument(..., help="Name of the project to set as CLI default"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (required in cloud mode)"
|
||||
),
|
||||
) -> None:
|
||||
"""Set the default project when 'config.default_project_mode' is set.
|
||||
|
||||
Note: This command is only available in local mode.
|
||||
In cloud mode, use --local to modify the local configuration.
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
|
||||
if config.cloud_mode_enabled:
|
||||
console.print("[red]Error: 'default' command is not available in cloud mode[/red]")
|
||||
# Trigger: cloud mode enabled without --local flag
|
||||
# Why: default project is a local configuration concept
|
||||
# Outcome: require explicit --local flag to modify local config in cloud mode
|
||||
if config.cloud_mode_enabled and not local:
|
||||
console.print(
|
||||
"[red]Error: 'default' command requires --local flag in cloud mode[/red]\n"
|
||||
"[yellow]Hint: Use 'bm project default <name> --local' to set local default[/yellow]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
async def _set_default():
|
||||
@@ -346,7 +401,8 @@ def set_default_project(
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
result = run_with_cleanup(_set_default())
|
||||
with force_routing(local=local):
|
||||
result = run_with_cleanup(_set_default())
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error setting default project: {str(e)}[/red]")
|
||||
@@ -354,15 +410,25 @@ def set_default_project(
|
||||
|
||||
|
||||
@project_app.command("sync-config")
|
||||
def synchronize_projects() -> None:
|
||||
def synchronize_projects(
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (required in cloud mode)"
|
||||
),
|
||||
) -> None:
|
||||
"""Synchronize project config between configuration file and database.
|
||||
|
||||
Note: This command is only available in local mode.
|
||||
In cloud mode, use --local to sync local configuration.
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
|
||||
if config.cloud_mode_enabled:
|
||||
console.print("[red]Error: 'sync-config' command is not available in cloud mode[/red]")
|
||||
# Trigger: cloud mode enabled without --local flag
|
||||
# Why: sync-config syncs local config file with local database
|
||||
# Outcome: require explicit --local flag to clarify intent in cloud mode
|
||||
if config.cloud_mode_enabled and not local:
|
||||
console.print(
|
||||
"[red]Error: 'sync-config' command requires --local flag in cloud mode[/red]\n"
|
||||
"[yellow]Hint: Use 'bm project sync-config --local' to sync local config[/yellow]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
async def _sync_config():
|
||||
@@ -371,7 +437,8 @@ def synchronize_projects() -> None:
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
result = run_with_cleanup(_sync_config())
|
||||
with force_routing(local=local):
|
||||
result = run_with_cleanup(_sync_config())
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
except Exception as e: # pragma: no cover
|
||||
console.print(f"[red]Error synchronizing projects: {str(e)}[/red]")
|
||||
@@ -382,15 +449,24 @@ def synchronize_projects() -> None:
|
||||
def move_project(
|
||||
name: str = typer.Argument(..., help="Name of the project to move"),
|
||||
new_path: str = typer.Argument(..., help="New absolute path for the project"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (required in cloud mode)"
|
||||
),
|
||||
) -> None:
|
||||
"""Move a project to a new location.
|
||||
|
||||
Note: This command is only available in local mode.
|
||||
In cloud mode, use --local to modify local project paths.
|
||||
"""
|
||||
config = ConfigManager().config
|
||||
|
||||
if config.cloud_mode_enabled:
|
||||
console.print("[red]Error: 'move' command is not available in cloud mode[/red]")
|
||||
# Trigger: cloud mode enabled without --local flag
|
||||
# Why: moving a project is a local file system operation
|
||||
# Outcome: require explicit --local flag to clarify intent in cloud mode
|
||||
if config.cloud_mode_enabled and not local:
|
||||
console.print(
|
||||
"[red]Error: 'move' command requires --local flag in cloud mode[/red]\n"
|
||||
"[yellow]Hint: Use 'bm project move <name> <path> --local' to move local project[/yellow]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Resolve to absolute path
|
||||
@@ -406,7 +482,8 @@ def move_project(
|
||||
return ProjectStatusResponse.model_validate(response.json())
|
||||
|
||||
try:
|
||||
result = run_with_cleanup(_move_project())
|
||||
with force_routing(local=local):
|
||||
result = run_with_cleanup(_move_project())
|
||||
console.print(f"[green]{result.message}[/green]")
|
||||
|
||||
# Show important file movement reminder
|
||||
@@ -779,11 +856,26 @@ def ls_project_command(
|
||||
def display_project_info(
|
||||
name: str = typer.Argument(..., help="Name of the project"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
):
|
||||
"""Display detailed information and statistics about the current project."""
|
||||
"""Display detailed information and statistics about the current project.
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
# Get project info
|
||||
info = run_with_cleanup(get_project_info(name))
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
info = run_with_cleanup(get_project_info(name))
|
||||
|
||||
if json_output:
|
||||
# Convert to JSON and print
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""CLI routing utilities for --local/--cloud flag handling.
|
||||
|
||||
This module provides utilities for CLI commands to override the default routing
|
||||
behavior (determined by cloud_mode_enabled in config). This allows users to:
|
||||
|
||||
1. Use local MCP server even when cloud mode is enabled
|
||||
2. Force local routing for specific CLI commands with --local flag
|
||||
3. Force cloud routing with --cloud flag (requires authentication)
|
||||
|
||||
The routing is controlled via environment variables:
|
||||
- BASIC_MEMORY_FORCE_LOCAL: When "true", forces local ASGI transport
|
||||
- These are checked in basic_memory.mcp.async_client.get_client()
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator
|
||||
|
||||
|
||||
@contextmanager
|
||||
def force_routing(local: bool = False, cloud: bool = False) -> Generator[None, None, None]:
|
||||
"""Context manager to temporarily override routing mode.
|
||||
|
||||
Sets environment variables that are checked by get_client() to determine
|
||||
whether to use local ASGI transport or cloud proxy transport.
|
||||
|
||||
Args:
|
||||
local: If True, force local ASGI transport (ignores cloud_mode_enabled)
|
||||
cloud: If True, clear force_local to allow cloud routing
|
||||
|
||||
Usage:
|
||||
with force_routing(local=True):
|
||||
# All API calls will use local ASGI transport
|
||||
await some_api_call()
|
||||
|
||||
Raises:
|
||||
ValueError: If both local and cloud are True
|
||||
"""
|
||||
if local and cloud:
|
||||
raise ValueError("Cannot specify both --local and --cloud")
|
||||
|
||||
# Save original values
|
||||
original_force_local = os.environ.get("BASIC_MEMORY_FORCE_LOCAL")
|
||||
|
||||
try:
|
||||
if local:
|
||||
# Force local routing by setting the env var
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
|
||||
elif cloud:
|
||||
# Ensure force_local is NOT set, let cloud_mode_enabled take effect
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
# If neither is set, don't change anything (use default behavior)
|
||||
yield
|
||||
finally:
|
||||
# Restore original value
|
||||
if original_force_local is None:
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
else:
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = original_force_local
|
||||
|
||||
|
||||
def validate_routing_flags(local: bool, cloud: bool) -> None:
|
||||
"""Validate that --local and --cloud flags are not both specified.
|
||||
|
||||
Args:
|
||||
local: Value of --local flag
|
||||
cloud: Value of --cloud flag
|
||||
|
||||
Raises:
|
||||
ValueError: If both flags are True
|
||||
"""
|
||||
if local and cloud:
|
||||
raise ValueError("Cannot specify both --local and --cloud flags")
|
||||
@@ -11,6 +11,7 @@ from rich.panel import Panel
|
||||
from rich.tree import Tree
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.schemas import SyncReportResponse
|
||||
@@ -162,12 +163,25 @@ def status(
|
||||
typer.Option(help="The project name."),
|
||||
] = None,
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed file information"),
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
):
|
||||
"""Show sync status between files and database."""
|
||||
"""Show sync status between files and database.
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
||||
|
||||
try:
|
||||
run_with_cleanup(run_status(project, verbose)) # pragma: no cover
|
||||
validate_routing_flags(local, cloud)
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
run_with_cleanup(run_status(project, verbose)) # pragma: no cover
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(code=1)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking status: {e}")
|
||||
typer.echo(f"Error checking status: {e}", err=True)
|
||||
|
||||
@@ -9,6 +9,7 @@ from rich import print as rprint
|
||||
|
||||
from basic_memory.cli.app import app
|
||||
from basic_memory.cli.commands.command_utils import run_with_cleanup
|
||||
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
# Import prompts
|
||||
@@ -50,6 +51,10 @@ def write_note(
|
||||
tags: Annotated[
|
||||
Optional[List[str]], typer.Option(help="A list of tags to apply to the note")
|
||||
] = None,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
):
|
||||
"""Create or update a markdown note. Content can be provided as an argument or read from stdin.
|
||||
|
||||
@@ -57,6 +62,9 @@ def write_note(
|
||||
1. Using the --content parameter
|
||||
2. Piping content through stdin (if --content is not provided)
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
|
||||
Examples:
|
||||
|
||||
# Using content parameter
|
||||
@@ -77,8 +85,13 @@ def write_note(
|
||||
|
||||
# Reading from a file
|
||||
cat document.md | basic-memory tools write-note --title "Document" --folder "docs"
|
||||
|
||||
# Force local routing in cloud mode
|
||||
basic-memory tools write-note --title "My Note" --folder "notes" --content "..." --local
|
||||
"""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
# If content is not provided, read from stdin
|
||||
if content is None:
|
||||
# Check if we're getting data from a pipe or redirect
|
||||
@@ -109,8 +122,12 @@ def write_note(
|
||||
# use the project name, or the default from the config
|
||||
project_name = project_name or config_manager.default_project
|
||||
|
||||
note = run_with_cleanup(mcp_write_note.fn(title, content, folder, project_name, tags))
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
note = run_with_cleanup(mcp_write_note.fn(title, content, folder, project_name, tags))
|
||||
rprint(note)
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during write_note: {e}", err=True)
|
||||
@@ -129,24 +146,37 @@ def read_note(
|
||||
] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
):
|
||||
"""Read a markdown note from the knowledge base."""
|
||||
|
||||
# look for the project in the config
|
||||
config_manager = ConfigManager()
|
||||
project_name = None
|
||||
if project is not None:
|
||||
project_name, _ = config_manager.get_project(project)
|
||||
if not project_name:
|
||||
typer.echo(f"No project found named: {project}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# use the project name, or the default from the config
|
||||
project_name = project_name or config_manager.default_project
|
||||
"""Read a markdown note from the knowledge base.
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
try:
|
||||
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
# look for the project in the config
|
||||
config_manager = ConfigManager()
|
||||
project_name = None
|
||||
if project is not None:
|
||||
project_name, _ = config_manager.get_project(project)
|
||||
if not project_name:
|
||||
typer.echo(f"No project found named: {project}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# use the project name, or the default from the config
|
||||
project_name = project_name or config_manager.default_project
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
note = run_with_cleanup(mcp_read_note.fn(identifier, project_name, page, page_size))
|
||||
rprint(note)
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during read_note: {e}", err=True)
|
||||
@@ -166,38 +196,51 @@ def build_context(
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
max_related: int = 10,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
):
|
||||
"""Get context needed to continue a discussion."""
|
||||
|
||||
# look for the project in the config
|
||||
config_manager = ConfigManager()
|
||||
project_name = None
|
||||
if project is not None:
|
||||
project_name, _ = config_manager.get_project(project)
|
||||
if not project_name:
|
||||
typer.echo(f"No project found named: {project}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# use the project name, or the default from the config
|
||||
project_name = project_name or config_manager.default_project
|
||||
"""Get context needed to continue a discussion.
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
try:
|
||||
context = run_with_cleanup(
|
||||
mcp_build_context.fn(
|
||||
project=project_name,
|
||||
url=url,
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
max_related=max_related,
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
# look for the project in the config
|
||||
config_manager = ConfigManager()
|
||||
project_name = None
|
||||
if project is not None:
|
||||
project_name, _ = config_manager.get_project(project)
|
||||
if not project_name:
|
||||
typer.echo(f"No project found named: {project}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# use the project name, or the default from the config
|
||||
project_name = project_name or config_manager.default_project
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
context = run_with_cleanup(
|
||||
mcp_build_context.fn(
|
||||
project=project_name,
|
||||
url=url,
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
max_related=max_related,
|
||||
)
|
||||
)
|
||||
)
|
||||
# Use json module for more controlled serialization
|
||||
import json
|
||||
|
||||
context_dict = context.model_dump(exclude_none=True)
|
||||
print(json.dumps(context_dict, indent=2, ensure_ascii=True, default=str))
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during build_context: {e}", err=True)
|
||||
@@ -210,18 +253,32 @@ def recent_activity(
|
||||
type: Annotated[Optional[List[SearchItemType]], typer.Option()] = None,
|
||||
depth: Optional[int] = 1,
|
||||
timeframe: Optional[TimeFrame] = "7d",
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
):
|
||||
"""Get recent activity across the knowledge base."""
|
||||
"""Get recent activity across the knowledge base.
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
try:
|
||||
result = run_with_cleanup(
|
||||
mcp_recent_activity.fn(
|
||||
type=type, # pyright: ignore [reportArgumentType]
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
mcp_recent_activity.fn(
|
||||
type=type, # pyright: ignore [reportArgumentType]
|
||||
depth=depth,
|
||||
timeframe=timeframe,
|
||||
)
|
||||
)
|
||||
)
|
||||
# The tool now returns a formatted string directly
|
||||
print(result)
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
typer.echo(f"Error during recent_activity: {e}", err=True)
|
||||
@@ -246,26 +303,31 @@ def search_notes(
|
||||
] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
):
|
||||
"""Search across all content in the knowledge base."""
|
||||
|
||||
# look for the project in the config
|
||||
config_manager = ConfigManager()
|
||||
project_name = None
|
||||
if project is not None:
|
||||
project_name, _ = config_manager.get_project(project)
|
||||
if not project_name:
|
||||
typer.echo(f"No project found named: {project}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# use the project name, or the default from the config
|
||||
project_name = project_name or config_manager.default_project
|
||||
|
||||
if permalink and title: # pragma: no cover
|
||||
print("Cannot search both permalink and title")
|
||||
raise typer.Abort()
|
||||
"""Search across all content in the knowledge base.
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
try:
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
# look for the project in the config
|
||||
config_manager = ConfigManager()
|
||||
project_name = None
|
||||
if project is not None:
|
||||
project_name, _ = config_manager.get_project(project)
|
||||
if not project_name:
|
||||
typer.echo(f"No project found named: {project}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# use the project name, or the default from the config
|
||||
project_name = project_name or config_manager.default_project
|
||||
|
||||
if permalink and title: # pragma: no cover
|
||||
typer.echo(
|
||||
"Use either --permalink or --title, not both. Exiting.",
|
||||
@@ -279,21 +341,25 @@ def search_notes(
|
||||
search_type = ("title" if title else None,)
|
||||
search_type = "text" if search_type is None else search_type
|
||||
|
||||
results = run_with_cleanup(
|
||||
mcp_search.fn(
|
||||
query,
|
||||
project_name,
|
||||
search_type=search_type,
|
||||
page=page,
|
||||
after_date=after_date,
|
||||
page_size=page_size,
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
results = run_with_cleanup(
|
||||
mcp_search.fn(
|
||||
query,
|
||||
project_name,
|
||||
search_type=search_type,
|
||||
page=page,
|
||||
after_date=after_date,
|
||||
page_size=page_size,
|
||||
)
|
||||
)
|
||||
)
|
||||
# Use json module for more controlled serialization
|
||||
import json
|
||||
|
||||
results_dict = results.model_dump(exclude_none=True)
|
||||
print(json.dumps(results_dict, indent=2, ensure_ascii=True, default=str))
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
logger.exception("Error during search", e)
|
||||
@@ -308,12 +374,28 @@ def continue_conversation(
|
||||
timeframe: Annotated[
|
||||
Optional[str], typer.Option(help="How far back to look for activity")
|
||||
] = None,
|
||||
local: bool = typer.Option(
|
||||
False, "--local", help="Force local API routing (ignore cloud mode)"
|
||||
),
|
||||
cloud: bool = typer.Option(False, "--cloud", help="Force cloud API routing"),
|
||||
):
|
||||
"""Prompt to continue a previous conversation or work session."""
|
||||
"""Prompt to continue a previous conversation or work session.
|
||||
|
||||
Use --local to force local routing when cloud mode is enabled.
|
||||
Use --cloud to force cloud routing when cloud mode is disabled.
|
||||
"""
|
||||
try:
|
||||
# Prompt functions return formatted strings directly
|
||||
session = run_with_cleanup(mcp_continue_conversation.fn(topic=topic, timeframe=timeframe)) # type: ignore
|
||||
validate_routing_flags(local, cloud)
|
||||
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
# Prompt functions return formatted strings directly
|
||||
session = run_with_cleanup(
|
||||
mcp_continue_conversation.fn(topic=topic, timeframe=timeframe) # type: ignore[arg-type]
|
||||
)
|
||||
rprint(session)
|
||||
except ValueError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(1)
|
||||
except Exception as e: # pragma: no cover
|
||||
if not isinstance(e, typer.Exit):
|
||||
logger.exception("Error continuing conversation", e)
|
||||
|
||||
@@ -450,16 +450,16 @@ def sanitize_for_filename(text: str, replacement: str = "-") -> str:
|
||||
return text.strip(replacement)
|
||||
|
||||
|
||||
def sanitize_for_folder(folder: str) -> str:
|
||||
def sanitize_for_directory(directory: str) -> str:
|
||||
"""
|
||||
Sanitize folder path to be safe for use in file system paths.
|
||||
Sanitize directory path to be safe for use in file system paths.
|
||||
Removes leading/trailing whitespace, compresses multiple slashes,
|
||||
and removes special characters except for /, -, and _.
|
||||
"""
|
||||
if not folder:
|
||||
if not directory:
|
||||
return ""
|
||||
|
||||
sanitized = folder.strip()
|
||||
sanitized = directory.strip()
|
||||
|
||||
if sanitized.startswith("./"):
|
||||
sanitized = sanitized[2:]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager, AbstractAsyncContextManager
|
||||
from typing import AsyncIterator, Callable, Optional
|
||||
|
||||
@@ -8,6 +9,19 @@ from basic_memory.api.app import app as fastapi_app
|
||||
from basic_memory.config import ConfigManager
|
||||
|
||||
|
||||
def _force_local_mode() -> bool:
|
||||
"""Check if local mode is forced via environment variable.
|
||||
|
||||
This allows commands like `bm mcp` to force local routing even when
|
||||
cloud_mode_enabled is True in config. The local MCP server should
|
||||
always talk to the local API, not the cloud proxy.
|
||||
|
||||
Returns:
|
||||
True if BASIC_MEMORY_FORCE_LOCAL is set to a truthy value
|
||||
"""
|
||||
return os.environ.get("BASIC_MEMORY_FORCE_LOCAL", "").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
# Optional factory override for dependency injection
|
||||
_client_factory: Optional[Callable[[], AbstractAsyncContextManager[AsyncClient]]] = None
|
||||
|
||||
@@ -71,7 +85,17 @@ async def get_client() -> AsyncIterator[AsyncClient]:
|
||||
pool=30.0, # 30 seconds for connection pool
|
||||
)
|
||||
|
||||
if config.cloud_mode_enabled:
|
||||
# Trigger: BASIC_MEMORY_FORCE_LOCAL env var is set
|
||||
# Why: allows local MCP server and CLI commands to route locally
|
||||
# even when cloud_mode_enabled is True
|
||||
# Outcome: uses ASGI transport for in-process local API calls
|
||||
if _force_local_mode():
|
||||
logger.info("Force local mode enabled - using ASGI client for local Basic Memory API")
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
|
||||
) as client:
|
||||
yield client
|
||||
elif config.cloud_mode_enabled:
|
||||
# CLI cloud mode: inject auth when creating client
|
||||
from basic_memory.cli.auth import CLIAuth
|
||||
|
||||
@@ -126,7 +150,13 @@ def create_client() -> AsyncClient:
|
||||
pool=30.0, # 30 seconds for connection pool
|
||||
)
|
||||
|
||||
if config.cloud_mode_enabled:
|
||||
# Check force local first (for local MCP server and CLI --local flag)
|
||||
if _force_local_mode():
|
||||
logger.info("Force local mode enabled - using ASGI client for local Basic Memory API")
|
||||
return AsyncClient(
|
||||
transport=ASGITransport(app=fastapi_app), base_url="http://test", timeout=timeout
|
||||
)
|
||||
elif config.cloud_mode_enabled:
|
||||
# Use HTTP transport to proxy endpoint
|
||||
proxy_base_url = f"{config.cloud_host}/proxy"
|
||||
logger.info(f"Creating HTTP client for proxy at: {proxy_base_url}")
|
||||
|
||||
@@ -8,7 +8,12 @@ from typing import Any
|
||||
from httpx import AsyncClient
|
||||
|
||||
from basic_memory.mcp.tools.utils import call_get, call_post, call_put, call_patch, call_delete
|
||||
from basic_memory.schemas.response import EntityResponse, DeleteEntitiesResponse
|
||||
from basic_memory.schemas.response import (
|
||||
EntityResponse,
|
||||
DeleteEntitiesResponse,
|
||||
DirectoryMoveResult,
|
||||
DirectoryDeleteResult,
|
||||
)
|
||||
|
||||
|
||||
class KnowledgeClient:
|
||||
@@ -153,6 +158,50 @@ class KnowledgeClient:
|
||||
)
|
||||
return EntityResponse.model_validate(response.json())
|
||||
|
||||
async def move_directory(
|
||||
self, source_directory: str, destination_directory: str
|
||||
) -> DirectoryMoveResult:
|
||||
"""Move all entities in a directory to a new location.
|
||||
|
||||
Args:
|
||||
source_directory: Source directory path (relative to project root)
|
||||
destination_directory: Destination directory path (relative to project root)
|
||||
|
||||
Returns:
|
||||
DirectoryMoveResult with counts and details of moved files
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
response = await call_post(
|
||||
self.http_client,
|
||||
f"{self._base_path}/move-directory",
|
||||
json={
|
||||
"source_directory": source_directory,
|
||||
"destination_directory": destination_directory,
|
||||
},
|
||||
)
|
||||
return DirectoryMoveResult.model_validate(response.json())
|
||||
|
||||
async def delete_directory(self, directory: str) -> DirectoryDeleteResult:
|
||||
"""Delete all entities in a directory.
|
||||
|
||||
Args:
|
||||
directory: Directory path to delete (relative to project root)
|
||||
|
||||
Returns:
|
||||
DirectoryDeleteResult with counts and details of deleted files
|
||||
|
||||
Raises:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
response = await call_post(
|
||||
self.http_client,
|
||||
f"{self._base_path}/delete-directory",
|
||||
json={"directory": directory},
|
||||
)
|
||||
return DirectoryDeleteResult.model_validate(response.json())
|
||||
|
||||
# --- Resolution ---
|
||||
|
||||
async def resolve_entity(self, identifier: str) -> str:
|
||||
|
||||
@@ -13,7 +13,6 @@ from basic_memory.config import get_project_config
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.prompt import ContinueConversationRequest
|
||||
|
||||
|
||||
@@ -24,7 +23,7 @@ from basic_memory.schemas.prompt import ContinueConversationRequest
|
||||
async def continue_conversation(
|
||||
topic: Annotated[Optional[str], Field(description="Topic or keyword to search for")] = None,
|
||||
timeframe: Annotated[
|
||||
Optional[TimeFrame],
|
||||
Optional[str],
|
||||
Field(description="How far back to look for activity (e.g. '1d', '1 week')"),
|
||||
] = None,
|
||||
) -> str:
|
||||
|
||||
@@ -3,17 +3,14 @@
|
||||
These prompts help users see what has changed in their knowledge base recently.
|
||||
"""
|
||||
|
||||
from textwrap import dedent
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from basic_memory.mcp.prompts.utils import format_prompt_context, PromptContext, PromptContextItem
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.recent_activity import recent_activity
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import GraphContext, ProjectActivitySummary
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
@mcp.prompt(
|
||||
@@ -22,7 +19,7 @@ from basic_memory.schemas.search import SearchItemType
|
||||
)
|
||||
async def recent_activity_prompt(
|
||||
timeframe: Annotated[
|
||||
TimeFrame,
|
||||
str,
|
||||
Field(description="How far back to look for activity (e.g. '1d', '1 week')"),
|
||||
] = "7d",
|
||||
project: Annotated[
|
||||
@@ -47,142 +44,57 @@ async def recent_activity_prompt(
|
||||
"""
|
||||
logger.info(f"Getting recent activity, timeframe: {timeframe}, project: {project}")
|
||||
|
||||
recent = await recent_activity.fn(
|
||||
project=project, timeframe=timeframe, type=[SearchItemType.ENTITY]
|
||||
# Call the tool function - it returns a well-formatted string
|
||||
# Pass type as string values (not enum) to match the tool's expected input
|
||||
activity_summary = await recent_activity.fn(
|
||||
project=project, timeframe=timeframe, type="entity"
|
||||
)
|
||||
|
||||
# Extract primary results from the hierarchical structure
|
||||
primary_results = []
|
||||
related_results = []
|
||||
# Build the prompt response
|
||||
# The tool already returns formatted markdown, so we use it directly
|
||||
# and add prompt-specific guidance
|
||||
target = project if project else "all projects"
|
||||
|
||||
if isinstance(recent, ProjectActivitySummary):
|
||||
# Discovery mode - extract results from all projects
|
||||
for _, project_activity in recent.projects.items():
|
||||
if project_activity.activity.results:
|
||||
# Take up to 2 primary results per project
|
||||
for item in project_activity.activity.results[:2]:
|
||||
primary_results.append(item.primary_result)
|
||||
# Add up to 1 related result per primary item
|
||||
if item.related_results:
|
||||
related_results.extend(item.related_results[:1]) # pragma: no cover
|
||||
prompt_guidance = dedent(f"""
|
||||
# Recent Activity Context
|
||||
|
||||
# Limit total results for readability
|
||||
primary_results = primary_results[:8]
|
||||
related_results = related_results[:6]
|
||||
This is a memory retrieval session showing recent activity from {target}.
|
||||
|
||||
elif isinstance(recent, GraphContext):
|
||||
# Project-specific mode - use existing logic
|
||||
if recent.results:
|
||||
# Take up to 5 primary results
|
||||
for item in recent.results[:5]:
|
||||
primary_results.append(item.primary_result)
|
||||
# Add up to 2 related results per primary item
|
||||
if item.related_results:
|
||||
related_results.extend(item.related_results[:2]) # pragma: no cover
|
||||
{activity_summary}
|
||||
|
||||
# Set topic based on mode
|
||||
if project:
|
||||
topic = f"Recent Activity in {project} ({timeframe})"
|
||||
else:
|
||||
topic = f"Recent Activity Across All Projects ({timeframe})"
|
||||
---
|
||||
|
||||
prompt_context = format_prompt_context(
|
||||
PromptContext(
|
||||
topic=topic,
|
||||
timeframe=timeframe,
|
||||
results=[
|
||||
PromptContextItem(
|
||||
primary_results=primary_results,
|
||||
related_results=related_results[:10], # Limit total related results
|
||||
)
|
||||
],
|
||||
## Next Steps
|
||||
|
||||
Based on this activity, you can:
|
||||
|
||||
1. **Explore specific items** - Use `read_note("permalink")` to dive deeper into any item
|
||||
2. **Search for related content** - Use `search_notes("topic")` to find connected knowledge
|
||||
3. **Build context** - Use `build_context("memory://path")` to see relationships
|
||||
|
||||
## Capture Opportunity
|
||||
|
||||
If you notice patterns or insights from this activity, consider documenting them:
|
||||
|
||||
```python
|
||||
write_note(
|
||||
title="Activity Insights - {timeframe}",
|
||||
content='''
|
||||
# Activity Insights
|
||||
|
||||
## Patterns Observed
|
||||
- [trend] [Pattern you noticed in the activity]
|
||||
|
||||
## Key Developments
|
||||
- [insight] [Important development worth tracking]
|
||||
|
||||
## Relations
|
||||
- summarizes [[Recent Work]]
|
||||
''',
|
||||
folder="insights",
|
||||
project="{project or 'default'}"
|
||||
)
|
||||
)
|
||||
```
|
||||
""")
|
||||
|
||||
# Add mode-specific suggestions
|
||||
first_title = "Recent Topic"
|
||||
if primary_results and len(primary_results) > 0:
|
||||
first_title = primary_results[0].title
|
||||
|
||||
if project:
|
||||
# Project-specific suggestions
|
||||
capture_suggestions = f"""
|
||||
## Opportunity to Capture Activity Summary
|
||||
|
||||
Consider creating a summary note of recent activity in {project}:
|
||||
|
||||
```python
|
||||
await write_note(
|
||||
"{project}",
|
||||
title="Activity Summary {timeframe}",
|
||||
content='''
|
||||
# Activity Summary for {project} ({timeframe})
|
||||
|
||||
## Overview
|
||||
[Summary of key changes and developments in this project over this period]
|
||||
|
||||
## Key Updates
|
||||
[List main updates and their significance within this project]
|
||||
|
||||
## Observations
|
||||
- [trend] [Observation about patterns in recent activity]
|
||||
- [insight] [Connection between different activities]
|
||||
|
||||
## Relations
|
||||
- summarizes [[{first_title}]]
|
||||
- relates_to [[{project} Overview]]
|
||||
''',
|
||||
folder="summaries"
|
||||
)
|
||||
```
|
||||
|
||||
Summarizing periodic activity helps create high-level insights and connections within the project.
|
||||
"""
|
||||
else:
|
||||
# Discovery mode suggestions
|
||||
project_count = len(recent.projects) if isinstance(recent, ProjectActivitySummary) else 0
|
||||
most_active = (
|
||||
getattr(recent.summary, "most_active_project", "Unknown")
|
||||
if isinstance(recent, ProjectActivitySummary)
|
||||
else "Unknown"
|
||||
)
|
||||
|
||||
capture_suggestions = f"""
|
||||
## Cross-Project Activity Discovery
|
||||
|
||||
Found activity across {project_count} projects. Most active: **{most_active}**
|
||||
|
||||
Consider creating a cross-project summary:
|
||||
|
||||
```python
|
||||
await write_note(
|
||||
"{most_active if most_active != "Unknown" else "main"}",
|
||||
title="Cross-Project Activity Summary {timeframe}",
|
||||
content='''
|
||||
# Cross-Project Activity Summary ({timeframe})
|
||||
|
||||
## Overview
|
||||
Activity found across {project_count} projects, with {most_active} showing the most activity.
|
||||
|
||||
## Key Developments
|
||||
[Summarize important changes across all projects]
|
||||
|
||||
## Project Insights
|
||||
[Note patterns or connections between projects]
|
||||
|
||||
## Observations
|
||||
- [trend] [Cross-project patterns observed]
|
||||
- [insight] [Connections between different project activities]
|
||||
|
||||
## Relations
|
||||
- summarizes [[{first_title}]]
|
||||
- relates_to [[Project Portfolio Overview]]
|
||||
''',
|
||||
folder="summaries"
|
||||
)
|
||||
```
|
||||
|
||||
Cross-project summaries help identify broader trends and project interconnections.
|
||||
"""
|
||||
|
||||
return prompt_context + capture_suggestions
|
||||
return prompt_guidance
|
||||
|
||||
@@ -12,7 +12,6 @@ from basic_memory.config import get_project_config
|
||||
from basic_memory.mcp.async_client import get_client
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.tools.utils import call_post
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.prompt import SearchPromptRequest
|
||||
|
||||
|
||||
@@ -23,7 +22,7 @@ from basic_memory.schemas.prompt import SearchPromptRequest
|
||||
async def search_prompt(
|
||||
query: str,
|
||||
timeframe: Annotated[
|
||||
Optional[TimeFrame],
|
||||
Optional[str],
|
||||
Field(description="How far back to search (e.g. '1d', '1 week')"),
|
||||
] = None,
|
||||
) -> str:
|
||||
|
||||
@@ -8,7 +8,6 @@ from dataclasses import dataclass
|
||||
from textwrap import dedent
|
||||
from typing import List
|
||||
|
||||
from basic_memory.schemas.base import TimeFrame
|
||||
from basic_memory.schemas.memory import (
|
||||
normalize_memory_url,
|
||||
EntitySummary,
|
||||
@@ -25,7 +24,7 @@ class PromptContextItem:
|
||||
|
||||
@dataclass
|
||||
class PromptContext:
|
||||
timeframe: TimeFrame
|
||||
timeframe: str
|
||||
topic: str
|
||||
results: List[PromptContextItem]
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ async def canvas(
|
||||
nodes: List[Dict[str, Any]],
|
||||
edges: List[Dict[str, Any]],
|
||||
title: str,
|
||||
folder: str,
|
||||
directory: str,
|
||||
project: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
@@ -43,8 +43,8 @@ async def canvas(
|
||||
nodes: List of node objects following JSON Canvas 1.0 spec
|
||||
edges: List of edge objects following JSON Canvas 1.0 spec
|
||||
title: The title of the canvas (will be saved as title.canvas)
|
||||
folder: Folder path relative to project root where the canvas should be saved.
|
||||
Use forward slashes (/) as separators. Examples: "diagrams", "projects/2025", "visual/maps"
|
||||
directory: Directory path relative to project root where the canvas should be saved.
|
||||
Use forward slashes (/) as separators. Examples: "diagrams", "projects/2025", "visual/maps"
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
@@ -52,7 +52,7 @@ async def canvas(
|
||||
|
||||
Important Notes:
|
||||
- When referencing files, use the exact file path as shown in Obsidian
|
||||
Example: "folder/Document Name.md" (not permalink format)
|
||||
Example: "docs/Document Name.md" (not permalink format)
|
||||
- For file nodes, the "file" attribute must reference an existing file
|
||||
- Nodes require id, type, x, y, width, height properties
|
||||
- Edges require id, fromNode, toNode properties
|
||||
@@ -66,7 +66,7 @@ async def canvas(
|
||||
{
|
||||
"id": "node1",
|
||||
"type": "file", // Options: "file", "text", "link", "group"
|
||||
"file": "folder/Document.md",
|
||||
"file": "docs/Document.md",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 400,
|
||||
@@ -85,21 +85,21 @@ async def canvas(
|
||||
```
|
||||
|
||||
Examples:
|
||||
# Create canvas in project
|
||||
canvas("my-project", nodes=[...], edges=[...], title="My Canvas", folder="diagrams")
|
||||
# Create canvas in default/current project
|
||||
canvas(nodes=[...], edges=[...], title="My Canvas", directory="diagrams")
|
||||
|
||||
# Create canvas in work project
|
||||
canvas("work-project", nodes=[...], edges=[...], title="Process Flow", folder="visual/maps")
|
||||
# Create canvas with explicit project
|
||||
canvas(nodes=[...], edges=[...], title="Process Flow", directory="visual/maps", project="work-project")
|
||||
|
||||
Raises:
|
||||
ToolError: If project doesn't exist or folder path is invalid
|
||||
ToolError: If project doesn't exist or directory path is invalid
|
||||
"""
|
||||
async with get_client() as client:
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
# Ensure path has .canvas extension
|
||||
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
|
||||
file_path = f"{folder}/{file_title}"
|
||||
file_path = f"{directory}/{file_title}"
|
||||
|
||||
# Create canvas data structure
|
||||
canvas_data = {"nodes": nodes, "edges": edges}
|
||||
|
||||
@@ -147,43 +147,58 @@ delete_note("{project}", "correct-identifier-from-search")
|
||||
If the note should be deleted but the operation keeps failing, send a message to support@basicmemory.com."""
|
||||
|
||||
|
||||
@mcp.tool(description="Delete a note by title or permalink")
|
||||
@mcp.tool(description="Delete a note or directory by title, permalink, or path")
|
||||
async def delete_note(
|
||||
identifier: str, project: Optional[str] = None, context: Context | None = None
|
||||
identifier: str,
|
||||
is_directory: bool = False,
|
||||
project: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> bool | str:
|
||||
"""Delete a note from the knowledge base.
|
||||
"""Delete a note or directory from the knowledge base.
|
||||
|
||||
Permanently removes a note from the specified project. The note is identified
|
||||
by title or permalink. If the note doesn't exist, the operation returns False
|
||||
without error. If deletion fails due to other issues, helpful error messages are provided.
|
||||
Permanently removes a note or directory from the specified project. For single notes,
|
||||
they are identified by title or permalink. For directories, use is_directory=True and
|
||||
provide the directory path. If the note/directory doesn't exist, the operation returns
|
||||
False without error. If deletion fails, helpful error messages are provided.
|
||||
|
||||
Project Resolution:
|
||||
Server resolves projects in this order: Single Project Mode → project parameter → default project.
|
||||
If project unknown, use list_memory_projects() or recent_activity() first.
|
||||
|
||||
Args:
|
||||
identifier: For files: note title or permalink to delete.
|
||||
For directories: the directory path (e.g., "docs", "projects/2025").
|
||||
Can be a title like "Meeting Notes" or permalink like "notes/meeting-notes"
|
||||
is_directory: If True, deletes an entire directory and all its contents.
|
||||
When True, identifier should be a directory path
|
||||
(without file extensions). Defaults to False.
|
||||
project: Project name to delete from. Optional - server will resolve using hierarchy.
|
||||
If unknown, use list_memory_projects() to discover available projects.
|
||||
identifier: Note title or permalink to delete
|
||||
Can be a title like "Meeting Notes" or permalink like "notes/meeting-notes"
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
True if note was successfully deleted, False if note was not found.
|
||||
For directories, returns a formatted summary of deleted files.
|
||||
On errors, returns a formatted string with helpful troubleshooting guidance.
|
||||
|
||||
Examples:
|
||||
# Delete by title
|
||||
delete_note("my-project", "Meeting Notes: Project Planning")
|
||||
delete_note("Meeting Notes: Project Planning")
|
||||
|
||||
# Delete by permalink
|
||||
delete_note("work-docs", "notes/project-planning")
|
||||
delete_note("notes/project-planning")
|
||||
|
||||
# Delete with exact path
|
||||
delete_note("research", "experiments/ml-model-results")
|
||||
# Delete with explicit project
|
||||
delete_note("experiments/ml-model-results", project="research")
|
||||
|
||||
# Delete entire directory
|
||||
delete_note("docs", is_directory=True)
|
||||
|
||||
# Delete nested directory
|
||||
delete_note("projects/2024", is_directory=True)
|
||||
|
||||
# Common usage pattern
|
||||
if delete_note("my-project", "old-draft"):
|
||||
if delete_note("old-draft"):
|
||||
print("Note deleted successfully")
|
||||
else:
|
||||
print("Note not found or already deleted")
|
||||
@@ -193,7 +208,7 @@ async def delete_note(
|
||||
SecurityError: If identifier attempts path traversal
|
||||
|
||||
Warning:
|
||||
This operation is permanent and cannot be undone. The note file
|
||||
This operation is permanent and cannot be undone. The note/directory files
|
||||
will be removed from the filesystem and all references will be lost.
|
||||
|
||||
Note:
|
||||
@@ -202,6 +217,10 @@ async def delete_note(
|
||||
commands and alternative formats to try.
|
||||
"""
|
||||
async with get_client() as client:
|
||||
logger.debug(
|
||||
f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {project}"
|
||||
)
|
||||
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
# Import here to avoid circular import
|
||||
@@ -210,6 +229,67 @@ async def delete_note(
|
||||
# Use typed KnowledgeClient for API calls
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
|
||||
# Handle directory deletes
|
||||
if is_directory:
|
||||
try:
|
||||
result = await knowledge_client.delete_directory(identifier)
|
||||
|
||||
# Build success message for directory delete
|
||||
result_lines = [
|
||||
"# Directory Deleted Successfully",
|
||||
"",
|
||||
f"**Directory:** `{identifier}`",
|
||||
"",
|
||||
"## Summary",
|
||||
f"- Total files: {result.total_files}",
|
||||
f"- Successfully deleted: {result.successful_deletes}",
|
||||
f"- Failed: {result.failed_deletes}",
|
||||
]
|
||||
|
||||
if result.deleted_files:
|
||||
result_lines.extend(["", "## Deleted Files"])
|
||||
for file_path in result.deleted_files[:10]: # Show first 10
|
||||
result_lines.append(f"- `{file_path}`")
|
||||
if len(result.deleted_files) > 10:
|
||||
result_lines.append(f"- ... and {len(result.deleted_files) - 10} more")
|
||||
|
||||
if result.errors: # pragma: no cover
|
||||
result_lines.extend(["", "## Errors"])
|
||||
for error in result.errors[:5]: # Show first 5 errors
|
||||
result_lines.append(f"- `{error.path}`: {error.error}")
|
||||
if len(result.errors) > 5:
|
||||
result_lines.append(f"- ... and {len(result.errors) - 5} more errors")
|
||||
|
||||
result_lines.extend(["", f"<!-- Project: {active_project.name} -->"])
|
||||
|
||||
logger.info(
|
||||
f"Directory delete completed: {identifier}, "
|
||||
f"deleted={result.successful_deletes}, failed={result.failed_deletes}"
|
||||
)
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Directory delete failed for '{identifier}': {e}")
|
||||
return f"""# Directory Delete Failed
|
||||
|
||||
Error deleting directory '{identifier}': {str(e)}
|
||||
|
||||
## Troubleshooting:
|
||||
1. **Verify the directory exists**: Use `list_directory("{identifier}")` to check
|
||||
2. **Check for permission issues**: Ensure you have delete access to the project
|
||||
3. **Try individual deletes**: Delete files one at a time if bulk delete fails
|
||||
|
||||
## Alternative approach:
|
||||
```
|
||||
# List directory contents first
|
||||
list_directory("{identifier}")
|
||||
|
||||
# Then delete individual files
|
||||
delete_note("path/to/file.md")
|
||||
```"""
|
||||
|
||||
# Handle single note deletes
|
||||
try:
|
||||
# Resolve identifier to entity ID
|
||||
entity_id = await knowledge_client.resolve_entity(identifier)
|
||||
|
||||
@@ -342,34 +342,41 @@ delete_note("{identifier}")
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
description="Move a note to a new location, updating database and maintaining links.",
|
||||
description="Move a note or directory to a new location, updating database and maintaining links.",
|
||||
)
|
||||
async def move_note(
|
||||
identifier: str,
|
||||
destination_path: str,
|
||||
is_directory: bool = False,
|
||||
project: Optional[str] = None,
|
||||
context: Context | None = None,
|
||||
) -> str:
|
||||
"""Move a note to a new file location within the same project.
|
||||
"""Move a note or directory to a new location within the same project.
|
||||
|
||||
Moves a note from one location to another within the project, updating all
|
||||
database references and maintaining semantic content. Uses stateless architecture -
|
||||
project parameter optional with server resolution.
|
||||
Moves a note or directory from one location to another within the project,
|
||||
updating all database references and maintaining semantic content. Uses stateless
|
||||
architecture - project parameter optional with server resolution.
|
||||
|
||||
Args:
|
||||
identifier: Exact entity identifier (title, permalink, or memory:// URL).
|
||||
identifier: For files: exact entity identifier (title, permalink, or memory:// URL).
|
||||
For directories: the directory path (e.g., "docs", "projects/2025").
|
||||
Must be an exact match - fuzzy matching is not supported for move operations.
|
||||
Use search_notes() or read_note() first to find the correct identifier if uncertain.
|
||||
destination_path: New path relative to project root (e.g., "work/meetings/2025-05-26.md")
|
||||
Use search_notes() or list_directory() first to find the correct path if uncertain.
|
||||
destination_path: For files: new path relative to project root (e.g., "work/meetings/note.md")
|
||||
For directories: new directory path (e.g., "archive/docs")
|
||||
is_directory: If True, moves an entire directory and all its contents.
|
||||
When True, identifier and destination_path should be directory paths
|
||||
(without file extensions). Defaults to False.
|
||||
project: Project name to move within. Optional - server will resolve using hierarchy.
|
||||
If unknown, use list_memory_projects() to discover available projects.
|
||||
context: Optional FastMCP context for performance caching.
|
||||
|
||||
Returns:
|
||||
Success message with move details and project information.
|
||||
For directories, includes count of files moved and any errors.
|
||||
|
||||
Examples:
|
||||
# Move to new folder (exact title match)
|
||||
# Move a single note to new folder (exact title match)
|
||||
move_note("My Note", "work/notes/my-note.md")
|
||||
|
||||
# Move by exact permalink
|
||||
@@ -381,6 +388,12 @@ async def move_note(
|
||||
# Explicit project specification
|
||||
move_note("My Note", "work/notes/my-note.md", project="work-project")
|
||||
|
||||
# Move entire directory
|
||||
move_note("docs", "archive/docs", is_directory=True)
|
||||
|
||||
# Move nested directory
|
||||
move_note("projects/2024", "archive/projects/2024", is_directory=True)
|
||||
|
||||
# If uncertain about identifier, search first:
|
||||
# search_notes("my note") # Find available notes
|
||||
# move_note("docs/my-note-2025", "archive/my-note.md") # Use exact result
|
||||
@@ -400,7 +413,9 @@ async def move_note(
|
||||
- Maintains all observations and relations
|
||||
"""
|
||||
async with get_client() as client:
|
||||
logger.debug(f"Moving note: {identifier} to {destination_path} in project: {project}")
|
||||
logger.debug(
|
||||
f"Moving {'directory' if is_directory else 'note'}: {identifier} to {destination_path} in project: {project}"
|
||||
)
|
||||
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
@@ -426,7 +441,73 @@ The destination path '{destination_path}' is not allowed - paths must stay withi
|
||||
move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
|
||||
```"""
|
||||
|
||||
# Check for potential cross-project move attempts
|
||||
# Handle directory moves
|
||||
if is_directory:
|
||||
# Import here to avoid circular import
|
||||
from basic_memory.mcp.clients import KnowledgeClient
|
||||
|
||||
knowledge_client = KnowledgeClient(client, active_project.external_id)
|
||||
|
||||
try:
|
||||
result = await knowledge_client.move_directory(identifier, destination_path)
|
||||
|
||||
# Build success message for directory move
|
||||
result_lines = [
|
||||
"# Directory Moved Successfully",
|
||||
"",
|
||||
f"**Source:** `{identifier}`",
|
||||
f"**Destination:** `{destination_path}`",
|
||||
"",
|
||||
"## Summary",
|
||||
f"- Total files: {result.total_files}",
|
||||
f"- Successfully moved: {result.successful_moves}",
|
||||
f"- Failed: {result.failed_moves}",
|
||||
]
|
||||
|
||||
if result.moved_files:
|
||||
result_lines.extend(["", "## Moved Files"])
|
||||
for file_path in result.moved_files[:10]: # Show first 10
|
||||
result_lines.append(f"- `{file_path}`")
|
||||
if len(result.moved_files) > 10:
|
||||
result_lines.append(f"- ... and {len(result.moved_files) - 10} more")
|
||||
|
||||
if result.errors: # pragma: no cover
|
||||
result_lines.extend(["", "## Errors"])
|
||||
for error in result.errors[:5]: # Show first 5 errors
|
||||
result_lines.append(f"- `{error.path}`: {error.error}")
|
||||
if len(result.errors) > 5:
|
||||
result_lines.append(f"- ... and {len(result.errors) - 5} more errors")
|
||||
|
||||
result_lines.extend(["", f"<!-- Project: {active_project.name} -->"])
|
||||
|
||||
logger.info(
|
||||
f"Directory move completed: {identifier} -> {destination_path}, "
|
||||
f"moved={result.successful_moves}, failed={result.failed_moves}"
|
||||
)
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.error(f"Directory move failed for '{identifier}' to '{destination_path}': {e}")
|
||||
return f"""# Directory Move Failed
|
||||
|
||||
Error moving directory '{identifier}' to '{destination_path}': {str(e)}
|
||||
|
||||
## Troubleshooting:
|
||||
1. **Verify the directory exists**: Use `list_directory("{identifier}")` to check
|
||||
2. **Check for conflicts**: The destination may already contain files
|
||||
3. **Try individual moves**: Move files one at a time if bulk move fails
|
||||
|
||||
## Alternative approach:
|
||||
```
|
||||
# List directory contents first
|
||||
list_directory("{identifier}")
|
||||
|
||||
# Then move individual files
|
||||
move_note("path/to/file.md", "{destination_path}/file.md")
|
||||
```"""
|
||||
|
||||
# Check for potential cross-project move attempts (file moves only)
|
||||
cross_project_error = await _detect_cross_project_move_attempt(
|
||||
client, identifier, destination_path, active_project.name
|
||||
)
|
||||
|
||||
@@ -345,7 +345,7 @@ async def search_notes(
|
||||
search_query.permalink_match = query
|
||||
elif search_type == "permalink":
|
||||
search_query.permalink = query
|
||||
else:
|
||||
else: # pragma: no cover
|
||||
search_query.text = query # Default to text search
|
||||
|
||||
# Add optional filters if provided (empty lists are treated as no filter)
|
||||
|
||||
@@ -21,7 +21,7 @@ TagType = Union[List[str], str, None]
|
||||
async def write_note(
|
||||
title: str,
|
||||
content: str,
|
||||
folder: str,
|
||||
directory: str,
|
||||
project: Optional[str] = None,
|
||||
tags: list[str] | str | None = None,
|
||||
note_type: str = "note",
|
||||
@@ -57,9 +57,9 @@ async def write_note(
|
||||
Args:
|
||||
title: The title of the note
|
||||
content: Markdown content for the note, can include observations and relations
|
||||
folder: Folder path relative to project root where the file should be saved.
|
||||
Use forward slashes (/) as separators. Use "/" or "" to write to project root.
|
||||
Examples: "notes", "projects/2025", "research/ml", "/" (root)
|
||||
directory: Directory path relative to project root where the file should be saved.
|
||||
Use forward slashes (/) as separators. Use "/" or "" to write to project root.
|
||||
Examples: "notes", "projects/2025", "research/ml", "/" (root)
|
||||
project: Project name to write to. Optional - server will resolve using the
|
||||
hierarchy above. If unknown, use list_memory_projects() to discover
|
||||
available projects.
|
||||
@@ -88,7 +88,7 @@ async def write_note(
|
||||
write_note(
|
||||
project="my-research",
|
||||
title="Meeting Notes",
|
||||
folder="meetings",
|
||||
directory="meetings",
|
||||
content="# Weekly Standup\\n\\n- [decision] Use SQLite for storage #tech"
|
||||
)
|
||||
|
||||
@@ -96,45 +96,45 @@ async def write_note(
|
||||
write_note(
|
||||
project="work-project",
|
||||
title="API Design",
|
||||
folder="specs",
|
||||
directory="specs",
|
||||
content="# REST API Specification\\n\\n- implements [[Authentication]]",
|
||||
tags=["api", "design"],
|
||||
note_type="guide"
|
||||
)
|
||||
|
||||
# Update existing note (same title/folder)
|
||||
# Update existing note (same title/directory)
|
||||
write_note(
|
||||
project="my-research",
|
||||
title="Meeting Notes",
|
||||
folder="meetings",
|
||||
directory="meetings",
|
||||
content="# Weekly Standup\\n\\n- [decision] Use PostgreSQL instead #tech"
|
||||
)
|
||||
|
||||
Raises:
|
||||
HTTPError: If project doesn't exist or is inaccessible
|
||||
SecurityError: If folder path attempts path traversal
|
||||
SecurityError: If directory path attempts path traversal
|
||||
"""
|
||||
async with get_client() as client:
|
||||
logger.info(
|
||||
f"MCP tool call tool=write_note project={project} folder={folder}, title={title}, tags={tags}"
|
||||
f"MCP tool call tool=write_note project={project} directory={directory}, title={title}, tags={tags}"
|
||||
)
|
||||
|
||||
# Get and validate the project (supports optional project parameter)
|
||||
active_project = await get_active_project(client, project, context)
|
||||
|
||||
# Normalize "/" to empty string for root folder (must happen before validation)
|
||||
if folder == "/":
|
||||
folder = ""
|
||||
# Normalize "/" to empty string for root directory (must happen before validation)
|
||||
if directory == "/":
|
||||
directory = ""
|
||||
|
||||
# Validate folder path to prevent path traversal attacks
|
||||
# Validate directory path to prevent path traversal attacks
|
||||
project_path = active_project.home
|
||||
if folder and not validate_project_path(folder, project_path):
|
||||
if directory and not validate_project_path(directory, project_path):
|
||||
logger.warning(
|
||||
"Attempted path traversal attack blocked",
|
||||
folder=folder,
|
||||
directory=directory,
|
||||
project=active_project.name,
|
||||
)
|
||||
return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
|
||||
return f"# Error\n\nDirectory path '{directory}' is not allowed - paths must stay within project boundaries"
|
||||
|
||||
# Process tags using the helper function
|
||||
tag_list = parse_tags(tags)
|
||||
@@ -142,7 +142,7 @@ async def write_note(
|
||||
metadata = {"tags": tag_list} if tag_list else None
|
||||
entity = Entity(
|
||||
title=title,
|
||||
folder=folder,
|
||||
directory=directory,
|
||||
entity_type=note_type,
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import List, Optional, Sequence, Union, Any
|
||||
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -69,12 +69,21 @@ class EntityRepository(Repository[Entity]):
|
||||
return await self.find_one(query)
|
||||
|
||||
async def get_by_title(self, title: str) -> Sequence[Entity]:
|
||||
"""Get entity by title.
|
||||
"""Get entities by title, ordered by shortest path first.
|
||||
|
||||
When multiple entities share the same title (in different folders),
|
||||
returns them ordered by file_path length then alphabetically.
|
||||
This provides "shortest path" resolution for duplicate titles.
|
||||
|
||||
Args:
|
||||
title: Title of the entity to find
|
||||
"""
|
||||
query = self.select().where(Entity.title == title).options(*self.get_load_options())
|
||||
query = (
|
||||
self.select()
|
||||
.where(Entity.title == title)
|
||||
.order_by(func.length(Entity.file_path), Entity.file_path)
|
||||
.options(*self.get_load_options())
|
||||
)
|
||||
result = await self.execute_query(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from dateparser import parse
|
||||
from pydantic import BaseModel, BeforeValidator, Field, model_validator, computed_field
|
||||
|
||||
from basic_memory.config import ConfigManager
|
||||
from basic_memory.file_utils import sanitize_for_filename, sanitize_for_folder
|
||||
from basic_memory.file_utils import sanitize_for_filename, sanitize_for_directory
|
||||
from basic_memory.utils import generate_permalink
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ class Entity(BaseModel):
|
||||
|
||||
title: str
|
||||
content: Optional[str] = None
|
||||
folder: str
|
||||
directory: str
|
||||
entity_type: EntityType = "note"
|
||||
entity_metadata: Optional[Dict] = Field(default=None, description="Optional metadata")
|
||||
content_type: ContentType = Field(
|
||||
@@ -237,7 +237,7 @@ class Entity(BaseModel):
|
||||
)
|
||||
|
||||
def __init__(self, **data):
|
||||
data["folder"] = sanitize_for_folder(data.get("folder", ""))
|
||||
data["directory"] = sanitize_for_directory(data.get("directory", ""))
|
||||
super().__init__(**data)
|
||||
|
||||
@property
|
||||
@@ -272,10 +272,12 @@ class Entity(BaseModel):
|
||||
safe_title = self.safe_title
|
||||
if self.content_type == "text/markdown":
|
||||
return (
|
||||
os.path.join(self.folder, f"{safe_title}.md") if self.folder else f"{safe_title}.md"
|
||||
os.path.join(self.directory, f"{safe_title}.md")
|
||||
if self.directory
|
||||
else f"{safe_title}.md"
|
||||
)
|
||||
else:
|
||||
return os.path.join(self.folder, safe_title) if self.folder else safe_title
|
||||
return os.path.join(self.directory, safe_title) if self.directory else safe_title
|
||||
|
||||
@property
|
||||
def permalink(self) -> Optional[Permalink]:
|
||||
|
||||
@@ -110,3 +110,27 @@ class MoveEntityRequest(BaseModel):
|
||||
if not v.strip():
|
||||
raise ValueError("destination_path cannot be empty or whitespace only")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class MoveDirectoryRequest(BaseModel):
|
||||
"""Request schema for moving an entire directory to a new location.
|
||||
|
||||
This moves all entities within a directory to a new location while
|
||||
maintaining project consistency and updating database references.
|
||||
"""
|
||||
|
||||
source_directory: Annotated[str, MinLen(1), MaxLen(500)]
|
||||
destination_directory: Annotated[str, MinLen(1), MaxLen(500)]
|
||||
project: Optional[str] = None
|
||||
|
||||
@field_validator("source_directory", "destination_directory")
|
||||
@classmethod
|
||||
def validate_directory_path(cls, v):
|
||||
"""Ensure directory path is relative and valid."""
|
||||
if v.startswith("/"): # pragma: no cover
|
||||
raise ValueError("directory path must be relative, not absolute")
|
||||
if ".." in v: # pragma: no cover
|
||||
raise ValueError("directory path cannot contain '..' path components")
|
||||
if not v.strip(): # pragma: no cover
|
||||
raise ValueError("directory path cannot be empty or whitespace only")
|
||||
return v.strip()
|
||||
|
||||
@@ -284,3 +284,71 @@ class DeleteEntitiesResponse(SQLAlchemyModel):
|
||||
"""
|
||||
|
||||
deleted: bool
|
||||
|
||||
|
||||
class DirectoryMoveError(BaseModel):
|
||||
"""Error details for a failed file move within a directory move operation."""
|
||||
|
||||
path: str
|
||||
error: str
|
||||
|
||||
|
||||
class DirectoryDeleteError(BaseModel):
|
||||
"""Error details for a failed file delete within a directory delete operation."""
|
||||
|
||||
path: str
|
||||
error: str
|
||||
|
||||
|
||||
class DirectoryMoveResult(SQLAlchemyModel):
|
||||
"""Response schema for directory move operations.
|
||||
|
||||
Returns detailed results of moving all files within a directory,
|
||||
including counts and any errors encountered.
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"total_files": 5,
|
||||
"successful_moves": 5,
|
||||
"failed_moves": 0,
|
||||
"moved_files": [
|
||||
"docs/file1.md",
|
||||
"docs/file2.md",
|
||||
"docs/subdir/file3.md"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
"""
|
||||
|
||||
total_files: int
|
||||
successful_moves: int
|
||||
failed_moves: int
|
||||
moved_files: List[str] # List of file paths that were moved
|
||||
errors: List[DirectoryMoveError] # List of errors for failed moves
|
||||
|
||||
|
||||
class DirectoryDeleteResult(SQLAlchemyModel):
|
||||
"""Response schema for directory delete operations.
|
||||
|
||||
Returns detailed results of deleting all files within a directory,
|
||||
including counts and any errors encountered.
|
||||
|
||||
Example Response:
|
||||
{
|
||||
"total_files": 5,
|
||||
"successful_deletes": 5,
|
||||
"failed_deletes": 0,
|
||||
"deleted_files": [
|
||||
"docs/file1.md",
|
||||
"docs/file2.md",
|
||||
"docs/subdir/file3.md"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
"""
|
||||
|
||||
total_files: int
|
||||
successful_deletes: int
|
||||
failed_deletes: int
|
||||
deleted_files: List[str] # List of file paths that were deleted
|
||||
errors: List[DirectoryDeleteError] # List of errors for failed deletes
|
||||
|
||||
@@ -5,6 +5,8 @@ from basic_memory.schemas.v2.entity import (
|
||||
EntityResolveResponse,
|
||||
EntityResponseV2,
|
||||
MoveEntityRequestV2,
|
||||
MoveDirectoryRequestV2,
|
||||
DeleteDirectoryRequestV2,
|
||||
ProjectResolveRequest,
|
||||
ProjectResolveResponse,
|
||||
)
|
||||
@@ -19,6 +21,8 @@ __all__ = [
|
||||
"EntityResolveResponse",
|
||||
"EntityResponseV2",
|
||||
"MoveEntityRequestV2",
|
||||
"MoveDirectoryRequestV2",
|
||||
"DeleteDirectoryRequestV2",
|
||||
"ProjectResolveRequest",
|
||||
"ProjectResolveResponse",
|
||||
"CreateResourceRequest",
|
||||
|
||||
@@ -15,6 +15,9 @@ class EntityResolveRequest(BaseModel):
|
||||
- Permalinks (e.g., "specs/search")
|
||||
- Titles (e.g., "Search Specification")
|
||||
- File paths (e.g., "specs/search.md")
|
||||
|
||||
When source_path is provided, resolution prefers notes closer to the source
|
||||
(context-aware resolution for duplicate titles).
|
||||
"""
|
||||
|
||||
identifier: str = Field(
|
||||
@@ -23,6 +26,15 @@ class EntityResolveRequest(BaseModel):
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
source_path: Optional[str] = Field(
|
||||
None,
|
||||
description="Path of the source file containing the link (for context-aware resolution)",
|
||||
max_length=500,
|
||||
)
|
||||
strict: bool = Field(
|
||||
False,
|
||||
description="If True, only exact matches are allowed (no fuzzy search fallback)",
|
||||
)
|
||||
|
||||
|
||||
class EntityResolveResponse(BaseModel):
|
||||
@@ -56,6 +68,42 @@ class MoveEntityRequestV2(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class MoveDirectoryRequestV2(BaseModel):
|
||||
"""V2 request schema for moving an entire directory to a new location.
|
||||
|
||||
This moves all entities within a source directory to a destination directory
|
||||
while maintaining project consistency and updating database references.
|
||||
"""
|
||||
|
||||
source_directory: str = Field(
|
||||
...,
|
||||
description="Source directory path (relative to project root)",
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
destination_directory: str = Field(
|
||||
...,
|
||||
description="Destination directory path (relative to project root)",
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
|
||||
|
||||
class DeleteDirectoryRequestV2(BaseModel):
|
||||
"""V2 request schema for deleting all entities in a directory.
|
||||
|
||||
This deletes all entities within a directory, removing them from the
|
||||
database and file system.
|
||||
"""
|
||||
|
||||
directory: str = Field(
|
||||
...,
|
||||
description="Directory path to delete (relative to project root)",
|
||||
min_length=1,
|
||||
max_length=500,
|
||||
)
|
||||
|
||||
|
||||
class EntityResponseV2(BaseModel):
|
||||
"""V2 entity response with external_id as the primary API identifier.
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ from basic_memory.repository import ObservationRepository, RelationRepository
|
||||
from basic_memory.repository.entity_repository import EntityRepository
|
||||
from basic_memory.schemas import Entity as EntitySchema
|
||||
from basic_memory.schemas.base import Permalink
|
||||
from basic_memory.schemas.response import (
|
||||
DirectoryMoveResult,
|
||||
DirectoryMoveError,
|
||||
DirectoryDeleteResult,
|
||||
DirectoryDeleteError,
|
||||
)
|
||||
from basic_memory.services import BaseService, FileService
|
||||
from basic_memory.services.exceptions import EntityCreationError, EntityNotFoundError
|
||||
from basic_memory.services.link_resolver import LinkResolver
|
||||
@@ -191,7 +197,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
|
||||
if await self.file_service.exists(file_path):
|
||||
raise EntityCreationError(
|
||||
f"file for entity {schema.folder}/{schema.title} already exists: {file_path}"
|
||||
f"file for entity {schema.directory}/{schema.title} already exists: {file_path}"
|
||||
)
|
||||
|
||||
# Parse content frontmatter to check for user-specified permalink and entity_type
|
||||
@@ -862,3 +868,171 @@ class EntityService(BaseService[EntityModel]):
|
||||
|
||||
# Re-raise the original error with context
|
||||
raise ValueError(f"Move failed: {str(e)}") from e
|
||||
|
||||
async def move_directory(
|
||||
self,
|
||||
source_directory: str,
|
||||
destination_directory: str,
|
||||
project_config: ProjectConfig,
|
||||
app_config: BasicMemoryConfig,
|
||||
) -> DirectoryMoveResult:
|
||||
"""Move all entities in a directory to a new location.
|
||||
|
||||
This operation moves all files within a source directory to a destination
|
||||
directory, updating database records and search indexes. The operation
|
||||
tracks successes and failures individually to provide detailed feedback.
|
||||
|
||||
Args:
|
||||
source_directory: Source directory path relative to project root
|
||||
destination_directory: Destination directory path relative to project root
|
||||
project_config: Project configuration for file operations
|
||||
app_config: App configuration for permalink update settings
|
||||
|
||||
Returns:
|
||||
DirectoryMoveResult with counts and details of moved files
|
||||
|
||||
Raises:
|
||||
ValueError: If source directory is empty or destination conflicts exist
|
||||
"""
|
||||
|
||||
logger.info(f"Moving directory: {source_directory} -> {destination_directory}")
|
||||
|
||||
# Normalize directory paths (remove trailing slashes)
|
||||
source_directory = source_directory.strip("/")
|
||||
destination_directory = destination_directory.strip("/")
|
||||
|
||||
# Find all entities in the source directory
|
||||
entities = await self.repository.find_by_directory_prefix(source_directory)
|
||||
|
||||
if not entities:
|
||||
logger.warning(f"No entities found in directory: {source_directory}")
|
||||
return DirectoryMoveResult(
|
||||
total_files=0,
|
||||
successful_moves=0,
|
||||
failed_moves=0,
|
||||
moved_files=[],
|
||||
errors=[],
|
||||
)
|
||||
|
||||
# Track results
|
||||
moved_files: list[str] = []
|
||||
errors: list[DirectoryMoveError] = []
|
||||
successful_moves = 0
|
||||
failed_moves = 0
|
||||
|
||||
# Process each entity
|
||||
for entity in entities:
|
||||
try:
|
||||
# Calculate new path by replacing source prefix with destination
|
||||
old_path = entity.file_path
|
||||
# Replace only the first occurrence of the source directory prefix
|
||||
if old_path.startswith(f"{source_directory}/"):
|
||||
new_path = old_path.replace(f"{source_directory}/", f"{destination_directory}/", 1)
|
||||
else: # pragma: no cover
|
||||
# Entity is directly in the source directory (shouldn't happen with prefix match)
|
||||
new_path = f"{destination_directory}/{old_path}"
|
||||
|
||||
# Move the individual entity
|
||||
await self.move_entity(
|
||||
identifier=entity.file_path,
|
||||
destination_path=new_path,
|
||||
project_config=project_config,
|
||||
app_config=app_config,
|
||||
)
|
||||
|
||||
moved_files.append(new_path)
|
||||
successful_moves += 1
|
||||
logger.debug(f"Moved entity: {old_path} -> {new_path}")
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
failed_moves += 1
|
||||
errors.append(DirectoryMoveError(path=entity.file_path, error=str(e)))
|
||||
logger.error(f"Failed to move entity {entity.file_path}: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Directory move complete: {successful_moves} succeeded, {failed_moves} failed "
|
||||
f"(source={source_directory}, dest={destination_directory})"
|
||||
)
|
||||
|
||||
return DirectoryMoveResult(
|
||||
total_files=len(entities),
|
||||
successful_moves=successful_moves,
|
||||
failed_moves=failed_moves,
|
||||
moved_files=moved_files,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def delete_directory(
|
||||
self,
|
||||
directory: str,
|
||||
) -> DirectoryDeleteResult:
|
||||
"""Delete all entities in a directory.
|
||||
|
||||
This operation deletes all files within a directory, updating database
|
||||
records and search indexes. The operation tracks successes and failures
|
||||
individually to provide detailed feedback.
|
||||
|
||||
Args:
|
||||
directory: Directory path relative to project root
|
||||
|
||||
Returns:
|
||||
DirectoryDeleteResult with counts and details of deleted files
|
||||
"""
|
||||
logger.info(f"Deleting directory: {directory}")
|
||||
|
||||
# Normalize directory path (remove trailing slashes)
|
||||
directory = directory.strip("/")
|
||||
|
||||
# Find all entities in the directory
|
||||
entities = await self.repository.find_by_directory_prefix(directory)
|
||||
|
||||
if not entities:
|
||||
logger.warning(f"No entities found in directory: {directory}")
|
||||
return DirectoryDeleteResult(
|
||||
total_files=0,
|
||||
successful_deletes=0,
|
||||
failed_deletes=0,
|
||||
deleted_files=[],
|
||||
errors=[],
|
||||
)
|
||||
|
||||
# Track results
|
||||
deleted_files: list[str] = []
|
||||
errors: list[DirectoryDeleteError] = []
|
||||
successful_deletes = 0
|
||||
failed_deletes = 0
|
||||
|
||||
# Process each entity
|
||||
for entity in entities:
|
||||
try:
|
||||
file_path = entity.file_path
|
||||
|
||||
# Delete the entity (this handles file deletion and database cleanup)
|
||||
deleted = await self.delete_entity(entity.id)
|
||||
|
||||
if deleted:
|
||||
deleted_files.append(file_path)
|
||||
successful_deletes += 1
|
||||
logger.debug(f"Deleted entity: {file_path}")
|
||||
else: # pragma: no cover
|
||||
failed_deletes += 1
|
||||
errors.append(DirectoryDeleteError(path=file_path, error="Delete returned False"))
|
||||
logger.warning(f"Delete returned False for entity: {file_path}")
|
||||
|
||||
except Exception as e: # pragma: no cover
|
||||
failed_deletes += 1
|
||||
errors.append(DirectoryDeleteError(path=entity.file_path, error=str(e)))
|
||||
logger.error(f"Failed to delete entity {entity.file_path}: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Directory delete complete: {successful_deletes} succeeded, {failed_deletes} failed "
|
||||
f"(directory={directory})"
|
||||
)
|
||||
|
||||
return DirectoryDeleteResult(
|
||||
total_files=len(entities),
|
||||
successful_deletes=successful_deletes,
|
||||
failed_deletes=failed_deletes,
|
||||
deleted_files=deleted_files,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -28,7 +28,11 @@ class LinkResolver:
|
||||
self.search_service = search_service
|
||||
|
||||
async def resolve_link(
|
||||
self, link_text: str, use_search: bool = True, strict: bool = False
|
||||
self,
|
||||
link_text: str,
|
||||
use_search: bool = True,
|
||||
strict: bool = False,
|
||||
source_path: Optional[str] = None,
|
||||
) -> Optional[Entity]:
|
||||
"""Resolve a markdown link to a permalink.
|
||||
|
||||
@@ -36,12 +40,69 @@ class LinkResolver:
|
||||
link_text: The link text to resolve
|
||||
use_search: Whether to use search-based fuzzy matching as fallback
|
||||
strict: If True, only exact matches are allowed (no fuzzy search fallback)
|
||||
source_path: Optional path of the source file containing the link.
|
||||
Used to prefer notes closer to the source (context-aware resolution).
|
||||
"""
|
||||
logger.trace(f"Resolving link: {link_text}")
|
||||
logger.trace(f"Resolving link: {link_text} (source: {source_path})")
|
||||
|
||||
# Clean link text and extract any alias
|
||||
clean_text, alias = self._normalize_link_text(link_text)
|
||||
|
||||
# --- Path Resolution ---
|
||||
# Note: All paths in Basic Memory are stored as POSIX strings (forward slashes)
|
||||
# for cross-platform compatibility. See entity_repository.py which normalizes
|
||||
# paths using Path().as_posix(). This allows consistent path operations here.
|
||||
|
||||
# --- Relative Path Resolution ---
|
||||
# Trigger: source_path is provided AND link contains "/"
|
||||
# Why: Resolve paths like [[nested/deep-note]] relative to source folder first
|
||||
# Outcome: [[nested/deep-note]] from testing/link-test.md → testing/nested/deep-note.md
|
||||
if source_path and "/" in clean_text:
|
||||
source_folder = source_path.rsplit("/", 1)[0] if "/" in source_path else ""
|
||||
if source_folder:
|
||||
# Construct relative path from source folder
|
||||
relative_path = f"{source_folder}/{clean_text}"
|
||||
|
||||
# Try with .md extension
|
||||
if not relative_path.endswith(".md"):
|
||||
relative_path_md = f"{relative_path}.md"
|
||||
entity = await self.entity_repository.get_by_file_path(relative_path_md)
|
||||
if entity:
|
||||
return entity
|
||||
|
||||
# Try as-is (already has extension or is a permalink)
|
||||
entity = await self.entity_repository.get_by_file_path(relative_path)
|
||||
if entity:
|
||||
return entity
|
||||
|
||||
# When source_path is provided, use context-aware resolution:
|
||||
# Check both permalink and title matches, prefer closest to source.
|
||||
# Example: [[testing]] from folder/note.md prefers folder/testing.md
|
||||
# over a root testing.md with permalink "testing".
|
||||
if source_path:
|
||||
# Gather all potential matches
|
||||
candidates: list[Entity] = []
|
||||
|
||||
# Check permalink match
|
||||
permalink_entity = await self.entity_repository.get_by_permalink(clean_text)
|
||||
if permalink_entity:
|
||||
candidates.append(permalink_entity)
|
||||
|
||||
# Check title matches
|
||||
title_entities = await self.entity_repository.get_by_title(clean_text)
|
||||
for entity in title_entities:
|
||||
# Avoid duplicates (permalink match might also be in title matches)
|
||||
if entity.id not in [c.id for c in candidates]:
|
||||
candidates.append(entity)
|
||||
|
||||
if candidates:
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
else:
|
||||
# Multiple candidates - pick closest to source
|
||||
return self._find_closest_entity(candidates, source_path)
|
||||
|
||||
# Standard resolution (no source context): permalink first, then title
|
||||
# 1. Try exact permalink match first (most efficient)
|
||||
entity = await self.entity_repository.get_by_permalink(clean_text)
|
||||
if entity:
|
||||
@@ -51,7 +112,7 @@ class LinkResolver:
|
||||
# 2. Try exact title match
|
||||
found = await self.entity_repository.get_by_title(clean_text)
|
||||
if found:
|
||||
# Return first match if there are duplicates (consistent behavior)
|
||||
# Return first match (shortest path) if no source context
|
||||
entity = found[0]
|
||||
logger.debug(f"Found title match: {entity.title}")
|
||||
return entity
|
||||
@@ -108,7 +169,7 @@ class LinkResolver:
|
||||
if text.startswith("[[") and text.endswith("]]"):
|
||||
text = text[2:-2]
|
||||
|
||||
# Handle Obsidian-style aliases (format: [[actual|alias]])
|
||||
# Handle wiki link aliases (format: [[actual|alias]])
|
||||
alias = None
|
||||
if "|" in text:
|
||||
text, alias = text.split("|", 1)
|
||||
@@ -119,3 +180,72 @@ class LinkResolver:
|
||||
text = text.strip()
|
||||
|
||||
return text, alias
|
||||
|
||||
def _find_closest_entity(self, entities: list[Entity], source_path: str) -> Entity:
|
||||
"""Find the entity closest to the source file path.
|
||||
|
||||
Context-aware resolution: prefer notes in the same folder or closer in hierarchy.
|
||||
|
||||
Proximity Scoring Algorithm:
|
||||
- Priority 0: Same folder as source (best match)
|
||||
- Priority 1-N: Ancestor folders (N = levels up from source)
|
||||
- Priority 100+N: Descendant folders (N = levels down, deprioritized)
|
||||
- Priority 1000: Completely unrelated paths (least preferred)
|
||||
- Ties are broken by shortest absolute path (consistent behavior)
|
||||
|
||||
Args:
|
||||
entities: List of entities with the same title
|
||||
source_path: Path of the file containing the link
|
||||
|
||||
Returns:
|
||||
The entity closest to the source path
|
||||
"""
|
||||
# Extract source folder (everything before the last /)
|
||||
source_folder = source_path.rsplit("/", 1)[0] if "/" in source_path else ""
|
||||
|
||||
def path_proximity(entity: Entity) -> Tuple[int, int]:
|
||||
"""Return (proximity_score, path_length) for sorting.
|
||||
|
||||
Lower is better for both values.
|
||||
"""
|
||||
entity_path = entity.file_path
|
||||
entity_folder = entity_path.rsplit("/", 1)[0] if "/" in entity_path else ""
|
||||
|
||||
# Trigger: entity is in the same folder as source
|
||||
# Why: same-folder notes are most contextually relevant
|
||||
# Outcome: priority = 0 (best), ties broken by shortest path
|
||||
if entity_folder == source_folder:
|
||||
return (0, len(entity_path))
|
||||
|
||||
# Trigger: entity is in an ancestor folder of source
|
||||
# e.g., source is "a/b/c/file.md", entity is "a/b/note.md" -> ancestor
|
||||
# Why: ancestors are contextually relevant (shared parent context)
|
||||
# Outcome: priority = levels_up (1, 2, 3...), closer ancestors preferred
|
||||
if source_folder.startswith(entity_folder + "/") if entity_folder else source_folder:
|
||||
# Count how many levels up
|
||||
if entity_folder:
|
||||
levels_up = source_folder.count("/") - entity_folder.count("/")
|
||||
else:
|
||||
# Root level
|
||||
levels_up = source_folder.count("/") + 1
|
||||
return (levels_up, len(entity_path))
|
||||
|
||||
# Trigger: entity is in a descendant folder of source
|
||||
# e.g., source is "a/file.md", entity is "a/b/c/note.md" -> descendant
|
||||
# Why: descendants are less contextually relevant than ancestors
|
||||
# Outcome: priority = 100 + levels_down, significantly deprioritized
|
||||
if entity_folder.startswith(source_folder + "/") if source_folder else entity_folder:
|
||||
if source_folder:
|
||||
levels_down = entity_folder.count("/") - source_folder.count("/")
|
||||
else:
|
||||
# Source is at root
|
||||
levels_down = entity_folder.count("/") + 1
|
||||
return (100 + levels_down, len(entity_path))
|
||||
|
||||
# Trigger: entity is in a completely unrelated path
|
||||
# Why: no folder relationship means minimal contextual relevance
|
||||
# Outcome: priority = 1000, only selected if no related paths exist
|
||||
return (1000, len(entity_path))
|
||||
|
||||
# Sort by proximity (lower is better), then by path length (shorter is better)
|
||||
return min(entities, key=path_proximity)
|
||||
|
||||
@@ -241,8 +241,13 @@ class ProjectService:
|
||||
|
||||
project_path = project.path
|
||||
|
||||
# Check if project is default (in cloud mode, check database; in local mode, check config)
|
||||
if project.is_default or name == self.config_manager.config.default_project:
|
||||
# Check if project is default
|
||||
# In cloud mode: database is source of truth
|
||||
# In local mode: also check config file
|
||||
is_default = project.is_default
|
||||
if not self.config_manager.config.cloud_mode:
|
||||
is_default = is_default or name == self.config_manager.config.default_project
|
||||
if is_default:
|
||||
raise ValueError(f"Cannot remove the default project '{name}'") # pragma: no cover
|
||||
|
||||
# Remove from config if it exists there (may not exist in cloud mode)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Integration tests for CLI routing flags (--local/--cloud).
|
||||
|
||||
These tests verify that the --local and --cloud flags work correctly
|
||||
across CLI commands, and that the MCP command forces local routing.
|
||||
|
||||
Note: Environment variable behavior during command execution is tested
|
||||
in unit tests (tests/cli/test_routing.py) which can properly monkeypatch
|
||||
the modules before they are imported. These integration tests focus on
|
||||
CLI behavior: flag acceptance and error handling.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from basic_memory.cli.main import app as cli_app
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class TestRoutingFlagsValidation:
|
||||
"""Tests for --local/--cloud flag validation.
|
||||
|
||||
These tests verify that using both --local and --cloud together
|
||||
produces an appropriate error message.
|
||||
"""
|
||||
|
||||
def test_status_both_flags_error(self):
|
||||
"""Using both --local and --cloud should produce an error."""
|
||||
result = runner.invoke(cli_app, ["status", "--local", "--cloud"])
|
||||
# Exit code can be 1 or 2 depending on how typer handles the exception
|
||||
assert result.exit_code != 0
|
||||
assert "Cannot specify both --local and --cloud" in result.output
|
||||
|
||||
def test_project_list_both_flags_error(self):
|
||||
"""Using both --local and --cloud should produce an error."""
|
||||
result = runner.invoke(cli_app, ["project", "list", "--local", "--cloud"])
|
||||
assert result.exit_code != 0
|
||||
assert "Cannot specify both --local and --cloud" in result.output
|
||||
|
||||
def test_tool_search_both_flags_error(self):
|
||||
"""Using both --local and --cloud should produce an error."""
|
||||
result = runner.invoke(cli_app, ["tool", "search-notes", "test", "--local", "--cloud"])
|
||||
assert result.exit_code != 0
|
||||
assert "Cannot specify both --local and --cloud" in result.output
|
||||
|
||||
def test_tool_read_note_both_flags_error(self):
|
||||
"""Using both --local and --cloud should produce an error."""
|
||||
result = runner.invoke(cli_app, ["tool", "read-note", "test", "--local", "--cloud"])
|
||||
assert result.exit_code != 0
|
||||
assert "Cannot specify both --local and --cloud" in result.output
|
||||
|
||||
def test_tool_build_context_both_flags_error(self):
|
||||
"""Using both --local and --cloud should produce an error."""
|
||||
result = runner.invoke(
|
||||
cli_app, ["tool", "build-context", "memory://test", "--local", "--cloud"]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Cannot specify both --local and --cloud" in result.output
|
||||
|
||||
|
||||
class TestMcpCommandForcesLocal:
|
||||
"""Tests that the MCP command forces local routing."""
|
||||
|
||||
def test_mcp_sets_force_local_env(self, monkeypatch):
|
||||
"""MCP command should set BASIC_MEMORY_FORCE_LOCAL before server starts."""
|
||||
# Track what environment variable was set
|
||||
env_set_value = []
|
||||
|
||||
# Mock the MCP server run to capture env state without actually starting server
|
||||
import basic_memory.cli.commands.mcp as mcp_mod
|
||||
|
||||
def mock_run(*args, **kwargs):
|
||||
env_set_value.append(os.environ.get("BASIC_MEMORY_FORCE_LOCAL"))
|
||||
# Don't actually start the server
|
||||
raise SystemExit(0)
|
||||
|
||||
# Get the actual mcp_server from the module
|
||||
monkeypatch.setattr(mcp_mod.mcp_server, "run", mock_run)
|
||||
|
||||
# Also mock init_mcp_logging to avoid file operations
|
||||
monkeypatch.setattr(mcp_mod, "init_mcp_logging", lambda: None)
|
||||
|
||||
runner.invoke(cli_app, ["mcp"])
|
||||
|
||||
# Environment variable should have been set to "true"
|
||||
assert len(env_set_value) == 1
|
||||
assert env_set_value[0] == "true"
|
||||
|
||||
|
||||
class TestToolCommandsAcceptFlags:
|
||||
"""Tests that tool commands accept routing flags without parsing errors."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command,args",
|
||||
[
|
||||
("search-notes", ["test query"]),
|
||||
("recent-activity", []),
|
||||
("read-note", ["test"]),
|
||||
("build-context", ["memory://test"]),
|
||||
("continue-conversation", []),
|
||||
],
|
||||
)
|
||||
def test_tool_commands_accept_local_flag(self, command, args, app_config):
|
||||
"""Tool commands should accept --local flag without parsing error."""
|
||||
full_args = ["tool", command] + args + ["--local"]
|
||||
result = runner.invoke(cli_app, full_args)
|
||||
# Should not fail due to flag parsing (No such option error)
|
||||
assert "No such option: --local" not in result.output
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command,args",
|
||||
[
|
||||
("search-notes", ["test query"]),
|
||||
("recent-activity", []),
|
||||
("read-note", ["test"]),
|
||||
("build-context", ["memory://test"]),
|
||||
("continue-conversation", []),
|
||||
],
|
||||
)
|
||||
def test_tool_commands_accept_cloud_flag(self, command, args, app_config):
|
||||
"""Tool commands should accept --cloud flag without parsing error."""
|
||||
full_args = ["tool", command] + args + ["--cloud"]
|
||||
result = runner.invoke(cli_app, full_args)
|
||||
# Should not fail due to flag parsing (No such option error)
|
||||
assert "No such option: --cloud" not in result.output
|
||||
|
||||
|
||||
class TestProjectCommandsAcceptFlags:
|
||||
"""Tests that project commands accept routing flags without parsing errors."""
|
||||
|
||||
def test_project_list_accepts_local_flag(self, app_config):
|
||||
"""project list should accept --local flag."""
|
||||
result = runner.invoke(cli_app, ["project", "list", "--local"])
|
||||
assert "No such option: --local" not in result.output
|
||||
|
||||
def test_project_list_accepts_cloud_flag(self, app_config):
|
||||
"""project list should accept --cloud flag."""
|
||||
result = runner.invoke(cli_app, ["project", "list", "--cloud"])
|
||||
assert "No such option: --cloud" not in result.output
|
||||
|
||||
def test_project_info_accepts_local_flag(self, app_config):
|
||||
"""project info should accept --local flag."""
|
||||
result = runner.invoke(cli_app, ["project", "info", "--local"])
|
||||
assert "No such option: --local" not in result.output
|
||||
|
||||
def test_project_info_accepts_cloud_flag(self, app_config):
|
||||
"""project info should accept --cloud flag."""
|
||||
result = runner.invoke(cli_app, ["project", "info", "--cloud"])
|
||||
assert "No such option: --cloud" not in result.output
|
||||
|
||||
def test_project_default_accepts_local_flag(self, app_config):
|
||||
"""project default should accept --local flag."""
|
||||
result = runner.invoke(cli_app, ["project", "default", "test", "--local"])
|
||||
assert "No such option: --local" not in result.output
|
||||
|
||||
def test_project_sync_config_accepts_local_flag(self, app_config):
|
||||
"""project sync-config should accept --local flag."""
|
||||
result = runner.invoke(cli_app, ["project", "sync-config", "test", "--local"])
|
||||
assert "No such option: --local" not in result.output
|
||||
|
||||
def test_project_move_accepts_local_flag(self, app_config):
|
||||
"""project move should accept --local flag."""
|
||||
result = runner.invoke(cli_app, ["project", "move", "test", "/tmp/dest", "--local"])
|
||||
assert "No such option: --local" not in result.output
|
||||
|
||||
|
||||
class TestStatusCommandAcceptsFlags:
|
||||
"""Tests that status command accepts routing flags."""
|
||||
|
||||
def test_status_accepts_local_flag(self, app_config):
|
||||
"""status should accept --local flag."""
|
||||
result = runner.invoke(cli_app, ["status", "--local"])
|
||||
assert "No such option: --local" not in result.output
|
||||
|
||||
def test_status_accepts_cloud_flag(self, app_config):
|
||||
"""status should accept --cloud flag."""
|
||||
result = runner.invoke(cli_app, ["status", "--cloud"])
|
||||
assert "No such option: --cloud" not in result.output
|
||||
@@ -15,7 +15,7 @@ async def test_build_context_underscore_normalization(mcp_server, app, test_proj
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Parent Entity",
|
||||
"folder": "testing",
|
||||
"directory": "testing",
|
||||
"content": "# Parent Entity\n\nMain entity for testing underscore relations.",
|
||||
"tags": "test,parent",
|
||||
},
|
||||
@@ -27,7 +27,7 @@ async def test_build_context_underscore_normalization(mcp_server, app, test_proj
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Child with Underscore",
|
||||
"folder": "testing",
|
||||
"directory": "testing",
|
||||
"content": """# Child with Underscore
|
||||
|
||||
- part_of [[Parent Entity]]
|
||||
@@ -42,7 +42,7 @@ async def test_build_context_underscore_normalization(mcp_server, app, test_proj
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Child with Hyphen",
|
||||
"folder": "testing",
|
||||
"directory": "testing",
|
||||
"content": """# Child with Hyphen
|
||||
|
||||
- part-of [[Parent Entity]]
|
||||
@@ -124,7 +124,7 @@ async def test_build_context_complex_underscore_paths(mcp_server, app, test_proj
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "workflow_manager_agent",
|
||||
"folder": "specs",
|
||||
"directory": "specs",
|
||||
"content": """# Workflow Manager Agent
|
||||
|
||||
Specification for the workflow manager agent.
|
||||
@@ -138,7 +138,7 @@ Specification for the workflow manager agent.
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "task_parser",
|
||||
"folder": "components",
|
||||
"directory": "components",
|
||||
"content": """# Task Parser
|
||||
|
||||
- part_of [[workflow_manager_agent]]
|
||||
|
||||
@@ -15,7 +15,7 @@ async def test_build_context_valid_urls(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "URL Validation Test",
|
||||
"folder": "testing",
|
||||
"directory": "testing",
|
||||
"content": "# URL Validation Test\n\nThis note tests URL validation.",
|
||||
"tags": "test,validation",
|
||||
},
|
||||
@@ -169,7 +169,7 @@ async def test_build_context_pattern_matching_works(mcp_server, app, test_projec
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": title,
|
||||
"folder": folder,
|
||||
"directory": folder,
|
||||
"content": content,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ async def test_chatgpt_search_basic(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Machine Learning Fundamentals",
|
||||
"folder": "ai",
|
||||
"directory": "ai",
|
||||
"content": (
|
||||
"# Machine Learning Fundamentals\n\nIntroduction to ML concepts and algorithms."
|
||||
),
|
||||
@@ -49,7 +49,7 @@ async def test_chatgpt_search_basic(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Deep Learning with PyTorch",
|
||||
"folder": "ai",
|
||||
"directory": "ai",
|
||||
"content": (
|
||||
"# Deep Learning with PyTorch\n\n"
|
||||
"Building neural networks using PyTorch framework."
|
||||
@@ -63,7 +63,7 @@ async def test_chatgpt_search_basic(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Data Visualization Guide",
|
||||
"folder": "data",
|
||||
"directory": "data",
|
||||
"content": (
|
||||
"# Data Visualization Guide\n\nCreating charts and graphs for data analysis."
|
||||
),
|
||||
@@ -127,7 +127,7 @@ async def test_chatgpt_search_with_boolean_operators(mcp_server, app, test_proje
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Python Web Frameworks",
|
||||
"folder": "dev",
|
||||
"directory": "dev",
|
||||
"content": (
|
||||
"# Python Web Frameworks\n\nComparing Django and Flask for web development."
|
||||
),
|
||||
@@ -140,7 +140,7 @@ async def test_chatgpt_search_with_boolean_operators(mcp_server, app, test_proje
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "JavaScript Frameworks",
|
||||
"folder": "dev",
|
||||
"directory": "dev",
|
||||
"content": "# JavaScript Frameworks\n\nReact, Vue, and Angular comparison.",
|
||||
"tags": "javascript,web,frameworks",
|
||||
},
|
||||
@@ -191,7 +191,7 @@ def my_decorator(func):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Advanced Python Techniques",
|
||||
"folder": "programming",
|
||||
"directory": "programming",
|
||||
"content": note_content,
|
||||
"tags": "python,advanced,programming",
|
||||
},
|
||||
@@ -231,7 +231,7 @@ async def test_chatgpt_fetch_by_permalink(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Test Document",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Test Document\n\nThis is test content for permalink fetching.",
|
||||
"tags": "test",
|
||||
},
|
||||
@@ -301,7 +301,7 @@ async def test_chatgpt_fetch_with_empty_title(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "untitled-note",
|
||||
"folder": "misc",
|
||||
"directory": "misc",
|
||||
"content": "This is content without a markdown header.\n\nJust plain text.",
|
||||
"tags": "misc",
|
||||
},
|
||||
@@ -337,7 +337,7 @@ async def test_chatgpt_search_pagination_default(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": f"Test Note {i}",
|
||||
"folder": "bulk",
|
||||
"directory": "bulk",
|
||||
"content": f"# Test Note {i}\n\nThis is test content number {i}.",
|
||||
"tags": "test,bulk",
|
||||
},
|
||||
@@ -419,7 +419,7 @@ async def test_chatgpt_integration_workflow(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": doc["title"],
|
||||
"folder": "architecture",
|
||||
"directory": "architecture",
|
||||
"content": doc["content"],
|
||||
"tags": doc["tags"],
|
||||
},
|
||||
|
||||
@@ -34,7 +34,7 @@ async def test_default_project_mode_enabled_write_note(mcp_server, app, test_pro
|
||||
"write_note",
|
||||
{
|
||||
"title": "Default Mode Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Default Mode Test\n\nThis should use the default project automatically.",
|
||||
"tags": "default,mode,test",
|
||||
},
|
||||
@@ -86,7 +86,7 @@ async def test_default_project_mode_explicit_override(
|
||||
"write_note",
|
||||
{
|
||||
"title": "Override Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Override Test\n\nThis should go to the explicitly specified project.",
|
||||
"project": other_project.name, # Explicit override
|
||||
},
|
||||
@@ -120,7 +120,7 @@ async def test_default_project_mode_disabled_requires_project(mcp_server, app, t
|
||||
"write_note",
|
||||
{
|
||||
"title": "Should Fail",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Should Fail\n\nThis should fail because no project specified.",
|
||||
},
|
||||
)
|
||||
@@ -173,7 +173,7 @@ async def test_cli_constraint_overrides_default_project_mode(
|
||||
"write_note",
|
||||
{
|
||||
"title": "CLI Constraint Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# CLI Constraint Test\n\nThis should use CLI constrained project.",
|
||||
},
|
||||
)
|
||||
@@ -210,7 +210,7 @@ async def test_default_project_mode_read_note(mcp_server, app, test_project):
|
||||
"write_note",
|
||||
{
|
||||
"title": "Read Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Read Test Note\n\nThis note will be read back.",
|
||||
},
|
||||
)
|
||||
@@ -249,7 +249,7 @@ async def test_default_project_mode_edit_note(mcp_server, app, test_project):
|
||||
"write_note",
|
||||
{
|
||||
"title": "Edit Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Edit Test Note\n\nOriginal content.",
|
||||
},
|
||||
)
|
||||
@@ -325,7 +325,7 @@ async def test_project_resolution_hierarchy(
|
||||
"write_note",
|
||||
{
|
||||
"title": "CLI Priority Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# CLI Priority Test",
|
||||
"project": explicit_project.name, # Should be ignored
|
||||
},
|
||||
@@ -343,7 +343,7 @@ async def test_project_resolution_hierarchy(
|
||||
"write_note",
|
||||
{
|
||||
"title": "Explicit Priority Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Explicit Priority Test",
|
||||
"project": explicit_project.name,
|
||||
},
|
||||
@@ -358,7 +358,7 @@ async def test_project_resolution_hierarchy(
|
||||
"write_note",
|
||||
{
|
||||
"title": "Default Priority Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Default Priority Test",
|
||||
# No project specified
|
||||
},
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
Integration tests for delete_note with is_directory=True.
|
||||
|
||||
Tests the complete directory delete workflow: MCP client -> MCP server -> FastAPI -> database -> file system
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_directory_basic(mcp_server, app, test_project):
|
||||
"""Test basic directory delete operation."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create multiple notes in a source directory
|
||||
for i in range(3):
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": f"Doc {i + 1}",
|
||||
"directory": "delete-dir",
|
||||
"content": f"# Doc {i + 1}\n\nContent for document {i + 1}.",
|
||||
"tags": "test,delete-dir",
|
||||
},
|
||||
)
|
||||
|
||||
# Verify notes exist
|
||||
for i in range(3):
|
||||
read_result = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": f"delete-dir/doc-{i + 1}",
|
||||
},
|
||||
)
|
||||
assert f"Content for document {i + 1}" in read_result.content[0].text
|
||||
|
||||
# Delete the entire directory
|
||||
delete_result = await client.call_tool(
|
||||
"delete_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "delete-dir",
|
||||
"is_directory": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Should return successful delete message with summary
|
||||
assert len(delete_result.content) == 1
|
||||
delete_text = delete_result.content[0].text
|
||||
assert "Directory Deleted Successfully" in delete_text
|
||||
assert "Total files: 3" in delete_text
|
||||
assert "delete-dir" in delete_text
|
||||
|
||||
# Verify all notes are deleted
|
||||
for i in range(3):
|
||||
read_result = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": f"delete-dir/doc-{i + 1}",
|
||||
},
|
||||
)
|
||||
assert "Note Not Found" in read_result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_directory_nested(mcp_server, app, test_project):
|
||||
"""Test deleting a directory with nested subdirectories."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create notes in nested structure
|
||||
directories = [
|
||||
"to-delete/2024",
|
||||
"to-delete/2024/q1",
|
||||
"to-delete/2024/q2",
|
||||
]
|
||||
|
||||
for dir_path in directories:
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": f"Note in {dir_path.split('/')[-1]}",
|
||||
"directory": dir_path,
|
||||
"content": f"# Note\n\nContent in {dir_path}.",
|
||||
"tags": "test,nested",
|
||||
},
|
||||
)
|
||||
|
||||
# Delete the parent directory
|
||||
delete_result = await client.call_tool(
|
||||
"delete_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "to-delete/2024",
|
||||
"is_directory": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Should delete all nested files
|
||||
assert len(delete_result.content) == 1
|
||||
delete_text = delete_result.content[0].text
|
||||
assert "Directory Deleted Successfully" in delete_text
|
||||
assert "Total files: 3" in delete_text
|
||||
|
||||
# Verify all nested notes are deleted
|
||||
read_result = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "to-delete/2024/q1/note-in-q1",
|
||||
},
|
||||
)
|
||||
assert "Note Not Found" in read_result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_directory_empty(mcp_server, app, test_project):
|
||||
"""Test deleting an empty/non-existent directory returns appropriate message."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Try to delete a non-existent/empty directory
|
||||
delete_result = await client.call_tool(
|
||||
"delete_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "nonexistent-dir",
|
||||
"is_directory": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Should return message about no files found
|
||||
assert len(delete_result.content) == 1
|
||||
delete_text = delete_result.content[0].text
|
||||
# Either shows "Directory Deleted Successfully" with 0 files or similar
|
||||
assert "Total files: 0" in delete_text or "0" in delete_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_directory_single_file(mcp_server, app, test_project):
|
||||
"""Test deleting a directory with only one file."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create single note in directory
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Single Note",
|
||||
"directory": "single-delete-dir",
|
||||
"content": "# Single Note\n\nOnly note in this directory.",
|
||||
"tags": "test,single",
|
||||
},
|
||||
)
|
||||
|
||||
# Delete directory
|
||||
delete_result = await client.call_tool(
|
||||
"delete_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "single-delete-dir",
|
||||
"is_directory": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert len(delete_result.content) == 1
|
||||
delete_text = delete_result.content[0].text
|
||||
assert "Directory Deleted Successfully" in delete_text
|
||||
assert "Total files: 1" in delete_text
|
||||
|
||||
# Verify note is deleted
|
||||
read_result = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "single-delete-dir/single-note",
|
||||
},
|
||||
)
|
||||
assert "Note Not Found" in read_result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_directory_search_no_longer_finds(mcp_server, app, test_project):
|
||||
"""Test that deleted directory contents are no longer searchable."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create searchable note
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Searchable Delete Doc",
|
||||
"directory": "searchable-delete-dir",
|
||||
"content": "# Searchable Delete Doc\n\nUnique fusionreactor quantum content.",
|
||||
"tags": "search,test",
|
||||
},
|
||||
)
|
||||
|
||||
# Verify searchable before delete
|
||||
search_before = await client.call_tool(
|
||||
"search_notes",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"query": "fusionreactor quantum",
|
||||
},
|
||||
)
|
||||
assert "Searchable Delete Doc" in search_before.content[0].text
|
||||
|
||||
# Delete directory
|
||||
await client.call_tool(
|
||||
"delete_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "searchable-delete-dir",
|
||||
"is_directory": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Verify no longer searchable after delete
|
||||
search_after = await client.call_tool(
|
||||
"search_notes",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"query": "fusionreactor quantum",
|
||||
},
|
||||
)
|
||||
search_text = search_after.content[0].text
|
||||
# Should not find the deleted note
|
||||
assert "Searchable Delete Doc" not in search_text or "No results" in search_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_directory_many_files(mcp_server, app, test_project):
|
||||
"""Test deleting a directory with more than 10 files shows truncated list."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create 12 notes in the directory
|
||||
for i in range(12):
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": f"DeleteManyDoc {i + 1}",
|
||||
"directory": "delete-many-dir",
|
||||
"content": f"# Delete Many Doc {i + 1}\n\nContent {i + 1}.",
|
||||
"tags": "test,many",
|
||||
},
|
||||
)
|
||||
|
||||
# Delete the entire directory
|
||||
delete_result = await client.call_tool(
|
||||
"delete_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "delete-many-dir",
|
||||
"is_directory": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert len(delete_result.content) == 1
|
||||
delete_text = delete_result.content[0].text
|
||||
assert "Directory Deleted Successfully" in delete_text
|
||||
assert "Total files: 12" in delete_text
|
||||
# Should show truncation message for >10 files
|
||||
assert "... and 2 more" in delete_text
|
||||
@@ -19,7 +19,7 @@ async def test_delete_note_by_title(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Note to Delete",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Note to Delete\n\nThis note will be deleted.",
|
||||
"tags": "test,delete",
|
||||
},
|
||||
@@ -77,7 +77,7 @@ async def test_delete_note_by_permalink(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Permalink Delete Test",
|
||||
"folder": "tests",
|
||||
"directory": "tests",
|
||||
"content": "# Permalink Delete Test\n\nTesting deletion by permalink.",
|
||||
"tags": "test,permalink",
|
||||
},
|
||||
@@ -140,7 +140,7 @@ The system handles multiple projects and users."""
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Project Management System",
|
||||
"folder": "projects",
|
||||
"directory": "projects",
|
||||
"content": complex_content,
|
||||
"tags": "project,management,system",
|
||||
},
|
||||
@@ -208,7 +208,7 @@ async def test_delete_note_special_characters_in_title(mcp_server, app, test_pro
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": title,
|
||||
"folder": "special",
|
||||
"directory": "special",
|
||||
"content": f"# {title}\n\nContent for {title}",
|
||||
"tags": "special,characters",
|
||||
},
|
||||
@@ -275,7 +275,7 @@ async def test_delete_note_by_file_path(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "File Path Delete",
|
||||
"folder": "docs",
|
||||
"directory": "docs",
|
||||
"content": "# File Path Delete\n\nTesting deletion by file path.",
|
||||
"tags": "test,filepath",
|
||||
},
|
||||
@@ -320,7 +320,7 @@ async def test_delete_note_case_insensitive(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "CamelCase Note Title",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# CamelCase Note Title\n\nTesting case sensitivity.",
|
||||
"tags": "test,case",
|
||||
},
|
||||
@@ -359,7 +359,7 @@ async def test_delete_multiple_notes_sequentially(mcp_server, app, test_project)
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": title,
|
||||
"folder": "batch",
|
||||
"directory": "batch",
|
||||
"content": f"# {title}\n\nContent for {title}",
|
||||
"tags": "batch,test",
|
||||
},
|
||||
@@ -421,7 +421,7 @@ This note contains various Unicode characters:
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Unicode Test Note",
|
||||
"folder": "unicode",
|
||||
"directory": "unicode",
|
||||
"content": unicode_content,
|
||||
"tags": "unicode,test,emoji",
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ async def test_edit_note_append_operation(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Append Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Append Test Note\n\nOriginal content here.",
|
||||
"tags": "test,append",
|
||||
},
|
||||
@@ -69,7 +69,7 @@ async def test_edit_note_prepend_operation(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Prepend Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Prepend Test Note\n\nExisting content.",
|
||||
"tags": "test,prepend",
|
||||
},
|
||||
@@ -122,7 +122,7 @@ async def test_edit_note_find_replace_operation(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Find Replace Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": """# Find Replace Test
|
||||
|
||||
This is version v1.0.0 of the system.
|
||||
@@ -182,7 +182,7 @@ async def test_edit_note_replace_section_operation(mcp_server, app, test_project
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Section Replace Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": """# Section Replace Test
|
||||
|
||||
## Overview
|
||||
@@ -268,7 +268,7 @@ Current endpoints include user management."""
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "API Documentation",
|
||||
"folder": "docs",
|
||||
"directory": "docs",
|
||||
"content": complex_content,
|
||||
"tags": "api,docs",
|
||||
},
|
||||
@@ -356,7 +356,7 @@ async def test_edit_note_error_handling_text_not_found(mcp_server, app, test_pro
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Error Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Error Test Note\n\nThis note has specific content.",
|
||||
"tags": "test,error",
|
||||
},
|
||||
@@ -394,7 +394,7 @@ async def test_edit_note_error_handling_wrong_replacement_count(mcp_server, app,
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Count Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": """# Count Test Note
|
||||
|
||||
The word "test" appears here.
|
||||
@@ -437,7 +437,7 @@ async def test_edit_note_invalid_operation(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Invalid Op Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Invalid Op Test\n\nSome content.",
|
||||
"tags": "test",
|
||||
},
|
||||
@@ -472,7 +472,7 @@ async def test_edit_note_missing_required_parameters(mcp_server, app, test_proje
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Param Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Param Test Note\n\nContent here.",
|
||||
"tags": "test",
|
||||
},
|
||||
@@ -507,7 +507,7 @@ async def test_edit_note_special_characters_in_content(mcp_server, app, test_pro
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Special Chars Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Special Chars Test\n\nBasic content here.",
|
||||
"tags": "test,unicode",
|
||||
},
|
||||
@@ -582,7 +582,7 @@ async def test_edit_note_using_different_identifiers(mcp_server, app, test_proje
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Identifier Test Note",
|
||||
"folder": "docs",
|
||||
"directory": "docs",
|
||||
"content": "# Identifier Test Note\n\nOriginal content.",
|
||||
"tags": "test,identifier",
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ async def test_list_directory_basic_operation(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Root Note",
|
||||
"folder": "", # Root folder
|
||||
"directory": "", # Root folder
|
||||
"content": "# Root Note\n\nThis is in the root directory.",
|
||||
"tags": "test,root",
|
||||
},
|
||||
@@ -30,7 +30,7 @@ async def test_list_directory_basic_operation(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Project Planning",
|
||||
"folder": "projects",
|
||||
"directory": "projects",
|
||||
"content": "# Project Planning\n\nPlanning document for projects.",
|
||||
"tags": "planning,project",
|
||||
},
|
||||
@@ -41,7 +41,7 @@ async def test_list_directory_basic_operation(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Meeting Notes",
|
||||
"folder": "meetings",
|
||||
"directory": "meetings",
|
||||
"content": "# Meeting Notes\n\nNotes from the meeting.",
|
||||
"tags": "meeting,notes",
|
||||
},
|
||||
@@ -83,7 +83,7 @@ async def test_list_directory_specific_folder(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Task List",
|
||||
"folder": "work",
|
||||
"directory": "work",
|
||||
"content": "# Task List\n\nWork tasks for today.",
|
||||
"tags": "work,tasks",
|
||||
},
|
||||
@@ -94,7 +94,7 @@ async def test_list_directory_specific_folder(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Project Alpha",
|
||||
"folder": "work/projects",
|
||||
"directory": "work/projects",
|
||||
"content": "# Project Alpha\n\nAlpha project documentation.",
|
||||
"tags": "project,alpha",
|
||||
},
|
||||
@@ -105,7 +105,7 @@ async def test_list_directory_specific_folder(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Daily Standup",
|
||||
"folder": "work/meetings",
|
||||
"directory": "work/meetings",
|
||||
"content": "# Daily Standup\n\nStandup meeting notes.",
|
||||
"tags": "meeting,standup",
|
||||
},
|
||||
@@ -143,7 +143,7 @@ async def test_list_directory_with_depth(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Deep Note",
|
||||
"folder": "research/ml/algorithms/neural-networks",
|
||||
"directory": "research/ml/algorithms/neural-networks",
|
||||
"content": "# Deep Note\n\nDeep learning research.",
|
||||
"tags": "research,ml,deep",
|
||||
},
|
||||
@@ -154,7 +154,7 @@ async def test_list_directory_with_depth(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "ML Overview",
|
||||
"folder": "research/ml",
|
||||
"directory": "research/ml",
|
||||
"content": "# ML Overview\n\nMachine learning overview.",
|
||||
"tags": "research,ml,overview",
|
||||
},
|
||||
@@ -165,7 +165,7 @@ async def test_list_directory_with_depth(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Research Index",
|
||||
"folder": "research",
|
||||
"directory": "research",
|
||||
"content": "# Research Index\n\nIndex of research topics.",
|
||||
"tags": "research,index",
|
||||
},
|
||||
@@ -203,7 +203,7 @@ async def test_list_directory_with_glob_pattern(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Meeting 2025-01-15",
|
||||
"folder": "meetings",
|
||||
"directory": "meetings",
|
||||
"content": "# Meeting 2025-01-15\n\nMonday meeting notes.",
|
||||
"tags": "meeting,january",
|
||||
},
|
||||
@@ -214,7 +214,7 @@ async def test_list_directory_with_glob_pattern(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Meeting 2025-01-22",
|
||||
"folder": "meetings",
|
||||
"directory": "meetings",
|
||||
"content": "# Meeting 2025-01-22\n\nMonday meeting notes.",
|
||||
"tags": "meeting,january",
|
||||
},
|
||||
@@ -225,7 +225,7 @@ async def test_list_directory_with_glob_pattern(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Project Status",
|
||||
"folder": "meetings",
|
||||
"directory": "meetings",
|
||||
"content": "# Project Status\n\nProject status update.",
|
||||
"tags": "meeting,project",
|
||||
},
|
||||
@@ -285,7 +285,7 @@ async def test_list_directory_glob_no_matches(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Document One",
|
||||
"folder": "docs",
|
||||
"directory": "docs",
|
||||
"content": "# Document One\n\nFirst document.",
|
||||
"tags": "doc",
|
||||
},
|
||||
@@ -320,7 +320,7 @@ async def test_list_directory_various_file_types(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Simple Note",
|
||||
"folder": "mixed",
|
||||
"directory": "mixed",
|
||||
"content": "# Simple Note\n\nA simple note.",
|
||||
"tags": "simple",
|
||||
},
|
||||
@@ -331,7 +331,7 @@ async def test_list_directory_various_file_types(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Complex Document with Long Title",
|
||||
"folder": "mixed",
|
||||
"directory": "mixed",
|
||||
"content": "# Complex Document with Long Title\n\nA more complex document.",
|
||||
"tags": "complex,long",
|
||||
},
|
||||
@@ -369,7 +369,7 @@ async def test_list_directory_default_parameters(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Default Test",
|
||||
"folder": "default-test",
|
||||
"directory": "default-test",
|
||||
"content": "# Default Test\n\nTesting default parameters.",
|
||||
"tags": "default",
|
||||
},
|
||||
@@ -401,7 +401,7 @@ async def test_list_directory_deep_recursion(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Level 5 Note",
|
||||
"folder": "level1/level2/level3/level4/level5",
|
||||
"directory": "level1/level2/level3/level4/level5",
|
||||
"content": "# Level 5 Note\n\nVery deep note.",
|
||||
"tags": "deep,level5",
|
||||
},
|
||||
@@ -412,7 +412,7 @@ async def test_list_directory_deep_recursion(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Level 3 Note",
|
||||
"folder": "level1/level2/level3",
|
||||
"directory": "level1/level2/level3",
|
||||
"content": "# Level 3 Note\n\nMid-level note.",
|
||||
"tags": "medium,level3",
|
||||
},
|
||||
@@ -449,7 +449,7 @@ async def test_list_directory_complex_glob_patterns(mcp_server, app, test_projec
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Project Alpha Plan",
|
||||
"folder": "patterns",
|
||||
"directory": "patterns",
|
||||
"content": "# Project Alpha Plan\n\nAlpha planning.",
|
||||
"tags": "project,alpha",
|
||||
},
|
||||
@@ -460,7 +460,7 @@ async def test_list_directory_complex_glob_patterns(mcp_server, app, test_projec
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Project Beta Plan",
|
||||
"folder": "patterns",
|
||||
"directory": "patterns",
|
||||
"content": "# Project Beta Plan\n\nBeta planning.",
|
||||
"tags": "project,beta",
|
||||
},
|
||||
@@ -471,7 +471,7 @@ async def test_list_directory_complex_glob_patterns(mcp_server, app, test_projec
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Meeting Minutes",
|
||||
"folder": "patterns",
|
||||
"directory": "patterns",
|
||||
"content": "# Meeting Minutes\n\nMeeting notes.",
|
||||
"tags": "meeting",
|
||||
},
|
||||
@@ -508,7 +508,7 @@ async def test_list_directory_dot_slash_prefix_paths(mcp_server, app, test_proje
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Artifact One",
|
||||
"folder": "artifacts",
|
||||
"directory": "artifacts",
|
||||
"content": "# Artifact One\n\nFirst artifact document.",
|
||||
"tags": "artifact,test",
|
||||
},
|
||||
@@ -519,7 +519,7 @@ async def test_list_directory_dot_slash_prefix_paths(mcp_server, app, test_proje
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Artifact Two",
|
||||
"folder": "artifacts",
|
||||
"directory": "artifacts",
|
||||
"content": "# Artifact Two\n\nSecond artifact document.",
|
||||
"tags": "artifact,test",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Integration tests for move_note with is_directory=True.
|
||||
|
||||
Tests the complete directory move workflow: MCP client -> MCP server -> FastAPI -> database -> file system
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_directory_basic(mcp_server, app, test_project):
|
||||
"""Test basic directory move operation."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create multiple notes in a source directory
|
||||
for i in range(3):
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": f"Doc {i + 1}",
|
||||
"directory": "source-dir",
|
||||
"content": f"# Doc {i + 1}\n\nContent for document {i + 1}.",
|
||||
"tags": "test,move-dir",
|
||||
},
|
||||
)
|
||||
|
||||
# Move the entire directory
|
||||
move_result = await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "source-dir",
|
||||
"destination_path": "dest-dir",
|
||||
"is_directory": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Should return successful move message with summary
|
||||
assert len(move_result.content) == 1
|
||||
move_text = move_result.content[0].text
|
||||
assert "Directory Moved Successfully" in move_text
|
||||
assert "Total files: 3" in move_text
|
||||
assert "source-dir" in move_text
|
||||
assert "dest-dir" in move_text
|
||||
|
||||
# Verify all notes can be read from new locations
|
||||
for i in range(3):
|
||||
read_result = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": f"dest-dir/doc-{i + 1}",
|
||||
},
|
||||
)
|
||||
content = read_result.content[0].text
|
||||
assert f"Content for document {i + 1}" in content
|
||||
|
||||
# Verify original locations no longer exist
|
||||
for i in range(3):
|
||||
read_original = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": f"source-dir/doc-{i + 1}",
|
||||
},
|
||||
)
|
||||
assert "Note Not Found" in read_original.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_directory_nested(mcp_server, app, test_project):
|
||||
"""Test moving a directory with nested subdirectories."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create notes in nested structure
|
||||
directories = [
|
||||
"projects/2024",
|
||||
"projects/2024/q1",
|
||||
"projects/2024/q2",
|
||||
]
|
||||
|
||||
for dir_path in directories:
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": f"Note in {dir_path.split('/')[-1]}",
|
||||
"directory": dir_path,
|
||||
"content": f"# Note\n\nContent in {dir_path}.",
|
||||
"tags": "test,nested",
|
||||
},
|
||||
)
|
||||
|
||||
# Move the parent directory
|
||||
move_result = await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "projects/2024",
|
||||
"destination_path": "archive/2024",
|
||||
"is_directory": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Should move all nested files
|
||||
assert len(move_result.content) == 1
|
||||
move_text = move_result.content[0].text
|
||||
assert "Directory Moved Successfully" in move_text
|
||||
assert "Total files: 3" in move_text
|
||||
|
||||
# Verify nested structure is preserved
|
||||
read_result = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "archive/2024/q1/note-in-q1",
|
||||
},
|
||||
)
|
||||
assert "Content in projects/2024/q1" in read_result.content[0].text
|
||||
|
||||
read_result2 = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "archive/2024/q2/note-in-q2",
|
||||
},
|
||||
)
|
||||
assert "Content in projects/2024/q2" in read_result2.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_directory_empty(mcp_server, app, test_project):
|
||||
"""Test moving an empty directory returns appropriate message."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Try to move a non-existent/empty directory
|
||||
move_result = await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "nonexistent-dir",
|
||||
"destination_path": "dest-dir",
|
||||
"is_directory": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Should return message about no files found
|
||||
assert len(move_result.content) == 1
|
||||
move_text = move_result.content[0].text
|
||||
assert "No files found" in move_text or "0" in move_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_directory_preserves_content(mcp_server, app, test_project):
|
||||
"""Test that directory move preserves all note content including observations and relations."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create note with complex content
|
||||
complex_content = """# Complex Note
|
||||
|
||||
## Observations
|
||||
- [feature] Important feature observation
|
||||
- [tech] Technical detail here
|
||||
|
||||
## Relations
|
||||
- relates_to [[Other Note]]
|
||||
- implements [[Specification]]
|
||||
|
||||
## Content
|
||||
Detailed content that must be preserved."""
|
||||
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Complex Note",
|
||||
"directory": "source-complex",
|
||||
"content": complex_content,
|
||||
"tags": "test,complex",
|
||||
},
|
||||
)
|
||||
|
||||
# Move the directory
|
||||
move_result = await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "source-complex",
|
||||
"destination_path": "dest-complex",
|
||||
"is_directory": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert "Directory Moved Successfully" in move_result.content[0].text
|
||||
|
||||
# Verify content preservation
|
||||
read_result = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "dest-complex/complex-note",
|
||||
},
|
||||
)
|
||||
|
||||
content = read_result.content[0].text
|
||||
assert "Important feature observation" in content
|
||||
assert "Technical detail here" in content
|
||||
assert "relates_to [[Other Note]]" in content
|
||||
assert "Detailed content that must be preserved" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_directory_search_still_works(mcp_server, app, test_project):
|
||||
"""Test that moved directory contents remain searchable."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create searchable note
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Searchable Doc",
|
||||
"directory": "searchable-dir",
|
||||
"content": "# Searchable Doc\n\nUnique quantum entanglement research content.",
|
||||
"tags": "search,test",
|
||||
},
|
||||
)
|
||||
|
||||
# Verify searchable before move
|
||||
search_before = await client.call_tool(
|
||||
"search_notes",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"query": "quantum entanglement",
|
||||
},
|
||||
)
|
||||
assert "Searchable Doc" in search_before.content[0].text
|
||||
|
||||
# Move directory
|
||||
await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "searchable-dir",
|
||||
"destination_path": "moved-searchable",
|
||||
"is_directory": True,
|
||||
},
|
||||
)
|
||||
|
||||
# Verify still searchable after move
|
||||
search_after = await client.call_tool(
|
||||
"search_notes",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"query": "quantum entanglement",
|
||||
},
|
||||
)
|
||||
search_text = search_after.content[0].text
|
||||
assert "quantum entanglement" in search_text
|
||||
assert "moved-searchable" in search_text or "searchable-doc" in search_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_directory_single_file(mcp_server, app, test_project):
|
||||
"""Test moving a directory with only one file."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create single note in directory
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Single Note",
|
||||
"directory": "single-dir",
|
||||
"content": "# Single Note\n\nOnly note in this directory.",
|
||||
"tags": "test,single",
|
||||
},
|
||||
)
|
||||
|
||||
# Move directory
|
||||
move_result = await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "single-dir",
|
||||
"destination_path": "moved-single",
|
||||
"is_directory": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert len(move_result.content) == 1
|
||||
move_text = move_result.content[0].text
|
||||
assert "Directory Moved Successfully" in move_text
|
||||
assert "Total files: 1" in move_text
|
||||
|
||||
# Verify note at new location
|
||||
read_result = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "moved-single/single-note",
|
||||
},
|
||||
)
|
||||
assert "Only note in this directory" in read_result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_directory_many_files(mcp_server, app, test_project):
|
||||
"""Test moving a directory with more than 10 files shows truncated list."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create 12 notes in the directory
|
||||
for i in range(12):
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": f"ManyDoc {i + 1}",
|
||||
"directory": "many-files-dir",
|
||||
"content": f"# Many Doc {i + 1}\n\nContent {i + 1}.",
|
||||
"tags": "test,many",
|
||||
},
|
||||
)
|
||||
|
||||
# Move the directory
|
||||
move_result = await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "many-files-dir",
|
||||
"destination_path": "moved-many",
|
||||
"is_directory": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert len(move_result.content) == 1
|
||||
move_text = move_result.content[0].text
|
||||
assert "Directory Moved Successfully" in move_text
|
||||
assert "Total files: 12" in move_text
|
||||
# Should show truncation message for >10 files
|
||||
assert "... and 2 more" in move_text
|
||||
@@ -19,7 +19,7 @@ async def test_move_note_basic_operation(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Move Test Note",
|
||||
"folder": "source",
|
||||
"directory": "source",
|
||||
"content": "# Move Test Note\n\nThis note will be moved to a new location.",
|
||||
"tags": "test,move",
|
||||
},
|
||||
@@ -79,7 +79,7 @@ async def test_move_note_using_permalink(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Permalink Move Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Permalink Move Test\n\nMoving by permalink.",
|
||||
"tags": "test,permalink",
|
||||
},
|
||||
@@ -142,7 +142,7 @@ This note demonstrates moving complex content."""
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Complex Note",
|
||||
"folder": "complex",
|
||||
"directory": "complex",
|
||||
"content": complex_content,
|
||||
"tags": "test,complex,move",
|
||||
},
|
||||
@@ -193,7 +193,7 @@ async def test_move_note_to_nested_directory(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Nested Move Test",
|
||||
"folder": "root",
|
||||
"directory": "root",
|
||||
"content": "# Nested Move Test\n\nThis will be moved deep.",
|
||||
"tags": "test,nested",
|
||||
},
|
||||
@@ -239,7 +239,7 @@ async def test_move_note_with_special_characters(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Special (Chars) & Symbols",
|
||||
"folder": "special",
|
||||
"directory": "special",
|
||||
"content": "# Special (Chars) & Symbols\n\nTesting special characters in move.",
|
||||
"tags": "test,special",
|
||||
},
|
||||
@@ -306,7 +306,7 @@ async def test_move_note_error_handling_invalid_destination(mcp_server, app, tes
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Invalid Dest Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Invalid Dest Test\n\nThis move should fail.",
|
||||
"tags": "test,error",
|
||||
},
|
||||
@@ -340,7 +340,7 @@ async def test_move_note_error_handling_destination_exists(mcp_server, app, test
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Source Note",
|
||||
"folder": "source",
|
||||
"directory": "source",
|
||||
"content": "# Source Note\n\nThis is the source.",
|
||||
"tags": "test,source",
|
||||
},
|
||||
@@ -352,7 +352,7 @@ async def test_move_note_error_handling_destination_exists(mcp_server, app, test
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Existing Note",
|
||||
"folder": "destination",
|
||||
"directory": "destination",
|
||||
"content": "# Existing Note\n\nThis already exists.",
|
||||
"tags": "test,existing",
|
||||
},
|
||||
@@ -386,7 +386,7 @@ async def test_move_note_preserves_search_functionality(mcp_server, app, test_pr
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Searchable Note",
|
||||
"folder": "original",
|
||||
"directory": "original",
|
||||
"content": """# Searchable Note
|
||||
|
||||
This note contains unique search terms:
|
||||
@@ -467,7 +467,7 @@ async def test_move_note_using_different_identifier_formats(mcp_server, app, tes
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Title ID Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Title ID Note\n\nMove by title.",
|
||||
"tags": "test,identifier",
|
||||
},
|
||||
@@ -478,7 +478,7 @@ async def test_move_note_using_different_identifier_formats(mcp_server, app, tes
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Permalink ID Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Permalink ID Note\n\nMove by permalink.",
|
||||
"tags": "test,identifier",
|
||||
},
|
||||
@@ -489,7 +489,7 @@ async def test_move_note_using_different_identifier_formats(mcp_server, app, tes
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Folder Title Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Folder Title Note\n\nMove by folder/title.",
|
||||
"tags": "test,identifier",
|
||||
},
|
||||
@@ -569,7 +569,7 @@ async def test_move_note_cross_project_detection(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Cross Project Test Note",
|
||||
"folder": "source",
|
||||
"directory": "source",
|
||||
"content": "# Cross Project Test Note\n\nThis note is in the default project.",
|
||||
"tags": "test,cross-project",
|
||||
},
|
||||
@@ -605,7 +605,7 @@ async def test_move_note_normal_moves_still_work(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Normal Move Note",
|
||||
"folder": "source",
|
||||
"directory": "source",
|
||||
"content": "# Normal Move Note\n\nThis should move normally.",
|
||||
"tags": "test,normal-move",
|
||||
},
|
||||
|
||||
@@ -276,7 +276,7 @@ async def test_project_lifecycle_workflow(mcp_server, app, test_project, tmp_pat
|
||||
{
|
||||
"project": project_name,
|
||||
"title": "Lifecycle Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Lifecycle Test\\n\\nThis note tests the project lifecycle.\\n\\n- [test] Lifecycle testing",
|
||||
"tags": "lifecycle,test",
|
||||
},
|
||||
@@ -390,7 +390,7 @@ async def test_case_insensitive_project_switching(mcp_server, app, test_project,
|
||||
{
|
||||
"project": test_input, # Use different case
|
||||
"title": f"Case Test {test_input}",
|
||||
"folder": "case-test",
|
||||
"directory": "case-test",
|
||||
"content": f"# Case Test\n\nTesting with {test_input}",
|
||||
},
|
||||
)
|
||||
@@ -427,7 +427,7 @@ async def test_case_insensitive_project_operations(mcp_server, app, test_project
|
||||
{
|
||||
"project": project_name,
|
||||
"title": "Case Test Note",
|
||||
"folder": "case-test",
|
||||
"directory": "case-test",
|
||||
"content": "# Case Test Note\n\nTesting case-insensitive operations.\n\n- [test] Case insensitive switch\n- relates_to [[Another Note]]",
|
||||
"tags": "case,test",
|
||||
},
|
||||
@@ -478,7 +478,7 @@ async def test_case_insensitive_error_handling(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_case,
|
||||
"title": "Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Test\n\nTest content.",
|
||||
},
|
||||
)
|
||||
@@ -524,7 +524,7 @@ async def test_case_preservation_in_project_list(mcp_server, app, test_project,
|
||||
{
|
||||
"project": project_name, # Use exact project name
|
||||
"title": f"Test Note {project_name}",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": f"# Test\n\nTesting {project_name}",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ async def test_project_state_sync_after_default_change(
|
||||
{
|
||||
"project": "minerva",
|
||||
"title": "Test Consistency Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Test Note\n\nThis note tests project state consistency.\n\n- [test] Project state sync working",
|
||||
"tags": "test,consistency",
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ async def test_read_content_markdown_file(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Content Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Content Test\n\nThis is test content with **markdown**.",
|
||||
"tags": "test,content",
|
||||
},
|
||||
@@ -72,7 +72,7 @@ async def test_read_content_by_permalink(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Permalink Test",
|
||||
"folder": "docs",
|
||||
"directory": "docs",
|
||||
"content": "# Permalink Test\n\nTesting permalink-based content reading.",
|
||||
},
|
||||
)
|
||||
@@ -105,7 +105,7 @@ async def test_read_content_memory_url(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Memory URL Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Memory URL Test\n\nTesting memory:// URL handling.",
|
||||
"tags": "memory,url",
|
||||
},
|
||||
@@ -143,7 +143,7 @@ async def test_read_content_unicode_file(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Unicode Content Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": unicode_content,
|
||||
"tags": "unicode,emoji",
|
||||
},
|
||||
@@ -204,7 +204,7 @@ Regular markdown content continues here."""
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Complex Note",
|
||||
"folder": "docs",
|
||||
"directory": "docs",
|
||||
"content": complex_content,
|
||||
"tags": "complex,frontmatter",
|
||||
},
|
||||
@@ -263,7 +263,7 @@ async def test_read_content_empty_file(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Empty Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "", # Empty content
|
||||
},
|
||||
)
|
||||
@@ -316,7 +316,7 @@ eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident.
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Large Content Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": large_content,
|
||||
"tags": "large,content,test",
|
||||
},
|
||||
@@ -362,7 +362,7 @@ async def test_read_content_special_characters_in_filename(mcp_server, app, test
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": title,
|
||||
"folder": folder,
|
||||
"directory": folder,
|
||||
"content": f"# {title}\n\nContent for {title}",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ async def test_read_note_after_write(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Test Note\n\nThis is test content.",
|
||||
"tags": "test,integration",
|
||||
},
|
||||
@@ -64,7 +64,7 @@ async def test_read_note_underscored_folder_by_permalink(mcp_server, app, test_p
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Example Note",
|
||||
"folder": "_archive/articles",
|
||||
"directory": "_archive/articles",
|
||||
"content": "# Example Note\n\nThis is a test note in an underscored folder.",
|
||||
"tags": "test,archive",
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ async def test_search_basic_text_search(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Python Programming Guide",
|
||||
"folder": "docs",
|
||||
"directory": "docs",
|
||||
"content": "# Python Programming Guide\n\nThis guide covers Python basics and advanced topics.",
|
||||
"tags": "python,programming",
|
||||
},
|
||||
@@ -31,7 +31,7 @@ async def test_search_basic_text_search(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Flask Web Development",
|
||||
"folder": "docs",
|
||||
"directory": "docs",
|
||||
"content": "# Flask Web Development\n\nBuilding web applications with Python Flask framework.",
|
||||
"tags": "python,flask,web",
|
||||
},
|
||||
@@ -42,7 +42,7 @@ async def test_search_basic_text_search(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "JavaScript Basics",
|
||||
"folder": "docs",
|
||||
"directory": "docs",
|
||||
"content": "# JavaScript Basics\n\nIntroduction to JavaScript programming language.",
|
||||
"tags": "javascript,programming",
|
||||
},
|
||||
@@ -78,7 +78,7 @@ async def test_search_boolean_operators(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Python Flask Tutorial",
|
||||
"folder": "tutorials",
|
||||
"directory": "tutorials",
|
||||
"content": "# Python Flask Tutorial\n\nLearn Python web development with Flask.",
|
||||
"tags": "python,flask,tutorial",
|
||||
},
|
||||
@@ -89,7 +89,7 @@ async def test_search_boolean_operators(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Python Django Guide",
|
||||
"folder": "tutorials",
|
||||
"directory": "tutorials",
|
||||
"content": "# Python Django Guide\n\nBuilding web apps with Python Django framework.",
|
||||
"tags": "python,django,web",
|
||||
},
|
||||
@@ -100,7 +100,7 @@ async def test_search_boolean_operators(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "React JavaScript",
|
||||
"folder": "tutorials",
|
||||
"directory": "tutorials",
|
||||
"content": "# React JavaScript\n\nBuilding frontend applications with React.",
|
||||
"tags": "javascript,react,frontend",
|
||||
},
|
||||
@@ -159,7 +159,7 @@ async def test_search_title_only(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Database Design",
|
||||
"folder": "docs",
|
||||
"directory": "docs",
|
||||
"content": "# Database Design\n\nThis covers SQL and database concepts.",
|
||||
"tags": "database,sql",
|
||||
},
|
||||
@@ -170,7 +170,7 @@ async def test_search_title_only(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Web Development",
|
||||
"folder": "docs",
|
||||
"directory": "docs",
|
||||
"content": "# Web Development\n\nDatabase integration in web applications.",
|
||||
"tags": "web,development",
|
||||
},
|
||||
@@ -202,7 +202,7 @@ async def test_search_permalink_exact(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "API Documentation",
|
||||
"folder": "api",
|
||||
"directory": "api",
|
||||
"content": "# API Documentation\n\nComplete API reference guide.",
|
||||
"tags": "api,docs",
|
||||
},
|
||||
@@ -213,7 +213,7 @@ async def test_search_permalink_exact(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "API Testing",
|
||||
"folder": "testing",
|
||||
"directory": "testing",
|
||||
"content": "# API Testing\n\nHow to test REST APIs.",
|
||||
"tags": "api,testing",
|
||||
},
|
||||
@@ -245,7 +245,7 @@ async def test_search_permalink_pattern(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Meeting Notes January",
|
||||
"folder": "meetings",
|
||||
"directory": "meetings",
|
||||
"content": "# Meeting Notes January\n\nJanuary team meeting notes.",
|
||||
"tags": "meetings,january",
|
||||
},
|
||||
@@ -256,7 +256,7 @@ async def test_search_permalink_pattern(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Meeting Notes February",
|
||||
"folder": "meetings",
|
||||
"directory": "meetings",
|
||||
"content": "# Meeting Notes February\n\nFebruary team meeting notes.",
|
||||
"tags": "meetings,february",
|
||||
},
|
||||
@@ -267,7 +267,7 @@ async def test_search_permalink_pattern(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Project Notes",
|
||||
"folder": "projects",
|
||||
"directory": "projects",
|
||||
"content": "# Project Notes\n\nGeneral project documentation.",
|
||||
"tags": "projects,notes",
|
||||
},
|
||||
@@ -314,7 +314,7 @@ Regular content about development practices."""
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Development Process",
|
||||
"folder": "processes",
|
||||
"directory": "processes",
|
||||
"content": content_with_observations,
|
||||
"tags": "development,process",
|
||||
},
|
||||
@@ -347,7 +347,7 @@ async def test_search_pagination(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": f"Test Note {i + 1:02d}",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": f"# Test Note {i + 1:02d}\n\nThis is test content for pagination testing.",
|
||||
"tags": "test,pagination",
|
||||
},
|
||||
@@ -395,7 +395,7 @@ async def test_search_no_results(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Sample Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Sample Note\n\nThis is a sample note for testing.",
|
||||
"tags": "sample,test",
|
||||
},
|
||||
@@ -425,7 +425,7 @@ async def test_search_complex_boolean_query(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Python Web Development",
|
||||
"folder": "tutorials",
|
||||
"directory": "tutorials",
|
||||
"content": "# Python Web Development\n\nLearn Python for web development using Flask and Django.",
|
||||
"tags": "python,web,development",
|
||||
},
|
||||
@@ -436,7 +436,7 @@ async def test_search_complex_boolean_query(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Python Data Science",
|
||||
"folder": "tutorials",
|
||||
"directory": "tutorials",
|
||||
"content": "# Python Data Science\n\nData analysis and machine learning with Python.",
|
||||
"tags": "python,data,science",
|
||||
},
|
||||
@@ -447,7 +447,7 @@ async def test_search_complex_boolean_query(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "JavaScript Web Development",
|
||||
"folder": "tutorials",
|
||||
"directory": "tutorials",
|
||||
"content": "# JavaScript Web Development\n\nBuilding web applications with JavaScript and React.",
|
||||
"tags": "javascript,web,development",
|
||||
},
|
||||
@@ -479,7 +479,7 @@ async def test_search_case_insensitive(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Machine Learning Guide",
|
||||
"folder": "guides",
|
||||
"directory": "guides",
|
||||
"content": "# Machine Learning Guide\n\nIntroduction to MACHINE LEARNING concepts.",
|
||||
"tags": "ML,AI",
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ async def test_project_constraint_override_content_tools(mcp_server, app, test_p
|
||||
{
|
||||
"project": "some-other-project", # Should be ignored
|
||||
"title": "Constraint Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Constraint Test\n\nThis should go to the constrained project.",
|
||||
"tags": "constraint,test",
|
||||
},
|
||||
@@ -62,7 +62,7 @@ async def test_project_constraint_read_note_override(mcp_server, app, test_proje
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Read Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Read Test\n\nContent for reading test.",
|
||||
},
|
||||
)
|
||||
@@ -103,7 +103,7 @@ async def test_project_constraint_search_notes_override(mcp_server, app, test_pr
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Searchable Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Searchable\n\nThis content has unique searchable terms.",
|
||||
},
|
||||
)
|
||||
@@ -221,7 +221,7 @@ async def test_normal_mode_without_constraint(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Normal Mode Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Normal Mode\n\nThis should work normally.",
|
||||
},
|
||||
)
|
||||
@@ -254,7 +254,7 @@ async def test_constraint_with_multiple_content_tools(mcp_server, app, test_proj
|
||||
{
|
||||
"project": "wrong-project",
|
||||
"title": "Multi Tool Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Multi Tool Test\n\n- [note] Testing multiple tools",
|
||||
},
|
||||
)
|
||||
@@ -324,7 +324,7 @@ async def test_constraint_with_invalid_project_override(mcp_server, app, test_pr
|
||||
{
|
||||
"project": invalid_project,
|
||||
"title": f"Test Invalid {i} {invalid_project[:5]}",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": f"Testing with invalid project: {invalid_project}",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ async def test_write_note_basic_creation(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Simple Note",
|
||||
"folder": "basic",
|
||||
"directory": "basic",
|
||||
"content": "# Simple Note\n\nThis is a simple note for testing.",
|
||||
"tags": "simple,test",
|
||||
},
|
||||
@@ -54,7 +54,7 @@ async def test_write_note_no_tags(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "No Tags Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "Just some plain text without tags.",
|
||||
},
|
||||
)
|
||||
@@ -80,7 +80,7 @@ async def test_write_note_update_existing(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Update Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Update Test\n\nOriginal content.",
|
||||
"tags": "original",
|
||||
},
|
||||
@@ -94,7 +94,7 @@ async def test_write_note_update_existing(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Update Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# Update Test\n\nUpdated content with changes.",
|
||||
"tags": "updated,modified",
|
||||
},
|
||||
@@ -123,7 +123,7 @@ async def test_write_note_tag_array(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Array Tags Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "Testing tag array handling",
|
||||
"tags": ["python", "testing", "integration", "mcp"],
|
||||
},
|
||||
@@ -164,7 +164,7 @@ async def test_write_note_custom_permalink(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Custom Permalink Note",
|
||||
"folder": "notes",
|
||||
"directory": "notes",
|
||||
"content": content_with_custom_permalink,
|
||||
},
|
||||
)
|
||||
@@ -192,7 +192,7 @@ async def test_write_note_unicode_content(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Unicode Test 🌟",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": unicode_content,
|
||||
"tags": "unicode,emoji,测试",
|
||||
},
|
||||
@@ -243,7 +243,7 @@ async def test_write_note_complex_content_with_observations_relations(
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Complex Knowledge Note",
|
||||
"folder": "knowledge",
|
||||
"directory": "knowledge",
|
||||
"content": complex_content,
|
||||
"tags": "complex,knowledge,relations",
|
||||
},
|
||||
@@ -296,7 +296,7 @@ async def test_write_note_preserve_frontmatter(mcp_server, app, test_project):
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Frontmatter Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": content_with_frontmatter,
|
||||
"tags": "frontmatter,preservation",
|
||||
},
|
||||
@@ -326,7 +326,7 @@ async def test_write_note_kebab_filenames_basic(mcp_server, app, test_project, a
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "My Note: With/Invalid|Chars?",
|
||||
"folder": "my-folder",
|
||||
"directory": "my-folder",
|
||||
"content": "Testing kebab-case and invalid characters.",
|
||||
"tags": "kebab,invalid,filename",
|
||||
},
|
||||
@@ -355,7 +355,7 @@ async def test_write_note_kebab_filenames_repeat_invalid(mcp_server, app, test_p
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": 'Crazy<>:"|?*Note/Name',
|
||||
"folder": "my-folder",
|
||||
"directory": "my-folder",
|
||||
"content": "Should be fully kebab-case and safe.",
|
||||
"tags": "crazy,filename,test",
|
||||
},
|
||||
@@ -404,7 +404,7 @@ async def test_write_note_file_path_os_path_join(mcp_server, app, test_project,
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": title,
|
||||
"folder": folder,
|
||||
"directory": folder,
|
||||
"content": "Testing os.path.join logic.",
|
||||
"tags": "integration,ospath",
|
||||
},
|
||||
@@ -462,7 +462,7 @@ async def test_write_note_project_path_validation(mcp_server, app, test_project)
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Validation Test",
|
||||
"folder": "documents",
|
||||
"directory": "documents",
|
||||
"content": "Testing path validation",
|
||||
"tags": "test",
|
||||
},
|
||||
|
||||
@@ -61,7 +61,7 @@ async def test_disable_permalinks_create_entity(tmp_path, engine_factory, app_co
|
||||
# Create entity via API
|
||||
entity_data = EntitySchema(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Test content",
|
||||
)
|
||||
|
||||
@@ -153,7 +153,7 @@ async def test_import_chatgpt(
|
||||
# Create a multipart form with the file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("conversations.json", f, "application/json")}
|
||||
data = {"folder": "test_chatgpt"}
|
||||
data = {"directory": "test_chatgpt"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(f"{project_url}/import/chatgpt", files=files, data=data)
|
||||
@@ -186,7 +186,7 @@ async def test_import_chatgpt_invalid_file(client: AsyncClient, tmp_path, projec
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"folder": "test_chatgpt"}
|
||||
data = {"directory": "test_chatgpt"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(f"{project_url}/import/chatgpt", files=files, data=data)
|
||||
@@ -207,7 +207,7 @@ async def test_import_claude_conversations(
|
||||
# Create a multipart form with the file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("conversations.json", f, "application/json")}
|
||||
data = {"folder": "test_claude_conversations"}
|
||||
data = {"directory": "test_claude_conversations"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(
|
||||
@@ -242,7 +242,7 @@ async def test_import_claude_conversations_invalid_file(client: AsyncClient, tmp
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"folder": "test_claude_conversations"}
|
||||
data = {"directory": "test_claude_conversations"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(
|
||||
@@ -265,7 +265,7 @@ async def test_import_claude_projects(
|
||||
# Create a multipart form with the file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("projects.json", f, "application/json")}
|
||||
data = {"folder": "test_claude_projects"}
|
||||
data = {"directory": "test_claude_projects"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(
|
||||
@@ -305,7 +305,7 @@ async def test_import_claude_projects_invalid_file(client: AsyncClient, tmp_path
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"folder": "test_claude_projects"}
|
||||
data = {"directory": "test_claude_projects"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(
|
||||
@@ -331,7 +331,7 @@ async def test_import_memory_json(
|
||||
# Create a multipart form with the file
|
||||
with open(json_file, "rb") as f:
|
||||
files = {"file": ("memory.json", f, "application/json")}
|
||||
data = {"folder": "test_memory_json"}
|
||||
data = {"directory": "test_memory_json"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(f"{project_url}/import/memory-json", files=files, data=data)
|
||||
@@ -409,7 +409,7 @@ async def test_import_memory_json_invalid_file(client: AsyncClient, tmp_path, pr
|
||||
async def test_import_missing_file(client: AsyncClient, tmp_path, project_url):
|
||||
"""Test importing with missing file."""
|
||||
# Send a request without a file
|
||||
response = await client.post(f"{project_url}/import/chatgpt", data={"folder": "test_folder"})
|
||||
response = await client.post(f"{project_url}/import/chatgpt", data={"directory": "test_folder"})
|
||||
|
||||
# Check that the request was rejected
|
||||
assert response.status_code in [400, 422] # Either bad request or unprocessable entity
|
||||
@@ -426,7 +426,7 @@ async def test_import_empty_file(client: AsyncClient, tmp_path, project_url):
|
||||
# Create multipart form with empty file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("empty.json", f, "application/json")}
|
||||
data = {"folder": "test_chatgpt"}
|
||||
data = {"directory": "test_chatgpt"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(f"{project_url}/import/chatgpt", files=files, data=data)
|
||||
@@ -446,8 +446,8 @@ async def test_import_malformed_json(client: AsyncClient, tmp_path, project_url)
|
||||
|
||||
# Test all import endpoints
|
||||
endpoints = [
|
||||
(f"{project_url}/import/chatgpt", {"folder": "test"}),
|
||||
(f"{project_url}/import/claude/conversations", {"folder": "test"}),
|
||||
(f"{project_url}/import/chatgpt", {"directory": "test"}),
|
||||
(f"{project_url}/import/claude/conversations", {"directory": "test"}),
|
||||
(f"{project_url}/import/claude/projects", {"base_folder": "test"}),
|
||||
(f"{project_url}/import/memory-json", {"destination_folder": "test"}),
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@ from basic_memory.schemas import (
|
||||
Entity,
|
||||
EntityResponse,
|
||||
)
|
||||
from basic_memory.schemas.response import DirectoryMoveResult
|
||||
from basic_memory.schemas.search import SearchItemType, SearchResponse
|
||||
from basic_memory.utils import normalize_newlines
|
||||
|
||||
@@ -19,7 +20,7 @@ async def test_create_entity(client: AsyncClient, file_service, project_url):
|
||||
|
||||
data = {
|
||||
"title": "TestEntity",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": "TestContent",
|
||||
"project": "Test Project Context",
|
||||
@@ -53,7 +54,7 @@ async def test_create_entity_observations_relations(client: AsyncClient, file_se
|
||||
|
||||
data = {
|
||||
"title": "TestEntity",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": """
|
||||
# TestContent
|
||||
|
||||
@@ -98,7 +99,7 @@ async def test_relation_resolution_after_creation(client: AsyncClient, project_u
|
||||
# Create first entity with unresolved relation
|
||||
entity1_data = {
|
||||
"title": "EntityOne",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": "This entity references [[EntityTwo]]",
|
||||
}
|
||||
@@ -116,7 +117,7 @@ async def test_relation_resolution_after_creation(client: AsyncClient, project_u
|
||||
# Create the referenced entity
|
||||
entity2_data = {
|
||||
"title": "EntityTwo",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": "This is the referenced entity",
|
||||
}
|
||||
@@ -141,7 +142,7 @@ async def test_relation_resolution_after_creation(client: AsyncClient, project_u
|
||||
async def test_get_entity_by_permalink(client: AsyncClient, project_url):
|
||||
"""Should retrieve an entity by path ID."""
|
||||
# First create an entity
|
||||
data = {"title": "TestEntity", "folder": "test", "entity_type": "test"}
|
||||
data = {"title": "TestEntity", "directory": "test", "entity_type": "test"}
|
||||
response = await client.post(f"{project_url}/knowledge/entities", json=data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -163,7 +164,7 @@ async def test_get_entity_by_permalink(client: AsyncClient, project_url):
|
||||
async def test_get_entity_by_file_path(client: AsyncClient, project_url):
|
||||
"""Should retrieve an entity by path ID."""
|
||||
# First create an entity
|
||||
data = {"title": "TestEntity", "folder": "test", "entity_type": "test"}
|
||||
data = {"title": "TestEntity", "directory": "test", "entity_type": "test"}
|
||||
response = await client.post(f"{project_url}/knowledge/entities", json=data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -187,11 +188,11 @@ async def test_get_entities(client: AsyncClient, project_url):
|
||||
# Create a few entities with different names
|
||||
await client.post(
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={"title": "AlphaTest", "folder": "", "entity_type": "test"},
|
||||
json={"title": "AlphaTest", "directory": "", "entity_type": "test"},
|
||||
)
|
||||
await client.post(
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={"title": "BetaTest", "folder": "", "entity_type": "test"},
|
||||
json={"title": "BetaTest", "directory": "", "entity_type": "test"},
|
||||
)
|
||||
|
||||
# Open nodes by path IDs
|
||||
@@ -241,7 +242,7 @@ async def test_delete_entity(client: AsyncClient, project_url):
|
||||
async def test_delete_single_entity(client: AsyncClient, project_url):
|
||||
"""Test DELETE /knowledge/entities with path ID."""
|
||||
# Create test entity
|
||||
entity_data = {"title": "TestEntity", "folder": "", "entity_type": "test"}
|
||||
entity_data = {"title": "TestEntity", "directory": "", "entity_type": "test"}
|
||||
await client.post(f"{project_url}/knowledge/entities", json=entity_data)
|
||||
|
||||
# Test deletion
|
||||
@@ -259,7 +260,7 @@ async def test_delete_single_entity(client: AsyncClient, project_url):
|
||||
async def test_delete_single_entity_by_title(client: AsyncClient, project_url):
|
||||
"""Test DELETE /knowledge/entities with file path."""
|
||||
# Create test entity
|
||||
entity_data = {"title": "TestEntity", "folder": "", "entity_type": "test"}
|
||||
entity_data = {"title": "TestEntity", "directory": "", "entity_type": "test"}
|
||||
response = await client.post(f"{project_url}/knowledge/entities", json=entity_data)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -328,7 +329,7 @@ async def test_entity_indexing(client: AsyncClient, project_url):
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "SearchTest",
|
||||
"folder": "",
|
||||
"directory": "",
|
||||
"entity_type": "test",
|
||||
"observations": ["Unique searchable observation"],
|
||||
},
|
||||
@@ -356,7 +357,7 @@ async def test_entity_delete_indexing(client: AsyncClient, project_url):
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "DeleteTest",
|
||||
"folder": "",
|
||||
"directory": "",
|
||||
"entity_type": "test",
|
||||
"observations": ["Searchable observation that should be removed"],
|
||||
},
|
||||
@@ -394,7 +395,7 @@ async def test_update_entity_basic(client: AsyncClient, project_url):
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "test",
|
||||
"folder": "",
|
||||
"directory": "",
|
||||
"entity_type": "test",
|
||||
"content": "Initial summary",
|
||||
"entity_metadata": {"status": "draft"},
|
||||
@@ -403,7 +404,7 @@ async def test_update_entity_basic(client: AsyncClient, project_url):
|
||||
entity_response = response.json()
|
||||
|
||||
# Update fields
|
||||
entity = Entity(**entity_response, folder="")
|
||||
entity = Entity(**entity_response, directory="")
|
||||
entity.entity_metadata["status"] = "final"
|
||||
entity.content = "Updated summary"
|
||||
|
||||
@@ -429,12 +430,12 @@ async def test_update_entity_content(client: AsyncClient, project_url):
|
||||
# Create a note entity
|
||||
response = await client.post(
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={"title": "test-note", "folder": "", "entity_type": "note", "summary": "Test note"},
|
||||
json={"title": "test-note", "directory": "", "entity_type": "note", "summary": "Test note"},
|
||||
)
|
||||
note = response.json()
|
||||
|
||||
# Update fields
|
||||
entity = Entity(**note, folder="")
|
||||
entity = Entity(**note, directory="")
|
||||
entity.content = "# Updated Note\n\nNew content."
|
||||
|
||||
response = await client.put(
|
||||
@@ -458,7 +459,7 @@ async def test_update_entity_type_conversion(client: AsyncClient, project_url):
|
||||
# Create a note
|
||||
note_data = {
|
||||
"title": "test-note",
|
||||
"folder": "",
|
||||
"directory": "",
|
||||
"entity_type": "note",
|
||||
"summary": "Test note",
|
||||
"content": "# Test Note\n\nInitial content.",
|
||||
@@ -467,7 +468,7 @@ async def test_update_entity_type_conversion(client: AsyncClient, project_url):
|
||||
note = response.json()
|
||||
|
||||
# Update fields
|
||||
entity = Entity(**note, folder="")
|
||||
entity = Entity(**note, directory="")
|
||||
entity.entity_type = "test"
|
||||
|
||||
response = await client.put(
|
||||
@@ -491,7 +492,7 @@ async def test_update_entity_metadata(client: AsyncClient, project_url):
|
||||
# Create entity
|
||||
data = {
|
||||
"title": "test",
|
||||
"folder": "",
|
||||
"directory": "",
|
||||
"entity_type": "test",
|
||||
"entity_metadata": {"status": "draft"},
|
||||
}
|
||||
@@ -499,7 +500,7 @@ async def test_update_entity_metadata(client: AsyncClient, project_url):
|
||||
entity_response = response.json()
|
||||
|
||||
# Update fields
|
||||
entity = Entity(**entity_response, folder="")
|
||||
entity = Entity(**entity_response, directory="")
|
||||
entity.entity_metadata["status"] = "final"
|
||||
entity.entity_metadata["reviewed"] = True
|
||||
|
||||
@@ -521,7 +522,7 @@ async def test_update_entity_not_found_does_create(client: AsyncClient, project_
|
||||
|
||||
data = {
|
||||
"title": "nonexistent",
|
||||
"folder": "",
|
||||
"directory": "",
|
||||
"entity_type": "test",
|
||||
"observations": ["First observation", "Second observation"],
|
||||
}
|
||||
@@ -538,7 +539,7 @@ async def test_update_entity_incorrect_permalink(client: AsyncClient, project_ur
|
||||
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"folder": "",
|
||||
"directory": "",
|
||||
"entity_type": "test",
|
||||
"observations": ["First observation", "Second observation"],
|
||||
}
|
||||
@@ -555,7 +556,7 @@ async def test_update_entity_search_index(client: AsyncClient, project_url):
|
||||
# Create entity
|
||||
data = {
|
||||
"title": "test",
|
||||
"folder": "",
|
||||
"directory": "",
|
||||
"entity_type": "test",
|
||||
"content": "Initial searchable content",
|
||||
}
|
||||
@@ -563,7 +564,7 @@ async def test_update_entity_search_index(client: AsyncClient, project_url):
|
||||
entity_response = response.json()
|
||||
|
||||
# Update fields
|
||||
entity = Entity(**entity_response, folder="")
|
||||
entity = Entity(**entity_response, directory="")
|
||||
entity.content = "Updated with unique sphinx marker"
|
||||
|
||||
response = await client.put(
|
||||
@@ -592,7 +593,7 @@ async def test_edit_entity_append(client: AsyncClient, project_url):
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "note",
|
||||
"content": "Original content",
|
||||
},
|
||||
@@ -627,7 +628,7 @@ async def test_edit_entity_prepend(client: AsyncClient, project_url):
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "note",
|
||||
"content": "Original content",
|
||||
},
|
||||
@@ -671,7 +672,7 @@ async def test_edit_entity_find_replace(client: AsyncClient, project_url):
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "note",
|
||||
"content": "This is old content that needs updating",
|
||||
},
|
||||
@@ -704,7 +705,7 @@ async def test_edit_entity_find_replace_with_expected_replacements(
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "Sample Note",
|
||||
"folder": "docs",
|
||||
"directory": "docs",
|
||||
"entity_type": "note",
|
||||
"content": "The word banana appears here. Another banana word here.",
|
||||
},
|
||||
@@ -747,7 +748,7 @@ Original section 2 content"""
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "Sample Note",
|
||||
"folder": "docs",
|
||||
"directory": "docs",
|
||||
"entity_type": "note",
|
||||
"content": content,
|
||||
},
|
||||
@@ -794,7 +795,7 @@ async def test_edit_entity_invalid_operation(client: AsyncClient, project_url):
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "note",
|
||||
"content": "Original content",
|
||||
},
|
||||
@@ -819,7 +820,7 @@ async def test_edit_entity_find_replace_missing_find_text(client: AsyncClient, p
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "note",
|
||||
"content": "Original content",
|
||||
},
|
||||
@@ -844,7 +845,7 @@ async def test_edit_entity_replace_section_missing_section(client: AsyncClient,
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "note",
|
||||
"content": "Original content",
|
||||
},
|
||||
@@ -869,7 +870,7 @@ async def test_edit_entity_find_replace_not_found(client: AsyncClient, project_u
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "Test Note",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "note",
|
||||
"content": "This is some content",
|
||||
},
|
||||
@@ -894,7 +895,7 @@ async def test_edit_entity_find_replace_wrong_expected_count(client: AsyncClient
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "Sample Note",
|
||||
"folder": "docs",
|
||||
"directory": "docs",
|
||||
"entity_type": "note",
|
||||
"content": "The word banana appears here. Another banana word here.",
|
||||
},
|
||||
@@ -925,7 +926,7 @@ async def test_edit_entity_search_reindex(client: AsyncClient, project_url):
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "Search Test",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "note",
|
||||
"content": "Original searchable content",
|
||||
},
|
||||
@@ -961,7 +962,7 @@ async def test_move_entity_success(client: AsyncClient, project_url):
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "TestNote",
|
||||
"folder": "source",
|
||||
"directory": "source",
|
||||
"entity_type": "note",
|
||||
"content": "Test content",
|
||||
},
|
||||
@@ -1006,7 +1007,7 @@ async def test_move_entity_with_folder_creation(client: AsyncClient, project_url
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "TestNote",
|
||||
"folder": "",
|
||||
"directory": "",
|
||||
"entity_type": "note",
|
||||
"content": "Test content",
|
||||
},
|
||||
@@ -1047,7 +1048,7 @@ Some additional content."""
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "ComplexEntity",
|
||||
"folder": "source",
|
||||
"directory": "source",
|
||||
"entity_type": "note",
|
||||
"content": content,
|
||||
},
|
||||
@@ -1098,7 +1099,7 @@ async def test_move_entity_search_reindexing(client: AsyncClient, project_url):
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "SearchableNote",
|
||||
"folder": "source",
|
||||
"directory": "source",
|
||||
"entity_type": "note",
|
||||
"content": "Unique searchable elephant content",
|
||||
},
|
||||
@@ -1144,7 +1145,7 @@ async def test_move_entity_invalid_destination_path(client: AsyncClient, project
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "TestNote",
|
||||
"folder": "",
|
||||
"directory": "",
|
||||
"entity_type": "note",
|
||||
"content": "Test content",
|
||||
},
|
||||
@@ -1177,7 +1178,7 @@ async def test_move_entity_destination_exists(client: AsyncClient, project_url):
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "SourceNote",
|
||||
"folder": "source",
|
||||
"directory": "source",
|
||||
"entity_type": "note",
|
||||
"content": "Source content",
|
||||
},
|
||||
@@ -1190,7 +1191,7 @@ async def test_move_entity_destination_exists(client: AsyncClient, project_url):
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "DestinationNote",
|
||||
"folder": "target",
|
||||
"directory": "target",
|
||||
"entity_type": "note",
|
||||
"content": "Destination content",
|
||||
},
|
||||
@@ -1235,7 +1236,7 @@ async def test_move_entity_by_file_path(client: AsyncClient, project_url):
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "TestNote",
|
||||
"folder": "source",
|
||||
"directory": "source",
|
||||
"entity_type": "note",
|
||||
"content": "Test content",
|
||||
},
|
||||
@@ -1266,7 +1267,7 @@ async def test_move_entity_by_title(client: AsyncClient, project_url):
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": "UniqueTestTitle",
|
||||
"folder": "source",
|
||||
"directory": "source",
|
||||
"entity_type": "note",
|
||||
"content": "Test content",
|
||||
},
|
||||
@@ -1287,3 +1288,119 @@ async def test_move_entity_by_title(client: AsyncClient, project_url):
|
||||
moved_entity = response.json()
|
||||
assert moved_entity["file_path"] == "target/MovedByTitle.md"
|
||||
assert moved_entity["title"] == "UniqueTestTitle"
|
||||
|
||||
|
||||
# --- Move directory tests ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_directory_success(client: AsyncClient, project_url):
|
||||
"""Test POST /move-directory endpoint successfully moves all files in a directory."""
|
||||
# Create multiple notes in a source directory
|
||||
for i in range(3):
|
||||
response = await client.post(
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": f"DirMoveDoc{i + 1}",
|
||||
"directory": "move-source",
|
||||
"entity_type": "note",
|
||||
"content": f"Content for document {i + 1}",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Move the entire directory
|
||||
move_data = {
|
||||
"source_directory": "move-source",
|
||||
"destination_directory": "move-dest",
|
||||
}
|
||||
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
result = DirectoryMoveResult.model_validate(response.json())
|
||||
assert result.total_files == 3
|
||||
assert result.successful_moves == 3
|
||||
assert result.failed_moves == 0
|
||||
assert len(result.moved_files) == 3
|
||||
|
||||
# Verify notes are accessible at new location
|
||||
for i in range(3):
|
||||
response = await client.get(
|
||||
f"{project_url}/knowledge/entities/move-dest/dir-move-doc{i + 1}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
entity = response.json()
|
||||
assert entity["file_path"].startswith("move-dest/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_directory_empty_directory(client: AsyncClient, project_url):
|
||||
"""Test move_directory with no files in source returns zero counts."""
|
||||
move_data = {
|
||||
"source_directory": "nonexistent-source-dir",
|
||||
"destination_directory": "some-dest",
|
||||
}
|
||||
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
result = DirectoryMoveResult.model_validate(response.json())
|
||||
assert result.total_files == 0
|
||||
assert result.successful_moves == 0
|
||||
assert result.failed_moves == 0
|
||||
assert len(result.moved_files) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_directory_validation_error(client: AsyncClient, project_url):
|
||||
"""Test move_directory with missing required fields returns validation error."""
|
||||
# Missing destination_directory
|
||||
move_data = {
|
||||
"source_directory": "some-source",
|
||||
}
|
||||
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
|
||||
assert response.status_code == 422
|
||||
|
||||
# Missing source_directory
|
||||
move_data = {
|
||||
"destination_directory": "some-dest",
|
||||
}
|
||||
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_directory_nested_structure(client: AsyncClient, project_url):
|
||||
"""Test move_directory preserves nested directory structure."""
|
||||
# Create notes in nested structure
|
||||
directories = [
|
||||
"nested-move/2024",
|
||||
"nested-move/2024/q1",
|
||||
]
|
||||
|
||||
for dir_path in directories:
|
||||
response = await client.post(
|
||||
f"{project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": f"Note in {dir_path.split('/')[-1]}",
|
||||
"directory": dir_path,
|
||||
"entity_type": "note",
|
||||
"content": f"Content in {dir_path}",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Move the parent directory
|
||||
move_data = {
|
||||
"source_directory": "nested-move/2024",
|
||||
"destination_directory": "archive/2024",
|
||||
}
|
||||
response = await client.post(f"{project_url}/knowledge/move-directory", json=move_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
result = DirectoryMoveResult.model_validate(response.json())
|
||||
assert result.total_files == 2
|
||||
assert result.successful_moves == 2
|
||||
|
||||
# Verify nested note is at new location
|
||||
response = await client.get(f"{project_url}/knowledge/entities/archive/2024/q1/note-in-q1")
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -133,7 +133,7 @@ async def test_get_resource_observation(client, project_config, entity_repositor
|
||||
content = "# Test Content\n\n- [note] an observation."
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content}",
|
||||
}
|
||||
@@ -173,7 +173,7 @@ async def test_get_resource_entities(client, project_config, entity_repository,
|
||||
content1 = "# Test Content\n"
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content1}",
|
||||
}
|
||||
@@ -184,7 +184,7 @@ async def test_get_resource_entities(client, project_config, entity_repository,
|
||||
content2 = "# Related Content\n- links to [[Test Entity]]"
|
||||
data = {
|
||||
"title": "Related Entity",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content2}",
|
||||
}
|
||||
@@ -229,7 +229,7 @@ async def test_get_resource_entities_pagination(
|
||||
content1 = "# Test Content\n"
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content1}",
|
||||
}
|
||||
@@ -241,7 +241,7 @@ async def test_get_resource_entities_pagination(
|
||||
content2 = "# Related Content\n- links to [[Test Entity]]"
|
||||
data = {
|
||||
"title": "Related Entity",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content2}",
|
||||
}
|
||||
@@ -281,7 +281,7 @@ async def test_get_resource_relation(client, project_config, entity_repository,
|
||||
content1 = "# Test Content\n"
|
||||
data = {
|
||||
"title": "Test Entity",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content1}",
|
||||
}
|
||||
@@ -292,7 +292,7 @@ async def test_get_resource_relation(client, project_config, entity_repository,
|
||||
content2 = "# Related Content\n- links to [[Test Entity]]"
|
||||
data = {
|
||||
"title": "Related Entity",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content": f"{content2}",
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ async def test_reindex(
|
||||
await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="TestEntity1",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -159,7 +159,7 @@ async def test_import_chatgpt(
|
||||
# Create a multipart form with the file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("conversations.json", f, "application/json")}
|
||||
data = {"folder": "test_chatgpt"}
|
||||
data = {"directory": "test_chatgpt"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(f"{v2_project_url}/import/chatgpt", files=files, data=data)
|
||||
@@ -192,7 +192,7 @@ async def test_import_chatgpt_invalid_file(client: AsyncClient, tmp_path, v2_pro
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"folder": "test_chatgpt"}
|
||||
data = {"directory": "test_chatgpt"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(f"{v2_project_url}/import/chatgpt", files=files, data=data)
|
||||
@@ -217,7 +217,7 @@ async def test_import_claude_conversations(
|
||||
# Create a multipart form with the file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("conversations.json", f, "application/json")}
|
||||
data = {"folder": "test_claude_conversations"}
|
||||
data = {"directory": "test_claude_conversations"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(
|
||||
@@ -254,7 +254,7 @@ async def test_import_claude_conversations_invalid_file(
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"folder": "test_claude_conversations"}
|
||||
data = {"directory": "test_claude_conversations"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(
|
||||
@@ -277,7 +277,7 @@ async def test_import_claude_projects(
|
||||
# Create a multipart form with the file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("projects.json", f, "application/json")}
|
||||
data = {"folder": "test_claude_projects"}
|
||||
data = {"directory": "test_claude_projects"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(
|
||||
@@ -319,7 +319,7 @@ async def test_import_claude_projects_invalid_file(
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"folder": "test_claude_projects"}
|
||||
data = {"directory": "test_claude_projects"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(
|
||||
@@ -345,7 +345,7 @@ async def test_import_memory_json(
|
||||
# Create a multipart form with the file
|
||||
with open(json_file, "rb") as f:
|
||||
files = {"file": ("memory.json", f, "application/json")}
|
||||
data = {"folder": "test_memory_json"}
|
||||
data = {"directory": "test_memory_json"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(f"{v2_project_url}/import/memory-json", files=files, data=data)
|
||||
@@ -409,7 +409,7 @@ async def test_import_memory_json_invalid_file(client: AsyncClient, tmp_path, v2
|
||||
# Create multipart form with invalid file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("invalid.json", f, "application/json")}
|
||||
data = {"folder": "test_memory_json"}
|
||||
data = {"directory": "test_memory_json"}
|
||||
|
||||
# Send request - this should return an error
|
||||
response = await client.post(f"{v2_project_url}/import/memory-json", files=files, data=data)
|
||||
@@ -430,7 +430,7 @@ async def test_v2_import_endpoints_use_project_id_not_name(
|
||||
# Try using project name instead of ID - should fail
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("conversations.json", f, "application/json")}
|
||||
data = {"folder": "test"}
|
||||
data = {"directory": "test"}
|
||||
|
||||
response = await client.post(
|
||||
f"/v2/projects/{test_project.name}/import/chatgpt",
|
||||
@@ -459,7 +459,7 @@ async def test_import_invalid_project_id(client: AsyncClient, tmp_path, chatgpt_
|
||||
for endpoint in endpoints:
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("test.json", f, "application/json")}
|
||||
data = {"folder": "test"}
|
||||
data = {"directory": "test"}
|
||||
|
||||
response = await client.post(
|
||||
f"/v2/projects/999999{endpoint}",
|
||||
@@ -474,7 +474,7 @@ async def test_import_invalid_project_id(client: AsyncClient, tmp_path, chatgpt_
|
||||
async def test_import_missing_file(client: AsyncClient, v2_project_url: str):
|
||||
"""Test importing with missing file via v2 endpoint."""
|
||||
# Send a request without a file
|
||||
response = await client.post(f"{v2_project_url}/import/chatgpt", data={"folder": "test_folder"})
|
||||
response = await client.post(f"{v2_project_url}/import/chatgpt", data={"directory": "test_folder"})
|
||||
|
||||
# Check that the request was rejected
|
||||
assert response.status_code in [400, 422] # Either bad request or unprocessable entity
|
||||
@@ -491,7 +491,7 @@ async def test_import_empty_file(client: AsyncClient, tmp_path, v2_project_url:
|
||||
# Create multipart form with empty file
|
||||
with open(file_path, "rb") as f:
|
||||
files = {"file": ("empty.json", f, "application/json")}
|
||||
data = {"folder": "test_chatgpt"}
|
||||
data = {"directory": "test_chatgpt"}
|
||||
|
||||
# Send request
|
||||
response = await client.post(f"{v2_project_url}/import/chatgpt", files=files, data=data)
|
||||
@@ -511,10 +511,10 @@ async def test_import_malformed_json(client: AsyncClient, tmp_path, v2_project_u
|
||||
|
||||
# Test all import endpoints
|
||||
endpoints = [
|
||||
(f"{v2_project_url}/import/chatgpt", {"folder": "test"}),
|
||||
(f"{v2_project_url}/import/claude/conversations", {"folder": "test"}),
|
||||
(f"{v2_project_url}/import/claude/projects", {"folder": "test"}),
|
||||
(f"{v2_project_url}/import/memory-json", {"folder": "test"}),
|
||||
(f"{v2_project_url}/import/chatgpt", {"directory": "test"}),
|
||||
(f"{v2_project_url}/import/claude/conversations", {"directory": "test"}),
|
||||
(f"{v2_project_url}/import/claude/projects", {"directory": "test"}),
|
||||
(f"{v2_project_url}/import/memory-json", {"directory": "test"}),
|
||||
]
|
||||
|
||||
for endpoint, data in endpoints:
|
||||
|
||||
@@ -5,6 +5,7 @@ from httpx import AsyncClient
|
||||
|
||||
from basic_memory.models import Project
|
||||
from basic_memory.schemas import DeleteEntitiesResponse
|
||||
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
|
||||
from basic_memory.schemas.v2 import EntityResponseV2, EntityResolveResponse
|
||||
|
||||
|
||||
@@ -19,7 +20,7 @@ async def test_resolve_identifier_by_permalink(
|
||||
# Create an entity first
|
||||
entity_data = {
|
||||
"title": "TestResolve",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "Test content for resolve",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
|
||||
@@ -51,13 +52,67 @@ async def test_resolve_identifier_not_found(client: AsyncClient, v2_project_url)
|
||||
assert "Entity not found" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_identifier_no_fuzzy_match(client: AsyncClient, v2_project_url):
|
||||
"""Test that resolve uses strict mode - no fuzzy search fallback.
|
||||
|
||||
This ensures wiki links only resolve to exact matches (permalink, title, or path),
|
||||
not to similar-sounding entities via fuzzy search.
|
||||
"""
|
||||
# Create an entity with a specific name
|
||||
entity_data = {
|
||||
"title": "link-test",
|
||||
"folder": "testing",
|
||||
"content": "A test note",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Try to resolve "nonexistent" - should NOT fuzzy match to "link-test"
|
||||
resolve_data = {"identifier": "nonexistent"}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
|
||||
|
||||
# Must return 404, not a fuzzy match to "link-test"
|
||||
assert response.status_code == 404
|
||||
assert "Entity not found" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_identifier_with_source_path_no_fuzzy_match(client: AsyncClient, v2_project_url):
|
||||
"""Test that context-aware resolution also uses strict mode.
|
||||
|
||||
Even with source_path for context-aware resolution, nonexistent
|
||||
links should return 404, not fuzzy match to nearby entities.
|
||||
"""
|
||||
# Create entities in a folder structure
|
||||
entity_data = {
|
||||
"title": "link-test",
|
||||
"folder": "testing/nested",
|
||||
"content": "A nested test note",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Try to resolve "nonexistent" with source_path context
|
||||
# Should NOT fuzzy match to "link-test" in the same or nearby folder
|
||||
resolve_data = {
|
||||
"identifier": "nonexistent",
|
||||
"source_path": "testing/nested/other-note.md",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/resolve", json=resolve_data)
|
||||
|
||||
# Must return 404, not a fuzzy match
|
||||
assert response.status_code == 404
|
||||
assert "Entity not found" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_entity_by_id(client: AsyncClient, test_graph, v2_project_url, entity_repository):
|
||||
"""Test getting an entity by its external_id (UUID)."""
|
||||
# Create an entity first
|
||||
entity_data = {
|
||||
"title": "TestGetById",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "Test content for get by ID",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
|
||||
@@ -94,7 +149,7 @@ async def test_create_entity(client: AsyncClient, file_service, v2_project_url):
|
||||
"""Test creating an entity via v2 endpoint."""
|
||||
data = {
|
||||
"title": "TestV2Entity",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "test",
|
||||
"content_type": "text/markdown",
|
||||
"content": "TestContent for V2",
|
||||
@@ -127,7 +182,7 @@ async def test_create_entity_with_observations_and_relations(
|
||||
"""Test creating an entity with observations and relations via v2."""
|
||||
data = {
|
||||
"title": "TestV2Complex",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": """
|
||||
# TestV2Complex
|
||||
|
||||
@@ -164,7 +219,7 @@ async def test_update_entity_by_id(
|
||||
# Create an entity first
|
||||
create_data = {
|
||||
"title": "TestUpdate",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "Original content",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
||||
@@ -178,7 +233,7 @@ async def test_update_entity_by_id(
|
||||
# Update it by external_id
|
||||
update_data = {
|
||||
"title": "TestUpdate",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "Updated content via V2",
|
||||
}
|
||||
response = await client.put(
|
||||
@@ -208,7 +263,7 @@ async def test_edit_entity_by_id_append(
|
||||
# Create an entity first
|
||||
create_data = {
|
||||
"title": "TestEdit",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# TestEdit\n\nOriginal content",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
||||
@@ -251,7 +306,7 @@ async def test_edit_entity_by_id_find_replace(
|
||||
# Create an entity first
|
||||
create_data = {
|
||||
"title": "TestFindReplace",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "# TestFindReplace\n\nOld text that will be replaced",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
||||
@@ -295,7 +350,7 @@ async def test_delete_entity_by_id(
|
||||
# Create an entity first
|
||||
create_data = {
|
||||
"title": "TestDelete",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "Content to be deleted",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
||||
@@ -337,7 +392,7 @@ async def test_move_entity(client: AsyncClient, file_service, v2_project_url, en
|
||||
# Create an entity first
|
||||
create_data = {
|
||||
"title": "TestMove",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "Content to be moved",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=create_data)
|
||||
@@ -390,7 +445,7 @@ async def test_entity_response_v2_has_api_version(
|
||||
# Create an entity
|
||||
entity_data = {
|
||||
"title": "TestApiVersion",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"content": "Test content",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
|
||||
@@ -409,3 +464,174 @@ async def test_entity_response_v2_has_api_version(
|
||||
entity_v2 = EntityResponseV2.model_validate(response.json())
|
||||
assert entity_v2.api_version == "v2"
|
||||
assert entity_v2.external_id == entity_external_id
|
||||
|
||||
|
||||
# --- Move directory tests (V2) ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_directory_v2_success(client: AsyncClient, v2_project_url):
|
||||
"""Test POST /v2/.../move-directory endpoint successfully moves all files."""
|
||||
# Create multiple notes in a source directory
|
||||
for i in range(3):
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": f"V2DirMoveDoc{i + 1}",
|
||||
"directory": "v2-move-source",
|
||||
"content": f"Content for document {i + 1}",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Move the entire directory
|
||||
move_data = {
|
||||
"source_directory": "v2-move-source",
|
||||
"destination_directory": "v2-move-dest",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/move-directory", json=move_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
result = DirectoryMoveResult.model_validate(response.json())
|
||||
assert result.total_files == 3
|
||||
assert result.successful_moves == 3
|
||||
assert result.failed_moves == 0
|
||||
assert len(result.moved_files) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_directory_v2_empty_directory(client: AsyncClient, v2_project_url):
|
||||
"""Test move_directory V2 with no files in source returns zero counts."""
|
||||
move_data = {
|
||||
"source_directory": "v2-nonexistent-source",
|
||||
"destination_directory": "v2-some-dest",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/move-directory", json=move_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
result = DirectoryMoveResult.model_validate(response.json())
|
||||
assert result.total_files == 0
|
||||
assert result.successful_moves == 0
|
||||
assert result.failed_moves == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_directory_v2_validation_error(client: AsyncClient, v2_project_url):
|
||||
"""Test move_directory V2 with missing required fields returns validation error."""
|
||||
# Missing destination_directory
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/knowledge/move-directory",
|
||||
json={"source_directory": "some-source"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
# Missing source_directory
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/knowledge/move-directory",
|
||||
json={"destination_directory": "some-dest"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
# --- Delete directory tests (V2) ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_directory_v2_success(client: AsyncClient, v2_project_url):
|
||||
"""Test POST /v2/.../delete-directory endpoint successfully deletes all files."""
|
||||
# Create multiple notes in a directory to delete
|
||||
for i in range(3):
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": f"V2DeleteDoc{i + 1}",
|
||||
"directory": "v2-delete-dir",
|
||||
"content": f"Content for document {i + 1}",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify notes exist
|
||||
created_entity = EntityResponseV2.model_validate(response.json())
|
||||
get_response = await client.get(
|
||||
f"{v2_project_url}/knowledge/entities/{created_entity.external_id}"
|
||||
)
|
||||
assert get_response.status_code == 200
|
||||
|
||||
# Delete the entire directory
|
||||
delete_data = {
|
||||
"directory": "v2-delete-dir",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/delete-directory", json=delete_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
result = DirectoryDeleteResult.model_validate(response.json())
|
||||
assert result.total_files == 3
|
||||
assert result.successful_deletes == 3
|
||||
assert result.failed_deletes == 0
|
||||
assert len(result.deleted_files) == 3
|
||||
|
||||
# Verify entity is no longer accessible
|
||||
get_response = await client.get(
|
||||
f"{v2_project_url}/knowledge/entities/{created_entity.external_id}"
|
||||
)
|
||||
assert get_response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_directory_v2_empty_directory(client: AsyncClient, v2_project_url):
|
||||
"""Test delete_directory V2 with no files returns zero counts."""
|
||||
delete_data = {
|
||||
"directory": "v2-nonexistent-delete-dir",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/delete-directory", json=delete_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
result = DirectoryDeleteResult.model_validate(response.json())
|
||||
assert result.total_files == 0
|
||||
assert result.successful_deletes == 0
|
||||
assert result.failed_deletes == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_directory_v2_validation_error(client: AsyncClient, v2_project_url):
|
||||
"""Test delete_directory V2 with missing required fields returns validation error."""
|
||||
# Missing directory field
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/knowledge/delete-directory",
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_directory_v2_nested_structure(client: AsyncClient, v2_project_url):
|
||||
"""Test delete_directory V2 handles nested directory structure."""
|
||||
# Create notes in nested structure
|
||||
directories = [
|
||||
"v2-nested-delete/2024",
|
||||
"v2-nested-delete/2024/q1",
|
||||
]
|
||||
|
||||
for dir_path in directories:
|
||||
response = await client.post(
|
||||
f"{v2_project_url}/knowledge/entities",
|
||||
json={
|
||||
"title": f"Note in {dir_path.split('/')[-1]}",
|
||||
"directory": dir_path,
|
||||
"content": f"Content in {dir_path}",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Delete the parent directory
|
||||
delete_data = {
|
||||
"directory": "v2-nested-delete/2024",
|
||||
}
|
||||
response = await client.post(f"{v2_project_url}/knowledge/delete-directory", json=delete_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
result = DirectoryDeleteResult.model_validate(response.json())
|
||||
assert result.total_files == 2
|
||||
assert result.successful_deletes == 2
|
||||
assert result.failed_deletes == 0
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Tests for CLI routing utilities."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.cli.commands.routing import force_routing, validate_routing_flags
|
||||
|
||||
|
||||
class TestValidateRoutingFlags:
|
||||
"""Tests for validate_routing_flags function."""
|
||||
|
||||
def test_neither_flag(self):
|
||||
"""Should not raise when neither flag is set."""
|
||||
validate_routing_flags(local=False, cloud=False)
|
||||
|
||||
def test_local_only(self):
|
||||
"""Should not raise when only local is set."""
|
||||
validate_routing_flags(local=True, cloud=False)
|
||||
|
||||
def test_cloud_only(self):
|
||||
"""Should not raise when only cloud is set."""
|
||||
validate_routing_flags(local=False, cloud=True)
|
||||
|
||||
def test_both_flags_raises(self):
|
||||
"""Should raise ValueError when both flags are set."""
|
||||
with pytest.raises(ValueError, match="Cannot specify both --local and --cloud"):
|
||||
validate_routing_flags(local=True, cloud=True)
|
||||
|
||||
|
||||
class TestForceRouting:
|
||||
"""Tests for force_routing context manager."""
|
||||
|
||||
def test_local_sets_env_var(self):
|
||||
"""Local flag should set BASIC_MEMORY_FORCE_LOCAL."""
|
||||
# Ensure env var is not set
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
|
||||
with force_routing(local=True):
|
||||
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true"
|
||||
|
||||
# Should be cleaned up after context exits
|
||||
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
|
||||
|
||||
def test_cloud_clears_env_var(self):
|
||||
"""Cloud flag should clear BASIC_MEMORY_FORCE_LOCAL if set."""
|
||||
# Set env var
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
|
||||
|
||||
with force_routing(cloud=True):
|
||||
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
|
||||
|
||||
# Should restore original value after context exits
|
||||
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true"
|
||||
|
||||
# Cleanup
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
|
||||
def test_neither_flag_no_change(self):
|
||||
"""Neither flag should not change env vars."""
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
|
||||
with force_routing():
|
||||
# Should not be set
|
||||
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
|
||||
|
||||
# Should still not be set
|
||||
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
|
||||
|
||||
def test_preserves_original_env_var(self):
|
||||
"""Should restore original env var value after context exits."""
|
||||
original_value = "original"
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = original_value
|
||||
|
||||
with force_routing(local=True):
|
||||
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true"
|
||||
|
||||
# Should restore original value
|
||||
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == original_value
|
||||
|
||||
# Cleanup
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
|
||||
def test_both_flags_raises(self):
|
||||
"""Should raise ValueError when both flags are set."""
|
||||
with pytest.raises(ValueError, match="Cannot specify both --local and --cloud"):
|
||||
with force_routing(local=True, cloud=True):
|
||||
pass
|
||||
|
||||
def test_restores_on_exception(self):
|
||||
"""Should restore env vars even when exception is raised."""
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
|
||||
try:
|
||||
with force_routing(local=True):
|
||||
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") == "true"
|
||||
raise RuntimeError("Test exception")
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
# Should be cleaned up even after exception
|
||||
assert os.environ.get("BASIC_MEMORY_FORCE_LOCAL") is None
|
||||
+6
-6
@@ -470,7 +470,7 @@ async def full_entity(sample_entity, entity_repository, file_service, entity_ser
|
||||
entity, created = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
title="Search_Entity",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
content=dedent("""
|
||||
## Observations
|
||||
@@ -502,7 +502,7 @@ async def test_graph(
|
||||
EntitySchema(
|
||||
title="Deeper Entity",
|
||||
entity_type="deeper",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content=dedent("""
|
||||
# Deeper Entity
|
||||
"""),
|
||||
@@ -513,7 +513,7 @@ async def test_graph(
|
||||
EntitySchema(
|
||||
title="Deep Entity",
|
||||
entity_type="deep",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content=dedent("""
|
||||
# Deep Entity
|
||||
- deeper_connection [[Deeper Entity]]
|
||||
@@ -525,7 +525,7 @@ async def test_graph(
|
||||
EntitySchema(
|
||||
title="Connected Entity 2",
|
||||
entity_type="test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content=dedent("""
|
||||
# Connected Entity 2
|
||||
- deep_connection [[Deep Entity]]
|
||||
@@ -537,7 +537,7 @@ async def test_graph(
|
||||
EntitySchema(
|
||||
title="Connected Entity 1",
|
||||
entity_type="test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content=dedent("""
|
||||
# Connected Entity 1
|
||||
- [note] Connected 1 note
|
||||
@@ -550,7 +550,7 @@ async def test_graph(
|
||||
EntitySchema(
|
||||
title="Root",
|
||||
entity_type="test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content=dedent("""
|
||||
# Root Entity
|
||||
- [note] Root note 1
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for async_client force_local_mode functionality."""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
from basic_memory.mcp.async_client import _force_local_mode
|
||||
|
||||
|
||||
class TestForceLocalMode:
|
||||
"""Tests for _force_local_mode function."""
|
||||
|
||||
def test_returns_false_when_not_set(self):
|
||||
"""Should return False when env var is not set."""
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
assert _force_local_mode() is False
|
||||
|
||||
def test_returns_true_for_true(self):
|
||||
"""Should return True when env var is 'true'."""
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "true"
|
||||
try:
|
||||
assert _force_local_mode() is True
|
||||
finally:
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
|
||||
def test_returns_true_for_1(self):
|
||||
"""Should return True when env var is '1'."""
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "1"
|
||||
try:
|
||||
assert _force_local_mode() is True
|
||||
finally:
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
|
||||
def test_returns_true_for_yes(self):
|
||||
"""Should return True when env var is 'yes'."""
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "yes"
|
||||
try:
|
||||
assert _force_local_mode() is True
|
||||
finally:
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
|
||||
def test_returns_true_for_TRUE_uppercase(self):
|
||||
"""Should return True when env var is 'TRUE' (case insensitive)."""
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "TRUE"
|
||||
try:
|
||||
assert _force_local_mode() is True
|
||||
finally:
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
|
||||
def test_returns_false_for_false(self):
|
||||
"""Should return False when env var is 'false'."""
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "false"
|
||||
try:
|
||||
assert _force_local_mode() is False
|
||||
finally:
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
|
||||
def test_returns_false_for_0(self):
|
||||
"""Should return False when env var is '0'."""
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "0"
|
||||
try:
|
||||
assert _force_local_mode() is False
|
||||
finally:
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
|
||||
def test_returns_false_for_empty(self):
|
||||
"""Should return False when env var is empty string."""
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = ""
|
||||
try:
|
||||
assert _force_local_mode() is False
|
||||
finally:
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
|
||||
def test_returns_false_for_random_string(self):
|
||||
"""Should return False when env var is random string."""
|
||||
os.environ["BASIC_MEMORY_FORCE_LOCAL"] = "random"
|
||||
try:
|
||||
assert _force_local_mode() is False
|
||||
finally:
|
||||
os.environ.pop("BASIC_MEMORY_FORCE_LOCAL", None)
|
||||
@@ -12,7 +12,7 @@ async def test_write_note_tags_yaml_format(app, project_config, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="YAML Format Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing YAML tag formatting",
|
||||
tags=["system", "overview", "reference"],
|
||||
)
|
||||
@@ -44,7 +44,7 @@ async def test_write_note_stringified_json_tags(app, project_config, test_projec
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Stringified JSON Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing stringified JSON tag input",
|
||||
tags='["python", "testing", "json"]', # Stringified JSON array
|
||||
)
|
||||
@@ -74,7 +74,7 @@ async def test_write_note_single_tag_yaml_format(app, project_config, test_proje
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Single Tag Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing single tag formatting",
|
||||
tags=["solo-tag"],
|
||||
)
|
||||
@@ -93,7 +93,7 @@ async def test_write_note_no_tags(app, project_config, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="No Tags Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing note without tags",
|
||||
tags=None,
|
||||
)
|
||||
@@ -112,7 +112,7 @@ async def test_write_note_empty_tags_list(app, project_config, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Empty Tags Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing empty tag list",
|
||||
tags=[],
|
||||
)
|
||||
@@ -131,7 +131,7 @@ async def test_write_note_update_preserves_yaml_format(app, project_config, test
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Update Format Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Initial content",
|
||||
tags=["initial", "tag"],
|
||||
)
|
||||
@@ -140,7 +140,7 @@ async def test_write_note_update_preserves_yaml_format(app, project_config, test
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Update Format Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Updated content",
|
||||
tags=["updated", "new-tag", "format"],
|
||||
)
|
||||
@@ -173,7 +173,7 @@ async def test_complex_tags_yaml_format(app, project_config, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Complex Tags Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing complex tag formats",
|
||||
tags=["python-3.9", "api_integration", "v2.0", "nested/category", "under_score"],
|
||||
)
|
||||
|
||||
@@ -64,7 +64,7 @@ async def test_permalink_collision_should_not_overwrite_different_file(app, test
|
||||
result_a = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Node A",
|
||||
folder="edge-cases",
|
||||
directory="edge-cases",
|
||||
content="# Node A\n\nOriginal content for Node A\n\n## Relations\n- links_to [[Node B]]",
|
||||
)
|
||||
|
||||
@@ -81,7 +81,7 @@ async def test_permalink_collision_should_not_overwrite_different_file(app, test
|
||||
result_b = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Node B",
|
||||
folder="edge-cases",
|
||||
directory="edge-cases",
|
||||
content="# Node B\n\nContent for Node B",
|
||||
)
|
||||
|
||||
@@ -93,7 +93,7 @@ async def test_permalink_collision_should_not_overwrite_different_file(app, test
|
||||
result_c = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Node C",
|
||||
folder="edge-cases",
|
||||
directory="edge-cases",
|
||||
content="# Node C\n\nContent for Node C\n\n## Relations\n- links_to [[Node A]]",
|
||||
)
|
||||
|
||||
@@ -165,7 +165,7 @@ async def test_notes_with_similar_titles_maintain_separate_files(app, test_proje
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title=title,
|
||||
folder=folder,
|
||||
directory=folder,
|
||||
content=f"# {title}\n\nUnique content for {title}",
|
||||
)
|
||||
|
||||
@@ -210,7 +210,7 @@ async def test_sequential_note_creation_preserves_all_files(app, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title=title,
|
||||
folder="sequence-test",
|
||||
directory="sequence-test",
|
||||
content=content,
|
||||
)
|
||||
assert "# Created note" in result or "# Updated note" in result
|
||||
|
||||
@@ -153,8 +153,9 @@ async def test_recent_activity_prompt_discovery_mode(client, test_project, test_
|
||||
result = await recent_activity_prompt.fn(timeframe="1w") # pyright: ignore [reportGeneralTypeIssues]
|
||||
|
||||
# Check the response contains expected discovery mode content
|
||||
assert "Recent Activity Across All Projects" in result # pyright: ignore [reportOperatorIssue]
|
||||
assert "Cross-Project Activity Discovery" in result # pyright: ignore [reportOperatorIssue]
|
||||
assert "Recent Activity Context" in result # pyright: ignore [reportOperatorIssue]
|
||||
assert "all projects" in result # pyright: ignore [reportOperatorIssue]
|
||||
assert "Next Steps" in result # pyright: ignore [reportOperatorIssue]
|
||||
assert "write_note" in result # pyright: ignore [reportOperatorIssue]
|
||||
|
||||
|
||||
@@ -165,9 +166,9 @@ async def test_recent_activity_prompt_project_specific(client, test_project, tes
|
||||
result = await recent_activity_prompt.fn(timeframe="1w", project=test_project.name) # pyright: ignore [reportGeneralTypeIssues]
|
||||
|
||||
# Check the response contains expected project-specific content
|
||||
assert f"Recent Activity in {test_project.name}" in result # pyright: ignore [reportOperatorIssue]
|
||||
assert "Opportunity to Capture Activity Summary" in result # pyright: ignore [reportOperatorIssue]
|
||||
assert f"recent activity in {test_project.name}" in result # pyright: ignore [reportOperatorIssue]
|
||||
assert "Recent Activity Context" in result # pyright: ignore [reportOperatorIssue]
|
||||
assert test_project.name in result # pyright: ignore [reportOperatorIssue]
|
||||
assert "Next Steps" in result # pyright: ignore [reportOperatorIssue]
|
||||
assert "write_note" in result # pyright: ignore [reportOperatorIssue]
|
||||
|
||||
|
||||
@@ -178,4 +179,5 @@ async def test_recent_activity_prompt_with_custom_timeframe(client, test_project
|
||||
result = await recent_activity_prompt.fn(timeframe="1d") # pyright: ignore [reportGeneralTypeIssues]
|
||||
|
||||
# Check the response includes the custom timeframe
|
||||
assert "Recent Activity Across All Projects (1d)" in result # pyright: ignore [reportOperatorIssue]
|
||||
assert "1d" in result # pyright: ignore [reportOperatorIssue]
|
||||
assert "Recent Activity Context" in result # pyright: ignore [reportOperatorIssue]
|
||||
|
||||
@@ -1,111 +1,91 @@
|
||||
from datetime import UTC, datetime
|
||||
"""Tests for recent_activity_prompt with different modes.
|
||||
|
||||
The prompt delegates to the recent_activity tool which returns a formatted string.
|
||||
These tests verify the prompt correctly uses the tool output and adds guidance.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.mcp.prompts.recent_activity import recent_activity_prompt
|
||||
from basic_memory.schemas.memory import (
|
||||
ActivityStats,
|
||||
ContextResult,
|
||||
GraphContext,
|
||||
MemoryMetadata,
|
||||
ProjectActivity,
|
||||
ProjectActivitySummary,
|
||||
EntitySummary,
|
||||
)
|
||||
from basic_memory.schemas.search import SearchItemType
|
||||
|
||||
|
||||
def _entity(title: str, entity_id: int = 1) -> EntitySummary:
|
||||
return EntitySummary(
|
||||
entity_id=entity_id,
|
||||
permalink=title.lower().replace(" ", "-"),
|
||||
title=title,
|
||||
content=None,
|
||||
file_path=f"{title}.md",
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_activity_prompt_discovery_mode(monkeypatch):
|
||||
recent = ProjectActivitySummary(
|
||||
projects={
|
||||
"p1": ProjectActivity(
|
||||
project_name="p1",
|
||||
project_path="/tmp/p1",
|
||||
activity=GraphContext(
|
||||
results=[
|
||||
ContextResult(
|
||||
primary_result=_entity("A"), observations=[], related_results=[]
|
||||
)
|
||||
],
|
||||
metadata=MemoryMetadata(
|
||||
uri=None,
|
||||
types=[SearchItemType.ENTITY],
|
||||
depth=1,
|
||||
timeframe="7d",
|
||||
generated_at=datetime.now(UTC),
|
||||
),
|
||||
),
|
||||
item_count=1,
|
||||
),
|
||||
"p2": ProjectActivity(
|
||||
project_name="p2",
|
||||
project_path="/tmp/p2",
|
||||
activity=GraphContext(
|
||||
results=[
|
||||
ContextResult(
|
||||
primary_result=_entity("B", 2), observations=[], related_results=[]
|
||||
)
|
||||
],
|
||||
metadata=MemoryMetadata(
|
||||
uri=None,
|
||||
types=[SearchItemType.ENTITY],
|
||||
depth=1,
|
||||
timeframe="7d",
|
||||
generated_at=datetime.now(UTC),
|
||||
),
|
||||
),
|
||||
item_count=1,
|
||||
),
|
||||
},
|
||||
summary=ActivityStats(
|
||||
total_projects=2, active_projects=2, most_active_project="p1", total_items=2
|
||||
),
|
||||
timeframe="7d",
|
||||
generated_at=datetime.now(UTC),
|
||||
)
|
||||
"""Test prompt in discovery mode (no project specified)."""
|
||||
# The tool returns a formatted string in discovery mode
|
||||
tool_output = """## Recent Activity Summary (7d)
|
||||
|
||||
**Most Active Project:** project-alpha (5 items)
|
||||
- 🔧 **Latest:** API Design Decision (2 hours ago)
|
||||
- 📋 **Focus areas:** decisions, specs
|
||||
|
||||
**Other Active Projects:**
|
||||
- **project-beta** (3 items)
|
||||
|
||||
**Summary:** 2 active projects, 8 recent items
|
||||
"""
|
||||
|
||||
async def fake_fn(**_kwargs):
|
||||
return recent
|
||||
return tool_output
|
||||
|
||||
monkeypatch.setattr("basic_memory.mcp.prompts.recent_activity.recent_activity.fn", fake_fn)
|
||||
|
||||
out = await recent_activity_prompt.fn(timeframe="7d", project=None) # pyright: ignore[reportGeneralTypeIssues]
|
||||
assert "Recent Activity Across All Projects" in out
|
||||
assert "Cross-Project Activity Discovery" in out
|
||||
|
||||
# Should contain the tool output
|
||||
assert "Recent Activity Summary (7d)" in out
|
||||
assert "Most Active Project" in out
|
||||
|
||||
# Should contain prompt guidance
|
||||
assert "Recent Activity Context" in out
|
||||
assert "Next Steps" in out
|
||||
assert "Capture Opportunity" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_activity_prompt_project_mode(monkeypatch):
|
||||
recent = GraphContext(
|
||||
results=[
|
||||
ContextResult(primary_result=_entity("Only"), observations=[], related_results=[])
|
||||
],
|
||||
metadata=MemoryMetadata(
|
||||
uri=None,
|
||||
types=[SearchItemType.ENTITY],
|
||||
depth=1,
|
||||
timeframe="1d",
|
||||
generated_at=datetime.now(UTC),
|
||||
),
|
||||
)
|
||||
"""Test prompt in project-specific mode."""
|
||||
# The tool returns a formatted string for a specific project
|
||||
tool_output = """## Recent Activity: my-project (1d)
|
||||
|
||||
**📄 Recent Notes & Documents (3):**
|
||||
• API Design Decision
|
||||
• User Authentication Spec
|
||||
• Database Schema
|
||||
|
||||
**Activity Summary:** 3 items found
|
||||
"""
|
||||
|
||||
async def fake_fn(**_kwargs):
|
||||
return recent
|
||||
return tool_output
|
||||
|
||||
monkeypatch.setattr("basic_memory.mcp.prompts.recent_activity.recent_activity.fn", fake_fn)
|
||||
|
||||
out = await recent_activity_prompt.fn(timeframe="1d", project="proj") # pyright: ignore[reportGeneralTypeIssues]
|
||||
assert "Recent Activity in proj" in out
|
||||
assert "Opportunity to Capture Activity Summary" in out
|
||||
out = await recent_activity_prompt.fn(timeframe="1d", project="my-project") # pyright: ignore[reportGeneralTypeIssues]
|
||||
|
||||
# Should contain the tool output
|
||||
assert "Recent Activity: my-project" in out
|
||||
assert "Recent Notes & Documents" in out
|
||||
|
||||
# Should contain prompt guidance
|
||||
assert "Recent Activity Context" in out
|
||||
assert "my-project" in out # Project mentioned in guidance
|
||||
assert "Next Steps" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_activity_prompt_passes_correct_params(monkeypatch):
|
||||
"""Test that prompt passes correct parameters to the tool."""
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_fn(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return "## Recent Activity"
|
||||
|
||||
monkeypatch.setattr("basic_memory.mcp.prompts.recent_activity.recent_activity.fn", fake_fn)
|
||||
|
||||
await recent_activity_prompt.fn(timeframe="2d", project="test-proj") # pyright: ignore[reportGeneralTypeIssues]
|
||||
|
||||
assert captured_kwargs["timeframe"] == "2d"
|
||||
assert captured_kwargs["project"] == "test-proj"
|
||||
assert captured_kwargs["type"] == "entity"
|
||||
|
||||
@@ -35,7 +35,7 @@ async def test_create_canvas(app, project_config, test_project):
|
||||
|
||||
# Execute
|
||||
result = await canvas.fn(
|
||||
project=test_project.name, nodes=nodes, edges=edges, title=title, folder=folder
|
||||
project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder
|
||||
)
|
||||
|
||||
# Verify result message
|
||||
@@ -74,7 +74,7 @@ async def test_create_canvas_with_extension(app, project_config, test_project):
|
||||
|
||||
# Execute
|
||||
result = await canvas.fn(
|
||||
project=test_project.name, nodes=nodes, edges=edges, title=title, folder=folder
|
||||
project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder
|
||||
)
|
||||
|
||||
# Verify
|
||||
@@ -109,7 +109,7 @@ async def test_update_existing_canvas(app, project_config, test_project):
|
||||
folder = "visualizations"
|
||||
|
||||
# Create initial canvas
|
||||
await canvas.fn(project=test_project.name, nodes=nodes, edges=edges, title=title, folder=folder)
|
||||
await canvas.fn(project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder)
|
||||
|
||||
# Verify file exists
|
||||
file_path = Path(project_config.home) / folder / f"{title}.canvas"
|
||||
@@ -137,7 +137,7 @@ async def test_update_existing_canvas(app, project_config, test_project):
|
||||
nodes=updated_nodes,
|
||||
edges=updated_edges,
|
||||
title=title,
|
||||
folder=folder,
|
||||
directory=folder,
|
||||
)
|
||||
|
||||
# Verify result indicates update
|
||||
@@ -170,7 +170,7 @@ async def test_create_canvas_with_nested_folders(app, project_config, test_proje
|
||||
|
||||
# Execute
|
||||
result = await canvas.fn(
|
||||
project=test_project.name, nodes=nodes, edges=edges, title=title, folder=folder
|
||||
project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder
|
||||
)
|
||||
|
||||
# Verify
|
||||
@@ -255,7 +255,7 @@ async def test_create_canvas_complex_content(app, project_config, test_project):
|
||||
|
||||
# Execute
|
||||
result = await canvas.fn(
|
||||
project=test_project.name, nodes=nodes, edges=edges, title=title, folder=folder
|
||||
project=test_project.name, nodes=nodes, edges=edges, title=title, directory=folder
|
||||
)
|
||||
|
||||
# Verify
|
||||
|
||||
@@ -13,7 +13,7 @@ async def test_edit_note_append_operation(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test Note\nOriginal content here.",
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ async def test_edit_note_prepend_operation(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Meeting Notes",
|
||||
folder="meetings",
|
||||
directory="meetings",
|
||||
content="# Meeting Notes\nExisting content.",
|
||||
)
|
||||
|
||||
@@ -69,7 +69,7 @@ async def test_edit_note_find_replace_operation(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Config Document",
|
||||
folder="config",
|
||||
directory="config",
|
||||
content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.",
|
||||
)
|
||||
|
||||
@@ -98,7 +98,7 @@ async def test_edit_note_replace_section_operation(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="API Specification",
|
||||
folder="specs",
|
||||
directory="specs",
|
||||
content="# API Spec\n\n## Overview\nAPI overview here.\n\n## Implementation\nOld implementation details.\n\n## Testing\nTest info here.",
|
||||
)
|
||||
|
||||
@@ -142,7 +142,7 @@ async def test_edit_note_invalid_operation(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nContent here.",
|
||||
)
|
||||
|
||||
@@ -164,7 +164,7 @@ async def test_edit_note_find_replace_missing_find_text(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nContent here.",
|
||||
)
|
||||
|
||||
@@ -186,7 +186,7 @@ async def test_edit_note_replace_section_missing_section(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nContent here.",
|
||||
)
|
||||
|
||||
@@ -208,7 +208,7 @@ async def test_edit_note_replace_section_nonexistent_section(client, test_projec
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Document",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
content="# Document\n\n## Existing Section\nSome content here.",
|
||||
)
|
||||
|
||||
@@ -236,7 +236,7 @@ async def test_edit_note_with_observations_and_relations(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Feature Spec",
|
||||
folder="features",
|
||||
directory="features",
|
||||
content="# Feature Spec\n\n- [design] Initial design thoughts #architecture\n- implements [[Base System]]\n\nOriginal content.",
|
||||
)
|
||||
|
||||
@@ -261,7 +261,7 @@ async def test_edit_note_identifier_variations(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Document",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
content="# Test Document\nOriginal content.",
|
||||
)
|
||||
|
||||
@@ -293,7 +293,7 @@ async def test_edit_note_find_replace_no_matches(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test Note\nSome content here.",
|
||||
)
|
||||
|
||||
@@ -319,7 +319,7 @@ async def test_edit_note_empty_content_operations(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test Note\nOriginal content.",
|
||||
)
|
||||
|
||||
@@ -340,7 +340,7 @@ async def test_edit_note_find_replace_wrong_count(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Config Document",
|
||||
folder="config",
|
||||
directory="config",
|
||||
content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.",
|
||||
)
|
||||
|
||||
@@ -369,7 +369,7 @@ async def test_edit_note_replace_section_multiple_sections(client, test_project)
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Sample Note",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
content="# Main Title\n\n## Section 1\nFirst instance\n\n## Section 2\nSome content\n\n## Section 1\nSecond instance",
|
||||
)
|
||||
|
||||
@@ -396,7 +396,7 @@ async def test_edit_note_find_replace_empty_find_text(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test Note\nSome content here.",
|
||||
)
|
||||
|
||||
@@ -426,7 +426,7 @@ async def test_edit_note_preserves_permalink_when_frontmatter_missing(client, te
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test Note\nOriginal content here.",
|
||||
)
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ async def test_list_directory_with_created_notes(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Project Planning",
|
||||
folder="projects",
|
||||
directory="projects",
|
||||
content="# Project Planning\nThis is about planning projects.",
|
||||
tags=["planning", "project"],
|
||||
)
|
||||
@@ -147,7 +147,7 @@ async def test_list_directory_with_created_notes(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Meeting Notes",
|
||||
folder="projects",
|
||||
directory="projects",
|
||||
content="# Meeting Notes\nNotes from the meeting.",
|
||||
tags=["meeting", "notes"],
|
||||
)
|
||||
@@ -155,7 +155,7 @@ async def test_list_directory_with_created_notes(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Research Document",
|
||||
folder="research",
|
||||
directory="research",
|
||||
content="# Research\nSome research findings.",
|
||||
tags=["research"],
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ async def test_move_note_success(app, client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Test Note\nOriginal content here.",
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ async def test_move_note_with_folder_creation(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Deep Note",
|
||||
folder="",
|
||||
directory="",
|
||||
content="# Deep Note\nContent in root folder.",
|
||||
)
|
||||
|
||||
@@ -104,7 +104,7 @@ async def test_move_note_with_observations_and_relations(app, client, test_proje
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Complex Entity",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="""# Complex Entity
|
||||
|
||||
## Observations
|
||||
@@ -145,7 +145,7 @@ async def test_move_note_by_title(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="UniqueTestTitle",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# UniqueTestTitle\nTest content.",
|
||||
)
|
||||
|
||||
@@ -172,7 +172,7 @@ async def test_move_note_by_file_path(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="PathTest",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# PathTest\nContent for path test.",
|
||||
)
|
||||
|
||||
@@ -215,7 +215,7 @@ async def test_move_note_invalid_destination_path(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="TestNote",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# TestNote\nTest content.",
|
||||
)
|
||||
|
||||
@@ -239,7 +239,7 @@ async def test_move_note_missing_file_extension(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="ExtensionTest",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Extension Test\nTesting extension validation.",
|
||||
)
|
||||
|
||||
@@ -281,7 +281,7 @@ async def test_move_note_file_extension_mismatch(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="MarkdownNote",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Markdown Note\nThis is a markdown file.",
|
||||
)
|
||||
|
||||
@@ -313,7 +313,7 @@ async def test_move_note_preserves_file_extension(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="PreserveExtension",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Preserve Extension\nTesting that extension is preserved.",
|
||||
)
|
||||
|
||||
@@ -348,7 +348,7 @@ async def test_move_note_destination_exists(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="SourceNote",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# SourceNote\nSource content.",
|
||||
)
|
||||
|
||||
@@ -356,7 +356,7 @@ async def test_move_note_destination_exists(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="DestinationNote",
|
||||
folder="target",
|
||||
directory="target",
|
||||
content="# DestinationNote\nDestination content.",
|
||||
)
|
||||
|
||||
@@ -380,7 +380,7 @@ async def test_move_note_same_location(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="SameLocationTest",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# SameLocationTest\nContent here.",
|
||||
)
|
||||
|
||||
@@ -404,7 +404,7 @@ async def test_move_note_rename_only(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="OriginalName",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# OriginalName\nContent to rename.",
|
||||
)
|
||||
|
||||
@@ -436,7 +436,7 @@ async def test_move_note_complex_filename(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Meeting Notes 2025",
|
||||
folder="meetings",
|
||||
directory="meetings",
|
||||
content="# Meeting Notes 2025\nMeeting content with dates.",
|
||||
)
|
||||
|
||||
@@ -465,7 +465,7 @@ async def test_move_note_with_tags(app, client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Tagged Note",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Tagged Note\nContent with tags.",
|
||||
tags=["important", "work", "project"],
|
||||
)
|
||||
@@ -494,7 +494,7 @@ async def test_move_note_empty_string_destination(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="TestNote",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# TestNote\nTest content.",
|
||||
)
|
||||
|
||||
@@ -518,7 +518,7 @@ async def test_move_note_parent_directory_path(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="TestNote",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# TestNote\nTest content.",
|
||||
)
|
||||
|
||||
@@ -542,7 +542,7 @@ async def test_move_note_identifier_variations(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Document",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
content="# Test Document\nContent for testing identifiers.",
|
||||
)
|
||||
|
||||
@@ -569,7 +569,7 @@ async def test_move_note_preserves_frontmatter(app, client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Custom Frontmatter Note",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Custom Frontmatter Note\nContent with custom metadata.",
|
||||
)
|
||||
|
||||
@@ -641,7 +641,7 @@ class TestMoveNoteSecurityValidation:
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Test Note\nTest content for security testing.",
|
||||
)
|
||||
|
||||
@@ -675,7 +675,7 @@ class TestMoveNoteSecurityValidation:
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Test Note\nTest content for security testing.",
|
||||
)
|
||||
|
||||
@@ -708,7 +708,7 @@ class TestMoveNoteSecurityValidation:
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Test Note\nTest content for security testing.",
|
||||
)
|
||||
|
||||
@@ -743,7 +743,7 @@ class TestMoveNoteSecurityValidation:
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Test Note\nTest content for security testing.",
|
||||
)
|
||||
|
||||
@@ -776,7 +776,7 @@ class TestMoveNoteSecurityValidation:
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Test Note\nTest content for security testing.",
|
||||
)
|
||||
|
||||
@@ -807,7 +807,7 @@ class TestMoveNoteSecurityValidation:
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Test Note\nTest content for security testing.",
|
||||
)
|
||||
|
||||
@@ -844,7 +844,7 @@ class TestMoveNoteSecurityValidation:
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Test Note\nTest content for security testing.",
|
||||
)
|
||||
|
||||
@@ -868,7 +868,7 @@ class TestMoveNoteSecurityValidation:
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Test Note\nTest content for security testing.",
|
||||
)
|
||||
|
||||
@@ -890,7 +890,7 @@ class TestMoveNoteSecurityValidation:
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="source",
|
||||
directory="source",
|
||||
content="# Test Note\nTest content for security testing.",
|
||||
)
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ async def test_read_content_allows_safe_path_integration(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Meeting",
|
||||
folder="notes",
|
||||
directory="notes",
|
||||
content="This is a safe note for read_content()",
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ async def test_read_note_by_title(app, test_project):
|
||||
"""Test reading a note by its title."""
|
||||
# First create a note
|
||||
await write_note.fn(
|
||||
project=test_project.name, title="Special Note", folder="test", content="Note content here"
|
||||
project=test_project.name, title="Special Note", directory="test", content="Note content here"
|
||||
)
|
||||
|
||||
# Should be able to read it by title
|
||||
@@ -28,7 +28,7 @@ async def test_read_note_title_search_fallback_fetches_by_permalink(monkeypatch,
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Fallback Title Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="fallback content",
|
||||
)
|
||||
|
||||
@@ -109,7 +109,7 @@ async def test_note_unicode_content(app, test_project):
|
||||
"""Test handling of unicode content in"""
|
||||
content = "# Test 🚀\nThis note has emoji 🎉 and unicode ♠♣♥♦"
|
||||
result = await write_note.fn(
|
||||
project=test_project.name, title="Unicode Test", folder="test", content=content
|
||||
project=test_project.name, title="Unicode Test", directory="test", content=content
|
||||
)
|
||||
|
||||
# Check that note was created (checksum is now "unknown" in v2)
|
||||
@@ -136,7 +136,7 @@ async def test_multiple_notes(app, test_project):
|
||||
|
||||
for _, title, folder, content, tags in notes_data:
|
||||
await write_note.fn(
|
||||
project=test_project.name, title=title, folder=folder, content=content, tags=tags
|
||||
project=test_project.name, title=title, directory=folder, content=content, tags=tags
|
||||
)
|
||||
|
||||
# Should be able to read each one individually
|
||||
@@ -161,7 +161,7 @@ async def test_multiple_notes_pagination(app, test_project):
|
||||
|
||||
for _, title, folder, content, tags in notes_data:
|
||||
await write_note.fn(
|
||||
project=test_project.name, title=title, folder=folder, content=content, tags=tags
|
||||
project=test_project.name, title=title, directory=folder, content=content, tags=tags
|
||||
)
|
||||
|
||||
# Should be able to read each one individually with pagination
|
||||
@@ -187,7 +187,7 @@ async def test_read_note_memory_url(app, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Memory URL Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing memory:// URL handling",
|
||||
)
|
||||
assert result
|
||||
@@ -365,7 +365,7 @@ class TestReadNoteSecurityValidation:
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Security Test Note",
|
||||
folder="security-tests",
|
||||
directory="security-tests",
|
||||
content="# Security Test Note\nThis is a legitimate note for security testing.",
|
||||
)
|
||||
|
||||
@@ -423,7 +423,7 @@ class TestReadNoteSecurityValidation:
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Full Feature Security Test Note",
|
||||
folder="security-tests",
|
||||
directory="security-tests",
|
||||
content=dedent("""
|
||||
# Full Feature Security Test Note
|
||||
|
||||
|
||||
@@ -170,8 +170,8 @@ async def test_recent_activity_discovery_mode_multiple_active_projects(
|
||||
)
|
||||
assert result.startswith("✓")
|
||||
|
||||
await write_note.fn(project=test_project.name, title="One", folder="notes", content="one")
|
||||
await write_note.fn(project="second-project", title="Two", folder="notes", content="two")
|
||||
await write_note.fn(project=test_project.name, title="One", directory="notes", content="one")
|
||||
await write_note.fn(project="second-project", title="Two", directory="notes", content="two")
|
||||
|
||||
out = await recent_activity.fn()
|
||||
assert "Recent Activity Summary" in out
|
||||
|
||||
@@ -28,7 +28,7 @@ async def test_read_file_text_file(app, synced_files, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Text Resource",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="This is a test text resource",
|
||||
tags=["test", "resource"],
|
||||
)
|
||||
@@ -56,7 +56,7 @@ async def test_read_content_file_path(app, synced_files, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Text Resource",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="This is a test text resource",
|
||||
tags=["test", "resource"],
|
||||
)
|
||||
@@ -138,7 +138,7 @@ async def test_read_file_memory_url(app, synced_files, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Memory URL Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing memory:// URL handling for resources",
|
||||
)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ async def test_search_text(client, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
tags=["test", "search"],
|
||||
)
|
||||
@@ -41,7 +41,7 @@ async def test_search_title(client, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
tags=["test", "search"],
|
||||
)
|
||||
@@ -69,7 +69,7 @@ async def test_search_permalink(client, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
tags=["test", "search"],
|
||||
)
|
||||
@@ -97,7 +97,7 @@ async def test_search_permalink_match(client, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
tags=["test", "search"],
|
||||
)
|
||||
@@ -125,7 +125,7 @@ async def test_search_pagination(client, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Search Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nThis is a searchable test note",
|
||||
tags=["test", "search"],
|
||||
)
|
||||
@@ -153,7 +153,7 @@ async def test_search_with_type_filter(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Entity Type Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nFiltered by type",
|
||||
)
|
||||
|
||||
@@ -176,7 +176,7 @@ async def test_search_with_entity_type_filter(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Entity Type Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nFiltered by type",
|
||||
)
|
||||
|
||||
@@ -201,7 +201,7 @@ async def test_search_with_date_filter(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Recent Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nRecent content",
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ async def test_view_note_basic_functionality(app, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test View Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test View Note\n\nThis is test content for viewing.",
|
||||
)
|
||||
|
||||
@@ -48,7 +48,7 @@ async def test_view_note_with_frontmatter_title(app, test_project):
|
||||
""").strip()
|
||||
|
||||
await write_note.fn(
|
||||
project=test_project.name, title="Frontmatter Title", folder="test", content=content
|
||||
project=test_project.name, title="Frontmatter Title", directory="test", content=content
|
||||
)
|
||||
|
||||
# View the note
|
||||
@@ -66,7 +66,7 @@ async def test_view_note_with_heading_title(app, test_project):
|
||||
content = "# Heading Title\n\nContent with heading title."
|
||||
|
||||
await write_note.fn(
|
||||
project=test_project.name, title="Heading Title", folder="test", content=content
|
||||
project=test_project.name, title="Heading Title", directory="test", content=content
|
||||
)
|
||||
|
||||
# View the note
|
||||
@@ -83,7 +83,7 @@ async def test_view_note_unicode_content(app, test_project):
|
||||
content = "# Unicode Test 🚀\n\nThis note has emoji 🎉 and unicode ♠♣♥♦"
|
||||
|
||||
await write_note.fn(
|
||||
project=test_project.name, title="Unicode Test 🚀", folder="test", content=content
|
||||
project=test_project.name, title="Unicode Test 🚀", directory="test", content=content
|
||||
)
|
||||
|
||||
# View the note
|
||||
@@ -102,7 +102,7 @@ async def test_view_note_by_permalink(app, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Permalink Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Content for permalink test.",
|
||||
)
|
||||
|
||||
@@ -121,7 +121,7 @@ async def test_view_note_with_memory_url(app, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Memory URL Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing memory:// URL handling in view_note",
|
||||
)
|
||||
|
||||
@@ -154,7 +154,7 @@ async def test_view_note_pagination(app, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Pagination Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Content for pagination test.",
|
||||
)
|
||||
|
||||
@@ -173,7 +173,7 @@ async def test_view_note_project_parameter(app, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Project Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Content for project test.",
|
||||
)
|
||||
|
||||
@@ -191,10 +191,10 @@ async def test_view_note_artifact_identifier_unique(app, test_project):
|
||||
"""Test that different notes are retrieved correctly with unique identifiers."""
|
||||
# Create two notes
|
||||
await write_note.fn(
|
||||
project=test_project.name, title="Note One", folder="test", content="Content one"
|
||||
project=test_project.name, title="Note One", directory="test", content="Content one"
|
||||
)
|
||||
await write_note.fn(
|
||||
project=test_project.name, title="Note Two", folder="test", content="Content two"
|
||||
project=test_project.name, title="Note Two", directory="test", content="Content two"
|
||||
)
|
||||
|
||||
# View both notes
|
||||
@@ -215,7 +215,7 @@ async def test_view_note_fallback_identifier_as_title(app, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Simple Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Just plain content with no headings or frontmatter title",
|
||||
)
|
||||
|
||||
@@ -233,7 +233,7 @@ async def test_view_note_direct_success(app, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test Note\n\nThis is a test note.",
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ async def test_write_note(app, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nThis is a test note",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
@@ -60,7 +60,7 @@ async def test_write_note(app, test_project):
|
||||
async def test_write_note_no_tags(app, test_project):
|
||||
"""Test creating a note without tags."""
|
||||
result = await write_note.fn(
|
||||
project=test_project.name, title="Simple Note", folder="test", content="Just some text"
|
||||
project=test_project.name, title="Simple Note", directory="test", content="Just some text"
|
||||
)
|
||||
|
||||
assert result
|
||||
@@ -100,7 +100,7 @@ async def test_write_note_update_existing(app, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nThis is a test note",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
@@ -117,7 +117,7 @@ async def test_write_note_update_existing(app, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nThis is an updated note",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
@@ -173,7 +173,7 @@ async def test_issue_93_write_note_respects_custom_permalink_new_note(app, test_
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="My New Note",
|
||||
folder="notes",
|
||||
directory="notes",
|
||||
content=content_with_custom_permalink,
|
||||
)
|
||||
|
||||
@@ -193,7 +193,7 @@ async def test_issue_93_write_note_respects_custom_permalink_existing_note(app,
|
||||
result1 = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Existing Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Initial content without custom permalink",
|
||||
)
|
||||
|
||||
@@ -225,7 +225,7 @@ async def test_issue_93_write_note_respects_custom_permalink_existing_note(app,
|
||||
result2 = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Existing Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content=updated_content,
|
||||
)
|
||||
|
||||
@@ -249,7 +249,7 @@ async def test_delete_note_existing(app, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Test\nThis is a test note",
|
||||
tags=["test", "documentation"],
|
||||
)
|
||||
@@ -284,7 +284,7 @@ async def test_write_note_with_tag_array_from_bug_report(app, test_project):
|
||||
bug_payload = {
|
||||
"project": test_project.name,
|
||||
"title": "Title",
|
||||
"folder": "folder",
|
||||
"directory": "folder",
|
||||
"content": "CONTENT",
|
||||
"tags": ["hipporag", "search", "fallback", "symfony", "error-handling"],
|
||||
}
|
||||
@@ -313,7 +313,7 @@ async def test_write_note_verbose(app, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="""
|
||||
# Test\nThis is a test note
|
||||
|
||||
@@ -352,7 +352,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config, test_pr
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Custom Metadata Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Initial content",
|
||||
tags=["test"],
|
||||
)
|
||||
@@ -380,7 +380,7 @@ async def test_write_note_preserves_custom_metadata(app, project_config, test_pr
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Custom Metadata Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Updated content",
|
||||
tags=["test", "updated"],
|
||||
)
|
||||
@@ -415,7 +415,7 @@ async def test_write_note_preserves_content_frontmatter(app, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content=dedent(
|
||||
"""
|
||||
---
|
||||
@@ -475,7 +475,7 @@ async def test_write_note_permalink_collision_fix_issue_139(app, test_project):
|
||||
result1 = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Note 1",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Original content for note 1",
|
||||
)
|
||||
assert "# Created note" in result1
|
||||
@@ -484,7 +484,7 @@ async def test_write_note_permalink_collision_fix_issue_139(app, test_project):
|
||||
|
||||
# Step 2: Create second note with different title
|
||||
result2 = await write_note.fn(
|
||||
project=test_project.name, title="Note 2", folder="test", content="Content for note 2"
|
||||
project=test_project.name, title="Note 2", directory="test", content="Content for note 2"
|
||||
)
|
||||
assert "# Created note" in result2
|
||||
assert f"project: {test_project.name}" in result2
|
||||
@@ -495,7 +495,7 @@ async def test_write_note_permalink_collision_fix_issue_139(app, test_project):
|
||||
result3 = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Note 1", # Same title as first note
|
||||
folder="test", # Same folder as first note
|
||||
directory="test", # Same folder as first note
|
||||
content="Replacement content for note 1", # Different content
|
||||
)
|
||||
|
||||
@@ -535,7 +535,7 @@ async def test_write_note_with_custom_entity_type(app, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Guide",
|
||||
folder="guides",
|
||||
directory="guides",
|
||||
content="# Guide Content\nThis is a guide",
|
||||
tags=["guide", "documentation"],
|
||||
note_type="guide",
|
||||
@@ -578,7 +578,7 @@ async def test_write_note_with_report_entity_type(app, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Monthly Report",
|
||||
folder="reports",
|
||||
directory="reports",
|
||||
content="# Monthly Report\nThis is a monthly report",
|
||||
tags=["report", "monthly"],
|
||||
note_type="report",
|
||||
@@ -603,7 +603,7 @@ async def test_write_note_with_config_entity_type(app, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="System Config",
|
||||
folder="config",
|
||||
directory="config",
|
||||
content="# System Configuration\nThis is a config file",
|
||||
note_type="config",
|
||||
)
|
||||
@@ -631,7 +631,7 @@ async def test_write_note_entity_type_default_behavior(app, test_project):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Default Type Test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Default Type Test\nThis should be type 'note'",
|
||||
tags=["test"],
|
||||
)
|
||||
@@ -656,7 +656,7 @@ async def test_write_note_update_existing_with_different_entity_type(app, test_p
|
||||
result1 = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Changeable Type",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Initial Content\nThis starts as a note",
|
||||
tags=["test"],
|
||||
note_type="note",
|
||||
@@ -670,7 +670,7 @@ async def test_write_note_update_existing_with_different_entity_type(app, test_p
|
||||
result2 = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Changeable Type",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="# Updated Content\nThis is now a guide",
|
||||
tags=["guide"],
|
||||
note_type="guide",
|
||||
@@ -711,7 +711,7 @@ async def test_write_note_respects_frontmatter_entity_type(app, test_project):
|
||||
|
||||
# Call write_note without entity_type parameter - it should respect frontmatter type
|
||||
result = await write_note.fn(
|
||||
project=test_project.name, title="Test Guide", folder="guides", content=note
|
||||
project=test_project.name, title="Test Guide", directory="guides", content=note
|
||||
)
|
||||
|
||||
assert result
|
||||
@@ -752,7 +752,7 @@ class TestWriteNoteSecurityValidation:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder=attack_folder,
|
||||
directory=attack_folder,
|
||||
content="# Test Content\nThis should be blocked by security validation.",
|
||||
)
|
||||
|
||||
@@ -781,7 +781,7 @@ class TestWriteNoteSecurityValidation:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder=attack_folder,
|
||||
directory=attack_folder,
|
||||
content="# Test Content\nThis should be blocked by security validation.",
|
||||
)
|
||||
|
||||
@@ -810,7 +810,7 @@ class TestWriteNoteSecurityValidation:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder=attack_folder,
|
||||
directory=attack_folder,
|
||||
content="# Test Content\nThis should be blocked by security validation.",
|
||||
)
|
||||
|
||||
@@ -838,7 +838,7 @@ class TestWriteNoteSecurityValidation:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder=attack_folder,
|
||||
directory=attack_folder,
|
||||
content="# Test Content\nThis should be blocked by security validation.",
|
||||
)
|
||||
|
||||
@@ -864,7 +864,7 @@ class TestWriteNoteSecurityValidation:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder=attack_folder,
|
||||
directory=attack_folder,
|
||||
content="# Test Content\nThis should be blocked by security validation.",
|
||||
)
|
||||
|
||||
@@ -891,7 +891,7 @@ class TestWriteNoteSecurityValidation:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title=f"Test Note in {safe_folder.replace('/', '-')}",
|
||||
folder=safe_folder,
|
||||
directory=safe_folder,
|
||||
content="# Test Content\nThis should work normally with security validation.",
|
||||
tags=["test", "security"],
|
||||
)
|
||||
@@ -911,7 +911,7 @@ class TestWriteNoteSecurityValidation:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Root Note",
|
||||
folder="",
|
||||
directory="",
|
||||
content="# Root Note\nThis note should be created in the project root.",
|
||||
)
|
||||
|
||||
@@ -930,7 +930,7 @@ class TestWriteNoteSecurityValidation:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Root Folder Note",
|
||||
folder="", # Empty string instead of None since folder is required
|
||||
directory="", # Empty string instead of None since folder is required
|
||||
content="# Root Folder Note\nThis note should be created in the project root.",
|
||||
)
|
||||
|
||||
@@ -955,7 +955,7 @@ class TestWriteNoteSecurityValidation:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title=f"Current Dir Test {safe_folder.replace('/', '-').replace('.', 'dot')}",
|
||||
folder=safe_folder,
|
||||
directory=safe_folder,
|
||||
content="# Current Directory Test\nThis should work with current directory references.",
|
||||
)
|
||||
|
||||
@@ -973,7 +973,7 @@ class TestWriteNoteSecurityValidation:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Security Test with All Params",
|
||||
folder="../../../etc/malicious",
|
||||
directory="../../../etc/malicious",
|
||||
content="# Malicious Content\nThis should be blocked by security validation.",
|
||||
tags=["malicious", "test"],
|
||||
note_type="guide",
|
||||
@@ -991,7 +991,7 @@ class TestWriteNoteSecurityValidation:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Security Logging Test",
|
||||
folder="../../../etc/passwd_folder",
|
||||
directory="../../../etc/passwd_folder",
|
||||
content="# Test Content\nThis should trigger security logging.",
|
||||
)
|
||||
|
||||
@@ -1009,7 +1009,7 @@ class TestWriteNoteSecurityValidation:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Full Feature Security Test",
|
||||
folder="security-tests",
|
||||
directory="security-tests",
|
||||
content=dedent("""
|
||||
# Full Feature Security Test
|
||||
|
||||
@@ -1064,7 +1064,7 @@ class TestWriteNoteSecurityEdgeCases:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Unicode Attack Test",
|
||||
folder=attack_folder,
|
||||
directory=attack_folder,
|
||||
content="# Unicode Attack\nThis should be blocked.",
|
||||
)
|
||||
|
||||
@@ -1081,7 +1081,7 @@ class TestWriteNoteSecurityEdgeCases:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Long Attack Test",
|
||||
folder=long_attack_folder,
|
||||
directory=long_attack_folder,
|
||||
content="# Long Attack\nThis should be blocked.",
|
||||
)
|
||||
|
||||
@@ -1104,7 +1104,7 @@ class TestWriteNoteSecurityEdgeCases:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Case Variation Attack Test",
|
||||
folder=attack_folder,
|
||||
directory=attack_folder,
|
||||
content="# Case Attack\nThis should be blocked.",
|
||||
)
|
||||
|
||||
@@ -1127,7 +1127,7 @@ class TestWriteNoteSecurityEdgeCases:
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Whitespace Attack Test",
|
||||
folder=attack_folder,
|
||||
directory=attack_folder,
|
||||
content="# Whitespace Attack\nThis should be blocked.",
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ async def test_write_note_spaces_to_hyphens(app, test_project, app_config):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="My Awesome Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing space conversion",
|
||||
)
|
||||
|
||||
@@ -48,7 +48,7 @@ async def test_write_note_underscores_to_hyphens(app, test_project, app_config):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="my_note_with_underscores",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing underscore conversion",
|
||||
)
|
||||
|
||||
@@ -64,7 +64,7 @@ async def test_write_note_camelcase_to_kebab(app, test_project, app_config):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="MyAwesomeFeature",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing CamelCase conversion",
|
||||
)
|
||||
|
||||
@@ -80,7 +80,7 @@ async def test_write_note_mixed_case_to_lowercase(app, test_project, app_config)
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="MIXED_Case_Example",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing case conversion",
|
||||
)
|
||||
|
||||
@@ -105,7 +105,7 @@ async def test_write_note_single_period_preserved(app, test_project, app_config)
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test 3.0 Version",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing period preservation",
|
||||
)
|
||||
|
||||
@@ -121,7 +121,7 @@ async def test_write_note_multiple_periods_preserved(app, test_project, app_conf
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Version 1.2.3 Release",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing multiple period preservation",
|
||||
)
|
||||
|
||||
@@ -142,7 +142,7 @@ async def test_write_note_special_chars_to_hyphens(app, test_project, app_config
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test 2.0: New Feature",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing special character conversion",
|
||||
)
|
||||
|
||||
@@ -158,7 +158,7 @@ async def test_write_note_parentheses_removed(app, test_project, app_config):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Feature (v2.0) Update",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing parentheses handling",
|
||||
)
|
||||
|
||||
@@ -174,7 +174,7 @@ async def test_write_note_apostrophes_removed(app, test_project, app_config):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="User's Guide",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing apostrophe handling",
|
||||
)
|
||||
|
||||
@@ -195,7 +195,7 @@ async def test_write_note_all_transformations_combined(app, test_project, app_co
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="MyProject_v3.0: Feature Update (DRAFT)",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing combined transformations",
|
||||
)
|
||||
|
||||
@@ -211,7 +211,7 @@ async def test_write_note_consecutive_special_chars_collapsed(app, test_project,
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test___Multiple---Separators",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing consecutive special character collapse",
|
||||
)
|
||||
|
||||
@@ -233,7 +233,7 @@ async def test_write_note_leading_trailing_hyphens_trimmed(app, test_project, ap
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="---Test Note---",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing leading/trailing hyphen trimming",
|
||||
)
|
||||
|
||||
@@ -249,7 +249,7 @@ async def test_write_note_all_special_chars_becomes_valid_filename(app, test_pro
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="!!!Test!!!",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing all special characters",
|
||||
)
|
||||
|
||||
@@ -270,7 +270,7 @@ async def test_write_note_folder_path_unaffected(app, test_project, app_config):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note",
|
||||
folder="My_Folder/Sub Folder", # Folder should remain as-is
|
||||
directory="My_Folder/Sub Folder", # Folder should remain as-is
|
||||
content="Testing folder path preservation",
|
||||
)
|
||||
|
||||
@@ -287,7 +287,7 @@ async def test_write_note_root_folder_with_kebab(app, test_project, app_config):
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test 3.0 Note",
|
||||
folder="", # Root folder
|
||||
directory="", # Root folder
|
||||
content="Testing root folder",
|
||||
)
|
||||
|
||||
@@ -308,7 +308,7 @@ async def test_write_note_kebab_disabled_preserves_original(app, test_project, a
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test 3.0 Version",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing backward compatibility",
|
||||
)
|
||||
|
||||
@@ -326,7 +326,7 @@ async def test_write_note_kebab_disabled_preserves_underscores(app, test_project
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="my_note_example",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing underscore preservation",
|
||||
)
|
||||
|
||||
@@ -342,7 +342,7 @@ async def test_write_note_kebab_disabled_preserves_case(app, test_project, app_c
|
||||
result = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="MyAwesomeNote",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing case preservation",
|
||||
)
|
||||
|
||||
@@ -369,7 +369,7 @@ async def test_permalinks_always_kebab_case(app, test_project, app_config):
|
||||
result1 = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note 1",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing permalink consistency",
|
||||
)
|
||||
|
||||
@@ -383,7 +383,7 @@ async def test_permalinks_always_kebab_case(app, test_project, app_config):
|
||||
result2 = await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Note 2",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content="Testing permalink consistency",
|
||||
)
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ async def test_search_successful_results(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Document 1",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
content="# Test Document 1\n\nThis is test content for document 1",
|
||||
)
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Document 2",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
content="# Test Document 2\n\nThis is test content for document 2",
|
||||
)
|
||||
|
||||
@@ -72,7 +72,7 @@ async def test_fetch_successful_document(client, test_project):
|
||||
await write_note.fn(
|
||||
project=test_project.name,
|
||||
title="Test Document",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
content="# Test Document\n\nThis is the content of a test document.",
|
||||
)
|
||||
|
||||
|
||||
@@ -456,6 +456,66 @@ async def test_get_by_title(entity_repository: EntityRepository, session_maker):
|
||||
assert len(found) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_title_returns_shortest_path_first(
|
||||
entity_repository: EntityRepository, session_maker
|
||||
):
|
||||
"""Test that duplicate titles are returned with shortest path first.
|
||||
|
||||
When multiple entities share the same title in different folders,
|
||||
the one with the shortest file path should be returned first.
|
||||
This provides consistent, predictable link resolution.
|
||||
"""
|
||||
async with db.scoped_session(session_maker) as session:
|
||||
# Create entities with same title but different path lengths
|
||||
# Insert in reverse order to ensure we're testing ordering, not insertion order
|
||||
entities = [
|
||||
Entity(
|
||||
project_id=entity_repository.project_id,
|
||||
title="My Note",
|
||||
entity_type="note",
|
||||
permalink="archive/old/2024/my-note",
|
||||
file_path="archive/old/2024/My Note.md", # longest path
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
Entity(
|
||||
project_id=entity_repository.project_id,
|
||||
title="My Note",
|
||||
entity_type="note",
|
||||
permalink="docs/my-note",
|
||||
file_path="docs/My Note.md", # medium path
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
Entity(
|
||||
project_id=entity_repository.project_id,
|
||||
title="My Note",
|
||||
entity_type="note",
|
||||
permalink="my-note",
|
||||
file_path="My Note.md", # shortest path (root)
|
||||
content_type="text/markdown",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
session.add_all(entities)
|
||||
await session.flush()
|
||||
|
||||
# Get all entities with title "My Note"
|
||||
found = await entity_repository.get_by_title("My Note")
|
||||
|
||||
# Should return all 3
|
||||
assert len(found) == 3
|
||||
|
||||
# Should be ordered by path length (shortest first)
|
||||
assert found[0].file_path == "My Note.md" # shortest
|
||||
assert found[1].file_path == "docs/My Note.md" # medium
|
||||
assert found[2].file_path == "archive/old/2024/My Note.md" # longest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_file_path(entity_repository: EntityRepository, session_maker):
|
||||
"""Test getting an entity by title."""
|
||||
|
||||
@@ -19,7 +19,7 @@ from basic_memory.schemas.base import to_snake_case, TimeFrame, parse_timeframe,
|
||||
|
||||
def test_entity_project_name():
|
||||
"""Test creating EntityIn with minimal required fields."""
|
||||
data = {"title": "Test Entity", "folder": "test", "entity_type": "knowledge"}
|
||||
data = {"title": "Test Entity", "directory": "test", "entity_type": "knowledge"}
|
||||
entity = Entity.model_validate(data)
|
||||
assert entity.file_path == os.path.join("test", "Test Entity.md")
|
||||
assert entity.permalink == "test/test-entity"
|
||||
@@ -28,7 +28,7 @@ def test_entity_project_name():
|
||||
|
||||
def test_entity_project_id():
|
||||
"""Test creating EntityIn with minimal required fields."""
|
||||
data = {"project": 2, "title": "Test Entity", "folder": "test", "entity_type": "knowledge"}
|
||||
data = {"project": 2, "title": "Test Entity", "directory": "test", "entity_type": "knowledge"}
|
||||
entity = Entity.model_validate(data)
|
||||
assert entity.file_path == os.path.join("test", "Test Entity.md")
|
||||
assert entity.permalink == "test/test-entity"
|
||||
@@ -39,7 +39,7 @@ def test_entity_non_markdown():
|
||||
"""Test entity for regular non-markdown file."""
|
||||
data = {
|
||||
"title": "Test Entity.txt",
|
||||
"folder": "test",
|
||||
"directory": "test",
|
||||
"entity_type": "file",
|
||||
"content_type": "text/plain",
|
||||
}
|
||||
@@ -243,11 +243,11 @@ def test_path_sanitization():
|
||||
def test_permalink_generation():
|
||||
"""Test permalink property generates correct paths."""
|
||||
test_cases = [
|
||||
({"title": "BasicMemory", "folder": "test"}, "test/basic-memory"),
|
||||
({"title": "Memory Service", "folder": "test"}, "test/memory-service"),
|
||||
({"title": "API Gateway", "folder": "test"}, "test/api-gateway"),
|
||||
({"title": "TestCase1", "folder": "test"}, "test/test-case1"),
|
||||
({"title": "TestCaseRoot", "folder": ""}, "test-case-root"),
|
||||
({"title": "BasicMemory", "directory": "test"}, "test/basic-memory"),
|
||||
({"title": "Memory Service", "directory": "test"}, "test/memory-service"),
|
||||
({"title": "API Gateway", "directory": "test"}, "test/api-gateway"),
|
||||
({"title": "TestCase1", "directory": "test"}, "test/test-case1"),
|
||||
({"title": "TestCaseRoot", "directory": ""}, "test-case-root"),
|
||||
]
|
||||
|
||||
for input_data, expected_path in test_cases:
|
||||
|
||||
@@ -23,7 +23,7 @@ async def test_create_entity(entity_service: EntityService, file_service: FileSe
|
||||
"""Test successful entity creation."""
|
||||
entity_data = EntitySchema(
|
||||
title="Test Entity",
|
||||
folder="",
|
||||
directory="",
|
||||
entity_type="test",
|
||||
)
|
||||
|
||||
@@ -62,7 +62,7 @@ async def test_create_entity_file_exists(entity_service: EntityService, file_ser
|
||||
"""Test successful entity creation."""
|
||||
entity_data = EntitySchema(
|
||||
title="Test Entity",
|
||||
folder="",
|
||||
directory="",
|
||||
entity_type="test",
|
||||
content="first",
|
||||
)
|
||||
@@ -81,7 +81,7 @@ async def test_create_entity_file_exists(entity_service: EntityService, file_ser
|
||||
|
||||
entity_data = EntitySchema(
|
||||
title="Test Entity",
|
||||
folder="",
|
||||
directory="",
|
||||
entity_type="test",
|
||||
content="second",
|
||||
)
|
||||
@@ -100,7 +100,7 @@ async def test_create_entity_unique_permalink(
|
||||
"""Test successful entity creation."""
|
||||
entity_data = EntitySchema(
|
||||
title="Test Entity",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
)
|
||||
|
||||
@@ -132,14 +132,14 @@ async def test_get_by_permalink(entity_service: EntityService):
|
||||
"""Test finding entity by type and name combination."""
|
||||
entity1_data = EntitySchema(
|
||||
title="TestEntity1",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
)
|
||||
entity1 = await entity_service.create_entity(entity1_data)
|
||||
|
||||
entity2_data = EntitySchema(
|
||||
title="TestEntity2",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
)
|
||||
entity2 = await entity_service.create_entity(entity2_data)
|
||||
@@ -166,7 +166,7 @@ async def test_get_entity_success(entity_service: EntityService):
|
||||
"""Test successful entity retrieval."""
|
||||
entity_data = EntitySchema(
|
||||
title="TestEntity",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
)
|
||||
await entity_service.create_entity(entity_data)
|
||||
@@ -184,7 +184,7 @@ async def test_delete_entity_success(entity_service: EntityService):
|
||||
"""Test successful entity deletion."""
|
||||
entity_data = EntitySchema(
|
||||
title="TestEntity",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
)
|
||||
await entity_service.create_entity(entity_data)
|
||||
@@ -203,7 +203,7 @@ async def test_delete_entity_by_id(entity_service: EntityService):
|
||||
"""Test successful entity deletion."""
|
||||
entity_data = EntitySchema(
|
||||
title="TestEntity",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
)
|
||||
created = await entity_service.create_entity(entity_data)
|
||||
@@ -236,7 +236,7 @@ async def test_create_entity_with_special_chars(entity_service: EntityService):
|
||||
name = "TestEntity_$pecial chars & symbols!" # Note: Using valid path characters
|
||||
entity_data = EntitySchema(
|
||||
title=name,
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
)
|
||||
entity = await entity_service.create_entity(entity_data)
|
||||
@@ -253,12 +253,12 @@ async def test_get_entities_by_permalinks(entity_service: EntityService):
|
||||
# Create test entities
|
||||
entity1_data = EntitySchema(
|
||||
title="Entity1",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
)
|
||||
entity2_data = EntitySchema(
|
||||
title="Entity2",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
)
|
||||
await entity_service.create_entity(entity1_data)
|
||||
@@ -286,7 +286,7 @@ async def test_get_entities_some_not_found(entity_service: EntityService):
|
||||
# Create one test entity
|
||||
entity_data = EntitySchema(
|
||||
title="Entity1",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
)
|
||||
await entity_service.create_entity(entity_data)
|
||||
@@ -317,7 +317,7 @@ async def test_update_note_entity_content(entity_service: EntityService, file_se
|
||||
# Create test entity
|
||||
schema = EntitySchema(
|
||||
title="test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
entity_metadata={"status": "draft"},
|
||||
)
|
||||
@@ -354,7 +354,7 @@ async def test_create_or_update_new(entity_service: EntityService, file_service:
|
||||
entity, created = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
title="test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
entity_metadata={"status": "draft"},
|
||||
)
|
||||
@@ -370,7 +370,7 @@ async def test_create_or_update_existing(entity_service: EntityService, file_ser
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
content="Test entity",
|
||||
entity_metadata={"status": "final"},
|
||||
@@ -413,7 +413,7 @@ async def test_create_with_content(entity_service: EntityService, file_service:
|
||||
entity, created = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
title="Git Workflow Guide",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
content=content,
|
||||
)
|
||||
@@ -480,7 +480,7 @@ async def test_update_with_content(entity_service: EntityService, file_service:
|
||||
EntitySchema(
|
||||
title="Git Workflow Guide",
|
||||
entity_type="test",
|
||||
folder="test",
|
||||
directory="test",
|
||||
content=content,
|
||||
)
|
||||
)
|
||||
@@ -538,7 +538,7 @@ async def test_update_with_content(entity_service: EntityService, file_service:
|
||||
entity, created = await entity_service.create_or_update_entity(
|
||||
EntitySchema(
|
||||
title="Git Workflow Guide",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="test",
|
||||
content=update_content,
|
||||
)
|
||||
@@ -612,7 +612,7 @@ async def test_edit_entity_append(entity_service: EntityService, file_service: F
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Original content",
|
||||
)
|
||||
@@ -638,7 +638,7 @@ async def test_edit_entity_prepend(entity_service: EntityService, file_service:
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Original content",
|
||||
)
|
||||
@@ -664,7 +664,7 @@ async def test_edit_entity_find_replace(entity_service: EntityService, file_serv
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="This is old content that needs updating",
|
||||
)
|
||||
@@ -704,7 +704,7 @@ async def test_edit_entity_replace_section(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Sample Note",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
entity_type="note",
|
||||
content=content,
|
||||
)
|
||||
@@ -735,7 +735,7 @@ async def test_edit_entity_replace_section_create_new(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="# Main Title\n\nSome content",
|
||||
)
|
||||
@@ -772,7 +772,7 @@ async def test_edit_entity_invalid_operation(entity_service: EntityService):
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Original content",
|
||||
)
|
||||
@@ -791,7 +791,7 @@ async def test_edit_entity_find_replace_missing_find_text(entity_service: Entity
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Original content",
|
||||
)
|
||||
@@ -810,7 +810,7 @@ async def test_edit_entity_replace_section_missing_section(entity_service: Entit
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Original content",
|
||||
)
|
||||
@@ -840,7 +840,7 @@ async def test_edit_entity_with_observations_and_relations(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Sample Note",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
entity_type="note",
|
||||
content=content,
|
||||
)
|
||||
@@ -949,7 +949,7 @@ async def test_edit_entity_find_replace_not_found(entity_service: EntityService)
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="This is some content",
|
||||
)
|
||||
@@ -974,7 +974,7 @@ async def test_edit_entity_find_replace_multiple_occurrences_expected_one(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Sample Note",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
entity_type="note",
|
||||
content="The word banana appears here. Another banana word here.",
|
||||
)
|
||||
@@ -1000,7 +1000,7 @@ async def test_edit_entity_find_replace_multiple_occurrences_success(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Sample Note",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
entity_type="note",
|
||||
content="The word banana appears here. Another banana word here.",
|
||||
)
|
||||
@@ -1028,7 +1028,7 @@ async def test_edit_entity_find_replace_empty_find_text(entity_service: EntitySe
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Some content",
|
||||
)
|
||||
@@ -1063,7 +1063,7 @@ async def test_edit_entity_find_replace_multiline(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Sample Note",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
entity_type="note",
|
||||
content=content,
|
||||
)
|
||||
@@ -1105,7 +1105,7 @@ async def test_edit_entity_replace_section_multiple_sections_error(entity_servic
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Sample Note",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
entity_type="note",
|
||||
content=content,
|
||||
)
|
||||
@@ -1128,7 +1128,7 @@ async def test_edit_entity_replace_section_empty_section(entity_service: EntityS
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Some content",
|
||||
)
|
||||
@@ -1163,7 +1163,7 @@ async def test_edit_entity_replace_section_header_variations(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Sample Note",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
entity_type="note",
|
||||
content=content,
|
||||
)
|
||||
@@ -1203,7 +1203,7 @@ async def test_edit_entity_replace_section_at_end_of_document(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Sample Note",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
entity_type="note",
|
||||
content=content,
|
||||
)
|
||||
@@ -1250,7 +1250,7 @@ async def test_edit_entity_replace_section_with_subsections(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Sample Note",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
entity_type="note",
|
||||
content=content,
|
||||
)
|
||||
@@ -1294,7 +1294,7 @@ async def test_edit_entity_replace_section_strips_duplicate_header(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Sample Note",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
entity_type="note",
|
||||
content=content,
|
||||
)
|
||||
@@ -1336,7 +1336,7 @@ async def test_move_entity_success(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="original",
|
||||
directory="original",
|
||||
entity_type="note",
|
||||
content="Original content",
|
||||
)
|
||||
@@ -1385,7 +1385,7 @@ async def test_move_entity_with_permalink_update(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="original",
|
||||
directory="original",
|
||||
entity_type="note",
|
||||
content="Original content",
|
||||
)
|
||||
@@ -1426,7 +1426,7 @@ async def test_move_entity_creates_destination_directory(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="original",
|
||||
directory="original",
|
||||
entity_type="note",
|
||||
content="Original content",
|
||||
)
|
||||
@@ -1476,7 +1476,7 @@ async def test_move_entity_source_file_missing(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Original content",
|
||||
)
|
||||
@@ -1508,7 +1508,7 @@ async def test_move_entity_destination_exists(
|
||||
entity1 = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note 1",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Content 1",
|
||||
)
|
||||
@@ -1517,7 +1517,7 @@ async def test_move_entity_destination_exists(
|
||||
entity2 = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note 2",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Content 2",
|
||||
)
|
||||
@@ -1545,7 +1545,7 @@ async def test_move_entity_invalid_destination_path(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Original content",
|
||||
)
|
||||
@@ -1584,7 +1584,7 @@ async def test_move_entity_by_title(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="original",
|
||||
directory="original",
|
||||
entity_type="note",
|
||||
content="Original content",
|
||||
)
|
||||
@@ -1629,7 +1629,7 @@ async def test_move_entity_preserves_observations_and_relations(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="original",
|
||||
directory="original",
|
||||
entity_type="note",
|
||||
content=content,
|
||||
)
|
||||
@@ -1676,7 +1676,7 @@ async def test_move_entity_rollback_on_database_failure(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Test Note",
|
||||
folder="original",
|
||||
directory="original",
|
||||
entity_type="note",
|
||||
content="Original content",
|
||||
)
|
||||
@@ -1736,7 +1736,7 @@ async def test_move_entity_with_complex_observations(
|
||||
entity = await entity_service.create_entity(
|
||||
EntitySchema(
|
||||
title="Complex Note",
|
||||
folder="docs",
|
||||
directory="docs",
|
||||
entity_type="note",
|
||||
content=content,
|
||||
)
|
||||
@@ -1868,7 +1868,7 @@ async def test_create_or_update_entity_fuzzy_search_bug(
|
||||
# Step 1: Create first entity "Node A"
|
||||
entity_a = EntitySchema(
|
||||
title="Node A",
|
||||
folder="edge-cases",
|
||||
directory="edge-cases",
|
||||
entity_type="note",
|
||||
content="# Node A\n\nOriginal content for Node A",
|
||||
)
|
||||
@@ -1892,7 +1892,7 @@ async def test_create_or_update_entity_fuzzy_search_bug(
|
||||
# Step 2: Create Node B to match live test scenario
|
||||
entity_b = EntitySchema(
|
||||
title="Node B",
|
||||
folder="edge-cases",
|
||||
directory="edge-cases",
|
||||
entity_type="note",
|
||||
content="# Node B\n\nContent for Node B",
|
||||
)
|
||||
@@ -1905,7 +1905,7 @@ async def test_create_or_update_entity_fuzzy_search_bug(
|
||||
# BUG: This will incorrectly match Node A via fuzzy search
|
||||
entity_c = EntitySchema(
|
||||
title="Node C",
|
||||
folder="edge-cases",
|
||||
directory="edge-cases",
|
||||
entity_type="note",
|
||||
content="# Node C\n\nContent for Node C",
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ async def test_create_entity_with_permalinks_disabled(
|
||||
|
||||
entity_data = EntitySchema(
|
||||
title="Test Entity",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Test content",
|
||||
)
|
||||
@@ -80,7 +80,7 @@ async def test_update_entity_with_permalinks_disabled(
|
||||
|
||||
entity_data = EntitySchema(
|
||||
title="Test Entity",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Original content",
|
||||
)
|
||||
@@ -150,7 +150,7 @@ async def test_create_entity_with_content_frontmatter_permalinks_disabled(
|
||||
|
||||
entity_data = EntitySchema(
|
||||
title="Test Entity",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content=content,
|
||||
)
|
||||
@@ -196,7 +196,7 @@ async def test_move_entity_with_permalinks_disabled(
|
||||
|
||||
entity_data = EntitySchema(
|
||||
title="Test Entity",
|
||||
folder="test",
|
||||
directory="test",
|
||||
entity_type="note",
|
||||
content="Test content",
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ async def test_entities(entity_service, file_service):
|
||||
EntitySchema(
|
||||
title="Core Service",
|
||||
entity_type="component",
|
||||
folder="components",
|
||||
directory="components",
|
||||
project=entity_service.repository.project_id,
|
||||
)
|
||||
)
|
||||
@@ -37,7 +37,7 @@ async def test_entities(entity_service, file_service):
|
||||
EntitySchema(
|
||||
title="Service Config",
|
||||
entity_type="config",
|
||||
folder="config",
|
||||
directory="config",
|
||||
project=entity_service.repository.project_id,
|
||||
)
|
||||
)
|
||||
@@ -45,7 +45,7 @@ async def test_entities(entity_service, file_service):
|
||||
EntitySchema(
|
||||
title="Auth Service",
|
||||
entity_type="component",
|
||||
folder="components",
|
||||
directory="components",
|
||||
project=entity_service.repository.project_id,
|
||||
)
|
||||
)
|
||||
@@ -53,7 +53,7 @@ async def test_entities(entity_service, file_service):
|
||||
EntitySchema(
|
||||
title="Core Features",
|
||||
entity_type="specs",
|
||||
folder="specs",
|
||||
directory="specs",
|
||||
project=entity_service.repository.project_id,
|
||||
)
|
||||
)
|
||||
@@ -61,7 +61,7 @@ async def test_entities(entity_service, file_service):
|
||||
EntitySchema(
|
||||
title="Sub Features 1",
|
||||
entity_type="specs",
|
||||
folder="specs/subspec",
|
||||
directory="specs/subspec",
|
||||
project=entity_service.repository.project_id,
|
||||
)
|
||||
)
|
||||
@@ -69,7 +69,7 @@ async def test_entities(entity_service, file_service):
|
||||
EntitySchema(
|
||||
title="Sub Features 2",
|
||||
entity_type="specs",
|
||||
folder="specs/subspec",
|
||||
directory="specs/subspec",
|
||||
project=entity_service.repository.project_id,
|
||||
)
|
||||
)
|
||||
@@ -92,7 +92,7 @@ async def test_entities(entity_service, file_service):
|
||||
EntitySchema(
|
||||
title="Core Service",
|
||||
entity_type="component",
|
||||
folder="components2",
|
||||
directory="components2",
|
||||
project=entity_service.repository.project_id,
|
||||
)
|
||||
)
|
||||
@@ -357,3 +357,475 @@ async def test_duplicate_title_handling_in_strict_mode(link_resolver, test_entit
|
||||
assert result is not None
|
||||
# Should return the first match (components/core-service based on test fixture order)
|
||||
assert result.permalink == "components/core-service"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Context-aware resolution tests (source_path parameter)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def context_aware_entities(entity_repository):
|
||||
"""Create entities for testing context-aware resolution.
|
||||
|
||||
Structure:
|
||||
├── testing.md (title: "testing", root level)
|
||||
├── main/
|
||||
│ └── testing/
|
||||
│ ├── testing.md (title: "testing", nested)
|
||||
│ └── another-test.md (title: "another-test")
|
||||
├── other/
|
||||
│ └── testing.md (title: "testing", different branch)
|
||||
└── deep/
|
||||
└── nested/
|
||||
└── folder/
|
||||
└── note.md (title: "note")
|
||||
"""
|
||||
entities = []
|
||||
now = datetime.now(timezone.utc)
|
||||
project_id = entity_repository.project_id
|
||||
|
||||
# Root level testing.md
|
||||
e1 = await entity_repository.add(
|
||||
EntityModel(
|
||||
title="testing",
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
file_path="testing.md",
|
||||
permalink="testing",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
entities.append(e1)
|
||||
|
||||
# main/testing/testing.md
|
||||
e2 = await entity_repository.add(
|
||||
EntityModel(
|
||||
title="testing",
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
file_path="main/testing/testing.md",
|
||||
permalink="main/testing/testing",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
entities.append(e2)
|
||||
|
||||
# main/testing/another-test.md
|
||||
e3 = await entity_repository.add(
|
||||
EntityModel(
|
||||
title="another-test",
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
file_path="main/testing/another-test.md",
|
||||
permalink="main/testing/another-test",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
entities.append(e3)
|
||||
|
||||
# other/testing.md
|
||||
e4 = await entity_repository.add(
|
||||
EntityModel(
|
||||
title="testing",
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
file_path="other/testing.md",
|
||||
permalink="other/testing",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
entities.append(e4)
|
||||
|
||||
# deep/nested/folder/note.md
|
||||
e5 = await entity_repository.add(
|
||||
EntityModel(
|
||||
title="note",
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
file_path="deep/nested/folder/note.md",
|
||||
permalink="deep/nested/folder/note",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
entities.append(e5)
|
||||
|
||||
# deep/note.md (for ancestor testing)
|
||||
e6 = await entity_repository.add(
|
||||
EntityModel(
|
||||
title="note",
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
file_path="deep/note.md",
|
||||
permalink="deep/note",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
entities.append(e6)
|
||||
|
||||
# note.md at root (for ancestor testing)
|
||||
e7 = await entity_repository.add(
|
||||
EntityModel(
|
||||
title="note",
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
file_path="note.md",
|
||||
permalink="note",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
entities.append(e7)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def context_link_resolver(entity_repository, search_service, context_aware_entities):
|
||||
"""Create LinkResolver instance with context-aware test data.
|
||||
|
||||
Note: We don't index entities for search because these tests focus on
|
||||
exact title/permalink matching, not fuzzy search. The entities are
|
||||
database-only records (no files on disk).
|
||||
"""
|
||||
return LinkResolver(entity_repository, search_service)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_path_same_folder_preference(context_link_resolver):
|
||||
"""Test that links prefer notes in the same folder as the source."""
|
||||
# From main/testing/another-test.md, [[testing]] should find main/testing/testing.md
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"testing",
|
||||
source_path="main/testing/another-test.md"
|
||||
)
|
||||
assert result is not None
|
||||
assert result.file_path == "main/testing/testing.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_path_from_root_prefers_root(context_link_resolver):
|
||||
"""Test that links from root-level notes prefer root-level matches."""
|
||||
# From root-note.md, [[testing]] should find testing.md (root level)
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"testing",
|
||||
source_path="some-root-note.md"
|
||||
)
|
||||
assert result is not None
|
||||
assert result.file_path == "testing.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_path_different_branch_prefers_closest(context_link_resolver):
|
||||
"""Test resolution when source is in a different branch of the folder tree."""
|
||||
# From other/testing.md, [[testing]] should find other/testing.md (same folder)
|
||||
# Wait, other/testing.md IS the testing note in that folder, so this tests self-reference
|
||||
# Let's test from a hypothetical other/different.md
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"testing",
|
||||
source_path="other/different.md"
|
||||
)
|
||||
assert result is not None
|
||||
# Should find other/testing.md since it's in the same folder
|
||||
assert result.file_path == "other/testing.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_path_ancestor_preference(context_link_resolver):
|
||||
"""Test that closer ancestors are preferred over distant ones."""
|
||||
# From deep/nested/folder/note.md, [[note]] with multiple "note" titles
|
||||
# should prefer the closest ancestor match
|
||||
|
||||
# First verify there are multiple "note" entities
|
||||
# deep/nested/folder/note.md, deep/note.md, note.md
|
||||
|
||||
# From deep/nested/folder/some-file.md, [[note]] should prefer:
|
||||
# 1. deep/nested/folder/note.md (same folder) - but that's the note itself
|
||||
# Let's say we're linking from a different file in that folder
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"note",
|
||||
source_path="deep/nested/folder/other-file.md"
|
||||
)
|
||||
assert result is not None
|
||||
# Should find deep/nested/folder/note.md (same folder)
|
||||
assert result.file_path == "deep/nested/folder/note.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_path_parent_folder_preference(context_link_resolver):
|
||||
"""Test that parent folder is preferred when no same-folder match exists."""
|
||||
# From deep/nested/folder/x.md where there's no "common" in same folder,
|
||||
# but there's one in deep/nested/ - should prefer closer ancestor
|
||||
|
||||
# For this test, let's check that from deep/nested/other/file.md,
|
||||
# [[note]] finds deep/note.md (ancestor) rather than note.md (root)
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"note",
|
||||
source_path="deep/nested/other/file.md"
|
||||
)
|
||||
assert result is not None
|
||||
# No note.md in deep/nested/other/, so should find deep/note.md (closest ancestor)
|
||||
# Actually deep/nested/folder/note.md might be considered... let me think
|
||||
# deep/nested/other/file.md -> ancestors are deep/nested/, deep/, root
|
||||
# Siblings/cousins like deep/nested/folder/ are NOT ancestors
|
||||
# So should find deep/note.md
|
||||
assert result.file_path == "deep/note.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_path_no_context_falls_back_to_shortest_path(context_link_resolver):
|
||||
"""Test that without source_path, resolution falls back to shortest path."""
|
||||
# Without source_path, should use standard resolution (permalink first, then title)
|
||||
result = await context_link_resolver.resolve_link("testing")
|
||||
assert result is not None
|
||||
# Should get the one with shortest path or matching permalink
|
||||
# "testing" matches permalink "testing" of root testing.md
|
||||
assert result.file_path == "testing.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_path_unique_title_ignores_context(context_link_resolver):
|
||||
"""Test that unique titles resolve correctly regardless of source_path."""
|
||||
# "another-test" only exists in one place
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"another-test",
|
||||
source_path="other/some-file.md" # Different folder
|
||||
)
|
||||
assert result is not None
|
||||
assert result.file_path == "main/testing/another-test.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_source_path_with_permalink_conflict(context_link_resolver):
|
||||
"""Test that same-folder title match beats permalink match from different folder."""
|
||||
# Root testing.md has permalink "testing"
|
||||
# main/testing/testing.md has title "testing"
|
||||
# From main/testing/another-test.md, [[testing]] should prefer the same-folder match
|
||||
# even though there's a permalink match at root
|
||||
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"testing",
|
||||
source_path="main/testing/another-test.md"
|
||||
)
|
||||
assert result is not None
|
||||
# Should prefer same-folder title match over root permalink match
|
||||
assert result.file_path == "main/testing/testing.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_closest_entity_same_folder(context_link_resolver, context_aware_entities):
|
||||
"""Test _find_closest_entity helper with same folder match."""
|
||||
# Get entities with title "testing"
|
||||
testing_entities = [e for e in context_aware_entities if e.title == "testing"]
|
||||
assert len(testing_entities) == 3 # root, main/testing, other
|
||||
|
||||
closest = context_link_resolver._find_closest_entity(
|
||||
testing_entities,
|
||||
"main/testing/another-test.md"
|
||||
)
|
||||
assert closest.file_path == "main/testing/testing.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_closest_entity_ancestor_preference(context_link_resolver, context_aware_entities):
|
||||
"""Test _find_closest_entity prefers closer ancestors."""
|
||||
# Get entities with title "note"
|
||||
note_entities = [e for e in context_aware_entities if e.title == "note"]
|
||||
assert len(note_entities) == 3 # deep/nested/folder, deep, root
|
||||
|
||||
# From deep/nested/other/file.md, should prefer deep/note.md over note.md
|
||||
closest = context_link_resolver._find_closest_entity(
|
||||
note_entities,
|
||||
"deep/nested/other/file.md"
|
||||
)
|
||||
assert closest.file_path == "deep/note.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_closest_entity_root_source(context_link_resolver, context_aware_entities):
|
||||
"""Test _find_closest_entity when source is at root."""
|
||||
testing_entities = [e for e in context_aware_entities if e.title == "testing"]
|
||||
|
||||
# From root level, should prefer root testing.md
|
||||
closest = context_link_resolver._find_closest_entity(
|
||||
testing_entities,
|
||||
"some-root-file.md"
|
||||
)
|
||||
assert closest.file_path == "testing.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nonexistent_link_with_source_path(context_link_resolver):
|
||||
"""Test that non-existent links return None even with source_path."""
|
||||
result = await context_link_resolver.resolve_link(
|
||||
"does-not-exist",
|
||||
source_path="main/testing/another-test.md"
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Relative path resolution tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def relative_path_entities(entity_repository):
|
||||
"""Create entities for testing relative path resolution.
|
||||
|
||||
Structure:
|
||||
├── testing/
|
||||
│ ├── link-test.md (source file for testing)
|
||||
│ └── nested/
|
||||
│ └── deep-note.md (target for relative path)
|
||||
├── nested/
|
||||
│ └── deep-note.md (different deep-note at root level)
|
||||
└── other/
|
||||
└── file.md
|
||||
"""
|
||||
entities = []
|
||||
now = datetime.now(timezone.utc)
|
||||
project_id = entity_repository.project_id
|
||||
|
||||
# testing/link-test.md (source file)
|
||||
e1 = await entity_repository.add(
|
||||
EntityModel(
|
||||
title="link-test",
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
file_path="testing/link-test.md",
|
||||
permalink="testing/link-test",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
entities.append(e1)
|
||||
|
||||
# testing/nested/deep-note.md (relative target)
|
||||
e2 = await entity_repository.add(
|
||||
EntityModel(
|
||||
title="deep-note",
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
file_path="testing/nested/deep-note.md",
|
||||
permalink="testing/nested/deep-note",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
entities.append(e2)
|
||||
|
||||
# nested/deep-note.md (absolute path target)
|
||||
e3 = await entity_repository.add(
|
||||
EntityModel(
|
||||
title="deep-note",
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
file_path="nested/deep-note.md",
|
||||
permalink="nested/deep-note",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
entities.append(e3)
|
||||
|
||||
# other/file.md
|
||||
e4 = await entity_repository.add(
|
||||
EntityModel(
|
||||
title="file",
|
||||
entity_type="note",
|
||||
content_type="text/markdown",
|
||||
file_path="other/file.md",
|
||||
permalink="other/file",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
entities.append(e4)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def relative_path_resolver(entity_repository, search_service, relative_path_entities):
|
||||
"""Create LinkResolver instance with relative path test data."""
|
||||
return LinkResolver(entity_repository, search_service)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relative_path_resolution_from_subfolder(relative_path_resolver):
|
||||
"""Test that [[nested/deep-note]] from testing/link-test.md resolves to testing/nested/deep-note.md."""
|
||||
# From testing/link-test.md, [[nested/deep-note]] should resolve to testing/nested/deep-note.md
|
||||
result = await relative_path_resolver.resolve_link(
|
||||
"nested/deep-note",
|
||||
source_path="testing/link-test.md"
|
||||
)
|
||||
assert result is not None
|
||||
assert result.file_path == "testing/nested/deep-note.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relative_path_falls_back_to_absolute(relative_path_resolver):
|
||||
"""Test that if relative path doesn't exist, falls back to absolute resolution."""
|
||||
# From other/file.md, [[nested/deep-note]] should resolve to nested/deep-note.md (absolute)
|
||||
# because other/nested/deep-note.md doesn't exist
|
||||
result = await relative_path_resolver.resolve_link(
|
||||
"nested/deep-note",
|
||||
source_path="other/file.md"
|
||||
)
|
||||
assert result is not None
|
||||
assert result.file_path == "nested/deep-note.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relative_path_without_source_uses_absolute(relative_path_resolver):
|
||||
"""Test that without source_path, paths are resolved as absolute."""
|
||||
# Without source_path, [[nested/deep-note]] should resolve to nested/deep-note.md
|
||||
result = await relative_path_resolver.resolve_link("nested/deep-note")
|
||||
assert result is not None
|
||||
assert result.file_path == "nested/deep-note.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relative_path_from_root_falls_through(relative_path_resolver):
|
||||
"""Test that paths from root-level files don't try relative resolution."""
|
||||
# From root-file.md (no folder), [[nested/deep-note]] should resolve to nested/deep-note.md
|
||||
result = await relative_path_resolver.resolve_link(
|
||||
"nested/deep-note",
|
||||
source_path="root-file.md"
|
||||
)
|
||||
assert result is not None
|
||||
assert result.file_path == "nested/deep-note.md"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_simple_link_no_slash_skips_relative_resolution(relative_path_resolver):
|
||||
"""Test that links without '/' don't trigger relative path resolution."""
|
||||
# [[deep-note]] should use context-aware title matching, not relative paths
|
||||
result = await relative_path_resolver.resolve_link(
|
||||
"deep-note",
|
||||
source_path="testing/link-test.md"
|
||||
)
|
||||
assert result is not None
|
||||
# Should find testing/nested/deep-note.md via title match with same-folder preference
|
||||
# Actually both have title "deep-note", so it should prefer the one closer to source
|
||||
# testing/nested/ is not the same folder as testing/, but it's closer than nested/
|
||||
# The context-aware resolution will pick the closest match
|
||||
assert result.file_path == "testing/nested/deep-note.md"
|
||||
|
||||
@@ -1348,3 +1348,184 @@ async def test_remove_project_delete_notes_missing_directory(project_service: Pr
|
||||
project_service.config_manager.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_project_cloud_mode_uses_database_not_config(project_service: ProjectService):
|
||||
"""Test that in cloud mode, remove_project only checks database for default status.
|
||||
|
||||
Regression test for bug where cloud mode checked config file (stale) instead of
|
||||
database (source of truth) when determining if a project is the default.
|
||||
"""
|
||||
test_project_name = f"test-cloud-default-{os.urandom(4).hex()}"
|
||||
test_project_path = f"/tmp/test-cloud-{os.urandom(8).hex()}"
|
||||
|
||||
# Save original cloud_mode setting
|
||||
config = project_service.config_manager.config
|
||||
original_cloud_mode = config.cloud_mode
|
||||
original_default = config.default_project
|
||||
|
||||
try:
|
||||
# Add a test project (not default)
|
||||
await project_service.add_project(test_project_name, test_project_path, set_default=False)
|
||||
|
||||
# Verify project exists and is NOT default in database
|
||||
db_project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert db_project is not None
|
||||
assert db_project.is_default is not True # Should be None or False
|
||||
|
||||
# Simulate stale config: manually set this project as default in config only
|
||||
# (This simulates what happens when config isn't updated after API calls)
|
||||
config.default_project = test_project_name
|
||||
|
||||
# Enable cloud mode
|
||||
config.cloud_mode = True
|
||||
project_service.config_manager.save_config(config)
|
||||
|
||||
# In cloud mode, should be able to remove the project because database says it's not default
|
||||
# (even though stale config says it is) - this should NOT raise ValueError
|
||||
await project_service.remove_project(test_project_name, delete_notes=False)
|
||||
|
||||
# Verify project was removed from database
|
||||
db_project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert db_project is None
|
||||
|
||||
finally:
|
||||
# Restore original settings
|
||||
config = project_service.config_manager.config
|
||||
config.cloud_mode = original_cloud_mode
|
||||
config.default_project = original_default
|
||||
project_service.config_manager.save_config(config)
|
||||
|
||||
# Cleanup from config if test failed partway
|
||||
try:
|
||||
project_service.config_manager.remove_project(test_project_name)
|
||||
except (ValueError, KeyError):
|
||||
pass # Project may not be in config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_project_local_mode_checks_both_config_and_database(
|
||||
project_service: ProjectService,
|
||||
):
|
||||
"""Test that in local mode, remove_project checks both config AND database for default status.
|
||||
|
||||
In local mode, we check both sources to be safe - if either says the project is default,
|
||||
we prevent deletion.
|
||||
"""
|
||||
test_project_name = f"test-local-default-{os.urandom(4).hex()}"
|
||||
test_project_path = f"/tmp/test-local-{os.urandom(8).hex()}"
|
||||
|
||||
# Save original settings
|
||||
config = project_service.config_manager.config
|
||||
original_cloud_mode = config.cloud_mode
|
||||
original_default = config.default_project
|
||||
|
||||
try:
|
||||
# Ensure we're in local mode before adding project
|
||||
config.cloud_mode = False
|
||||
project_service.config_manager.save_config(config)
|
||||
|
||||
# Add a test project (not default) - this will add to both DB and config in local mode
|
||||
await project_service.add_project(test_project_name, test_project_path, set_default=False)
|
||||
|
||||
# Verify project exists and is NOT default in database
|
||||
db_project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert db_project is not None
|
||||
assert db_project.is_default is not True
|
||||
|
||||
# Re-read config to get the updated version (after add_project added the project)
|
||||
config = project_service.config_manager.config
|
||||
|
||||
# Set this project as default in config only (not in DB)
|
||||
config.default_project = test_project_name
|
||||
project_service.config_manager.save_config(config)
|
||||
|
||||
# In local mode, should NOT be able to remove because config says it's default
|
||||
with pytest.raises(ValueError, match="Cannot remove the default project"):
|
||||
await project_service.remove_project(test_project_name, delete_notes=False)
|
||||
|
||||
# Verify project still exists in database
|
||||
db_project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert db_project is not None
|
||||
|
||||
finally:
|
||||
# Restore original settings
|
||||
config = project_service.config_manager.config
|
||||
config.cloud_mode = original_cloud_mode
|
||||
config.default_project = original_default
|
||||
project_service.config_manager.save_config(config)
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
project_service.config_manager.remove_project(test_project_name)
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_project_rejects_database_default_in_both_modes(
|
||||
project_service: ProjectService,
|
||||
):
|
||||
"""Test that remove_project rejects deletion when project is default in database.
|
||||
|
||||
This should be blocked in BOTH cloud mode and local mode.
|
||||
"""
|
||||
test_project_name = f"test-db-default-{os.urandom(4).hex()}"
|
||||
test_project_path = f"/tmp/test-db-default-{os.urandom(8).hex()}"
|
||||
|
||||
# Save original settings
|
||||
original_cloud_mode = project_service.config_manager.config.cloud_mode
|
||||
original_default = project_service.config_manager.config.default_project
|
||||
|
||||
try:
|
||||
# Add a test project and set it as default
|
||||
await project_service.add_project(test_project_name, test_project_path, set_default=True)
|
||||
|
||||
# Verify project is default in database
|
||||
db_project = await project_service.repository.get_by_name(test_project_name)
|
||||
assert db_project is not None
|
||||
assert db_project.is_default is True
|
||||
|
||||
# Test in cloud mode - should reject
|
||||
config = project_service.config_manager.config
|
||||
config.cloud_mode = True
|
||||
project_service.config_manager.save_config(config)
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot remove the default project"):
|
||||
await project_service.remove_project(test_project_name, delete_notes=False)
|
||||
|
||||
# Test in local mode - should also reject
|
||||
config.cloud_mode = False
|
||||
project_service.config_manager.save_config(config)
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot remove the default project"):
|
||||
await project_service.remove_project(test_project_name, delete_notes=False)
|
||||
|
||||
# Verify project still exists in both cases
|
||||
assert test_project_name in project_service.projects
|
||||
|
||||
finally:
|
||||
# Restore original settings
|
||||
config = project_service.config_manager.config
|
||||
config.cloud_mode = original_cloud_mode
|
||||
config.default_project = original_default
|
||||
project_service.config_manager.save_config(config)
|
||||
|
||||
# Set original default back in database so we can clean up
|
||||
if original_default:
|
||||
original_project = await project_service.repository.get_by_name(original_default)
|
||||
if original_project:
|
||||
await project_service.repository.set_as_default(original_project.id)
|
||||
|
||||
# Cleanup test project
|
||||
if test_project_name in project_service.projects:
|
||||
try:
|
||||
# Clear default in DB first
|
||||
db_project = await project_service.repository.get_by_name(test_project_name)
|
||||
if db_project and db_project.is_default:
|
||||
# Find another project to make default
|
||||
pass # Let the config_manager handle it
|
||||
project_service.config_manager.remove_project(test_project_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -757,10 +757,11 @@ Testing file timestamps
|
||||
entity_created_epoch = file_entity.created_at.timestamp()
|
||||
entity_updated_epoch = file_entity.updated_at.timestamp()
|
||||
|
||||
# Allow 2s difference on Windows due to filesystem timing precision
|
||||
tolerance = 2 if os.name == "nt" else 1
|
||||
# Allow 2s difference due to filesystem timing precision and database processing delays
|
||||
# Windows has coarser filesystem timestamps, but Postgres can also have slight timing differences
|
||||
tolerance = 2
|
||||
assert abs(entity_created_epoch - file_stats.st_ctime) < tolerance
|
||||
assert abs(entity_updated_epoch - file_stats.st_mtime) < tolerance # Allow tolerance difference
|
||||
assert abs(entity_updated_epoch - file_stats.st_mtime) < tolerance
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -19,7 +19,7 @@ from basic_memory.file_utils import (
|
||||
parse_frontmatter,
|
||||
remove_frontmatter,
|
||||
sanitize_for_filename,
|
||||
sanitize_for_folder,
|
||||
sanitize_for_directory,
|
||||
write_file_atomic,
|
||||
)
|
||||
|
||||
@@ -199,29 +199,29 @@ def test_sanitize_for_filename_removes_invalid_characters():
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_folder,expected",
|
||||
"input_directory,expected",
|
||||
[
|
||||
("", ""), # Empty string
|
||||
(" ", ""), # Whitespace only
|
||||
("my-folder", "my-folder"), # Simple folder
|
||||
("my/folder", "my/folder"), # Nested folder
|
||||
("my//folder", "my/folder"), # Double slash compressed
|
||||
("my\\\\folder", "my/folder"), # Windows-style double backslash compressed
|
||||
("my/folder/", "my/folder"), # Trailing slash removed
|
||||
("/my/folder", "my/folder"), # Leading slash removed
|
||||
("./my/folder", "my/folder"), # Leading ./ removed
|
||||
("my<>folder", "myfolder"), # Special chars removed
|
||||
("my:folder|test", "myfoldertest"), # More special chars removed
|
||||
("my_folder-1", "my_folder-1"), # Allowed chars preserved
|
||||
("my folder", "my folder"), # Space preserved
|
||||
("my/folder//sub//", "my/folder/sub"), # Multiple compressions and trims
|
||||
("my\\folder\\sub", "my/folder/sub"), # Windows-style separators normalized
|
||||
("my/folder<>:|?*sub", "my/foldersub"), # All invalid chars removed
|
||||
("////my////folder////", "my/folder"), # Excessive leading/trailing/multiple slashes
|
||||
("my-directory", "my-directory"), # Simple directory
|
||||
("my/directory", "my/directory"), # Nested directory
|
||||
("my//directory", "my/directory"), # Double slash compressed
|
||||
("my\\\\directory", "my/directory"), # Windows-style double backslash compressed
|
||||
("my/directory/", "my/directory"), # Trailing slash removed
|
||||
("/my/directory", "my/directory"), # Leading slash removed
|
||||
("./my/directory", "my/directory"), # Leading ./ removed
|
||||
("my<>directory", "mydirectory"), # Special chars removed
|
||||
("my:directory|test", "mydirectorytest"), # More special chars removed
|
||||
("my_directory-1", "my_directory-1"), # Allowed chars preserved
|
||||
("my directory", "my directory"), # Space preserved
|
||||
("my/directory//sub//", "my/directory/sub"), # Multiple compressions and trims
|
||||
("my\\directory\\sub", "my/directory/sub"), # Windows-style separators normalized
|
||||
("my/directory<>:|?*sub", "my/directorysub"), # All invalid chars removed
|
||||
("////my////directory////", "my/directory"), # Excessive leading/trailing/multiple slashes
|
||||
],
|
||||
)
|
||||
def test_sanitize_for_folder_edge_cases(input_folder, expected):
|
||||
assert sanitize_for_folder(input_folder) == expected
|
||||
def test_sanitize_for_directory_edge_cases(input_directory, expected):
|
||||
assert sanitize_for_directory(input_directory) == expected
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user