footprinter-cli 1.0.0__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 (134) hide show
  1. footprinter/__init__.py +8 -0
  2. footprinter/access.py +444 -0
  3. footprinter/api/__init__.py +1 -0
  4. footprinter/api/db.py +61 -0
  5. footprinter/api/entities.py +250 -0
  6. footprinter/api/search.py +47 -0
  7. footprinter/api/semantic.py +33 -0
  8. footprinter/api/server.py +66 -0
  9. footprinter/api/status.py +15 -0
  10. footprinter/bundled/__init__.py +0 -0
  11. footprinter/bundled/config.example.yaml +161 -0
  12. footprinter/bundled/patterns/context_patterns.yaml +18 -0
  13. footprinter/bundled/patterns/extensions.yaml +283 -0
  14. footprinter/bundled/patterns/filename_patterns.yaml +61 -0
  15. footprinter/bundled/patterns/mime_mappings.yaml +68 -0
  16. footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
  17. footprinter/bundled/patterns/security_patterns.yaml +27 -0
  18. footprinter/cli/__init__.py +128 -0
  19. footprinter/cli/__main__.py +6 -0
  20. footprinter/cli/_common.py +332 -0
  21. footprinter/cli/_policy_helpers.py +646 -0
  22. footprinter/cli/_prompt.py +220 -0
  23. footprinter/cli/api_cmd.py +32 -0
  24. footprinter/cli/connect.py +591 -0
  25. footprinter/cli/data.py +879 -0
  26. footprinter/cli/delete.py +128 -0
  27. footprinter/cli/ingest.py +579 -0
  28. footprinter/cli/mcp_cmd.py +750 -0
  29. footprinter/cli/mcp_setup.py +306 -0
  30. footprinter/cli/search.py +393 -0
  31. footprinter/cli/search_cmd.py +69 -0
  32. footprinter/cli/setup.py +1836 -0
  33. footprinter/cli/status.py +729 -0
  34. footprinter/cli/status_cmd.py +104 -0
  35. footprinter/cli/upsert.py +794 -0
  36. footprinter/cli/vectorize_cmd.py +215 -0
  37. footprinter/cli/view.py +322 -0
  38. footprinter/connectors/__init__.py +171 -0
  39. footprinter/connectors/config_utils.py +141 -0
  40. footprinter/db/__init__.py +37 -0
  41. footprinter/db/browser.py +198 -0
  42. footprinter/db/chats.py +610 -0
  43. footprinter/db/clients.py +307 -0
  44. footprinter/db/emails.py +279 -0
  45. footprinter/db/files.py +741 -0
  46. footprinter/db/folders.py +659 -0
  47. footprinter/db/messages.py +192 -0
  48. footprinter/db/policies.py +151 -0
  49. footprinter/db/projects.py +673 -0
  50. footprinter/db/search.py +573 -0
  51. footprinter/db/sql_utils.py +168 -0
  52. footprinter/db/status.py +320 -0
  53. footprinter/db/uploads.py +70 -0
  54. footprinter/ingest/__init__.py +0 -0
  55. footprinter/ingest/adapters/__init__.py +33 -0
  56. footprinter/ingest/adapters/browser.py +54 -0
  57. footprinter/ingest/adapters/chat.py +57 -0
  58. footprinter/ingest/adapters/ingest.py +146 -0
  59. footprinter/ingest/adapters/local_files.py +68 -0
  60. footprinter/ingest/adapters/local_folders.py +52 -0
  61. footprinter/ingest/adapters/protocol.py +174 -0
  62. footprinter/ingest/browser_indexer.py +216 -0
  63. footprinter/ingest/chat_dedup.py +156 -0
  64. footprinter/ingest/chat_indexer.py +515 -0
  65. footprinter/ingest/chat_parsers/__init__.py +8 -0
  66. footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
  67. footprinter/ingest/chat_parsers/claude_parser.py +161 -0
  68. footprinter/ingest/cli.py +827 -0
  69. footprinter/ingest/content_extractors.py +117 -0
  70. footprinter/ingest/database.py +36 -0
  71. footprinter/ingest/db/__init__.py +1 -0
  72. footprinter/ingest/db/connector_schema.py +47 -0
  73. footprinter/ingest/db/migration.py +328 -0
  74. footprinter/ingest/db/schema.py +1043 -0
  75. footprinter/ingest/db/security.py +6 -0
  76. footprinter/ingest/file_indexer.py +261 -0
  77. footprinter/ingest/file_scanner.py +277 -0
  78. footprinter/ingest/folder_indexer.py +226 -0
  79. footprinter/ingest/full_content_extractor.py +321 -0
  80. footprinter/ingest/orchestrator.py +125 -0
  81. footprinter/ingest/pipe_runner.py +217 -0
  82. footprinter/ingest/processing.py +165 -0
  83. footprinter/ingest/registry.py +201 -0
  84. footprinter/ingest/run_record.py +91 -0
  85. footprinter/ingest/status.py +346 -0
  86. footprinter/mcp/__init__.py +0 -0
  87. footprinter/mcp/__main__.py +5 -0
  88. footprinter/mcp/db.py +57 -0
  89. footprinter/mcp/errors.py +102 -0
  90. footprinter/mcp/extraction.py +226 -0
  91. footprinter/mcp/server.py +39 -0
  92. footprinter/mcp/tools/__init__.py +0 -0
  93. footprinter/mcp/tools/navigation.py +70 -0
  94. footprinter/mcp/tools/read.py +75 -0
  95. footprinter/mcp/tools/search.py +158 -0
  96. footprinter/mcp/tools/semantic.py +79 -0
  97. footprinter/mcp/tools/status.py +15 -0
  98. footprinter/paths.py +91 -0
  99. footprinter/permissions.py +1160 -0
  100. footprinter/semantic/__init__.py +13 -0
  101. footprinter/semantic/chunking.py +52 -0
  102. footprinter/semantic/embeddings.py +23 -0
  103. footprinter/semantic/hybrid_search.py +273 -0
  104. footprinter/semantic/vector_store.py +471 -0
  105. footprinter/services/__init__.py +49 -0
  106. footprinter/services/access_service.py +342 -0
  107. footprinter/services/chat_service.py +85 -0
  108. footprinter/services/client_service.py +267 -0
  109. footprinter/services/content_service.py +181 -0
  110. footprinter/services/email_service.py +89 -0
  111. footprinter/services/file_service.py +83 -0
  112. footprinter/services/folder_service.py +122 -0
  113. footprinter/services/includes.py +19 -0
  114. footprinter/services/ingest_service.py +231 -0
  115. footprinter/services/project_service.py +262 -0
  116. footprinter/services/roles.py +25 -0
  117. footprinter/services/search_service.py +177 -0
  118. footprinter/services/semantic_service.py +360 -0
  119. footprinter/services/status_service.py +18 -0
  120. footprinter/services/visit_service.py +65 -0
  121. footprinter/source_registry.py +194 -0
  122. footprinter/utils/__init__.py +7 -0
  123. footprinter/utils/hash_utils.py +59 -0
  124. footprinter/utils/logging_config.py +68 -0
  125. footprinter/utils/mime.py +30 -0
  126. footprinter/utils/text.py +6 -0
  127. footprinter/utils/time.py +11 -0
  128. footprinter/visibility.py +1272 -0
  129. footprinter_cli-1.0.0.dist-info/LICENSE +21 -0
  130. footprinter_cli-1.0.0.dist-info/METADATA +229 -0
  131. footprinter_cli-1.0.0.dist-info/RECORD +134 -0
  132. footprinter_cli-1.0.0.dist-info/WHEEL +5 -0
  133. footprinter_cli-1.0.0.dist-info/entry_points.txt +2 -0
  134. footprinter_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,673 @@
