basic-memory 0.17.1__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.
Files changed (171) hide show
  1. basic_memory/__init__.py +7 -0
  2. basic_memory/alembic/alembic.ini +119 -0
  3. basic_memory/alembic/env.py +185 -0
  4. basic_memory/alembic/migrations.py +24 -0
  5. basic_memory/alembic/script.py.mako +26 -0
  6. basic_memory/alembic/versions/314f1ea54dc4_add_postgres_full_text_search_support_.py +131 -0
  7. basic_memory/alembic/versions/3dae7c7b1564_initial_schema.py +93 -0
  8. basic_memory/alembic/versions/502b60eaa905_remove_required_from_entity_permalink.py +51 -0
  9. basic_memory/alembic/versions/5fe1ab1ccebe_add_projects_table.py +120 -0
  10. basic_memory/alembic/versions/647e7a75e2cd_project_constraint_fix.py +112 -0
  11. basic_memory/alembic/versions/9d9c1cb7d8f5_add_mtime_and_size_columns_to_entity_.py +49 -0
  12. basic_memory/alembic/versions/a1b2c3d4e5f6_fix_project_foreign_keys.py +49 -0
  13. basic_memory/alembic/versions/a2b3c4d5e6f7_add_search_index_entity_cascade.py +56 -0
  14. basic_memory/alembic/versions/b3c3938bacdb_relation_to_name_unique_index.py +44 -0
  15. basic_memory/alembic/versions/cc7172b46608_update_search_index_schema.py +113 -0
  16. basic_memory/alembic/versions/e7e1f4367280_add_scan_watermark_tracking_to_project.py +37 -0
  17. basic_memory/alembic/versions/f8a9b2c3d4e5_add_pg_trgm_for_fuzzy_link_resolution.py +239 -0
  18. basic_memory/api/__init__.py +5 -0
  19. basic_memory/api/app.py +131 -0
  20. basic_memory/api/routers/__init__.py +11 -0
  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 +318 -0
  24. basic_memory/api/routers/management_router.py +80 -0
  25. basic_memory/api/routers/memory_router.py +90 -0
  26. basic_memory/api/routers/project_router.py +448 -0
  27. basic_memory/api/routers/prompt_router.py +260 -0
  28. basic_memory/api/routers/resource_router.py +249 -0
  29. basic_memory/api/routers/search_router.py +36 -0
  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 +182 -0
  36. basic_memory/api/v2/routers/knowledge_router.py +413 -0
  37. basic_memory/api/v2/routers/memory_router.py +130 -0
  38. basic_memory/api/v2/routers/project_router.py +342 -0
  39. basic_memory/api/v2/routers/prompt_router.py +270 -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/__init__.py +1 -0
  43. basic_memory/cli/app.py +84 -0
  44. basic_memory/cli/auth.py +277 -0
  45. basic_memory/cli/commands/__init__.py +18 -0
  46. basic_memory/cli/commands/cloud/__init__.py +6 -0
  47. basic_memory/cli/commands/cloud/api_client.py +112 -0
  48. basic_memory/cli/commands/cloud/bisync_commands.py +110 -0
  49. basic_memory/cli/commands/cloud/cloud_utils.py +101 -0
  50. basic_memory/cli/commands/cloud/core_commands.py +195 -0
  51. basic_memory/cli/commands/cloud/rclone_commands.py +371 -0
  52. basic_memory/cli/commands/cloud/rclone_config.py +110 -0
  53. basic_memory/cli/commands/cloud/rclone_installer.py +263 -0
  54. basic_memory/cli/commands/cloud/upload.py +233 -0
  55. basic_memory/cli/commands/cloud/upload_command.py +124 -0
  56. basic_memory/cli/commands/command_utils.py +77 -0
  57. basic_memory/cli/commands/db.py +44 -0
  58. basic_memory/cli/commands/format.py +198 -0
  59. basic_memory/cli/commands/import_chatgpt.py +84 -0
  60. basic_memory/cli/commands/import_claude_conversations.py +87 -0
  61. basic_memory/cli/commands/import_claude_projects.py +86 -0
  62. basic_memory/cli/commands/import_memory_json.py +87 -0
  63. basic_memory/cli/commands/mcp.py +76 -0
  64. basic_memory/cli/commands/project.py +889 -0
  65. basic_memory/cli/commands/status.py +174 -0
  66. basic_memory/cli/commands/telemetry.py +81 -0
  67. basic_memory/cli/commands/tool.py +341 -0
  68. basic_memory/cli/main.py +28 -0
  69. basic_memory/config.py +616 -0
  70. basic_memory/db.py +394 -0
  71. basic_memory/deps.py +705 -0
  72. basic_memory/file_utils.py +478 -0
  73. basic_memory/ignore_utils.py +297 -0
  74. basic_memory/importers/__init__.py +27 -0
  75. basic_memory/importers/base.py +79 -0
  76. basic_memory/importers/chatgpt_importer.py +232 -0
  77. basic_memory/importers/claude_conversations_importer.py +180 -0
  78. basic_memory/importers/claude_projects_importer.py +148 -0
  79. basic_memory/importers/memory_json_importer.py +108 -0
  80. basic_memory/importers/utils.py +61 -0
  81. basic_memory/markdown/__init__.py +21 -0
  82. basic_memory/markdown/entity_parser.py +279 -0
  83. basic_memory/markdown/markdown_processor.py +160 -0
  84. basic_memory/markdown/plugins.py +242 -0
  85. basic_memory/markdown/schemas.py +70 -0
  86. basic_memory/markdown/utils.py +117 -0
  87. basic_memory/mcp/__init__.py +1 -0
  88. basic_memory/mcp/async_client.py +139 -0
  89. basic_memory/mcp/project_context.py +141 -0
  90. basic_memory/mcp/prompts/__init__.py +19 -0
  91. basic_memory/mcp/prompts/ai_assistant_guide.py +70 -0
  92. basic_memory/mcp/prompts/continue_conversation.py +62 -0
  93. basic_memory/mcp/prompts/recent_activity.py +188 -0
  94. basic_memory/mcp/prompts/search.py +57 -0
  95. basic_memory/mcp/prompts/utils.py +162 -0
  96. basic_memory/mcp/resources/ai_assistant_guide.md +283 -0
  97. basic_memory/mcp/resources/project_info.py +71 -0
  98. basic_memory/mcp/server.py +81 -0
  99. basic_memory/mcp/tools/__init__.py +48 -0
  100. basic_memory/mcp/tools/build_context.py +120 -0
  101. basic_memory/mcp/tools/canvas.py +152 -0
  102. basic_memory/mcp/tools/chatgpt_tools.py +190 -0
  103. basic_memory/mcp/tools/delete_note.py +242 -0
  104. basic_memory/mcp/tools/edit_note.py +324 -0
  105. basic_memory/mcp/tools/list_directory.py +168 -0
  106. basic_memory/mcp/tools/move_note.py +551 -0
  107. basic_memory/mcp/tools/project_management.py +201 -0
  108. basic_memory/mcp/tools/read_content.py +281 -0
  109. basic_memory/mcp/tools/read_note.py +267 -0
  110. basic_memory/mcp/tools/recent_activity.py +534 -0
  111. basic_memory/mcp/tools/search.py +385 -0
  112. basic_memory/mcp/tools/utils.py +540 -0
  113. basic_memory/mcp/tools/view_note.py +78 -0
  114. basic_memory/mcp/tools/write_note.py +230 -0
  115. basic_memory/models/__init__.py +15 -0
  116. basic_memory/models/base.py +10 -0
  117. basic_memory/models/knowledge.py +226 -0
  118. basic_memory/models/project.py +87 -0
  119. basic_memory/models/search.py +85 -0
  120. basic_memory/repository/__init__.py +11 -0
  121. basic_memory/repository/entity_repository.py +503 -0
  122. basic_memory/repository/observation_repository.py +73 -0
  123. basic_memory/repository/postgres_search_repository.py +379 -0
  124. basic_memory/repository/project_info_repository.py +10 -0
  125. basic_memory/repository/project_repository.py +128 -0
  126. basic_memory/repository/relation_repository.py +146 -0
  127. basic_memory/repository/repository.py +385 -0
  128. basic_memory/repository/search_index_row.py +95 -0
  129. basic_memory/repository/search_repository.py +94 -0
  130. basic_memory/repository/search_repository_base.py +241 -0
  131. basic_memory/repository/sqlite_search_repository.py +439 -0
  132. basic_memory/schemas/__init__.py +86 -0
  133. basic_memory/schemas/base.py +297 -0
  134. basic_memory/schemas/cloud.py +50 -0
  135. basic_memory/schemas/delete.py +37 -0
  136. basic_memory/schemas/directory.py +30 -0
  137. basic_memory/schemas/importer.py +35 -0
  138. basic_memory/schemas/memory.py +285 -0
  139. basic_memory/schemas/project_info.py +212 -0
  140. basic_memory/schemas/prompt.py +90 -0
  141. basic_memory/schemas/request.py +112 -0
  142. basic_memory/schemas/response.py +229 -0
  143. basic_memory/schemas/search.py +117 -0
  144. basic_memory/schemas/sync_report.py +72 -0
  145. basic_memory/schemas/v2/__init__.py +27 -0
  146. basic_memory/schemas/v2/entity.py +129 -0
  147. basic_memory/schemas/v2/resource.py +46 -0
  148. basic_memory/services/__init__.py +8 -0
  149. basic_memory/services/context_service.py +601 -0
  150. basic_memory/services/directory_service.py +308 -0
  151. basic_memory/services/entity_service.py +864 -0
  152. basic_memory/services/exceptions.py +37 -0
  153. basic_memory/services/file_service.py +541 -0
  154. basic_memory/services/initialization.py +216 -0
  155. basic_memory/services/link_resolver.py +121 -0
  156. basic_memory/services/project_service.py +880 -0
  157. basic_memory/services/search_service.py +404 -0
  158. basic_memory/services/service.py +15 -0
  159. basic_memory/sync/__init__.py +6 -0
  160. basic_memory/sync/background_sync.py +26 -0
  161. basic_memory/sync/sync_service.py +1259 -0
  162. basic_memory/sync/watch_service.py +510 -0
  163. basic_memory/telemetry.py +249 -0
  164. basic_memory/templates/prompts/continue_conversation.hbs +110 -0
  165. basic_memory/templates/prompts/search.hbs +101 -0
  166. basic_memory/utils.py +468 -0
  167. basic_memory-0.17.1.dist-info/METADATA +617 -0
  168. basic_memory-0.17.1.dist-info/RECORD +171 -0
  169. basic_memory-0.17.1.dist-info/WHEEL +4 -0
  170. basic_memory-0.17.1.dist-info/entry_points.txt +3 -0
  171. basic_memory-0.17.1.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,182 @@
