basic-memory 0.14.2__py3-none-any.whl → 0.14.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of basic-memory might be problematic. Click here for more details.
- basic_memory/__init__.py +1 -1
- basic_memory/alembic/env.py +3 -1
- basic_memory/api/app.py +4 -1
- basic_memory/api/routers/management_router.py +3 -1
- basic_memory/api/routers/project_router.py +21 -13
- basic_memory/cli/app.py +3 -3
- basic_memory/cli/commands/__init__.py +1 -2
- basic_memory/cli/commands/db.py +5 -5
- basic_memory/cli/commands/import_chatgpt.py +3 -2
- basic_memory/cli/commands/import_claude_conversations.py +3 -1
- basic_memory/cli/commands/import_claude_projects.py +3 -1
- basic_memory/cli/commands/import_memory_json.py +5 -2
- basic_memory/cli/commands/mcp.py +3 -15
- basic_memory/cli/commands/project.py +41 -0
- basic_memory/cli/commands/status.py +4 -1
- basic_memory/cli/commands/sync.py +10 -2
- basic_memory/cli/main.py +0 -1
- basic_memory/config.py +46 -31
- basic_memory/db.py +2 -6
- basic_memory/deps.py +3 -2
- basic_memory/importers/chatgpt_importer.py +19 -9
- basic_memory/importers/memory_json_importer.py +22 -7
- basic_memory/mcp/async_client.py +22 -2
- basic_memory/mcp/project_session.py +6 -4
- basic_memory/mcp/prompts/__init__.py +0 -2
- basic_memory/mcp/server.py +8 -71
- basic_memory/mcp/tools/move_note.py +24 -12
- basic_memory/mcp/tools/read_content.py +16 -0
- basic_memory/mcp/tools/read_note.py +12 -0
- basic_memory/mcp/tools/sync_status.py +3 -2
- basic_memory/mcp/tools/write_note.py +9 -1
- basic_memory/models/project.py +3 -3
- basic_memory/repository/project_repository.py +18 -0
- basic_memory/schemas/importer.py +1 -0
- basic_memory/services/entity_service.py +49 -3
- basic_memory/services/initialization.py +0 -75
- basic_memory/services/project_service.py +85 -28
- basic_memory/sync/background_sync.py +4 -3
- basic_memory/sync/sync_service.py +50 -1
- basic_memory/utils.py +105 -4
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/METADATA +2 -2
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/RECORD +45 -51
- basic_memory/cli/commands/auth.py +0 -136
- basic_memory/mcp/auth_provider.py +0 -270
- basic_memory/mcp/external_auth_provider.py +0 -321
- basic_memory/mcp/prompts/sync_status.py +0 -112
- basic_memory/mcp/supabase_auth_provider.py +0 -463
- basic_memory/services/migration_service.py +0 -168
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -4,8 +4,10 @@ from typing import Optional
|
|
|
4
4
|
|
|
5
5
|
from loguru import logger
|
|
6
6
|
|
|
7
|
+
from basic_memory.config import ConfigManager
|
|
7
8
|
from basic_memory.mcp.server import mcp
|
|
8
9
|
from basic_memory.mcp.project_session import get_active_project
|
|
10
|
+
from basic_memory.services.sync_status_service import sync_status_tracker
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
def _get_all_projects_status() -> list[str]:
|
|
@@ -13,8 +15,7 @@ def _get_all_projects_status() -> list[str]:
|
|
|
13
15
|
status_lines = []
|
|
14
16
|
|
|
15
17
|
try:
|
|
16
|
-
|
|
17
|
-
from basic_memory.services.sync_status_service import sync_status_tracker
|
|
18
|
+
app_config = ConfigManager().config
|
|
18
19
|
|
|
19
20
|
if app_config.projects:
|
|
20
21
|
status_lines.extend(["", "---", "", "**All Projects Status:**"])
|
|
@@ -10,7 +10,7 @@ from basic_memory.mcp.tools.utils import call_put
|
|
|
10
10
|
from basic_memory.mcp.project_session import get_active_project
|
|
11
11
|
from basic_memory.schemas import EntityResponse
|
|
12
12
|
from basic_memory.schemas.base import Entity
|
|
13
|
-
from basic_memory.utils import parse_tags
|
|
13
|
+
from basic_memory.utils import parse_tags, validate_project_path
|
|
14
14
|
|
|
15
15
|
# Define TagType as a Union that can accept either a string or a list of strings or None
|
|
16
16
|
TagType = Union[List[str], str, None]
|
|
@@ -75,6 +75,14 @@ async def write_note(
|
|
|
75
75
|
# Get the active project first to check project-specific sync status
|
|
76
76
|
active_project = get_active_project(project)
|
|
77
77
|
|
|
78
|
+
# Validate folder path to prevent path traversal attacks
|
|
79
|
+
project_path = active_project.home
|
|
80
|
+
if folder and not validate_project_path(folder, project_path):
|
|
81
|
+
logger.warning(
|
|
82
|
+
"Attempted path traversal attack blocked", folder=folder, project=active_project.name
|
|
83
|
+
)
|
|
84
|
+
return f"# Error\n\nFolder path '{folder}' is not allowed - paths must stay within project boundaries"
|
|
85
|
+
|
|
78
86
|
# Check migration status and wait briefly if needed
|
|
79
87
|
from basic_memory.mcp.tools.utils import wait_for_migration_or_return_status
|
|
80
88
|
|
basic_memory/models/project.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Project model for Basic Memory."""
|
|
2
2
|
|
|
3
|
-
from datetime import datetime
|
|
3
|
+
from datetime import datetime, UTC
|
|
4
4
|
from typing import Optional
|
|
5
5
|
|
|
6
6
|
from sqlalchemy import (
|
|
@@ -52,9 +52,9 @@ class Project(Base):
|
|
|
52
52
|
is_default: Mapped[Optional[bool]] = mapped_column(Boolean, default=None, nullable=True)
|
|
53
53
|
|
|
54
54
|
# Timestamps
|
|
55
|
-
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.
|
|
55
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(UTC))
|
|
56
56
|
updated_at: Mapped[datetime] = mapped_column(
|
|
57
|
-
DateTime, default=datetime.
|
|
57
|
+
DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
|
|
58
58
|
)
|
|
59
59
|
|
|
60
60
|
# Define relationships to entities, observations, and relations
|
|
@@ -83,3 +83,21 @@ class ProjectRepository(Repository[Project]):
|
|
|
83
83
|
await session.flush()
|
|
84
84
|
return target_project
|
|
85
85
|
return None # pragma: no cover
|
|
86
|
+
|
|
87
|
+
async def update_path(self, project_id: int, new_path: str) -> Optional[Project]:
|
|
88
|
+
"""Update project path.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
project_id: ID of the project to update
|
|
92
|
+
new_path: New filesystem path for the project
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
The updated project if found, None otherwise
|
|
96
|
+
"""
|
|
97
|
+
async with db.scoped_session(self.session_maker) as session:
|
|
98
|
+
project = await self.select_by_id(session, project_id)
|
|
99
|
+
if project:
|
|
100
|
+
project.path = new_path
|
|
101
|
+
await session.flush()
|
|
102
|
+
return project
|
|
103
|
+
return None
|
basic_memory/schemas/importer.py
CHANGED
|
@@ -15,6 +15,7 @@ from basic_memory.markdown.entity_parser import EntityParser
|
|
|
15
15
|
from basic_memory.markdown.utils import entity_model_from_markdown, schema_to_markdown
|
|
16
16
|
from basic_memory.models import Entity as EntityModel
|
|
17
17
|
from basic_memory.models import Observation, Relation
|
|
18
|
+
from basic_memory.models.knowledge import Entity
|
|
18
19
|
from basic_memory.repository import ObservationRepository, RelationRepository
|
|
19
20
|
from basic_memory.repository.entity_repository import EntityRepository
|
|
20
21
|
from basic_memory.schemas import Entity as EntitySchema
|
|
@@ -44,6 +45,39 @@ class EntityService(BaseService[EntityModel]):
|
|
|
44
45
|
self.file_service = file_service
|
|
45
46
|
self.link_resolver = link_resolver
|
|
46
47
|
|
|
48
|
+
async def detect_file_path_conflicts(self, file_path: str) -> List[Entity]:
|
|
49
|
+
"""Detect potential file path conflicts for a given file path.
|
|
50
|
+
|
|
51
|
+
This checks for entities with similar file paths that might cause conflicts:
|
|
52
|
+
- Case sensitivity differences (Finance/file.md vs finance/file.md)
|
|
53
|
+
- Character encoding differences
|
|
54
|
+
- Hyphen vs space differences
|
|
55
|
+
- Unicode normalization differences
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
file_path: The file path to check for conflicts
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
List of entities that might conflict with the given file path
|
|
62
|
+
"""
|
|
63
|
+
from basic_memory.utils import detect_potential_file_conflicts
|
|
64
|
+
|
|
65
|
+
conflicts = []
|
|
66
|
+
|
|
67
|
+
# Get all existing file paths
|
|
68
|
+
all_entities = await self.repository.find_all()
|
|
69
|
+
existing_paths = [entity.file_path for entity in all_entities]
|
|
70
|
+
|
|
71
|
+
# Use the enhanced conflict detection utility
|
|
72
|
+
conflicting_paths = detect_potential_file_conflicts(file_path, existing_paths)
|
|
73
|
+
|
|
74
|
+
# Find the entities corresponding to conflicting paths
|
|
75
|
+
for entity in all_entities:
|
|
76
|
+
if entity.file_path in conflicting_paths:
|
|
77
|
+
conflicts.append(entity)
|
|
78
|
+
|
|
79
|
+
return conflicts
|
|
80
|
+
|
|
47
81
|
async def resolve_permalink(
|
|
48
82
|
self, file_path: Permalink | Path, markdown: Optional[EntityMarkdown] = None
|
|
49
83
|
) -> str:
|
|
@@ -54,18 +88,30 @@ class EntityService(BaseService[EntityModel]):
|
|
|
54
88
|
2. If markdown has permalink but it's used by another file -> make unique
|
|
55
89
|
3. For existing files, keep current permalink from db
|
|
56
90
|
4. Generate new unique permalink from file path
|
|
91
|
+
|
|
92
|
+
Enhanced to detect and handle character-related conflicts.
|
|
57
93
|
"""
|
|
94
|
+
file_path_str = str(file_path)
|
|
95
|
+
|
|
96
|
+
# Check for potential file path conflicts before resolving permalink
|
|
97
|
+
conflicts = await self.detect_file_path_conflicts(file_path_str)
|
|
98
|
+
if conflicts:
|
|
99
|
+
logger.warning(
|
|
100
|
+
f"Detected potential file path conflicts for '{file_path_str}': "
|
|
101
|
+
f"{[entity.file_path for entity in conflicts]}"
|
|
102
|
+
)
|
|
103
|
+
|
|
58
104
|
# If markdown has explicit permalink, try to validate it
|
|
59
105
|
if markdown and markdown.frontmatter.permalink:
|
|
60
106
|
desired_permalink = markdown.frontmatter.permalink
|
|
61
107
|
existing = await self.repository.get_by_permalink(desired_permalink)
|
|
62
108
|
|
|
63
109
|
# If no conflict or it's our own file, use as is
|
|
64
|
-
if not existing or existing.file_path ==
|
|
110
|
+
if not existing or existing.file_path == file_path_str:
|
|
65
111
|
return desired_permalink
|
|
66
112
|
|
|
67
113
|
# For existing files, try to find current permalink
|
|
68
|
-
existing = await self.repository.get_by_file_path(
|
|
114
|
+
existing = await self.repository.get_by_file_path(file_path_str)
|
|
69
115
|
if existing:
|
|
70
116
|
return existing.permalink
|
|
71
117
|
|
|
@@ -75,7 +121,7 @@ class EntityService(BaseService[EntityModel]):
|
|
|
75
121
|
else:
|
|
76
122
|
desired_permalink = generate_permalink(file_path)
|
|
77
123
|
|
|
78
|
-
# Make unique if needed
|
|
124
|
+
# Make unique if needed - enhanced to handle character conflicts
|
|
79
125
|
permalink = desired_permalink
|
|
80
126
|
suffix = 1
|
|
81
127
|
while await self.repository.get_by_permalink(permalink):
|
|
@@ -5,14 +5,12 @@ to ensure consistent application startup across all entry points.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import asyncio
|
|
8
|
-
import shutil
|
|
9
8
|
from pathlib import Path
|
|
10
9
|
|
|
11
10
|
from loguru import logger
|
|
12
11
|
|
|
13
12
|
from basic_memory import db
|
|
14
13
|
from basic_memory.config import BasicMemoryConfig
|
|
15
|
-
from basic_memory.models import Project
|
|
16
14
|
from basic_memory.repository import ProjectRepository
|
|
17
15
|
|
|
18
16
|
|
|
@@ -70,63 +68,6 @@ async def reconcile_projects_with_config(app_config: BasicMemoryConfig):
|
|
|
70
68
|
logger.info("Continuing with initialization despite synchronization error")
|
|
71
69
|
|
|
72
70
|
|
|
73
|
-
async def migrate_legacy_projects(app_config: BasicMemoryConfig):
|
|
74
|
-
# Get database session - migrations handled centrally
|
|
75
|
-
_, session_maker = await db.get_or_create_db(
|
|
76
|
-
db_path=app_config.database_path,
|
|
77
|
-
db_type=db.DatabaseType.FILESYSTEM,
|
|
78
|
-
ensure_migrations=False,
|
|
79
|
-
)
|
|
80
|
-
logger.info("Migrating legacy projects...")
|
|
81
|
-
project_repository = ProjectRepository(session_maker)
|
|
82
|
-
|
|
83
|
-
# For each project in config.json, check if it has a .basic-memory dir
|
|
84
|
-
for project_name, project_path in app_config.projects.items():
|
|
85
|
-
legacy_dir = Path(project_path) / ".basic-memory"
|
|
86
|
-
if not legacy_dir.exists():
|
|
87
|
-
continue
|
|
88
|
-
logger.info(f"Detected legacy project directory: {legacy_dir}")
|
|
89
|
-
project = await project_repository.get_by_name(project_name)
|
|
90
|
-
if not project: # pragma: no cover
|
|
91
|
-
logger.error(f"Project {project_name} not found in database, skipping migration")
|
|
92
|
-
continue
|
|
93
|
-
|
|
94
|
-
logger.info(f"Starting migration for project: {project_name} (id: {project.id})")
|
|
95
|
-
await migrate_legacy_project_data(project, legacy_dir)
|
|
96
|
-
logger.info(f"Completed migration for project: {project_name}")
|
|
97
|
-
logger.info("Legacy projects successfully migrated")
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
async def migrate_legacy_project_data(project: Project, legacy_dir: Path) -> bool:
|
|
101
|
-
"""Check if project has legacy .basic-memory dir and migrate if needed.
|
|
102
|
-
|
|
103
|
-
Args:
|
|
104
|
-
project: The project to check and potentially migrate
|
|
105
|
-
|
|
106
|
-
Returns:
|
|
107
|
-
True if migration occurred, False otherwise
|
|
108
|
-
"""
|
|
109
|
-
|
|
110
|
-
# avoid circular imports
|
|
111
|
-
from basic_memory.cli.commands.sync import get_sync_service
|
|
112
|
-
|
|
113
|
-
sync_service = await get_sync_service(project)
|
|
114
|
-
sync_dir = Path(project.path)
|
|
115
|
-
|
|
116
|
-
logger.info(f"Sync starting project: {project.name}")
|
|
117
|
-
await sync_service.sync(sync_dir, project_name=project.name)
|
|
118
|
-
logger.info(f"Sync completed successfully for project: {project.name}")
|
|
119
|
-
|
|
120
|
-
# After successful sync, remove the legacy directory
|
|
121
|
-
try:
|
|
122
|
-
logger.info(f"Removing legacy directory: {legacy_dir}")
|
|
123
|
-
shutil.rmtree(legacy_dir)
|
|
124
|
-
return True
|
|
125
|
-
except Exception as e:
|
|
126
|
-
logger.error(f"Error removing legacy directory: {e}")
|
|
127
|
-
return False
|
|
128
|
-
|
|
129
|
-
|
|
130
71
|
async def initialize_file_sync(
|
|
131
72
|
app_config: BasicMemoryConfig,
|
|
132
73
|
):
|
|
@@ -186,16 +127,6 @@ async def initialize_file_sync(
|
|
|
186
127
|
sync_status_tracker.fail_project_sync(project.name, str(e))
|
|
187
128
|
# Continue with other projects even if one fails
|
|
188
129
|
|
|
189
|
-
# Mark migration complete if it was in progress
|
|
190
|
-
try:
|
|
191
|
-
from basic_memory.services.migration_service import migration_manager
|
|
192
|
-
|
|
193
|
-
if not migration_manager.is_ready: # pragma: no cover
|
|
194
|
-
migration_manager.mark_completed("Migration completed with file sync")
|
|
195
|
-
logger.info("Marked migration as completed after file sync")
|
|
196
|
-
except Exception as e: # pragma: no cover
|
|
197
|
-
logger.warning(f"Could not update migration status: {e}")
|
|
198
|
-
|
|
199
130
|
# Then start the watch service in the background
|
|
200
131
|
logger.info("Starting watch service for all projects")
|
|
201
132
|
# run the watch service
|
|
@@ -229,13 +160,7 @@ async def initialize_app(
|
|
|
229
160
|
# Reconcile projects from config.json with projects table
|
|
230
161
|
await reconcile_projects_with_config(app_config)
|
|
231
162
|
|
|
232
|
-
# Start background migration for legacy project data (non-blocking)
|
|
233
|
-
from basic_memory.services.migration_service import migration_manager
|
|
234
|
-
|
|
235
|
-
await migration_manager.start_background_migration(app_config)
|
|
236
|
-
|
|
237
163
|
logger.info("App initialization completed (migration running in background if needed)")
|
|
238
|
-
return migration_manager
|
|
239
164
|
|
|
240
165
|
|
|
241
166
|
def ensure_initialization(app_config: BasicMemoryConfig) -> None:
|
|
@@ -9,7 +9,6 @@ from typing import Dict, Optional, Sequence
|
|
|
9
9
|
from loguru import logger
|
|
10
10
|
from sqlalchemy import text
|
|
11
11
|
|
|
12
|
-
from basic_memory.config import config, app_config
|
|
13
12
|
from basic_memory.models import Project
|
|
14
13
|
from basic_memory.repository.project_repository import ProjectRepository
|
|
15
14
|
from basic_memory.schemas import (
|
|
@@ -18,9 +17,8 @@ from basic_memory.schemas import (
|
|
|
18
17
|
ProjectStatistics,
|
|
19
18
|
SystemStatus,
|
|
20
19
|
)
|
|
21
|
-
from basic_memory.config import WATCH_STATUS_JSON
|
|
20
|
+
from basic_memory.config import WATCH_STATUS_JSON, ConfigManager, get_project_config, ProjectConfig
|
|
22
21
|
from basic_memory.utils import generate_permalink
|
|
23
|
-
from basic_memory.config import config_manager
|
|
24
22
|
|
|
25
23
|
|
|
26
24
|
class ProjectService:
|
|
@@ -33,6 +31,24 @@ class ProjectService:
|
|
|
33
31
|
super().__init__()
|
|
34
32
|
self.repository = repository
|
|
35
33
|
|
|
34
|
+
@property
|
|
35
|
+
def config_manager(self) -> ConfigManager:
|
|
36
|
+
"""Get a ConfigManager instance.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Fresh ConfigManager instance for each access
|
|
40
|
+
"""
|
|
41
|
+
return ConfigManager()
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def config(self) -> ProjectConfig:
|
|
45
|
+
"""Get the current project configuration.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Current project configuration
|
|
49
|
+
"""
|
|
50
|
+
return get_project_config()
|
|
51
|
+
|
|
36
52
|
@property
|
|
37
53
|
def projects(self) -> Dict[str, str]:
|
|
38
54
|
"""Get all configured projects.
|
|
@@ -40,7 +56,7 @@ class ProjectService:
|
|
|
40
56
|
Returns:
|
|
41
57
|
Dict mapping project names to their file paths
|
|
42
58
|
"""
|
|
43
|
-
return config_manager.projects
|
|
59
|
+
return self.config_manager.projects
|
|
44
60
|
|
|
45
61
|
@property
|
|
46
62
|
def default_project(self) -> str:
|
|
@@ -49,7 +65,7 @@ class ProjectService:
|
|
|
49
65
|
Returns:
|
|
50
66
|
The name of the default project
|
|
51
67
|
"""
|
|
52
|
-
return config_manager.default_project
|
|
68
|
+
return self.config_manager.default_project
|
|
53
69
|
|
|
54
70
|
@property
|
|
55
71
|
def current_project(self) -> str:
|
|
@@ -58,7 +74,7 @@ class ProjectService:
|
|
|
58
74
|
Returns:
|
|
59
75
|
The name of the current project
|
|
60
76
|
"""
|
|
61
|
-
return os.environ.get("BASIC_MEMORY_PROJECT", config_manager.default_project)
|
|
77
|
+
return os.environ.get("BASIC_MEMORY_PROJECT", self.config_manager.default_project)
|
|
62
78
|
|
|
63
79
|
async def list_projects(self) -> Sequence[Project]:
|
|
64
80
|
return await self.repository.find_all()
|
|
@@ -87,7 +103,7 @@ class ProjectService:
|
|
|
87
103
|
resolved_path = os.path.abspath(os.path.expanduser(path))
|
|
88
104
|
|
|
89
105
|
# First add to config file (this will validate the project doesn't exist)
|
|
90
|
-
project_config = config_manager.add_project(name, resolved_path)
|
|
106
|
+
project_config = self.config_manager.add_project(name, resolved_path)
|
|
91
107
|
|
|
92
108
|
# Then add to database
|
|
93
109
|
project_data = {
|
|
@@ -103,7 +119,7 @@ class ProjectService:
|
|
|
103
119
|
# If this should be the default project, ensure only one default exists
|
|
104
120
|
if set_default:
|
|
105
121
|
await self.repository.set_as_default(created_project.id)
|
|
106
|
-
config_manager.set_default_project(name)
|
|
122
|
+
self.config_manager.set_default_project(name)
|
|
107
123
|
logger.info(f"Project '{name}' set as default")
|
|
108
124
|
|
|
109
125
|
logger.info(f"Project '{name}' added at {resolved_path}")
|
|
@@ -121,7 +137,7 @@ class ProjectService:
|
|
|
121
137
|
raise ValueError("Repository is required for remove_project")
|
|
122
138
|
|
|
123
139
|
# First remove from config (this will validate the project exists and is not default)
|
|
124
|
-
config_manager.remove_project(name)
|
|
140
|
+
self.config_manager.remove_project(name)
|
|
125
141
|
|
|
126
142
|
# Then remove from database
|
|
127
143
|
project = await self.repository.get_by_name(name)
|
|
@@ -143,7 +159,7 @@ class ProjectService:
|
|
|
143
159
|
raise ValueError("Repository is required for set_default_project")
|
|
144
160
|
|
|
145
161
|
# First update config file (this will validate the project exists)
|
|
146
|
-
config_manager.set_default_project(name)
|
|
162
|
+
self.config_manager.set_default_project(name)
|
|
147
163
|
|
|
148
164
|
# Then update database
|
|
149
165
|
project = await self.repository.get_by_name(name)
|
|
@@ -196,7 +212,7 @@ class ProjectService:
|
|
|
196
212
|
elif len(default_projects) == 0: # pragma: no cover
|
|
197
213
|
# No default project - set the config default as default
|
|
198
214
|
# This is defensive code for edge cases where no default exists
|
|
199
|
-
config_default = config_manager.default_project # pragma: no cover
|
|
215
|
+
config_default = self.config_manager.default_project # pragma: no cover
|
|
200
216
|
config_project = await self.repository.get_by_name(config_default) # pragma: no cover
|
|
201
217
|
if config_project: # pragma: no cover
|
|
202
218
|
await self.repository.set_as_default(config_project.id) # pragma: no cover
|
|
@@ -221,7 +237,7 @@ class ProjectService:
|
|
|
221
237
|
db_projects_by_permalink = {p.permalink: p for p in db_projects}
|
|
222
238
|
|
|
223
239
|
# Get all projects from configuration and normalize names if needed
|
|
224
|
-
config_projects = config_manager.projects.copy()
|
|
240
|
+
config_projects = self.config_manager.projects.copy()
|
|
225
241
|
updated_config = {}
|
|
226
242
|
config_updated = False
|
|
227
243
|
|
|
@@ -237,8 +253,9 @@ class ProjectService:
|
|
|
237
253
|
|
|
238
254
|
# Update the configuration if any changes were made
|
|
239
255
|
if config_updated:
|
|
240
|
-
|
|
241
|
-
|
|
256
|
+
config = self.config_manager.load_config()
|
|
257
|
+
config.projects = updated_config
|
|
258
|
+
self.config_manager.save_config(config)
|
|
242
259
|
logger.info("Config updated with normalized project names")
|
|
243
260
|
|
|
244
261
|
# Use the normalized config for further processing
|
|
@@ -261,19 +278,19 @@ class ProjectService:
|
|
|
261
278
|
for name, project in db_projects_by_permalink.items():
|
|
262
279
|
if name not in config_projects:
|
|
263
280
|
logger.info(f"Adding project '{name}' to configuration")
|
|
264
|
-
config_manager.add_project(name, project.path)
|
|
281
|
+
self.config_manager.add_project(name, project.path)
|
|
265
282
|
|
|
266
283
|
# Ensure database default project state is consistent
|
|
267
284
|
await self._ensure_single_default_project()
|
|
268
285
|
|
|
269
286
|
# Make sure default project is synchronized between config and database
|
|
270
287
|
db_default = await self.repository.get_default_project()
|
|
271
|
-
config_default = config_manager.default_project
|
|
288
|
+
config_default = self.config_manager.default_project
|
|
272
289
|
|
|
273
290
|
if db_default and db_default.name != config_default:
|
|
274
291
|
# Update config to match DB default
|
|
275
292
|
logger.info(f"Updating default project in config to '{db_default.name}'")
|
|
276
|
-
config_manager.set_default_project(db_default.name)
|
|
293
|
+
self.config_manager.set_default_project(db_default.name)
|
|
277
294
|
elif not db_default and config_default:
|
|
278
295
|
# Update DB to match config default (if the project exists)
|
|
279
296
|
project = await self.repository.get_by_name(config_default)
|
|
@@ -292,6 +309,47 @@ class ProjectService:
|
|
|
292
309
|
# MCP components might not be available in all contexts
|
|
293
310
|
logger.debug("MCP session not available, skipping session refresh")
|
|
294
311
|
|
|
312
|
+
async def move_project(self, name: str, new_path: str) -> None:
|
|
313
|
+
"""Move a project to a new location.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
name: The name of the project to move
|
|
317
|
+
new_path: The new absolute path for the project
|
|
318
|
+
|
|
319
|
+
Raises:
|
|
320
|
+
ValueError: If the project doesn't exist or repository isn't initialized
|
|
321
|
+
"""
|
|
322
|
+
if not self.repository:
|
|
323
|
+
raise ValueError("Repository is required for move_project")
|
|
324
|
+
|
|
325
|
+
# Resolve to absolute path
|
|
326
|
+
resolved_path = os.path.abspath(os.path.expanduser(new_path))
|
|
327
|
+
|
|
328
|
+
# Validate project exists in config
|
|
329
|
+
if name not in self.config_manager.projects:
|
|
330
|
+
raise ValueError(f"Project '{name}' not found in configuration")
|
|
331
|
+
|
|
332
|
+
# Create the new directory if it doesn't exist
|
|
333
|
+
Path(resolved_path).mkdir(parents=True, exist_ok=True)
|
|
334
|
+
|
|
335
|
+
# Update in configuration
|
|
336
|
+
config = self.config_manager.load_config()
|
|
337
|
+
old_path = config.projects[name]
|
|
338
|
+
config.projects[name] = resolved_path
|
|
339
|
+
self.config_manager.save_config(config)
|
|
340
|
+
|
|
341
|
+
# Update in database
|
|
342
|
+
project = await self.repository.get_by_name(name)
|
|
343
|
+
if project:
|
|
344
|
+
await self.repository.update_path(project.id, resolved_path)
|
|
345
|
+
logger.info(f"Moved project '{name}' from {old_path} to {resolved_path}")
|
|
346
|
+
else:
|
|
347
|
+
logger.error(f"Project '{name}' exists in config but not in database")
|
|
348
|
+
# Restore the old path in config since DB update failed
|
|
349
|
+
config.projects[name] = old_path
|
|
350
|
+
self.config_manager.save_config(config)
|
|
351
|
+
raise ValueError(f"Project '{name}' not found in database")
|
|
352
|
+
|
|
295
353
|
async def update_project( # pragma: no cover
|
|
296
354
|
self, name: str, updated_path: Optional[str] = None, is_active: Optional[bool] = None
|
|
297
355
|
) -> None:
|
|
@@ -309,7 +367,7 @@ class ProjectService:
|
|
|
309
367
|
raise ValueError("Repository is required for update_project")
|
|
310
368
|
|
|
311
369
|
# Validate project exists in config
|
|
312
|
-
if name not in config_manager.projects:
|
|
370
|
+
if name not in self.config_manager.projects:
|
|
313
371
|
raise ValueError(f"Project '{name}' not found in configuration")
|
|
314
372
|
|
|
315
373
|
# Get project from database
|
|
@@ -323,10 +381,9 @@ class ProjectService:
|
|
|
323
381
|
resolved_path = os.path.abspath(os.path.expanduser(updated_path))
|
|
324
382
|
|
|
325
383
|
# Update in config
|
|
326
|
-
|
|
327
|
-
projects[name] = resolved_path
|
|
328
|
-
config_manager.config
|
|
329
|
-
config_manager.save_config(config_manager.config)
|
|
384
|
+
config = self.config_manager.load_config()
|
|
385
|
+
config.projects[name] = resolved_path
|
|
386
|
+
self.config_manager.save_config(config)
|
|
330
387
|
|
|
331
388
|
# Update in database
|
|
332
389
|
project.path = resolved_path
|
|
@@ -347,7 +404,7 @@ class ProjectService:
|
|
|
347
404
|
if active_projects:
|
|
348
405
|
new_default = active_projects[0]
|
|
349
406
|
await self.repository.set_as_default(new_default.id)
|
|
350
|
-
config_manager.set_default_project(new_default.name)
|
|
407
|
+
self.config_manager.set_default_project(new_default.name)
|
|
351
408
|
logger.info(
|
|
352
409
|
f"Changed default project to '{new_default.name}' as '{name}' was deactivated"
|
|
353
410
|
)
|
|
@@ -365,9 +422,9 @@ class ProjectService:
|
|
|
365
422
|
raise ValueError("Repository is required for get_project_info")
|
|
366
423
|
|
|
367
424
|
# Use specified project or fall back to config project
|
|
368
|
-
project_name = project_name or config.project
|
|
425
|
+
project_name = project_name or self.config.project
|
|
369
426
|
# Get project path from configuration
|
|
370
|
-
name, project_path = config_manager.get_project(project_name)
|
|
427
|
+
name, project_path = self.config_manager.get_project(project_name)
|
|
371
428
|
if not name: # pragma: no cover
|
|
372
429
|
raise ValueError(f"Project '{project_name}' not found in configuration")
|
|
373
430
|
|
|
@@ -393,11 +450,11 @@ class ProjectService:
|
|
|
393
450
|
db_projects_by_permalink = {p.permalink: p for p in db_projects}
|
|
394
451
|
|
|
395
452
|
# Get default project info
|
|
396
|
-
default_project = config_manager.default_project
|
|
453
|
+
default_project = self.config_manager.default_project
|
|
397
454
|
|
|
398
455
|
# Convert config projects to include database info
|
|
399
456
|
enhanced_projects = {}
|
|
400
|
-
for name, path in config_manager.projects.items():
|
|
457
|
+
for name, path in self.config_manager.projects.items():
|
|
401
458
|
config_permalink = generate_permalink(name)
|
|
402
459
|
db_project = db_projects_by_permalink.get(config_permalink)
|
|
403
460
|
enhanced_projects[name] = {
|
|
@@ -673,7 +730,7 @@ class ProjectService:
|
|
|
673
730
|
import basic_memory
|
|
674
731
|
|
|
675
732
|
# Get database information
|
|
676
|
-
db_path =
|
|
733
|
+
db_path = self.config_manager.config.database_path
|
|
677
734
|
db_size = db_path.stat().st_size if db_path.exists() else 0
|
|
678
735
|
db_size_readable = f"{db_size / (1024 * 1024):.2f} MB"
|
|
679
736
|
|
|
@@ -2,7 +2,7 @@ import asyncio
|
|
|
2
2
|
|
|
3
3
|
from loguru import logger
|
|
4
4
|
|
|
5
|
-
from basic_memory.config import
|
|
5
|
+
from basic_memory.config import get_project_config
|
|
6
6
|
from basic_memory.sync import SyncService, WatchService
|
|
7
7
|
|
|
8
8
|
|
|
@@ -11,9 +11,10 @@ async def sync_and_watch(
|
|
|
11
11
|
): # pragma: no cover
|
|
12
12
|
"""Run sync and watch service."""
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
config = get_project_config()
|
|
15
|
+
logger.info(f"Starting watch service to sync file changes in dir: {config.home}")
|
|
15
16
|
# full sync
|
|
16
|
-
await sync_service.sync(
|
|
17
|
+
await sync_service.sync(config.home)
|
|
17
18
|
|
|
18
19
|
# watch changes
|
|
19
20
|
await watch_service.run()
|
|
@@ -453,6 +453,36 @@ class SyncService:
|
|
|
453
453
|
|
|
454
454
|
entity = await self.entity_repository.get_by_file_path(old_path)
|
|
455
455
|
if entity:
|
|
456
|
+
# Check if destination path is already occupied by another entity
|
|
457
|
+
existing_at_destination = await self.entity_repository.get_by_file_path(new_path)
|
|
458
|
+
if existing_at_destination and existing_at_destination.id != entity.id:
|
|
459
|
+
# Handle the conflict - this could be a file swap or replacement scenario
|
|
460
|
+
logger.warning(
|
|
461
|
+
f"File path conflict detected during move: "
|
|
462
|
+
f"entity_id={entity.id} trying to move from '{old_path}' to '{new_path}', "
|
|
463
|
+
f"but entity_id={existing_at_destination.id} already occupies '{new_path}'"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
# Check if this is a file swap (the destination entity is being moved to our old path)
|
|
467
|
+
# This would indicate a simultaneous move operation
|
|
468
|
+
old_path_after_swap = await self.entity_repository.get_by_file_path(old_path)
|
|
469
|
+
if old_path_after_swap and old_path_after_swap.id == existing_at_destination.id:
|
|
470
|
+
logger.info(f"Detected file swap between '{old_path}' and '{new_path}'")
|
|
471
|
+
# This is a swap scenario - both moves should succeed
|
|
472
|
+
# We'll allow this to proceed since the other file has moved out
|
|
473
|
+
else:
|
|
474
|
+
# This is a conflict where the destination is occupied
|
|
475
|
+
raise ValueError(
|
|
476
|
+
f"Cannot move entity from '{old_path}' to '{new_path}': "
|
|
477
|
+
f"destination path is already occupied by another file. "
|
|
478
|
+
f"This may be caused by: "
|
|
479
|
+
f"1. Conflicting file names with different character encodings, "
|
|
480
|
+
f"2. Case sensitivity differences (e.g., 'Finance/' vs 'finance/'), "
|
|
481
|
+
f"3. Character conflicts between hyphens in filenames and generated permalinks, "
|
|
482
|
+
f"4. Files with similar names containing special characters. "
|
|
483
|
+
f"Try renaming one of the conflicting files to resolve this issue."
|
|
484
|
+
)
|
|
485
|
+
|
|
456
486
|
# Update file_path in all cases
|
|
457
487
|
updates = {"file_path": new_path}
|
|
458
488
|
|
|
@@ -477,7 +507,26 @@ class SyncService:
|
|
|
477
507
|
f"new_checksum={new_checksum}"
|
|
478
508
|
)
|
|
479
509
|
|
|
480
|
-
|
|
510
|
+
try:
|
|
511
|
+
updated = await self.entity_repository.update(entity.id, updates)
|
|
512
|
+
except Exception as e:
|
|
513
|
+
# Catch any database integrity errors and provide helpful context
|
|
514
|
+
if "UNIQUE constraint failed" in str(e):
|
|
515
|
+
logger.error(
|
|
516
|
+
f"Database constraint violation during move: "
|
|
517
|
+
f"entity_id={entity.id}, old_path='{old_path}', new_path='{new_path}'"
|
|
518
|
+
)
|
|
519
|
+
raise ValueError(
|
|
520
|
+
f"Cannot complete move from '{old_path}' to '{new_path}': "
|
|
521
|
+
f"a database constraint was violated. This usually indicates "
|
|
522
|
+
f"a file path or permalink conflict. Please check for: "
|
|
523
|
+
f"1. Duplicate file names, "
|
|
524
|
+
f"2. Case sensitivity issues (e.g., 'File.md' vs 'file.md'), "
|
|
525
|
+
f"3. Character encoding conflicts in file names."
|
|
526
|
+
) from e
|
|
527
|
+
else:
|
|
528
|
+
# Re-raise other exceptions as-is
|
|
529
|
+
raise
|
|
481
530
|
|
|
482
531
|
if updated is None: # pragma: no cover
|
|
483
532
|
logger.error(
|