basic-memory 0.7.0__py3-none-any.whl → 0.17.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.

Files changed (195) hide show
  1. basic_memory/__init__.py +5 -1
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +130 -20
  4. basic_memory/alembic/migrations.py +4 -9
  5. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  6. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  7. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  8. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  9. basic_memory/alembic/versions/6830751f5fb6_merge_multiple_heads.py +24 -0
  10. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  11. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  12. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  13. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  14. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  15. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  16. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  17. basic_memory/alembic/versions/g9a0b3c4d5e6_add_external_id_to_project_and_entity.py +173 -0
  18. basic_memory/api/app.py +87 -20
  19. basic_memory/api/container.py +133 -0
  20. basic_memory/api/routers/__init__.py +4 -1
  21. basic_memory/api/routers/directory_router.py +84 -0
  22. basic_memory/api/routers/importer_router.py +152 -0
  23. basic_memory/api/routers/knowledge_router.py +180 -23
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +9 -64
  26. basic_memory/api/routers/project_router.py +460 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +136 -11
  29. basic_memory/api/routers/search_router.py +5 -5
  30. basic_memory/api/routers/utils.py +169 -0
  31. basic_memory/api/template_loader.py +292 -0
  32. basic_memory/api/v2/__init__.py +35 -0
  33. basic_memory/api/v2/routers/__init__.py +21 -0
  34. basic_memory/api/v2/routers/directory_router.py +93 -0
  35. basic_memory/api/v2/routers/importer_router.py +181 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +427 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +359 -0
  39. basic_memory/api/v2/routers/prompt_router.py +269 -0
  40. basic_memory/api/v2/routers/resource_router.py +286 -0
  41. basic_memory/api/v2/routers/search_router.py +73 -0
  42. basic_memory/cli/app.py +80 -10
  43. basic_memory/cli/auth.py +300 -0
  44. basic_memory/cli/commands/__init__.py +15 -2
  45. basic_memory/cli/commands/cloud/__init__.py +6 -0
  46. basic_memory/cli/commands/cloud/api_client.py +127 -0
  47. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  48. basic_memory/cli/commands/cloud/cloud_utils.py +108 -0
  49. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  50. basic_memory/cli/commands/cloud/rclone_commands.py +397 -0
  51. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  52. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  53. basic_memory/cli/commands/cloud/upload.py +240 -0
  54. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  55. basic_memory/cli/commands/command_utils.py +99 -0
  56. basic_memory/cli/commands/db.py +87 -12
  57. basic_memory/cli/commands/format.py +198 -0
  58. basic_memory/cli/commands/import_chatgpt.py +47 -223
  59. basic_memory/cli/commands/import_claude_conversations.py +48 -171
  60. basic_memory/cli/commands/import_claude_projects.py +53 -160
  61. basic_memory/cli/commands/import_memory_json.py +55 -111
  62. basic_memory/cli/commands/mcp.py +67 -11
  63. basic_memory/cli/commands/project.py +889 -0
  64. basic_memory/cli/commands/status.py +52 -34
  65. basic_memory/cli/commands/telemetry.py +81 -0
  66. basic_memory/cli/commands/tool.py +341 -0
  67. basic_memory/cli/container.py +84 -0
  68. basic_memory/cli/main.py +14 -6
  69. basic_memory/config.py +580 -26
  70. basic_memory/db.py +285 -28
  71. basic_memory/deps/__init__.py +293 -0
  72. basic_memory/deps/config.py +26 -0
  73. basic_memory/deps/db.py +56 -0
  74. basic_memory/deps/importers.py +200 -0
  75. basic_memory/deps/projects.py +238 -0
  76. basic_memory/deps/repositories.py +179 -0
  77. basic_memory/deps/services.py +480 -0
  78. basic_memory/deps.py +16 -185
  79. basic_memory/file_utils.py +318 -54
  80. basic_memory/ignore_utils.py +297 -0
  81. basic_memory/importers/__init__.py +27 -0
  82. basic_memory/importers/base.py +100 -0
  83. basic_memory/importers/chatgpt_importer.py +245 -0
  84. basic_memory/importers/claude_conversations_importer.py +192 -0
  85. basic_memory/importers/claude_projects_importer.py +184 -0
  86. basic_memory/importers/memory_json_importer.py +128 -0
  87. basic_memory/importers/utils.py +61 -0
  88. basic_memory/markdown/entity_parser.py +182 -23
  89. basic_memory/markdown/markdown_processor.py +70 -7
  90. basic_memory/markdown/plugins.py +43 -23
  91. basic_memory/markdown/schemas.py +1 -1
  92. basic_memory/markdown/utils.py +38 -14
  93. basic_memory/mcp/async_client.py +135 -4
  94. basic_memory/mcp/clients/__init__.py +28 -0
  95. basic_memory/mcp/clients/directory.py +70 -0
  96. basic_memory/mcp/clients/knowledge.py +176 -0
  97. basic_memory/mcp/clients/memory.py +120 -0
  98. basic_memory/mcp/clients/project.py +89 -0
  99. basic_memory/mcp/clients/resource.py +71 -0
  100. basic_memory/mcp/clients/search.py +65 -0
  101. basic_memory/mcp/container.py +110 -0
  102. basic_memory/mcp/project_context.py +155 -0
  103. basic_memory/mcp/prompts/__init__.py +19 -0
  104. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  105. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  106. basic_memory/mcp/prompts/recent_activity.py +188 -0
  107. basic_memory/mcp/prompts/search.py +57 -0
  108. basic_memory/mcp/prompts/utils.py +162 -0
  109. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  110. basic_memory/mcp/resources/project_info.py +71 -0
  111. basic_memory/mcp/server.py +61 -9
  112. basic_memory/mcp/tools/__init__.py +33 -21
  113. basic_memory/mcp/tools/build_context.py +120 -0
  114. basic_memory/mcp/tools/canvas.py +152 -0
  115. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  116. basic_memory/mcp/tools/delete_note.py +249 -0
  117. basic_memory/mcp/tools/edit_note.py +325 -0
  118. basic_memory/mcp/tools/list_directory.py +157 -0
  119. basic_memory/mcp/tools/move_note.py +549 -0
  120. basic_memory/mcp/tools/project_management.py +204 -0
  121. basic_memory/mcp/tools/read_content.py +281 -0
  122. basic_memory/mcp/tools/read_note.py +265 -0
  123. basic_memory/mcp/tools/recent_activity.py +528 -0
  124. basic_memory/mcp/tools/search.py +377 -24
  125. basic_memory/mcp/tools/utils.py +402 -16
  126. basic_memory/mcp/tools/view_note.py +78 -0
  127. basic_memory/mcp/tools/write_note.py +230 -0
  128. basic_memory/models/__init__.py +3 -2
  129. basic_memory/models/knowledge.py +82 -17
  130. basic_memory/models/project.py +93 -0
  131. basic_memory/models/search.py +68 -8
  132. basic_memory/project_resolver.py +222 -0
  133. basic_memory/repository/__init__.py +2 -0
  134. basic_memory/repository/entity_repository.py +437 -8
  135. basic_memory/repository/observation_repository.py +36 -3
  136. basic_memory/repository/postgres_search_repository.py +451 -0
  137. basic_memory/repository/project_info_repository.py +10 -0
  138. basic_memory/repository/project_repository.py +140 -0
  139. basic_memory/repository/relation_repository.py +79 -4
  140. basic_memory/repository/repository.py +148 -29
  141. basic_memory/repository/search_index_row.py +95 -0
  142. basic_memory/repository/search_repository.py +79 -268
  143. basic_memory/repository/search_repository_base.py +241 -0
  144. basic_memory/repository/sqlite_search_repository.py +437 -0
  145. basic_memory/runtime.py +61 -0
  146. basic_memory/schemas/__init__.py +22 -9
  147. basic_memory/schemas/base.py +131 -12
  148. basic_memory/schemas/cloud.py +50 -0
  149. basic_memory/schemas/directory.py +31 -0
  150. basic_memory/schemas/importer.py +35 -0
  151. basic_memory/schemas/memory.py +194 -25
  152. basic_memory/schemas/project_info.py +213 -0
  153. basic_memory/schemas/prompt.py +90 -0
  154. basic_memory/schemas/request.py +56 -2
  155. basic_memory/schemas/response.py +85 -28
  156. basic_memory/schemas/search.py +36 -35
  157. basic_memory/schemas/sync_report.py +72 -0
  158. basic_memory/schemas/v2/__init__.py +27 -0
  159. basic_memory/schemas/v2/entity.py +133 -0
  160. basic_memory/schemas/v2/resource.py +47 -0
  161. basic_memory/services/__init__.py +2 -1
  162. basic_memory/services/context_service.py +451 -138
  163. basic_memory/services/directory_service.py +310 -0
  164. basic_memory/services/entity_service.py +636 -71
  165. basic_memory/services/exceptions.py +21 -0
  166. basic_memory/services/file_service.py +402 -33
  167. basic_memory/services/initialization.py +216 -0
  168. basic_memory/services/link_resolver.py +50 -56
  169. basic_memory/services/project_service.py +888 -0
  170. basic_memory/services/search_service.py +232 -37
  171. basic_memory/sync/__init__.py +4 -2
  172. basic_memory/sync/background_sync.py +26 -0
  173. basic_memory/sync/coordinator.py +160 -0
  174. basic_memory/sync/sync_service.py +1200 -109
  175. basic_memory/sync/watch_service.py +432 -135
  176. basic_memory/telemetry.py +249 -0
  177. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  178. basic_memory/templates/prompts/search.hbs +101 -0
  179. basic_memory/utils.py +407 -54
  180. basic_memory-0.17.4.dist-info/METADATA +617 -0
  181. basic_memory-0.17.4.dist-info/RECORD +193 -0
  182. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/WHEEL +1 -1
  183. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/entry_points.txt +1 -0
  184. basic_memory/alembic/README +0 -1
  185. basic_memory/cli/commands/sync.py +0 -206
  186. basic_memory/cli/commands/tools.py +0 -157
  187. basic_memory/mcp/tools/knowledge.py +0 -68
  188. basic_memory/mcp/tools/memory.py +0 -170
  189. basic_memory/mcp/tools/notes.py +0 -202
  190. basic_memory/schemas/discovery.py +0 -28
  191. basic_memory/sync/file_change_scanner.py +0 -158
  192. basic_memory/sync/utils.py +0 -31
  193. basic_memory-0.7.0.dist-info/METADATA +0 -378
  194. basic_memory-0.7.0.dist-info/RECORD +0 -82
  195. {basic_memory-0.7.0.dist-info → basic_memory-0.17.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,79 +1,22 @@
1
1
  """Routes for memory:// URI operations."""
2
2
 
3
- from typing import Annotated
3
+ from typing import Annotated, Optional
4
4
 
5
- from dateparser import parse
6
5
  from fastapi import APIRouter, Query
7
6
  from loguru import logger
8
7
 
9
8
  from basic_memory.deps import ContextServiceDep, EntityRepositoryDep
10
- from basic_memory.repository import EntityRepository
11
- from basic_memory.repository.search_repository import SearchIndexRow
12
- from basic_memory.schemas.base import TimeFrame
9
+ from basic_memory.schemas.base import TimeFrame, parse_timeframe
13
10
  from basic_memory.schemas.memory import (
14
11
  GraphContext,
15
- RelationSummary,
16
- EntitySummary,
17
- ObservationSummary,
18
- MemoryMetadata,
19
12
  normalize_memory_url,
20
13
  )
21
14
  from basic_memory.schemas.search import SearchItemType
22
- from basic_memory.services.context_service import ContextResultRow
15
+ from basic_memory.api.routers.utils import to_graph_context
23
16
 
24
17
  router = APIRouter(prefix="/memory", tags=["memory"])
25
18
 
26
19
 
27
- async def to_graph_context(context, entity_repository: EntityRepository, page: int, page_size: int):
28
- # return results
29
- async def to_summary(item: SearchIndexRow | ContextResultRow):
30
- match item.type:
31
- case SearchItemType.ENTITY:
32
- assert item.title is not None
33
- assert item.created_at is not None
34
-
35
- return EntitySummary(
36
- title=item.title,
37
- permalink=item.permalink,
38
- file_path=item.file_path,
39
- created_at=item.created_at,
40
- )
41
- case SearchItemType.OBSERVATION:
42
- assert item.category is not None
43
- assert item.content is not None
44
-
45
- return ObservationSummary(
46
- category=item.category, content=item.content, permalink=item.permalink
47
- )
48
- case SearchItemType.RELATION:
49
- assert item.from_id is not None
50
- from_entity = await entity_repository.find_by_id(item.from_id)
51
- assert from_entity is not None
52
-
53
- to_entity = await entity_repository.find_by_id(item.to_id) if item.to_id else None
54
-
55
- return RelationSummary(
56
- permalink=item.permalink,
57
- relation_type=item.type,
58
- from_id=from_entity.permalink,
59
- to_id=to_entity.permalink if to_entity else None,
60
- )
61
- case _: # pragma: no cover
62
- raise ValueError(f"Unexpected type: {item.type}")
63
-
64
- primary_results = [await to_summary(r) for r in context["primary_results"]]
65
- related_results = [await to_summary(r) for r in context["related_results"]]
66
- metadata = MemoryMetadata.model_validate(context["metadata"])
67
- # Transform to GraphContext
68
- return GraphContext(
69
- primary_results=primary_results,
70
- related_results=related_results,
71
- metadata=metadata,
72
- page=page,
73
- page_size=page_size,
74
- )
75
-
76
-
77
20
  @router.get("/recent", response_model=GraphContext)
78
21
  async def recent(
79
22
  context_service: ContextServiceDep,
@@ -96,7 +39,7 @@ async def recent(
96
39
  f"Getting recent context: `{types}` depth: `{depth}` timeframe: `{timeframe}` page: `{page}` page_size: `{page_size}` max_related: `{max_related}`"
97
40
  )
98
41
  # Parse timeframe
99
- since = parse(timeframe)
42
+ since = parse_timeframe(timeframe)
100
43
  limit = page_size
101
44
  offset = (page - 1) * page_size
102
45
 
@@ -104,9 +47,11 @@ async def recent(
104
47
  context = await context_service.build_context(
105
48
  types=types, depth=depth, since=since, limit=limit, offset=offset, max_related=max_related
106
49
  )
107
- return await to_graph_context(
50
+ recent_context = await to_graph_context(
108
51
  context, entity_repository=entity_repository, page=page, page_size=page_size
109
52
  )
53
+ logger.debug(f"Recent context: {recent_context.model_dump_json()}")
54
+ return recent_context
110
55
 
111
56
 
112
57
  # get_memory_context needs to be declared last so other paths can match
@@ -118,7 +63,7 @@ async def get_memory_context(
118
63
  entity_repository: EntityRepositoryDep,
119
64
  uri: str,
120
65
  depth: int = 1,
121
- timeframe: TimeFrame = "7d",
66
+ timeframe: Optional[TimeFrame] = None,
122
67
  page: int = 1,
123
68
  page_size: int = 10,
124
69
  max_related: int = 10,
@@ -132,7 +77,7 @@ async def get_memory_context(
132
77
  memory_url = normalize_memory_url(uri)
133
78
 
134
79
  # Parse timeframe
135
- since = parse(timeframe)
80
+ since = parse_timeframe(timeframe) if timeframe else None
136
81
  limit = page_size
137
82
  offset = (page - 1) * page_size
138
83
 
@@ -0,0 +1,460 @@
1
+ """Router for project management."""
2
+
3
+ import os
4
+ from fastapi import APIRouter, HTTPException, Path, Body, BackgroundTasks, Response, Query
5
+ from typing import Optional
6
+ from loguru import logger
7
+
8
+ from basic_memory.deps import (
9
+ ProjectConfigDep,
10
+ ProjectServiceDep,
11
+ ProjectPathDep,
12
+ SyncServiceDep,
13
+ )
14
+ from basic_memory.schemas import ProjectInfoResponse, SyncReportResponse
15
+ from basic_memory.schemas.project_info import (
16
+ ProjectList,
17
+ ProjectItem,
18
+ ProjectInfoRequest,
19
+ ProjectStatusResponse,
20
+ )
21
+ from basic_memory.utils import normalize_project_path
22
+
23
+ # Router for resources in a specific project
24
+ # The ProjectPathDep is used in the path as a prefix, so the request path is like /{project}/project/info
25
+ project_router = APIRouter(prefix="/project", tags=["project"])
26
+
27
+ # Router for managing project resources
28
+ project_resource_router = APIRouter(prefix="/projects", tags=["project_management"])
29
+
30
+
31
+ @project_router.get("/info", response_model=ProjectInfoResponse)
32
+ async def get_project_info(
33
+ project_service: ProjectServiceDep,
34
+ project: ProjectPathDep,
35
+ ) -> ProjectInfoResponse:
36
+ """Get comprehensive information about the specified Basic Memory project."""
37
+ return await project_service.get_project_info(project)
38
+
39
+
40
+ @project_router.get("/item", response_model=ProjectItem)
41
+ async def get_project(
42
+ project_service: ProjectServiceDep,
43
+ project: ProjectPathDep,
44
+ ) -> ProjectItem:
45
+ """Get bassic info about the specified Basic Memory project."""
46
+ found_project = await project_service.get_project(project)
47
+ if not found_project:
48
+ raise HTTPException(
49
+ status_code=404, detail=f"Project: '{project}' does not exist"
50
+ ) # pragma: no cover
51
+
52
+ return ProjectItem(
53
+ id=found_project.id,
54
+ external_id=found_project.external_id,
55
+ name=found_project.name,
56
+ path=normalize_project_path(found_project.path),
57
+ is_default=found_project.is_default or False,
58
+ )
59
+
60
+
61
+ # Update a project
62
+ @project_router.patch("/{name}", response_model=ProjectStatusResponse)
63
+ async def update_project(
64
+ project_service: ProjectServiceDep,
65
+ name: str = Path(..., description="Name of the project to update"),
66
+ path: Optional[str] = Body(None, description="New absolute path for the project"),
67
+ is_active: Optional[bool] = Body(None, description="Status of the project (active/inactive)"),
68
+ ) -> ProjectStatusResponse:
69
+ """Update a project's information in configuration and database.
70
+
71
+ Args:
72
+ name: The name of the project to update
73
+ path: Optional new absolute path for the project
74
+ is_active: Optional status update for the project
75
+
76
+ Returns:
77
+ Response confirming the project was updated
78
+ """
79
+ try:
80
+ # Validate that path is absolute if provided
81
+ if path and not os.path.isabs(path):
82
+ raise HTTPException(status_code=400, detail="Path must be absolute")
83
+
84
+ # Get original project info for the response
85
+ old_project = await project_service.get_project(name)
86
+ if not old_project:
87
+ raise HTTPException(
88
+ status_code=400, detail=f"Project '{name}' not found in configuration"
89
+ )
90
+
91
+ old_project_info = ProjectItem(
92
+ id=old_project.id,
93
+ external_id=old_project.external_id,
94
+ name=old_project.name,
95
+ path=old_project.path,
96
+ is_default=old_project.is_default or False,
97
+ )
98
+
99
+ if path:
100
+ await project_service.move_project(name, path)
101
+ elif is_active is not None:
102
+ await project_service.update_project(name, is_active=is_active)
103
+
104
+ # Get updated project info
105
+ updated_project = await project_service.get_project(name)
106
+ if not updated_project:
107
+ raise HTTPException( # pragma: no cover
108
+ status_code=404, detail=f"Project '{name}' not found after update"
109
+ )
110
+
111
+ return ProjectStatusResponse(
112
+ message=f"Project '{name}' updated successfully",
113
+ status="success",
114
+ default=(name == project_service.default_project),
115
+ old_project=old_project_info,
116
+ new_project=ProjectItem(
117
+ id=updated_project.id,
118
+ external_id=updated_project.external_id,
119
+ name=updated_project.name,
120
+ path=updated_project.path,
121
+ is_default=updated_project.is_default or False,
122
+ ),
123
+ )
124
+ except ValueError as e:
125
+ raise HTTPException(status_code=400, detail=str(e)) # pragma: no cover
126
+
127
+
128
+ # Sync project filesystem
129
+ @project_router.post("/sync")
130
+ async def sync_project(
131
+ background_tasks: BackgroundTasks,
132
+ sync_service: SyncServiceDep,
133
+ project_config: ProjectConfigDep,
134
+ force_full: bool = Query(
135
+ False, description="Force full scan, bypassing watermark optimization"
136
+ ),
137
+ run_in_background: bool = Query(True, description="Run in background"),
138
+ ):
139
+ """Force project filesystem sync to database.
140
+
141
+ Scans the project directory and updates the database with any new or modified files.
142
+
143
+ Args:
144
+ background_tasks: FastAPI background tasks
145
+ sync_service: Sync service for this project
146
+ project_config: Project configuration
147
+ force_full: If True, force a full scan even if watermark exists
148
+ run_in_background: If True, run sync in background and return immediately
149
+
150
+ Returns:
151
+ Response confirming sync was initiated (background) or SyncReportResponse (foreground)
152
+ """
153
+ if run_in_background:
154
+ background_tasks.add_task(
155
+ sync_service.sync, project_config.home, project_config.name, force_full=force_full
156
+ )
157
+ logger.info(
158
+ f"Filesystem sync initiated for project: {project_config.name} (force_full={force_full})"
159
+ )
160
+
161
+ return {
162
+ "status": "sync_started",
163
+ "message": f"Filesystem sync initiated for project '{project_config.name}'",
164
+ }
165
+ else:
166
+ report = await sync_service.sync(
167
+ project_config.home, project_config.name, force_full=force_full
168
+ )
169
+ logger.info(
170
+ f"Filesystem sync completed for project: {project_config.name} (force_full={force_full})"
171
+ )
172
+ return SyncReportResponse.from_sync_report(report)
173
+
174
+
175
+ @project_router.post("/status", response_model=SyncReportResponse)
176
+ async def project_sync_status(
177
+ sync_service: SyncServiceDep,
178
+ project_config: ProjectConfigDep,
179
+ ) -> SyncReportResponse:
180
+ """Scan directory for changes compared to database state.
181
+
182
+ Args:
183
+ sync_service: Sync service for this project
184
+ project_config: Project configuration
185
+
186
+ Returns:
187
+ Scan report with details on files that need syncing
188
+ """
189
+ logger.info(f"Scanning filesystem for project: {project_config.name}") # pragma: no cover
190
+ sync_report = await sync_service.scan(project_config.home) # pragma: no cover
191
+
192
+ return SyncReportResponse.from_sync_report(sync_report) # pragma: no cover
193
+
194
+
195
+ # List all available projects
196
+ @project_resource_router.get("/projects", response_model=ProjectList)
197
+ async def list_projects(
198
+ project_service: ProjectServiceDep,
199
+ ) -> ProjectList:
200
+ """List all configured projects.
201
+
202
+ Returns:
203
+ A list of all projects with metadata
204
+ """
205
+ projects = await project_service.list_projects()
206
+ default_project = project_service.default_project
207
+
208
+ project_items = [
209
+ ProjectItem(
210
+ id=project.id,
211
+ external_id=project.external_id,
212
+ name=project.name,
213
+ path=normalize_project_path(project.path),
214
+ is_default=project.is_default or False,
215
+ )
216
+ for project in projects
217
+ ]
218
+
219
+ return ProjectList(
220
+ projects=project_items,
221
+ default_project=default_project,
222
+ )
223
+
224
+
225
+ # Add a new project
226
+ @project_resource_router.post("/projects", response_model=ProjectStatusResponse, status_code=201)
227
+ async def add_project(
228
+ response: Response,
229
+ project_data: ProjectInfoRequest,
230
+ project_service: ProjectServiceDep,
231
+ ) -> ProjectStatusResponse:
232
+ """Add a new project to configuration and database.
233
+
234
+ Args:
235
+ project_data: The project name and path, with option to set as default
236
+
237
+ Returns:
238
+ Response confirming the project was added
239
+ """
240
+ # Check if project already exists before attempting to add
241
+ existing_project = await project_service.get_project(project_data.name)
242
+ if existing_project:
243
+ # Project exists - check if paths match for true idempotency
244
+ # Normalize paths for comparison (resolve symlinks, etc.)
245
+ from pathlib import Path
246
+
247
+ requested_path = Path(project_data.path).resolve()
248
+ existing_path = Path(existing_project.path).resolve()
249
+
250
+ if requested_path == existing_path:
251
+ # Same name, same path - return 200 OK (idempotent)
252
+ response.status_code = 200
253
+ return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
254
+ message=f"Project '{project_data.name}' already exists",
255
+ status="success",
256
+ default=existing_project.is_default or False,
257
+ new_project=ProjectItem(
258
+ id=existing_project.id,
259
+ external_id=existing_project.external_id,
260
+ name=existing_project.name,
261
+ path=existing_project.path,
262
+ is_default=existing_project.is_default or False,
263
+ ),
264
+ )
265
+ else:
266
+ # Same name, different path - this is an error
267
+ raise HTTPException(
268
+ status_code=400,
269
+ detail=f"Project '{project_data.name}' already exists with different path. Existing: {existing_project.path}, Requested: {project_data.path}",
270
+ )
271
+
272
+ try: # pragma: no cover
273
+ # The service layer now handles cloud mode validation and path sanitization
274
+ await project_service.add_project(
275
+ project_data.name, project_data.path, set_default=project_data.set_default
276
+ )
277
+
278
+ # Fetch the newly created project to get its ID
279
+ new_project = await project_service.get_project(project_data.name)
280
+ if not new_project:
281
+ raise HTTPException(status_code=500, detail="Failed to retrieve newly created project")
282
+
283
+ return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
284
+ message=f"Project '{new_project.name}' added successfully",
285
+ status="success",
286
+ default=project_data.set_default,
287
+ new_project=ProjectItem(
288
+ id=new_project.id,
289
+ external_id=new_project.external_id,
290
+ name=new_project.name,
291
+ path=new_project.path,
292
+ is_default=new_project.is_default or False,
293
+ ),
294
+ )
295
+ except ValueError as e: # pragma: no cover
296
+ raise HTTPException(status_code=400, detail=str(e))
297
+
298
+
299
+ # Remove a project
300
+ @project_resource_router.delete("/{name}", response_model=ProjectStatusResponse)
301
+ async def remove_project(
302
+ project_service: ProjectServiceDep,
303
+ name: str = Path(..., description="Name of the project to remove"),
304
+ delete_notes: bool = Query(
305
+ False, description="If True, delete project directory from filesystem"
306
+ ),
307
+ ) -> ProjectStatusResponse:
308
+ """Remove a project from configuration and database.
309
+
310
+ Args:
311
+ name: The name of the project to remove
312
+ delete_notes: If True, delete the project directory from the filesystem
313
+
314
+ Returns:
315
+ Response confirming the project was removed
316
+ """
317
+ try:
318
+ old_project = await project_service.get_project(name)
319
+ if not old_project: # pragma: no cover
320
+ raise HTTPException(
321
+ status_code=404, detail=f"Project: '{name}' does not exist"
322
+ ) # pragma: no cover
323
+
324
+ # Check if trying to delete the default project
325
+ if name == project_service.default_project:
326
+ available_projects = await project_service.list_projects()
327
+ other_projects = [p.name for p in available_projects if p.name != name]
328
+ detail = f"Cannot delete default project '{name}'. "
329
+ if other_projects:
330
+ detail += (
331
+ f"Set another project as default first. Available: {', '.join(other_projects)}"
332
+ )
333
+ else:
334
+ detail += "This is the only project in your configuration."
335
+ raise HTTPException(status_code=400, detail=detail)
336
+
337
+ await project_service.remove_project(name, delete_notes=delete_notes)
338
+
339
+ return ProjectStatusResponse(
340
+ message=f"Project '{old_project.name}' removed successfully",
341
+ status="success",
342
+ default=False,
343
+ old_project=ProjectItem(
344
+ id=old_project.id,
345
+ external_id=old_project.external_id,
346
+ name=old_project.name,
347
+ path=old_project.path,
348
+ is_default=old_project.is_default or False,
349
+ ),
350
+ new_project=None,
351
+ )
352
+ except ValueError as e: # pragma: no cover
353
+ raise HTTPException(status_code=400, detail=str(e))
354
+
355
+
356
+ # Set a project as default
357
+ @project_resource_router.put("/{name}/default", response_model=ProjectStatusResponse)
358
+ async def set_default_project(
359
+ project_service: ProjectServiceDep,
360
+ name: str = Path(..., description="Name of the project to set as default"),
361
+ ) -> ProjectStatusResponse:
362
+ """Set a project as the default project.
363
+
364
+ Args:
365
+ name: The name of the project to set as default
366
+
367
+ Returns:
368
+ Response confirming the project was set as default
369
+ """
370
+ try:
371
+ # Get the old default project
372
+ default_name = project_service.default_project
373
+ default_project = await project_service.get_project(default_name)
374
+ if not default_project: # pragma: no cover
375
+ raise HTTPException( # pragma: no cover
376
+ status_code=404, detail=f"Default Project: '{default_name}' does not exist"
377
+ )
378
+
379
+ # get the new project
380
+ new_default_project = await project_service.get_project(name)
381
+ if not new_default_project: # pragma: no cover
382
+ raise HTTPException(
383
+ status_code=404, detail=f"Project: '{name}' does not exist"
384
+ ) # pragma: no cover
385
+
386
+ await project_service.set_default_project(name)
387
+
388
+ return ProjectStatusResponse(
389
+ message=f"Project '{name}' set as default successfully",
390
+ status="success",
391
+ default=True,
392
+ old_project=ProjectItem(
393
+ id=default_project.id,
394
+ external_id=default_project.external_id,
395
+ name=default_name,
396
+ path=default_project.path,
397
+ is_default=False,
398
+ ),
399
+ new_project=ProjectItem(
400
+ id=new_default_project.id,
401
+ external_id=new_default_project.external_id,
402
+ name=name,
403
+ path=new_default_project.path,
404
+ is_default=True,
405
+ ),
406
+ )
407
+ except ValueError as e: # pragma: no cover
408
+ raise HTTPException(status_code=400, detail=str(e))
409
+
410
+
411
+ # Get the default project
412
+ @project_resource_router.get("/default", response_model=ProjectItem)
413
+ async def get_default_project(
414
+ project_service: ProjectServiceDep,
415
+ ) -> ProjectItem:
416
+ """Get the default project.
417
+
418
+ Returns:
419
+ Response with project default information
420
+ """
421
+ # Get the old default project
422
+ default_name = project_service.default_project
423
+ default_project = await project_service.get_project(default_name)
424
+ if not default_project: # pragma: no cover
425
+ raise HTTPException( # pragma: no cover
426
+ status_code=404, detail=f"Default Project: '{default_name}' does not exist"
427
+ )
428
+
429
+ return ProjectItem(
430
+ id=default_project.id,
431
+ external_id=default_project.external_id,
432
+ name=default_project.name,
433
+ path=default_project.path,
434
+ is_default=True,
435
+ )
436
+
437
+
438
+ # Synchronize projects between config and database
439
+ @project_resource_router.post("/config/sync", response_model=ProjectStatusResponse)
440
+ async def synchronize_projects(
441
+ project_service: ProjectServiceDep,
442
+ ) -> ProjectStatusResponse:
443
+ """Synchronize projects between configuration file and database.
444
+
445
+ Ensures that all projects in the configuration file exist in the database
446
+ and vice versa.
447
+
448
+ Returns:
449
+ Response confirming synchronization was completed
450
+ """
451
+ try: # pragma: no cover
452
+ await project_service.synchronize_projects()
453
+
454
+ return ProjectStatusResponse( # pyright: ignore [reportCallIssue]
455
+ message="Projects synchronized successfully between configuration and database",
456
+ status="success",
457
+ default=False,
458
+ )
459
+ except ValueError as e: # pragma: no cover
460
+ raise HTTPException(status_code=400, detail=str(e))