basic-memory 0.14.2__py3-none-any.whl → 0.14.4__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/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +53 -0
- 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/api/routers/resource_router.py +3 -3
- 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 +46 -6
- 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 +61 -34
- basic_memory/db.py +2 -6
- basic_memory/deps.py +3 -2
- basic_memory/file_utils.py +65 -0
- basic_memory/importers/chatgpt_importer.py +20 -10
- basic_memory/importers/memory_json_importer.py +22 -7
- basic_memory/importers/utils.py +2 -2
- basic_memory/markdown/entity_parser.py +2 -2
- basic_memory/markdown/markdown_processor.py +2 -2
- basic_memory/markdown/plugins.py +42 -26
- basic_memory/markdown/utils.py +1 -1
- 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/build_context.py +12 -2
- basic_memory/mcp/tools/move_note.py +24 -12
- basic_memory/mcp/tools/project_management.py +22 -7
- basic_memory/mcp/tools/read_content.py +16 -0
- basic_memory/mcp/tools/read_note.py +17 -2
- basic_memory/mcp/tools/sync_status.py +3 -2
- basic_memory/mcp/tools/write_note.py +9 -1
- basic_memory/models/knowledge.py +13 -2
- basic_memory/models/project.py +3 -3
- basic_memory/repository/entity_repository.py +2 -2
- basic_memory/repository/project_repository.py +19 -1
- basic_memory/repository/search_repository.py +7 -3
- basic_memory/schemas/base.py +40 -10
- basic_memory/schemas/importer.py +1 -0
- basic_memory/schemas/memory.py +23 -11
- basic_memory/services/context_service.py +12 -2
- basic_memory/services/directory_service.py +7 -0
- basic_memory/services/entity_service.py +56 -10
- basic_memory/services/initialization.py +0 -75
- basic_memory/services/project_service.py +93 -36
- basic_memory/sync/background_sync.py +4 -3
- basic_memory/sync/sync_service.py +53 -4
- basic_memory/sync/watch_service.py +31 -8
- basic_memory/utils.py +234 -71
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/METADATA +21 -92
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/RECORD +63 -68
- 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.4.dist-info}/WHEEL +0 -0
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/entry_points.txt +0 -0
- {basic_memory-0.14.2.dist-info → basic_memory-0.14.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -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()
|
|
@@ -84,10 +100,10 @@ class ProjectService:
|
|
|
84
100
|
raise ValueError("Repository is required for add_project")
|
|
85
101
|
|
|
86
102
|
# Resolve to absolute path
|
|
87
|
-
resolved_path = os.path.abspath(os.path.expanduser(path))
|
|
103
|
+
resolved_path = Path(os.path.abspath(os.path.expanduser(path))).as_posix()
|
|
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,10 +137,10 @@ 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
|
-
# Then remove from database
|
|
127
|
-
project = await self.
|
|
142
|
+
# Then remove from database using robust lookup
|
|
143
|
+
project = await self.get_project(name)
|
|
128
144
|
if project:
|
|
129
145
|
await self.repository.delete(project.id)
|
|
130
146
|
|
|
@@ -143,10 +159,10 @@ 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
|
-
# Then update database
|
|
149
|
-
project = await self.
|
|
164
|
+
# Then update database using the same lookup logic as get_project
|
|
165
|
+
project = await self.get_project(name)
|
|
150
166
|
if project:
|
|
151
167
|
await self.repository.set_as_default(project.id)
|
|
152
168
|
else:
|
|
@@ -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 = Path(os.path.abspath(os.path.expanduser(new_path))).as_posix()
|
|
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 using robust lookup
|
|
342
|
+
project = await self.get_project(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,24 +367,23 @@ 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
|
-
# Get project from database
|
|
316
|
-
project = await self.
|
|
373
|
+
# Get project from database using robust lookup
|
|
374
|
+
project = await self.get_project(name)
|
|
317
375
|
if not project:
|
|
318
376
|
logger.error(f"Project '{name}' exists in config but not in database")
|
|
319
377
|
return
|
|
320
378
|
|
|
321
379
|
# Update path if provided
|
|
322
380
|
if updated_path:
|
|
323
|
-
resolved_path = os.path.abspath(os.path.expanduser(updated_path))
|
|
381
|
+
resolved_path = Path(os.path.abspath(os.path.expanduser(updated_path))).as_posix()
|
|
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()
|
|
@@ -357,8 +357,8 @@ class SyncService:
|
|
|
357
357
|
|
|
358
358
|
# get file timestamps
|
|
359
359
|
file_stats = self.file_service.file_stats(path)
|
|
360
|
-
created = datetime.fromtimestamp(file_stats.st_ctime)
|
|
361
|
-
modified = datetime.fromtimestamp(file_stats.st_mtime)
|
|
360
|
+
created = datetime.fromtimestamp(file_stats.st_ctime).astimezone()
|
|
361
|
+
modified = datetime.fromtimestamp(file_stats.st_mtime).astimezone()
|
|
362
362
|
|
|
363
363
|
# get mime type
|
|
364
364
|
content_type = self.file_service.content_type(path)
|
|
@@ -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(
|
|
@@ -570,7 +619,7 @@ class SyncService:
|
|
|
570
619
|
continue
|
|
571
620
|
|
|
572
621
|
path = Path(root) / filename
|
|
573
|
-
rel_path =
|
|
622
|
+
rel_path = path.relative_to(directory).as_posix()
|
|
574
623
|
checksum = await self.file_service.compute_checksum(rel_path)
|
|
575
624
|
result.files[rel_path] = checksum
|
|
576
625
|
result.checksums[checksum] = rel_path
|
|
@@ -197,7 +197,7 @@ class WatchService:
|
|
|
197
197
|
|
|
198
198
|
for change, path in changes:
|
|
199
199
|
# convert to relative path
|
|
200
|
-
relative_path =
|
|
200
|
+
relative_path = Path(path).relative_to(directory).as_posix()
|
|
201
201
|
|
|
202
202
|
# Skip .tmp files - they're temporary and shouldn't be synced
|
|
203
203
|
if relative_path.endswith(".tmp"):
|
|
@@ -284,13 +284,36 @@ class WatchService:
|
|
|
284
284
|
# Process deletes
|
|
285
285
|
for path in deletes:
|
|
286
286
|
if path not in processed:
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
287
|
+
# Check if file still exists on disk (vim atomic write edge case)
|
|
288
|
+
full_path = directory / path
|
|
289
|
+
if full_path.exists() and full_path.is_file():
|
|
290
|
+
# File still exists despite DELETE event - treat as modification
|
|
291
|
+
logger.debug("File exists despite DELETE event, treating as modification", path=path)
|
|
292
|
+
entity, checksum = await sync_service.sync_file(path, new=False)
|
|
293
|
+
self.state.add_event(path=path, action="modified", status="success", checksum=checksum)
|
|
294
|
+
self.console.print(f"[yellow]✎[/yellow] {path} (atomic write)")
|
|
295
|
+
logger.info(f"atomic write detected: {path}")
|
|
296
|
+
processed.add(path)
|
|
297
|
+
modify_count += 1
|
|
298
|
+
else:
|
|
299
|
+
# Check if this was a directory - skip if so
|
|
300
|
+
# (we can't tell if the deleted path was a directory since it no longer exists,
|
|
301
|
+
# so we check if there's an entity in the database for it)
|
|
302
|
+
entity = await sync_service.entity_repository.get_by_file_path(path)
|
|
303
|
+
if entity is None:
|
|
304
|
+
# No entity means this was likely a directory - skip it
|
|
305
|
+
logger.debug(f"Skipping deleted path with no entity (likely directory), path={path}")
|
|
306
|
+
processed.add(path)
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
# File truly deleted
|
|
310
|
+
logger.debug("Processing deleted file", path=path)
|
|
311
|
+
await sync_service.handle_delete(path)
|
|
312
|
+
self.state.add_event(path=path, action="deleted", status="success")
|
|
313
|
+
self.console.print(f"[red]✕[/red] {path}")
|
|
314
|
+
logger.info(f"deleted: {path}")
|
|
315
|
+
processed.add(path)
|
|
316
|
+
delete_count += 1
|
|
294
317
|
|
|
295
318
|
# Process adds
|
|
296
319
|
for path in adds:
|