Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions src/basic_memory/api/routers/project_router.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Router for project management."""

import os
from fastapi import APIRouter, HTTPException, Path, Body
from typing import Optional

Expand Down Expand Up @@ -32,40 +33,47 @@ async def get_project_info(
@project_router.patch("/{name}", response_model=ProjectStatusResponse)
async def update_project(
project_service: ProjectServiceDep,
project_name: str = Path(..., description="Name of the project to update"),
path: Optional[str] = Body(None, description="New path for the project"),
name: str = Path(..., description="Name of the project to update"),
path: Optional[str] = Body(None, description="New absolute path for the project"),
is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
) -> ProjectStatusResponse:
"""Update a project's information in configuration and database.

Args:
project_name: The name of the project to update
path: Optional new path for the project
name: The name of the project to update
path: Optional new absolute path for the project
is_active: Optional status update for the project

Returns:
Response confirming the project was updated
"""
try: # pragma: no cover
try:
# Validate that path is absolute if provided
if path and not os.path.isabs(path):
raise HTTPException(status_code=400, detail="Path must be absolute")

# Get original project info for the response
old_project_info = ProjectItem(
name=project_name,
path=project_service.projects.get(project_name, ""),
name=name,
path=project_service.projects.get(name, ""),
)

await project_service.update_project(project_name, updated_path=path, is_active=is_active)
if path:
await project_service.move_project(name, path)
elif is_active is not None:
await project_service.update_project(name, is_active=is_active)

# Get updated project info
updated_path = path if path else project_service.projects.get(project_name, "")
updated_path = path if path else project_service.projects.get(name, "")

return ProjectStatusResponse(
message=f"Project '{project_name}' updated successfully",
message=f"Project '{name}' updated successfully",
status="success",
default=(project_name == project_service.default_project),
default=(name == project_service.default_project),
old_project=old_project_info,
new_project=ProjectItem(name=project_name, path=updated_path),
new_project=ProjectItem(name=name, path=updated_path),
)
except ValueError as e: # pragma: no cover
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))


Expand Down
37 changes: 37 additions & 0 deletions src/basic_memory/cli/commands/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from basic_memory.schemas.project_info import ProjectStatusResponse
from basic_memory.mcp.tools.utils import call_delete
from basic_memory.mcp.tools.utils import call_put
from basic_memory.mcp.tools.utils import call_patch
from basic_memory.utils import generate_permalink

console = Console()
Expand Down Expand Up @@ -148,6 +149,42 @@ def synchronize_projects() -> None:
raise typer.Exit(1)


@project_app.command("move")
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"),
) -> None:
"""Move a project to a new location."""
# Resolve to absolute path
resolved_path = os.path.abspath(os.path.expanduser(new_path))

try:
data = {"path": resolved_path}
project_name = generate_permalink(name)

current_project = session.get_current_project()
response = asyncio.run(call_patch(client, f"/{current_project}/project/{project_name}", json=data))
result = ProjectStatusResponse.model_validate(response.json())

console.print(f"[green]{result.message}[/green]")

# Show important file movement reminder
console.print() # Empty line for spacing
console.print(Panel(
"[bold red]IMPORTANT:[/bold red] Project configuration updated successfully.\n\n"
"[yellow]You must manually move your project files from the old location to:[/yellow]\n"
f"[cyan]{resolved_path}[/cyan]\n\n"
"[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
title="⚠️ Manual File Movement Required",
border_style="yellow",
expand=False
))

except Exception as e:
console.print(f"[red]Error moving project: {str(e)}[/red]")
raise typer.Exit(1)


@project_app.command("info")
def display_project_info(
json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
Expand Down
6 changes: 2 additions & 4 deletions src/basic_memory/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,8 @@ def load_config(self) -> BasicMemoryConfig:
data = json.loads(self.config_file.read_text(encoding="utf-8"))
return BasicMemoryConfig(**data)
except Exception as e: # pragma: no cover
logger.error(f"Failed to load config: {e}")
config = BasicMemoryConfig()
self.save_config(config)
return config
logger.exception(f"Failed to load config: {e}")
raise e
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this fixes the issue with config file being overwritten by default values. Just propagate the error.

else:
config = BasicMemoryConfig()
self.save_config(config)
Expand Down
18 changes: 18 additions & 0 deletions src/basic_memory/repository/project_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,21 @@ async def set_as_default(self, project_id: int) -> Optional[Project]:
await session.flush()
return target_project
return None # pragma: no cover

async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
"""Update project path.

Args:
project_id: ID of the project to update
new_path: New filesystem path for the project

Returns:
The updated project if found, None otherwise
"""
async with db.scoped_session(self.session_maker) as session:
project = await self.select_by_id(session, project_id)
if project:
project.path = new_path
await session.flush()
return project
return None
41 changes: 41 additions & 0 deletions src/basic_memory/services/project_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,47 @@ async def synchronize_projects(self) -> None: # pragma: no cover
# MCP components might not be available in all contexts
logger.debug("MCP session not available, skipping session refresh")

async def move_project(self, name: str, new_path: str) -> None:
"""Move a project to a new location.

Args:
name: The name of the project to move
new_path: The new absolute path for the project

Raises:
ValueError: If the project doesn't exist or repository isn't initialized
"""
if not self.repository:
raise ValueError("Repository is required for move_project")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like Claude doing goldplating BS. We should always have a repository. But whatever.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah seems overkill. All the other service method have it, so that's probably why Claude did it here.


# Resolve to absolute path
resolved_path = os.path.abspath(os.path.expanduser(new_path))

# Validate project exists in config
if name not in self.config_manager.projects:
raise ValueError(f"Project '{name}' not found in configuration")

# Create the new directory if it doesn't exist
Path(resolved_path).mkdir(parents=True, exist_ok=True)

# Update in configuration
config = self.config_manager.load_config()
old_path = config.projects[name]
config.projects[name] = resolved_path
self.config_manager.save_config(config)

# Update in database
project = await self.repository.get_by_name(name)
if project:
await self.repository.update_path(project.id, resolved_path)
logger.info(f"Moved project '{name}' from {old_path} to {resolved_path}")
else:
logger.error(f"Project '{name}' exists in config but not in database")
# Restore the old path in config since DB update failed
config.projects[name] = old_path
self.config_manager.save_config(config)
raise ValueError(f"Project '{name}' not found in database")

async def update_project( # pragma: no cover
self, name: str, updated_path: Optional[str] = None, is_active: Optional[bool] = None
) -> None:
Expand Down
26 changes: 13 additions & 13 deletions src/basic_memory/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,19 +146,19 @@ def setup_logging(
# logger.remove()

# Add file handler if we are not running tests and a log file is specified
# if log_file and env != "test":
# # Setup file logger
# log_path = home_dir / log_file
# logger.add(
# str(log_path),
# level=log_level,
# rotation="10 MB",
# retention="10 days",
# backtrace=True,
# diagnose=True,
# enqueue=True,
# colorize=False,
# )
if log_file and env != "test":
# Setup file logger
log_path = home_dir / log_file
logger.add(
str(log_path),
level=log_level,
rotation="10 MB",
retention="10 days",
backtrace=True,
diagnose=True,
enqueue=True,
colorize=False,
)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

re-enable logging to log file


# Add console logger if requested or in test mode
# if env == "test" or console:
Expand Down
Loading
Loading