folderlake 0.2.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.
folderlake/server.py ADDED
@@ -0,0 +1,329 @@
1
+ """Local viewer server — Phase 2 (three-pane UI) and Phase 3 (graph + search).
2
+
3
+ Standard library only, no dependencies: a ThreadingHTTPServer serving one
4
+ single-file viewer page plus a small read-only JSON API over the catalog
5
+ built in Phase 1. Files are never modified by the server; every request
6
+ opens its own read-only DuckDB connection.
7
+
8
+ Search is a LIKE fallback over extracted text, name and summary. The
9
+ catalog's FTS extension (``try_build_fts``) cannot be installed inside the
10
+ Muse sandbox (egress proxy blocks the extension download), so search does
11
+ not depend on it — on a normal machine the scan still tries to build the
12
+ FTS index, but the API works either way.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import html
18
+ import json
19
+ import urllib.parse
20
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
21
+ from pathlib import Path
22
+
23
+ import duckdb
24
+
25
+ from .cypher import build_cypher
26
+ from .preview import build_preview
27
+
28
+ VIEWER_PATH = Path(__file__).with_name("viewer.html")
29
+ ECHARTS_PATH = Path(__file__).parent / "assets" / "echarts.min.js"
30
+
31
+
32
+ def _connect(db_path: Path) -> duckdb.DuckDBPyConnection:
33
+ return duckdb.connect(str(db_path), read_only=True)
34
+
35
+
36
+ def _like_pattern(raw: str) -> str:
37
+ """Escape a user string for use inside a LIKE pattern."""
38
+ return "%" + raw.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
39
+
40
+
41
+ def list_files(db_path: Path, kinds: list[str] | None = None,
42
+ topic: str | None = None, query: str | None = None,
43
+ node: str | None = None) -> list[dict]:
44
+ """Files matching the filters, each with its tags. Ordered by name."""
45
+ con = _connect(db_path)
46
+ try:
47
+ sql = """
48
+ SELECT f.id, f.name, f.ext, f.kind, f.size_bytes, f.mtime,
49
+ f.title, coalesce(string_agg(t.tag, ', '), '') AS tags
50
+ FROM files f
51
+ LEFT JOIN file_topics t ON t.file_id = f.id
52
+ """
53
+ where: list[str] = []
54
+ params: list = []
55
+ if kinds:
56
+ where.append("f.kind IN (" + ",".join("?" * len(kinds)) + ")")
57
+ params.extend(kinds)
58
+ if topic:
59
+ where.append(
60
+ "EXISTS (SELECT 1 FROM file_topics t2 "
61
+ "WHERE t2.file_id = f.id AND t2.tag = ?)"
62
+ )
63
+ params.append(topic)
64
+ if node:
65
+ where.append(
66
+ "EXISTS (SELECT 1 FROM topic_node_files nf "
67
+ "WHERE nf.file_id = f.id AND nf.node_id = ?)"
68
+ )
69
+ params.append(node)
70
+ if query:
71
+ where.append(
72
+ "(f.name ILIKE ? ESCAPE '\\' "
73
+ "OR EXISTS (SELECT 1 FROM file_text x WHERE x.file_id = f.id "
74
+ "AND (x.summary ILIKE ? ESCAPE '\\' OR x.text ILIKE ? ESCAPE '\\')))"
75
+ )
76
+ pat = _like_pattern(query)
77
+ params.extend([pat, pat, pat])
78
+ if where:
79
+ sql += " WHERE " + " AND ".join(where)
80
+ sql += " GROUP BY f.id, f.name, f.ext, f.kind, f.size_bytes, f.mtime, f.title ORDER BY f.name"
81
+ rows = con.execute(sql, params).fetchall()
82
+ return [
83
+ {
84
+ "id": r[0], "name": r[1], "ext": r[2], "kind": r[3],
85
+ "size_bytes": r[4], "mtime": r[5], "title": r[6],
86
+ "tags": [t for t in r[7].split(", ") if t] if r[7] else [],
87
+ }
88
+ for r in rows
89
+ ]
90
+ finally:
91
+ con.close()
92
+
93
+
94
+ def facets(db_path: Path) -> dict:
95
+ con = _connect(db_path)
96
+ try:
97
+ kinds = [
98
+ {"kind": k, "count": c}
99
+ for k, c in con.execute(
100
+ "SELECT kind, count(*) FROM files GROUP BY kind ORDER BY count(*) DESC"
101
+ ).fetchall()
102
+ ]
103
+ topics = [
104
+ {"tag": t, "count": c}
105
+ for t, c in con.execute(
106
+ "SELECT tag, count(*) FROM file_topics GROUP BY tag "
107
+ "ORDER BY count(*) DESC, tag"
108
+ ).fetchall()
109
+ ]
110
+ total = con.execute("SELECT count(*) FROM files").fetchone()[0]
111
+ return {"kinds": kinds, "topics": topics, "total_files": total}
112
+ finally:
113
+ con.close()
114
+
115
+
116
+ def topics_tree(db_path: Path) -> dict:
117
+ """The hierarchical topic tree: nested nodes per kind root.
118
+
119
+ Empty roots when no hierarchy has been built (the viewer then
120
+ falls back to the flat facet topics).
121
+ """
122
+ con = _connect(db_path)
123
+ try:
124
+ try:
125
+ rows = con.execute(
126
+ "SELECT id, parent_id, kind, label, depth, file_count, "
127
+ "cohesion FROM topic_nodes ORDER BY depth, label"
128
+ ).fetchall()
129
+ except Exception:
130
+ return {"roots": []}
131
+ nodes: dict[str, dict] = {}
132
+ roots: list[dict] = []
133
+ for nid, parent, kind, label, depth, count, cohesion in rows:
134
+ nodes[nid] = {
135
+ "id": nid, "kind": kind, "label": label, "depth": depth,
136
+ "count": count, "cohesion": cohesion, "children": [],
137
+ }
138
+ for nid, parent, kind, label, depth, count, cohesion in rows:
139
+ if parent is not None and parent in nodes:
140
+ nodes[parent]["children"].append(nodes[nid])
141
+ elif parent is None:
142
+ roots.append(nodes[nid])
143
+ return {"roots": roots}
144
+ finally:
145
+ con.close()
146
+
147
+
148
+ def graph(db_path: Path) -> dict:
149
+ con = _connect(db_path)
150
+ try:
151
+ nodes = [
152
+ {
153
+ "id": r[0], "name": r[1], "kind": r[2],
154
+ "tags": [t for t in r[3].split(", ") if t] if r[3] else [],
155
+ }
156
+ for r in con.execute(
157
+ """
158
+ SELECT f.id, f.name, f.kind,
159
+ coalesce(string_agg(t.tag, ', '), '')
160
+ FROM files f LEFT JOIN file_topics t ON t.file_id = f.id
161
+ GROUP BY f.id, f.name, f.kind ORDER BY f.name
162
+ """
163
+ ).fetchall()
164
+ ]
165
+ edges = [
166
+ {"a": r[0], "b": r[1], "kind": r[2], "detail": r[3]}
167
+ for r in con.execute(
168
+ "SELECT file_a, file_b, kind, detail FROM relations"
169
+ ).fetchall()
170
+ ]
171
+ return {"nodes": nodes, "edges": edges}
172
+ finally:
173
+ con.close()
174
+
175
+
176
+ def file_detail(db_path: Path, file_id: str) -> dict | None:
177
+ preview = build_preview(db_path, file_id)
178
+ if preview is None:
179
+ return None
180
+ con = _connect(db_path)
181
+ try:
182
+ preview["entities"] = [
183
+ r[0]
184
+ for r in con.execute(
185
+ "SELECT entity FROM file_entities WHERE file_id = ?", [file_id]
186
+ ).fetchall()
187
+ ]
188
+ preview["relations"] = [
189
+ {"other_id": r[0], "kind": r[1], "detail": r[2]}
190
+ for r in con.execute(
191
+ """
192
+ SELECT CASE WHEN file_a = ? THEN file_b ELSE file_a END,
193
+ kind, detail FROM relations
194
+ WHERE file_a = ? OR file_b = ?
195
+ """,
196
+ [file_id, file_id, file_id],
197
+ ).fetchall()
198
+ ]
199
+ preview["mtime"] = con.execute(
200
+ "SELECT mtime FROM files WHERE id = ?", [file_id]
201
+ ).fetchone()[0]
202
+ return preview
203
+ finally:
204
+ con.close()
205
+
206
+
207
+ class _Handler(BaseHTTPRequestHandler):
208
+ db_path: Path = Path("folderlake.duckdb")
209
+ folder: Path = Path(".")
210
+ home_url: str | None = None # set in app mode: a "back to home" link
211
+
212
+ def log_message(self, *args): # keep the console quiet
213
+ pass
214
+
215
+ def _send_json(self, payload, status: int = 200) -> None:
216
+ body = json.dumps(payload, ensure_ascii=False).encode()
217
+ self.send_response(status)
218
+ self.send_header("Content-Type", "application/json; charset=utf-8")
219
+ self.send_header("Content-Length", str(len(body)))
220
+ self.end_headers()
221
+ self.wfile.write(body)
222
+
223
+ def _send_html(self, text: str) -> None:
224
+ body = text.encode()
225
+ self.send_response(200)
226
+ self.send_header("Content-Type", "text/html; charset=utf-8")
227
+ self.send_header("Content-Length", str(len(body)))
228
+ self.end_headers()
229
+ self.wfile.write(body)
230
+
231
+ def _send_bytes(self, body: bytes, content_type: str,
232
+ disposition: str | None = None) -> None:
233
+ self.send_response(200)
234
+ self.send_header("Content-Type", content_type)
235
+ self.send_header("Content-Length", str(len(body)))
236
+ if disposition:
237
+ self.send_header("Content-Disposition", disposition)
238
+ self.end_headers()
239
+ self.wfile.write(body)
240
+
241
+ def do_GET(self) -> None: # noqa: N802 - stdlib name
242
+ parsed = urllib.parse.urlparse(self.path)
243
+ query = urllib.parse.parse_qs(parsed.query)
244
+ db_path = self.db_path
245
+
246
+ if parsed.path == "/":
247
+ page = VIEWER_PATH.read_text(encoding="utf-8")
248
+ if self.home_url:
249
+ link = (
250
+ f'<a href="{html.escape(self.home_url)}" '
251
+ 'style="margin-left:auto;margin-right:12px;color:#4a80d0;'
252
+ 'text-decoration:none;font-size:13px;white-space:nowrap">'
253
+ "← FolderLake Home</a>"
254
+ )
255
+ page = page.replace("</header>", link + "</header>", 1)
256
+ self._send_html(page)
257
+ return
258
+ if parsed.path == "/assets/echarts.min.js":
259
+ if ECHARTS_PATH.exists():
260
+ self._send_bytes(ECHARTS_PATH.read_bytes(),
261
+ "application/javascript; charset=utf-8")
262
+ else:
263
+ self._send_json({"error": "echarts asset missing"}, 404)
264
+ return
265
+ if parsed.path == "/api/export.cypher":
266
+ script = build_cypher(db_path)
267
+ self._send_bytes(script.encode(),
268
+ "text/plain; charset=utf-8",
269
+ 'attachment; filename="folderlake.cypher"')
270
+ return
271
+ if parsed.path == "/api/meta":
272
+ self._send_json({
273
+ "folder": str(self.folder),
274
+ "catalog": str(db_path),
275
+ "catalog_bytes": db_path.stat().st_size if db_path.exists() else 0,
276
+ })
277
+ return
278
+ if parsed.path == "/api/files":
279
+ kinds = query.get("kind", [None])[0]
280
+ self._send_json(list_files(
281
+ db_path,
282
+ kinds=kinds.split(",") if kinds else None,
283
+ topic=query.get("topic", [None])[0],
284
+ query=query.get("q", [None])[0],
285
+ node=query.get("node", [None])[0],
286
+ ))
287
+ return
288
+ if parsed.path == "/api/facets":
289
+ self._send_json(facets(db_path))
290
+ return
291
+ if parsed.path == "/api/topics":
292
+ self._send_json(topics_tree(db_path))
293
+ return
294
+ if parsed.path == "/api/graph":
295
+ self._send_json(graph(db_path))
296
+ return
297
+ if parsed.path.startswith("/api/file/"):
298
+ file_id = urllib.parse.unquote(parsed.path[len("/api/file/"):])
299
+ detail = file_detail(db_path, file_id)
300
+ if detail is None:
301
+ self._send_json({"error": f"not found: {html.escape(file_id)}"}, 404)
302
+ else:
303
+ self._send_json(detail)
304
+ return
305
+ self._send_json({"error": "not found"}, 404)
306
+
307
+
308
+ def create_server(db_path: Path, folder: Path, host: str = "127.0.0.1",
309
+ port: int = 0,
310
+ home_url: str | None = None) -> ThreadingHTTPServer:
311
+ """Build (but do not start) the viewer server. Port 0 picks a free port."""
312
+ handler = type("FolderLakeHandler", (_Handler,), {
313
+ "db_path": db_path, "folder": folder, "home_url": home_url,
314
+ })
315
+ return ThreadingHTTPServer((host, port), handler)
316
+
317
+
318
+ def serve(db_path: Path, folder: Path, host: str = "127.0.0.1",
319
+ port: int = 0) -> str:
320
+ """Start the viewer server in the foreground; returns the URL."""
321
+ server = create_server(db_path, folder, host, port)
322
+ url = f"http://{host}:{server.server_address[1]}/"
323
+ print(f"FolderLake viewer: {url} (folder: {folder}, catalog: {db_path})")
324
+ print("Press Ctrl-C to stop.")
325
+ try:
326
+ server.serve_forever()
327
+ except KeyboardInterrupt:
328
+ print("\nStopped.")
329
+ return url