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.
- footprinter/__init__.py +8 -0
- footprinter/access.py +444 -0
- footprinter/api/__init__.py +1 -0
- footprinter/api/db.py +61 -0
- footprinter/api/entities.py +250 -0
- footprinter/api/search.py +47 -0
- footprinter/api/semantic.py +33 -0
- footprinter/api/server.py +66 -0
- footprinter/api/status.py +15 -0
- footprinter/bundled/__init__.py +0 -0
- footprinter/bundled/config.example.yaml +161 -0
- footprinter/bundled/patterns/context_patterns.yaml +18 -0
- footprinter/bundled/patterns/extensions.yaml +283 -0
- footprinter/bundled/patterns/filename_patterns.yaml +61 -0
- footprinter/bundled/patterns/mime_mappings.yaml +68 -0
- footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
- footprinter/bundled/patterns/security_patterns.yaml +27 -0
- footprinter/cli/__init__.py +128 -0
- footprinter/cli/__main__.py +6 -0
- footprinter/cli/_common.py +332 -0
- footprinter/cli/_policy_helpers.py +646 -0
- footprinter/cli/_prompt.py +220 -0
- footprinter/cli/api_cmd.py +32 -0
- footprinter/cli/connect.py +591 -0
- footprinter/cli/data.py +879 -0
- footprinter/cli/delete.py +128 -0
- footprinter/cli/ingest.py +579 -0
- footprinter/cli/mcp_cmd.py +750 -0
- footprinter/cli/mcp_setup.py +306 -0
- footprinter/cli/search.py +393 -0
- footprinter/cli/search_cmd.py +69 -0
- footprinter/cli/setup.py +1836 -0
- footprinter/cli/status.py +729 -0
- footprinter/cli/status_cmd.py +104 -0
- footprinter/cli/upsert.py +794 -0
- footprinter/cli/vectorize_cmd.py +215 -0
- footprinter/cli/view.py +322 -0
- footprinter/connectors/__init__.py +171 -0
- footprinter/connectors/config_utils.py +141 -0
- footprinter/db/__init__.py +37 -0
- footprinter/db/browser.py +198 -0
- footprinter/db/chats.py +610 -0
- footprinter/db/clients.py +307 -0
- footprinter/db/emails.py +279 -0
- footprinter/db/files.py +741 -0
- footprinter/db/folders.py +659 -0
- footprinter/db/messages.py +192 -0
- footprinter/db/policies.py +151 -0
- footprinter/db/projects.py +673 -0
- footprinter/db/search.py +573 -0
- footprinter/db/sql_utils.py +168 -0
- footprinter/db/status.py +320 -0
- footprinter/db/uploads.py +70 -0
- footprinter/ingest/__init__.py +0 -0
- footprinter/ingest/adapters/__init__.py +33 -0
- footprinter/ingest/adapters/browser.py +54 -0
- footprinter/ingest/adapters/chat.py +57 -0
- footprinter/ingest/adapters/ingest.py +146 -0
- footprinter/ingest/adapters/local_files.py +68 -0
- footprinter/ingest/adapters/local_folders.py +52 -0
- footprinter/ingest/adapters/protocol.py +174 -0
- footprinter/ingest/browser_indexer.py +216 -0
- footprinter/ingest/chat_dedup.py +156 -0
- footprinter/ingest/chat_indexer.py +515 -0
- footprinter/ingest/chat_parsers/__init__.py +8 -0
- footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
- footprinter/ingest/chat_parsers/claude_parser.py +161 -0
- footprinter/ingest/cli.py +827 -0
- footprinter/ingest/content_extractors.py +117 -0
- footprinter/ingest/database.py +36 -0
- footprinter/ingest/db/__init__.py +1 -0
- footprinter/ingest/db/connector_schema.py +47 -0
- footprinter/ingest/db/migration.py +328 -0
- footprinter/ingest/db/schema.py +1043 -0
- footprinter/ingest/db/security.py +6 -0
- footprinter/ingest/file_indexer.py +261 -0
- footprinter/ingest/file_scanner.py +277 -0
- footprinter/ingest/folder_indexer.py +226 -0
- footprinter/ingest/full_content_extractor.py +321 -0
- footprinter/ingest/orchestrator.py +125 -0
- footprinter/ingest/pipe_runner.py +217 -0
- footprinter/ingest/processing.py +165 -0
- footprinter/ingest/registry.py +201 -0
- footprinter/ingest/run_record.py +91 -0
- footprinter/ingest/status.py +346 -0
- footprinter/mcp/__init__.py +0 -0
- footprinter/mcp/__main__.py +5 -0
- footprinter/mcp/db.py +57 -0
- footprinter/mcp/errors.py +102 -0
- footprinter/mcp/extraction.py +226 -0
- footprinter/mcp/server.py +39 -0
- footprinter/mcp/tools/__init__.py +0 -0
- footprinter/mcp/tools/navigation.py +70 -0
- footprinter/mcp/tools/read.py +75 -0
- footprinter/mcp/tools/search.py +158 -0
- footprinter/mcp/tools/semantic.py +79 -0
- footprinter/mcp/tools/status.py +15 -0
- footprinter/paths.py +91 -0
- footprinter/permissions.py +1160 -0
- footprinter/semantic/__init__.py +13 -0
- footprinter/semantic/chunking.py +52 -0
- footprinter/semantic/embeddings.py +23 -0
- footprinter/semantic/hybrid_search.py +273 -0
- footprinter/semantic/vector_store.py +471 -0
- footprinter/services/__init__.py +49 -0
- footprinter/services/access_service.py +342 -0
- footprinter/services/chat_service.py +85 -0
- footprinter/services/client_service.py +267 -0
- footprinter/services/content_service.py +181 -0
- footprinter/services/email_service.py +89 -0
- footprinter/services/file_service.py +83 -0
- footprinter/services/folder_service.py +122 -0
- footprinter/services/includes.py +19 -0
- footprinter/services/ingest_service.py +231 -0
- footprinter/services/project_service.py +262 -0
- footprinter/services/roles.py +25 -0
- footprinter/services/search_service.py +177 -0
- footprinter/services/semantic_service.py +360 -0
- footprinter/services/status_service.py +18 -0
- footprinter/services/visit_service.py +65 -0
- footprinter/source_registry.py +194 -0
- footprinter/utils/__init__.py +7 -0
- footprinter/utils/hash_utils.py +59 -0
- footprinter/utils/logging_config.py +68 -0
- footprinter/utils/mime.py +30 -0
- footprinter/utils/text.py +6 -0
- footprinter/utils/time.py +11 -0
- footprinter/visibility.py +1272 -0
- footprinter_cli-1.0.0.dist-info/LICENSE +21 -0
- footprinter_cli-1.0.0.dist-info/METADATA +229 -0
- footprinter_cli-1.0.0.dist-info/RECORD +134 -0
- footprinter_cli-1.0.0.dist-info/WHEEL +5 -0
- footprinter_cli-1.0.0.dist-info/entry_points.txt +2 -0
- 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}
|