1
+ """V2 Import Router - ID-based data import operations.
2
+
3
+ This router uses v2 dependencies for consistent project ID handling.
4
+ Import endpoints use project_id in the path for consistency with other v2 endpoints.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+
10
+ from fastapi import APIRouter, Form, HTTPException, UploadFile, status
11
+
12
+ from basic_memory.deps import (
13
+ ChatGPTImporterV2Dep,
14
+ ClaudeConversationsImporterV2Dep,
15
+ ClaudeProjectsImporterV2Dep,
16
+ MemoryJsonImporterV2Dep,
17
+ ProjectIdPathDep,
18
+ )
19
+ from basic_memory.importers import Importer
20
+ from basic_memory.schemas.importer import (
21
+ ChatImportResult,
22
+ EntityImportResult,
23
+ ProjectImportResult,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ router = APIRouter(prefix="/import", tags=["import-v2"])
29
+
30
+
31
+ @router.post("/chatgpt", response_model=ChatImportResult)
32
+ async def import_chatgpt(
33
+ project_id: ProjectIdPathDep,
34
+ importer: ChatGPTImporterV2Dep,
35
+ file: UploadFile,
36
+ folder: str = Form("conversations"),
37
+ ) -> ChatImportResult:
38
+ """Import conversations from ChatGPT JSON export.
39
+
40
+ Args:
41
+ project_id: Validated numeric project ID from URL path
42
+ file: The ChatGPT conversations.json file.
43
+ folder: The folder to place the files in.
44
+ importer: ChatGPT importer instance.
45
+
46
+ Returns:
47
+ ChatImportResult with import statistics.
48
+
49
+ Raises:
50
+ HTTPException: If import fails.
51
+ """
52
+ logger.info(f"V2 Importing ChatGPT conversations for project {project_id}")
53
+ return await import_file(importer, file, folder)
54
+
55
+
56
+ @router.post("/claude/conversations", response_model=ChatImportResult)
57
+ async def import_claude_conversations(
58
+ project_id: ProjectIdPathDep,
59
+ importer: ClaudeConversationsImporterV2Dep,
60
+ file: UploadFile,
61
+ folder: str = Form("conversations"),
62
+ ) -> ChatImportResult:
63
+ """Import conversations from Claude conversations.json export.
64
+
65
+ Args:
66
+ project_id: Validated numeric project ID from URL path
67
+ file: The Claude conversations.json file.
68
+ folder: The folder to place the files in.
69
+ importer: Claude conversations importer instance.
70
+
71
+ Returns:
72
+ ChatImportResult with import statistics.
73
+
74
+ Raises:
75
+ HTTPException: If import fails.
76
+ """
77
+ logger.info(f"V2 Importing Claude conversations for project {project_id}")
78
+ return await import_file(importer, file, folder)
79
+
80
+
81
+ @router.post("/claude/projects", response_model=ProjectImportResult)
82
+ async def import_claude_projects(
83
+ project_id: ProjectIdPathDep,
84
+ importer: ClaudeProjectsImporterV2Dep,
85
+ file: UploadFile,
86
+ folder: str = Form("projects"),
87
+ ) -> ProjectImportResult:
88
+ """Import projects from Claude projects.json export.
89
+
90
+ Args:
91
+ project_id: Validated numeric project ID from URL path
92
+ file: The Claude projects.json file.
93
+ folder: The base folder to place the files in.
94
+ importer: Claude projects importer instance.
95
+
96
+ Returns:
97
+ ProjectImportResult with import statistics.
98
+
99
+ Raises:
100
+ HTTPException: If import fails.
101
+ """
102
+ logger.info(f"V2 Importing Claude projects for project {project_id}")
103
+ return await import_file(importer, file, folder)
104
+
105
+
106
+ @router.post("/memory-json", response_model=EntityImportResult)
107
+ async def import_memory_json(
108
+ project_id: ProjectIdPathDep,
109
+ importer: MemoryJsonImporterV2Dep,
110
+ file: UploadFile,
111
+ folder: str = Form("conversations"),
112
+ ) -> EntityImportResult:
113
+ """Import entities and relations from a memory.json file.
114
+
115
+ Args:
116
+ project_id: Validated numeric project ID from URL path
117
+ file: The memory.json file.
118
+ folder: Optional destination folder within the project.
119
+ importer: Memory JSON importer instance.
120
+
121
+ Returns:
122
+ EntityImportResult with import statistics.
123
+
124
+ Raises:
125
+ HTTPException: If import fails.
126
+ """
127
+ logger.info(f"V2 Importing memory.json for project {project_id}")
128
+ try:
129
+ file_data = []
130
+ file_bytes = await file.read()
131
+ file_str = file_bytes.decode("utf-8")
132
+ for line in file_str.splitlines():
133
+ json_data = json.loads(line)
134
+ file_data.append(json_data)
135
+
136
+ result = await importer.import_data(file_data, folder)
137
+ if not result.success: # pragma: no cover
138
+ raise HTTPException(
139
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
140
+ detail=result.error_message or "Import failed",
141
+ )
142
+ except Exception as e:
143
+ logger.exception("V2 Import failed")
144
+ raise HTTPException(
145
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
146
+ detail=f"Import failed: {str(e)}",
147
+ )
148
+ return result
149
+
150
+
151
+ async def import_file(importer: Importer, file: UploadFile, destination_folder: str):
152
+ """Helper function to import a file using an importer instance.
153
+
154
+ Args:
155
+ importer: The importer instance to use
156
+ file: The file to import
157
+ destination_folder: Destination folder for imported content
158
+
159
+ Returns:
160
+ Import result from the importer
161
+
162
+ Raises:
163
+ HTTPException: If import fails
164
+ """
165
+ try:
166
+ # Process file
167
+ json_data = json.load(file.file)
168
+ result = await importer.import_data(json_data, destination_folder)
169
+ if not result.success: # pragma: no cover
170
+ raise HTTPException(
171
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
172
+ detail=result.error_message or "Import failed",
173
+ )
174
+
175
+ return result
176
+
177
+ except Exception as e:
178
+ logger.exception("V2 Import failed")
179
+ raise HTTPException(
180
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
181
+ detail=f"Import failed: {str(e)}",
182
+ )
@@ -0,0 +1,413 @@
1
+ """V2 Knowledge Router - ID-based entity operations.
2
+
3
+ This router provides ID-based CRUD operations for entities, replacing the
4
+ path-based identifiers used in v1 with direct integer ID lookups.
5
+
6
+ Key improvements:
7
+ - Direct database lookups via integer primary keys
8
+ - Stable references that don't change with file moves
9
+ - Better performance through indexed queries
10
+ - Simplified caching strategies
11
+ """
12
+
13
+ from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Response
14
+ from loguru import logger
15
+
16
+ from basic_memory.deps import (
17
+ EntityServiceV2Dep,
18
+ SearchServiceV2Dep,
19
+ LinkResolverV2Dep,
20
+ ProjectConfigV2Dep,
21
+ AppConfigDep,
22
+ SyncServiceV2Dep,
23
+ EntityRepositoryV2Dep,
24
+ ProjectIdPathDep,
25
+ )
26
+ from basic_memory.schemas import DeleteEntitiesResponse
27
+ from basic_memory.schemas.base import Entity
28
+ from basic_memory.schemas.request import EditEntityRequest
29
+ from basic_memory.schemas.v2 import (
30
+ EntityResolveRequest,
31
+ EntityResolveResponse,
32
+ EntityResponseV2,
33
+ MoveEntityRequestV2,
34
+ )
35
+
36
+ router = APIRouter(prefix="/knowledge", tags=["knowledge-v2"])
37
+
38
+
39
+ async def resolve_relations_background(sync_service, entity_id: int, entity_permalink: str) -> None:
40
+ """Background task to resolve relations for a specific entity.
41
+
42
+ This runs asynchronously after the API response is sent, preventing
43
+ long delays when creating entities with many relations.
44
+ """
45
+ try:
46
+ # Only resolve relations for the newly created entity
47
+ await sync_service.resolve_relations(entity_id=entity_id)
48
+ logger.debug(
49
+ f"Background: Resolved relations for entity {entity_permalink} (id={entity_id})"
50
+ )
51
+ except Exception as e:
52
+ # Log but don't fail - this is a background task
53
+ logger.warning(
54
+ f"Background: Failed to resolve relations for entity {entity_permalink}: {e}"
55
+ )
56
+
57
+
58
+ ## Resolution endpoint
59
+
60
+
61
+ @router.post("/resolve", response_model=EntityResolveResponse)
62
+ async def resolve_identifier(
63
+ project_id: ProjectIdPathDep,
64
+ data: EntityResolveRequest,
65
+ link_resolver: LinkResolverV2Dep,
66
+ ) -> EntityResolveResponse:
67
+ """Resolve a string identifier (permalink, title, or path) to an entity ID.
68
+
69
+ This endpoint provides a bridge between v1-style identifiers and v2 entity IDs.
70
+ Use this to convert existing references to the new ID-based format.
71
+
72
+ Args:
73
+ data: Request containing the identifier to resolve
74
+
75
+ Returns:
76
+ Entity ID and metadata about how it was resolved
77
+
78
+ Raises:
79
+ HTTPException: 404 if identifier cannot be resolved
80
+
81
+ Example:
82
+ POST /v2/{project}/knowledge/resolve
83
+ {"identifier": "specs/search"}
84
+
85
+ Returns:
86
+ {
87
+ "entity_id": 123,
88
+ "permalink": "specs/search",
89
+ "file_path": "specs/search.md",
90
+ "title": "Search Specification",
91
+ "resolution_method": "permalink"
92
+ }
93
+ """
94
+ logger.info(f"API v2 request: resolve_identifier for '{data.identifier}'")
95
+
96
+ # Try to resolve the identifier
97
+ entity = await link_resolver.resolve_link(data.identifier)
98
+ if not entity:
99
+ raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")
100
+
101
+ # Determine resolution method
102
+ resolution_method = "search" # default
103
+ if data.identifier.isdigit():
104
+ resolution_method = "id"
105
+ elif entity.permalink == data.identifier:
106
+ resolution_method = "permalink"
107
+ elif entity.title == data.identifier:
108
+ resolution_method = "title"
109
+ elif entity.file_path == data.identifier:
110
+ resolution_method = "path"
111
+
112
+ result = EntityResolveResponse(
113
+ entity_id=entity.id,
114
+ permalink=entity.permalink,
115
+ file_path=entity.file_path,
116
+ title=entity.title,
117
+ resolution_method=resolution_method,
118
+ )
119
+
120
+ logger.info(
121
+ f"API v2 response: resolved '{data.identifier}' to entity_id={result.entity_id} via {resolution_method}"
122
+ )
123
+
124
+ return result
125
+
126
+
127
+ ## Read endpoints
128
+
129
+
130
+ @router.get("/entities/{entity_id}", response_model=EntityResponseV2)
131
+ async def get_entity_by_id(
132
+ project_id: ProjectIdPathDep,
133
+ entity_id: int,
134
+ entity_repository: EntityRepositoryV2Dep,
135
+ ) -> EntityResponseV2:
136
+ """Get an entity by its numeric ID.
137
+
138
+ This is the primary entity retrieval method in v2, using direct database
139
+ lookups for maximum performance.
140
+
141
+ Args:
142
+ entity_id: Numeric entity ID
143
+
144
+ Returns:
145
+ Complete entity with observations and relations
146
+
147
+ Raises:
148
+ HTTPException: 404 if entity not found
149
+ """
150
+ logger.info(f"API v2 request: get_entity_by_id entity_id={entity_id}")
151
+
152
+ entity = await entity_repository.get_by_id(entity_id)
153
+ if not entity:
154
+ raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
155
+
156
+ result = EntityResponseV2.model_validate(entity)
157
+ logger.info(f"API v2 response: entity_id={entity_id}, title='{result.title}'")
158
+
159
+ return result
160
+
161
+
162
+ ## Create endpoints
163
+
164
+
165
+ @router.post("/entities", response_model=EntityResponseV2)
166
+ async def create_entity(
167
+ project_id: ProjectIdPathDep,
168
+ data: Entity,
169
+ background_tasks: BackgroundTasks,
170
+ entity_service: EntityServiceV2Dep,
171
+ search_service: SearchServiceV2Dep,
172
+ ) -> EntityResponseV2:
173
+ """Create a new entity.
174
+
175
+ Args:
176
+ data: Entity data to create
177
+
178
+ Returns:
179
+ Created entity with generated ID
180
+ """
181
+ logger.info(
182
+ "API v2 request", endpoint="create_entity", entity_type=data.entity_type, title=data.title
183
+ )
184
+
185
+ entity = await entity_service.create_entity(data)
186
+
187
+ # reindex
188
+ await search_service.index_entity(entity, background_tasks=background_tasks)
189
+ result = EntityResponseV2.model_validate(entity)
190
+
191
+ logger.info(
192
+ f"API v2 response: endpoint='create_entity' id={entity.id}, title={result.title}, permalink={result.permalink}, status_code=201"
193
+ )
194
+ return result
195
+
196
+
197
+ ## Update endpoints
198
+
199
+
200
+ @router.put("/entities/{entity_id}", response_model=EntityResponseV2)
201
+ async def update_entity_by_id(
202
+ project_id: ProjectIdPathDep,
203
+ entity_id: int,
204
+ data: Entity,
205
+ response: Response,
206
+ background_tasks: BackgroundTasks,
207
+ entity_service: EntityServiceV2Dep,
208
+ search_service: SearchServiceV2Dep,
209
+ sync_service: SyncServiceV2Dep,
210
+ entity_repository: EntityRepositoryV2Dep,
211
+ ) -> EntityResponseV2:
212
+ """Update an entity by ID.
213
+
214
+ If the entity doesn't exist, it will be created (upsert behavior).
215
+
216
+ Args:
217
+ entity_id: Numeric entity ID
218
+ data: Updated entity data
219
+
220
+ Returns:
221
+ Updated entity
222
+ """
223
+ logger.info(f"API v2 request: update_entity_by_id entity_id={entity_id}")
224
+
225
+ # Check if entity exists
226
+ existing = await entity_repository.get_by_id(entity_id)
227
+ created = existing is None
228
+
229
+ # Perform update or create
230
+ entity, _ = await entity_service.create_or_update_entity(data)
231
+ response.status_code = 201 if created else 200
232
+
233
+ # reindex
234
+ await search_service.index_entity(entity, background_tasks=background_tasks)
235
+
236
+ # Schedule relation resolution for new entities
237
+ if created:
238
+ background_tasks.add_task(
239
+ resolve_relations_background, sync_service, entity.id, entity.permalink or ""
240
+ )
241
+
242
+ result = EntityResponseV2.model_validate(entity)
243
+
244
+ logger.info(
245
+ f"API v2 response: entity_id={entity_id}, created={created}, status_code={response.status_code}"
246
+ )
247
+ return result
248
+
249
+
250
+ @router.patch("/entities/{entity_id}", response_model=EntityResponseV2)
251
+ async def edit_entity_by_id(
252
+ project_id: ProjectIdPathDep,
253
+ entity_id: int,
254
+ data: EditEntityRequest,
255
+ background_tasks: BackgroundTasks,
256
+ entity_service: EntityServiceV2Dep,
257
+ search_service: SearchServiceV2Dep,
258
+ entity_repository: EntityRepositoryV2Dep,
259
+ ) -> EntityResponseV2:
260
+ """Edit an existing entity by ID using operations like append, prepend, etc.
261
+
262
+ Args:
263
+ entity_id: Numeric entity ID
264
+ data: Edit operation details
265
+
266
+ Returns:
267
+ Updated entity
268
+
269
+ Raises:
270
+ HTTPException: 404 if entity not found, 400 if edit fails
271
+ """
272
+ logger.info(
273
+ f"API v2 request: edit_entity_by_id entity_id={entity_id}, operation='{data.operation}'"
274
+ )
275
+
276
+ # Verify entity exists
277
+ entity = await entity_repository.get_by_id(entity_id)
278
+ if not entity:
279
+ raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
280
+
281
+ try:
282
+ # Edit using the entity's permalink or path
283
+ identifier = entity.permalink or entity.file_path
284
+ updated_entity = await entity_service.edit_entity(
285
+ identifier=identifier,
286
+ operation=data.operation,
287
+ content=data.content,
288
+ section=data.section,
289
+ find_text=data.find_text,
290
+ expected_replacements=data.expected_replacements,
291
+ )
292
+
293
+ # Reindex
294
+ await search_service.index_entity(updated_entity, background_tasks=background_tasks)
295
+
296
+ result = EntityResponseV2.model_validate(updated_entity)
297
+
298
+ logger.info(
299
+ f"API v2 response: entity_id={entity_id}, operation='{data.operation}', status_code=200"
300
+ )
301
+
302
+ return result
303
+
304
+ except Exception as e:
305
+ logger.error(f"Error editing entity {entity_id}: {e}")
306
+ raise HTTPException(status_code=400, detail=str(e))
307
+
308
+
309
+ ## Delete endpoints
310
+
311
+
312
+ @router.delete("/entities/{entity_id}", response_model=DeleteEntitiesResponse)
313
+ async def delete_entity_by_id(
314
+ project_id: ProjectIdPathDep,
315
+ entity_id: int,
316
+ background_tasks: BackgroundTasks,
317
+ entity_service: EntityServiceV2Dep,
318
+ entity_repository: EntityRepositoryV2Dep,
319
+ search_service=Depends(lambda: None), # Optional for now
320
+ ) -> DeleteEntitiesResponse:
321
+ """Delete an entity by ID.
322
+
323
+ Args:
324
+ entity_id: Numeric entity ID
325
+
326
+ Returns:
327
+ Deletion status
328
+
329
+ Note: Returns deleted=False if entity doesn't exist (idempotent)
330
+ """
331
+ logger.info(f"API v2 request: delete_entity_by_id entity_id={entity_id}")
332
+
333
+ entity = await entity_repository.get_by_id(entity_id)
334
+ if entity is None:
335
+ logger.info(f"API v2 response: entity_id={entity_id} not found, deleted=False")
336
+ return DeleteEntitiesResponse(deleted=False)
337
+
338
+ # Delete the entity
339
+ deleted = await entity_service.delete_entity(entity_id)
340
+
341
+ # Remove from search index if search service available
342
+ if search_service:
343
+ background_tasks.add_task(search_service.handle_delete, entity)
344
+
345
+ logger.info(f"API v2 response: entity_id={entity_id}, deleted={deleted}")
346
+
347
+ return DeleteEntitiesResponse(deleted=deleted)
348
+
349
+
350
+ ## Move endpoint
351
+
352
+
353
+ @router.put("/entities/{entity_id}/move", response_model=EntityResponseV2)
354
+ async def move_entity(
355
+ project_id: ProjectIdPathDep,
356
+ entity_id: int,
357
+ data: MoveEntityRequestV2,
358
+ background_tasks: BackgroundTasks,
359
+ entity_service: EntityServiceV2Dep,
360
+ entity_repository: EntityRepositoryV2Dep,
361
+ project_config: ProjectConfigV2Dep,
362
+ app_config: AppConfigDep,
363
+ search_service: SearchServiceV2Dep,
364
+ ) -> EntityResponseV2:
365
+ """Move an entity to a new file location.
366
+
367
+ V2 API uses entity ID in the URL path for stable references.
368
+ The entity ID will remain stable after the move.
369
+
370
+ Args:
371
+ project_id: Project ID from URL path
372
+ entity_id: Entity ID from URL path (primary identifier)
373
+ data: Move request with destination path only
374
+
375
+ Returns:
376
+ Updated entity with new file path
377
+ """
378
+ logger.info(
379
+ f"API v2 request: move_entity entity_id={entity_id}, destination='{data.destination_path}'"
380
+ )
381
+
382
+ try:
383
+ # First, get the entity by ID to verify it exists
384
+ entity = await entity_repository.find_by_id(entity_id)
385
+ if not entity:
386
+ raise HTTPException(status_code=404, detail=f"Entity not found: {entity_id}")
387
+
388
+ # Move the entity using its current file path as identifier
389
+ moved_entity = await entity_service.move_entity(
390
+ identifier=entity.file_path, # Use file path for resolution
391
+ destination_path=data.destination_path,
392
+ project_config=project_config,
393
+ app_config=app_config,
394
+ )
395
+
396
+ # Reindex at new location
397
+ reindexed_entity = await entity_service.link_resolver.resolve_link(data.destination_path)
398
+ if reindexed_entity:
399
+ await search_service.index_entity(reindexed_entity, background_tasks=background_tasks)
400
+
401
+ result = EntityResponseV2.model_validate(moved_entity)
402
+
403
+ logger.info(
404
+ f"API v2 response: moved entity_id={moved_entity.id} to '{data.destination_path}'"
405
+ )
406
+
407
+ return result
408
+
409
+ except HTTPException:
410
+ raise
411
+ except Exception as e:
412
+ logger.error(f"Error moving entity: {e}")
413
+ raise HTTPException(status_code=400, detail=str(e))