1
+ """Project listing, file queries, and CRUD operations.
2
+
3
+ Public API for project data — no restricted dependencies
4
+ (permissions, visibility, source_registry).
5
+ """
6
+
7
+ import sqlite3
8
+ from typing import Optional
9
+
10
+ from footprinter.db.sql_utils import build_status_filter, paginate, paginated_response
11
+
12
+ VALID_STATUSES = frozenset(
13
+ {
14
+ "active",
15
+ "hidden",
16
+ "removed",
17
+ "paused",
18
+ "completed",
19
+ "abandoned",
20
+ "archived",
21
+ "merged",
22
+ }
23
+ )
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Shared helpers (used by app_projects.py)
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ def fetch_project(conn: sqlite3.Connection, project_id: int):
32
+ """Return a project row or None."""
33
+ cursor = conn.cursor()
34
+ cursor.execute("SELECT * FROM projects WHERE id = ?", (project_id,))
35
+ return cursor.fetchone()
36
+
37
+
38
+ def resolve_client_name(conn: sqlite3.Connection, client_id: int) -> Optional[str]:
39
+ """Look up client name by id. Returns name or None if not found."""
40
+ cursor = conn.cursor()
41
+ cursor.execute("SELECT name FROM clients WHERE id = ?", (client_id,))
42
+ row = cursor.fetchone()
43
+ return row["name"] if row else None
44
+
45
+
46
+ def find_project_id_by_key(
47
+ conn: sqlite3.Connection,
48
+ *,
49
+ root_path: Optional[str] = None,
50
+ project_name: Optional[str] = None,
51
+ ) -> Optional[int]:
52
+ """Find a project ID by match key: root_path first, then project_name.
53
+
54
+ Returns the project ID or None. root_path has priority (UNIQUE constraint);
55
+ project_name is a softer fallback (takes first match).
56
+ """
57
+ cursor = conn.cursor()
58
+ if root_path:
59
+ cursor.execute("SELECT id FROM projects WHERE root_path = ?", (root_path,))
60
+ row = cursor.fetchone()
61
+ if row:
62
+ return row["id"]
63
+ if project_name:
64
+ cursor.execute(
65
+ "SELECT id FROM projects WHERE project_name = ?",
66
+ (project_name,),
67
+ )
68
+ row = cursor.fetchone()
69
+ if row:
70
+ return row["id"]
71
+ return None
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Name resolution and navigation (used by MCP tools via service layer)
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def find_by_name_fuzzy(conn: sqlite3.Connection, name: str) -> list[dict]:
80
+ """Find projects matching name with LIKE %name%.
81
+
82
+ Returns all columns including mcp_view. Does NOT filter by visibility
83
+ — the service layer handles that.
84
+ """
85
+ rows = conn.execute(
86
+ """SELECT id, project_name, project_type, root_path, status, client,
87
+ description, github_url, mcp_view, mcp_read
88
+ FROM projects
89
+ WHERE project_name LIKE ?""",
90
+ (f"%{name}%",),
91
+ ).fetchall()
92
+ return [dict(r) for r in rows]
93
+
94
+
95
+ def count_hidden_by_name(conn: sqlite3.Connection, name: str) -> int:
96
+ """Count hidden projects matching a fuzzy name query (for diagnostics)."""
97
+ row = conn.execute(
98
+ "SELECT COUNT(*) FROM projects WHERE project_name LIKE ? AND COALESCE(mcp_view, 'inherit') = 'hidden'",
99
+ (f"%{name}%",),
100
+ ).fetchone()
101
+ return row[0]
102
+
103
+
104
+ def get_project_navigation(conn: sqlite3.Connection, project_id: int) -> dict:
105
+ """Return navigation aggregates for an MCP project view.
106
+
107
+ Includes file stats, content type breakdown, folders, and entity counts.
108
+ All results include mcp_view for service-layer filtering.
109
+ """
110
+ _not_hidden = "AND COALESCE(mcp_view, 'inherit') != 'hidden'"
111
+
112
+ # File stats (hidden files excluded)
113
+ stats = conn.execute(
114
+ f"""SELECT COUNT(*) as count, COALESCE(SUM(size_bytes), 0) as size,
115
+ SUM(CASE WHEN source = 'local' THEN 1 ELSE 0 END) as local_count,
116
+ SUM(CASE WHEN source != 'local' THEN 1 ELSE 0 END) as drive_count
117
+ FROM files
118
+ WHERE project_id = ? AND status != 'removed' {_not_hidden}""",
119
+ (project_id,),
120
+ ).fetchone()
121
+
122
+ # Top content types (hidden files excluded)
123
+ types = conn.execute(
124
+ f"""SELECT content_type, COUNT(*) as count
125
+ FROM files
126
+ WHERE project_id = ? AND status != 'removed' AND content_type IS NOT NULL
127
+ {_not_hidden}
128
+ GROUP BY content_type ORDER BY count DESC LIMIT 10""",
129
+ (project_id,),
130
+ ).fetchall()
131
+
132
+ # Folders (include all — service layer filters by visibility)
133
+ folders = conn.execute(
134
+ """SELECT id, path, name, direct_file_count, total_size_bytes, source,
135
+ mcp_view, mcp_read
136
+ FROM folders
137
+ WHERE project_id = ?
138
+ ORDER BY path""",
139
+ (project_id,),
140
+ ).fetchall()
141
+
142
+ # Entity counts (hidden excluded)
143
+ email_count = conn.execute(
144
+ f"SELECT COUNT(*) FROM emails WHERE project_id = ? AND status != 'removed' {_not_hidden}",
145
+ (project_id,),
146
+ ).fetchone()[0]
147
+ chat_count = conn.execute(
148
+ f"SELECT COUNT(*) FROM chats WHERE project_id = ? AND status != 'removed' {_not_hidden}",
149
+ (project_id,),
150
+ ).fetchone()[0]
151
+ browser_count = conn.execute(
152
+ f"SELECT COUNT(*) FROM visits WHERE project_id = ? AND status != 'removed' {_not_hidden}",
153
+ (project_id,),
154
+ ).fetchone()[0]
155
+
156
+ return {
157
+ "file_count": stats["count"],
158
+ "file_size_bytes": stats["size"],
159
+ "local_count": stats["local_count"] or 0,
160
+ "drive_count": stats["drive_count"] or 0,
161
+ "top_content_types": {r["content_type"]: r["count"] for r in types},
162
+ "folders": [dict(f) for f in folders],
163
+ "entity_counts": {
164
+ "emails": email_count,
165
+ "chats": chat_count,
166
+ "visits": browser_count,
167
+ },
168
+ }
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Query functions
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ def list_projects(
177
+ conn: sqlite3.Connection,
178
+ *,
179
+ page: int = 1,
180
+ limit: int = 50,
181
+ status: Optional[str | list[str]] = None,
182
+ client: Optional[str] = None,
183
+ project_type: Optional[str] = None,
184
+ ) -> dict:
185
+ """List projects with file counts, pagination, and SQL-side filtering.
186
+
187
+ Parameters
188
+ ----------
189
+ conn : sqlite3.Connection
190
+ page, limit : int
191
+ Pagination.
192
+ status : str, list[str], or None
193
+ Filter by status value(s). ``None`` → exclude removed and merged
194
+ (default). ``"all"`` → no status filter.
195
+ client : str or None
196
+ Filter by client name (exact match).
197
+ project_type : str or None
198
+ Filter by project_type (exact match).
199
+
200
+ Returns
201
+ -------
202
+ dict with keys: projects, pagination, types, clients,
203
+ no_project_count, no_project_size_bytes
204
+ """
205
+ cursor = conn.cursor()
206
+
207
+ # Build dynamic WHERE clause
208
+ conditions: list[str] = []
209
+ params: list = []
210
+
211
+ status_conds, status_params = build_status_filter(
212
+ status,
213
+ column="project.status",
214
+ default_exclude=["removed", "merged"],
215
+ )
216
+ conditions.extend(status_conds)
217
+ params.extend(status_params)
218
+
219
+ if client is not None:
220
+ conditions.append("client.name = ?")
221
+ params.append(client)
222
+
223
+ if project_type is not None:
224
+ conditions.append("project.project_type = ?")
225
+ params.append(project_type)
226
+
227
+ where = " WHERE " + " AND ".join(conditions) if conditions else ""
228
+
229
+ # The count query needs the same JOIN as the fetch for client filtering
230
+ count_sql = f"""
231
+ SELECT COUNT(*) FROM projects project
232
+ LEFT JOIN clients client ON project.client_id = client.id
233
+ {where}
234
+ """
235
+ fetch_sql = f"""
236
+ SELECT project.id, project.project_name, project.project_type, project.root_path,
237
+ project.status, client.name AS client, project.description, project.github_url,
238
+ project.root_folder_id, project.mcp_view, project.mcp_read,
239
+ root_folder.direct_file_count as root_file_count,
240
+ (SELECT COUNT(*) FROM folders folder
241
+ WHERE folder.project_id = project.id) as folder_count
242
+ FROM projects project
243
+ LEFT JOIN folders root_folder ON project.root_folder_id = root_folder.id
244
+ LEFT JOIN clients client ON project.client_id = client.id
245
+ {where}
246
+ ORDER BY project.project_name
247
+ LIMIT ? OFFSET ?
248
+ """
249
+ project_rows, pagination = paginate(
250
+ conn,
251
+ count_sql,
252
+ fetch_sql,
253
+ params,
254
+ page=page,
255
+ limit=limit,
256
+ )
257
+
258
+ # Batch query: file stats per project
259
+ cursor.execute(
260
+ """
261
+ SELECT project_id, COUNT(*) as count, COALESCE(SUM(size_bytes), 0) as size
262
+ FROM files WHERE status != 'removed'
263
+ GROUP BY project_id
264
+ """
265
+ )
266
+ stats_by_project = {r["project_id"]: {"count": r["count"], "size": r["size"]} for r in cursor.fetchall()}
267
+
268
+ projects = []
269
+ for row in project_rows:
270
+ project_id = row["id"]
271
+ stats = stats_by_project.get(project_id, {"count": 0, "size": 0})
272
+
273
+ projects.append(
274
+ {
275
+ "id": project_id,
276
+ "name": row["project_name"],
277
+ "type": row["project_type"] or "unknown",
278
+ "client": row["client"] or "",
279
+ "root_path": row["root_path"] or "",
280
+ "status": row["status"] or "active",
281
+ "description": row["description"] or "",
282
+ "github_url": row["github_url"] or "",
283
+ "file_count": stats["count"],
284
+ "size_bytes": stats["size"],
285
+ "root_folder_id": row["root_folder_id"],
286
+ "root_file_count": row["root_file_count"] or 0,
287
+ "folder_count": row["folder_count"] or 0,
288
+ "mcp_view": row["mcp_view"] or "inherit",
289
+ "mcp_read": row["mcp_read"] or "inherit",
290
+ }
291
+ )
292
+
293
+ # Extras: types and clients from ALL projects (for filter dropdowns)
294
+ cursor.execute("SELECT DISTINCT project_type FROM projects WHERE project_type IS NOT NULL")
295
+ types = sorted(r["project_type"] for r in cursor.fetchall())
296
+
297
+ cursor.execute("""
298
+ SELECT DISTINCT client.name FROM clients client
299
+ INNER JOIN projects project ON project.client_id = client.id
300
+ ORDER BY client.name
301
+ """)
302
+ clients = [r["name"] for r in cursor.fetchall()]
303
+
304
+ # Count files with no project (active only)
305
+ cursor.execute(
306
+ """
307
+ SELECT COUNT(*) as count, COALESCE(SUM(size_bytes), 0) as size
308
+ FROM files
309
+ WHERE project_id IS NULL AND status != 'removed'
310
+ """
311
+ )
312
+ no_project_stats = cursor.fetchone()
313
+
314
+ return paginated_response(
315
+ "projects",
316
+ projects,
317
+ pagination,
318
+ types=types,
319
+ clients=clients,
320
+ no_project_count=no_project_stats["count"],
321
+ no_project_size_bytes=no_project_stats["size"],
322
+ )
323
+
324
+
325
+ def get_project_detail(conn: sqlite3.Connection, project_id: int) -> Optional[dict]:
326
+ """Return enriched project dict.
327
+
328
+ Adds client name, file/folder counts, and total size on top of
329
+ the raw ``projects`` row. Returns ``None`` if the project doesn't exist.
330
+ """
331
+ cursor = conn.cursor()
332
+ cursor.execute(
333
+ """
334
+ SELECT project.id, project.project_name, project.description,
335
+ project.status, project.project_type, project.root_path,
336
+ project.client_id, project.client, project.github_url,
337
+ project.root_folder_id, project.metadata,
338
+ project.mcp_read, project.mcp_view,
339
+ project.created_at, project.updated_at,
340
+ client.name AS client_name,
341
+ (SELECT COUNT(*) FROM files file
342
+ WHERE file.project_id = project.id AND file.status != 'removed') AS file_count,
343
+ (SELECT COALESCE(SUM(file.size_bytes), 0) FROM files file
344
+ WHERE file.project_id = project.id AND file.status != 'removed') AS total_size,
345
+ (SELECT COUNT(*) FROM folders folder
346
+ WHERE folder.project_id = project.id) AS folder_count
347
+ FROM projects project
348
+ LEFT JOIN clients client ON project.client_id = client.id
349
+ WHERE project.id = ?
350
+ """,
351
+ (project_id,),
352
+ )
353
+ row = cursor.fetchone()
354
+ if not row:
355
+ return None
356
+
357
+ root_path = row["root_path"] or ""
358
+ result = {
359
+ "id": row["id"],
360
+ "name": row["project_name"],
361
+ "type": row["project_type"] or "unknown",
362
+ "client": row["client_name"] or row["client"] or "",
363
+ "root_path": root_path,
364
+ "status": row["status"] or "active",
365
+ "description": row["description"] or "",
366
+ "github_url": row["github_url"] or "",
367
+ "file_count": row["file_count"],
368
+ "total_size": row["total_size"],
369
+ "folder_count": row["folder_count"],
370
+ "mcp_view": row["mcp_view"] or "inherit",
371
+ "mcp_read": row["mcp_read"] or "inherit",
372
+ }
373
+ return result
374
+
375
+
376
+ def list_project_files(
377
+ conn: sqlite3.Connection,
378
+ project_id: int,
379
+ *,
380
+ sort: str = "modified_at",
381
+ order: str = "desc",
382
+ page: int = 1,
383
+ limit: int = 50,
384
+ ) -> Optional[dict]:
385
+ """List files for a specific project.
386
+
387
+ Lightweight version — no SourceRegistry or permission resolution.
388
+
389
+ Parameters
390
+ ----------
391
+ conn : sqlite3.Connection
392
+ project_id : int
393
+ sort, order, page, limit : standard pagination/sort params
394
+
395
+ Returns
396
+ -------
397
+ dict | None
398
+ None if project not found.
399
+ Otherwise dict with keys: project, files, pagination
400
+ """
401
+ cursor = conn.cursor()
402
+
403
+ cursor.execute(
404
+ """
405
+ SELECT id, project_name, project_type, root_path,
406
+ status, description, client
407
+ FROM projects WHERE id = ?
408
+ """,
409
+ (project_id,),
410
+ )
411
+ project = cursor.fetchone()
412
+ if not project:
413
+ return None
414
+
415
+ root_path = project["root_path"] or ""
416
+ order_sql = "DESC" if order == "desc" else "ASC"
417
+ sort_col = sort if sort in ("modified_at", "name", "size_bytes", "content_type") else "modified_at"
418
+
419
+ count_sql = "SELECT COUNT(*) FROM files WHERE project_id = ? AND status != 'removed'"
420
+ fetch_sql = f"""
421
+ SELECT id, source, account, name, path, content_type, size_bytes,
422
+ modified_at, status, status_reason
423
+ FROM files
424
+ WHERE project_id = ? AND status != 'removed'
425
+ ORDER BY {sort_col} {order_sql}
426
+ LIMIT ? OFFSET ?
427
+ """
428
+ rows, pagination = paginate(conn, count_sql, fetch_sql, (project_id,), page=page, limit=limit)
429
+
430
+ files = []
431
+ for row in rows:
432
+ file_path = row["path"] or ""
433
+ rel_path = file_path[len(root_path) + 1 :] if file_path.startswith(root_path) else file_path
434
+ files.append(
435
+ {
436
+ "id": row["id"],
437
+ "name": row["name"],
438
+ "content_type": row["content_type"] or "",
439
+ "path": rel_path,
440
+ "size_bytes": row["size_bytes"],
441
+ "modified_at": row["modified_at"] or "",
442
+ "source": row["source"],
443
+ "account": row["account"] or "",
444
+ "status": row["status"] or "active",
445
+ "status_reason": row["status_reason"] or "",
446
+ }
447
+ )
448
+
449
+ project_dict = {
450
+ "id": project["id"],
451
+ "name": project["project_name"],
452
+ "type": project["project_type"],
453
+ "root_path": root_path,
454
+ "status": project["status"] or "active",
455
+ "description": project["description"] or "",
456
+ "client": project["client"] or "",
457
+ }
458
+ return paginated_response("files", files, pagination, project=project_dict)
459
+
460
+
461
+ # ---------------------------------------------------------------------------
462
+ # CRUD functions
463
+ # ---------------------------------------------------------------------------
464
+
465
+
466
+ def create_project(
467
+ conn: sqlite3.Connection,
468
+ *,
469
+ project_name: str,
470
+ root_path: Optional[str] = None,
471
+ client_id: Optional[int] = None,
472
+ project_type: Optional[str] = None,
473
+ description: Optional[str] = None,
474
+ github_url: Optional[str] = None,
475
+ status: str = "active",
476
+ ) -> dict:
477
+ """Create a new project.
478
+
479
+ Returns a dict of the full project row.
480
+ Raises ValueError on invalid input.
481
+ """
482
+ project_name = (project_name or "").strip()
483
+ if not project_name:
484
+ raise ValueError("project_name is required")
485
+
486
+ cursor = conn.cursor()
487
+
488
+ # Check root_path uniqueness
489
+ if root_path:
490
+ cursor.execute("SELECT id FROM projects WHERE root_path = ?", (root_path,))
491
+ if cursor.fetchone():
492
+ raise ValueError("A project with that root_path already exists")
493
+
494
+ # Resolve client name
495
+ client_name = None
496
+ if client_id is not None:
497
+ client_name = resolve_client_name(conn, client_id)
498
+ if client_name is None:
499
+ raise ValueError("Client not found")
500
+
501
+ cursor.execute(
502
+ """
503
+ INSERT INTO projects (project_name, root_path, project_type,
504
+ client_id, client, description, github_url,
505
+ status)
506
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
507
+ """,
508
+ (
509
+ project_name,
510
+ root_path,
511
+ project_type,
512
+ client_id,
513
+ client_name,
514
+ description,
515
+ github_url,
516
+ status,
517
+ ),
518
+ )
519
+ conn.commit()
520
+ new_id = cursor.lastrowid
521
+
522
+ cursor.execute("SELECT * FROM projects WHERE id = ?", (new_id,))
523
+ return dict(cursor.fetchone())
524
+
525
+
526
+ def update_project(conn: sqlite3.Connection, project_id: int, **fields) -> Optional[bool]:
527
+ """Update a project's fields.
528
+
529
+ Returns True on success, None if not found.
530
+ Raises ValueError on invalid input.
531
+ """
532
+ if not fetch_project(conn, project_id):
533
+ return None
534
+
535
+ cursor = conn.cursor()
536
+ updatable = {
537
+ "project_name",
538
+ "description",
539
+ "github_url",
540
+ "metadata",
541
+ "project_type",
542
+ "root_path",
543
+ "status",
544
+ "status_reason",
545
+ }
546
+ sql_fields: list[str] = []
547
+ values: list = []
548
+
549
+ # Special handling for client_id: sync denormalized client name
550
+ if "client_id" in fields:
551
+ client_id = fields["client_id"]
552
+ if client_id is not None:
553
+ client_name = resolve_client_name(conn, client_id)
554
+ if client_name is None:
555
+ raise ValueError("Client not found")
556
+ sql_fields.append("client_id = ?")
557
+ values.append(client_id)
558
+ sql_fields.append("client = ?")
559
+ values.append(client_name)
560
+ else:
561
+ sql_fields.append("client_id = ?")
562
+ values.append(None)
563
+ sql_fields.append("client = ?")
564
+ values.append(None)
565
+
566
+ # Check root_path uniqueness when changing it
567
+ if "root_path" in fields and fields["root_path"]:
568
+ cursor.execute(
569
+ "SELECT id FROM projects WHERE root_path = ? AND id != ?",
570
+ (fields["root_path"], project_id),
571
+ )
572
+ if cursor.fetchone():
573
+ raise ValueError("A project with that root_path already exists")
574
+
575
+ for key in updatable:
576
+ if key in fields:
577
+ val = fields[key]
578
+ if key == "metadata" and val is not None:
579
+ import json
580
+
581
+ val = json.dumps(val)
582
+ sql_fields.append(f"{key} = ?")
583
+ values.append(val)
584
+
585
+ if not sql_fields:
586
+ return True
587
+
588
+ sql_fields.append("updated_at = CURRENT_TIMESTAMP")
589
+ values.append(project_id)
590
+ cursor.execute(
591
+ f"UPDATE projects SET {', '.join(sql_fields)} WHERE id = ?",
592
+ values,
593
+ )
594
+ conn.commit()
595
+ return True
596
+
597
+
598
+ def merge_projects(conn: sqlite3.Connection, target_id: int, source_id: int) -> Optional[dict]:
599
+ """Merge source project into target project.
600
+
601
+ Returns dict with moved counts, or None if either not found.
602
+ Raises ValueError if target_id == source_id.
603
+ """
604
+ if target_id == source_id:
605
+ raise ValueError("Cannot merge a project into itself")
606
+
607
+ if not fetch_project(conn, target_id):
608
+ return None
609
+ if not fetch_project(conn, source_id):
610
+ return None
611
+
612
+ cursor = conn.cursor()
613
+
614
+ cursor.execute(
615
+ "UPDATE files SET project_id = ? WHERE project_id = ?",
616
+ (target_id, source_id),
617
+ )
618
+ files_moved = cursor.rowcount
619
+
620
+ cursor.execute(
621
+ "UPDATE folders SET project_id = ? WHERE project_id = ?",
622
+ (target_id, source_id),
623
+ )
624
+ folders_moved = cursor.rowcount
625
+
626
+ cursor.execute(
627
+ "UPDATE projects SET status = 'merged', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
628
+ (source_id,),
629
+ )
630
+ conn.commit()
631
+
632
+ return {
633
+ "files_moved": files_moved,
634
+ "folders_moved": folders_moved,
635
+ }
636
+
637
+
638
+ def link_files(conn: sqlite3.Connection, project_id: int, file_ids: list[int]) -> Optional[dict]:
639
+ """Link files to a project.
640
+
641
+ Returns dict with linked count, or None if project not found.
642
+ Skips removed files.
643
+ """
644
+ if not fetch_project(conn, project_id):
645
+ return None
646
+
647
+ cursor = conn.cursor()
648
+ placeholders = ",".join("?" * len(file_ids))
649
+ cursor.execute(
650
+ f"UPDATE files SET project_id = ? WHERE id IN ({placeholders}) AND status != 'removed'",
651
+ [project_id] + list(file_ids),
652
+ )
653
+ conn.commit()
654
+ return {"linked": cursor.rowcount}
655
+
656
+
657
+ def unlink_files(conn: sqlite3.Connection, project_id: int, file_ids: list[int]) -> Optional[dict]:
658
+ """Unlink files from a project.
659
+
660
+ Returns dict with unlinked count, or None if project not found.
661
+ Only unlinks files that belong to this project.
662
+ """
663
+ if not fetch_project(conn, project_id):
664
+ return None
665
+
666
+ cursor = conn.cursor()
667
+ placeholders = ",".join("?" * len(file_ids))
668
+ cursor.execute(
669
+ f"UPDATE files SET project_id = NULL WHERE id IN ({placeholders}) AND project_id = ?",
670
+ list(file_ids) + [project_id],
671
+ )
672
+ conn.commit()
673
+ return {"unlinked": cursor.rowcount}