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/__init__.py +3 -0
- folderlake/appserver.py +307 -0
- folderlake/assets/echarts.min.js +45 -0
- folderlake/catalog.py +184 -0
- folderlake/cli.py +209 -0
- folderlake/cypher.py +100 -0
- folderlake/extractors.py +360 -0
- folderlake/hierarchy.py +297 -0
- folderlake/home.html +305 -0
- folderlake/llm.py +210 -0
- folderlake/mcp_server.py +331 -0
- folderlake/preview.py +57 -0
- folderlake/registry.py +157 -0
- folderlake/relations.py +103 -0
- folderlake/server.py +329 -0
- folderlake/topics.py +591 -0
- folderlake/viewer.html +397 -0
- folderlake-0.2.0.dist-info/METADATA +278 -0
- folderlake-0.2.0.dist-info/RECORD +23 -0
- folderlake-0.2.0.dist-info/WHEEL +5 -0
- folderlake-0.2.0.dist-info/entry_points.txt +3 -0
- folderlake-0.2.0.dist-info/licenses/LICENSE +21 -0
- folderlake-0.2.0.dist-info/top_level.txt +1 -0
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
|