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/__init__.py
ADDED
folderlake/appserver.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""FolderLake home server — the web-app front door.
|
|
2
|
+
|
|
3
|
+
``folderlake app`` starts this server and opens it immediately: no
|
|
4
|
+
folder picker, no scan before the page appears. The home page lists
|
|
5
|
+
every catalog built so far (from the SQLite registry), starts new
|
|
6
|
+
scans with live progress, refreshes or deletes old ones, and holds
|
|
7
|
+
the LLM settings. Opening a catalog spins up the ordinary
|
|
8
|
+
single-catalog viewer server (in a thread, on its own port) with a
|
|
9
|
+
"← FolderLake Home" link injected into its header.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import uuid
|
|
21
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .catalog import CATALOG_NAME, scan
|
|
25
|
+
from .hierarchy import build_hierarchy
|
|
26
|
+
from .registry import Registry, catalog_id_for
|
|
27
|
+
from .relations import build_relations
|
|
28
|
+
from .server import create_server
|
|
29
|
+
from .catalog import try_build_fts
|
|
30
|
+
|
|
31
|
+
HOME_PATH = Path(__file__).with_name("home.html")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class App:
|
|
35
|
+
def __init__(self, host: str = "127.0.0.1", port: int = 8770,
|
|
36
|
+
registry: Registry | None = None):
|
|
37
|
+
self.host = host
|
|
38
|
+
self.registry = registry or Registry()
|
|
39
|
+
self.jobs: dict[str, dict] = {}
|
|
40
|
+
self._job_lock = threading.Lock()
|
|
41
|
+
self.viewers: dict[str, tuple[ThreadingHTTPServer, int]] = {}
|
|
42
|
+
handler = type("AppHandler", (_Handler,), {"app": self})
|
|
43
|
+
try:
|
|
44
|
+
self.server = ThreadingHTTPServer((host, port), handler)
|
|
45
|
+
except OSError:
|
|
46
|
+
self.server = ThreadingHTTPServer((host, 0), handler)
|
|
47
|
+
self.port = self.server.server_address[1]
|
|
48
|
+
self.home_url = f"http://{host}:{self.port}/"
|
|
49
|
+
|
|
50
|
+
# -- jobs ---------------------------------------------------------
|
|
51
|
+
def running_job(self) -> dict | None:
|
|
52
|
+
with self._job_lock:
|
|
53
|
+
for j in self.jobs.values():
|
|
54
|
+
if j["status"] == "running":
|
|
55
|
+
return j
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def start_scan(self, folder: Path, use_llm: bool) -> dict:
|
|
59
|
+
if self.running_job() is not None:
|
|
60
|
+
raise RuntimeError("a scan is already running")
|
|
61
|
+
job = {
|
|
62
|
+
"id": uuid.uuid4().hex[:12], "status": "running",
|
|
63
|
+
"phase": "starting", "done": 0, "total": 0, "current": "",
|
|
64
|
+
"message": "", "catalog_id": None, "path": str(folder),
|
|
65
|
+
}
|
|
66
|
+
with self._job_lock:
|
|
67
|
+
self.jobs[job["id"]] = job
|
|
68
|
+
threading.Thread(target=self._run_scan, args=(job, folder, use_llm),
|
|
69
|
+
daemon=True).start()
|
|
70
|
+
return job
|
|
71
|
+
|
|
72
|
+
def _apply_llm_env(self, use_llm: bool) -> dict:
|
|
73
|
+
"""Snapshot the settings a scan runs with; when the LLM is on,
|
|
74
|
+
also publish them to the env the LLM provider layer reads."""
|
|
75
|
+
s = self.registry.get_settings()
|
|
76
|
+
snapshot = {
|
|
77
|
+
"use_llm": bool(use_llm),
|
|
78
|
+
"llm_provider": s["llm_provider"],
|
|
79
|
+
"llm_model": s["llm_model"],
|
|
80
|
+
}
|
|
81
|
+
if use_llm:
|
|
82
|
+
os.environ["FOLDERLAKE_LLM_PROVIDER"] = s["llm_provider"]
|
|
83
|
+
if s["llm_model"]:
|
|
84
|
+
os.environ["FOLDERLAKE_LLM_MODEL"] = s["llm_model"]
|
|
85
|
+
if s["llm_base_url"]:
|
|
86
|
+
os.environ["FOLDERLAKE_LLM_BASE_URL"] = s["llm_base_url"]
|
|
87
|
+
key = self.registry.get_api_key()
|
|
88
|
+
if key:
|
|
89
|
+
os.environ["FOLDERLAKE_LLM_API_KEY"] = key
|
|
90
|
+
return snapshot
|
|
91
|
+
|
|
92
|
+
def _run_scan(self, job: dict, folder: Path, use_llm: bool) -> None:
|
|
93
|
+
cid = catalog_id_for(folder)
|
|
94
|
+
was_open = cid in self.viewers
|
|
95
|
+
try:
|
|
96
|
+
if was_open:
|
|
97
|
+
self._stop_viewer(cid) # DuckDB allows one writer
|
|
98
|
+
snapshot = self._apply_llm_env(use_llm)
|
|
99
|
+
db_path = folder / CATALOG_NAME
|
|
100
|
+
|
|
101
|
+
def progress(done, total, name):
|
|
102
|
+
job.update(phase="scanning", done=done, total=total,
|
|
103
|
+
current=name)
|
|
104
|
+
|
|
105
|
+
stats = scan(folder, db_path, use_llm=use_llm,
|
|
106
|
+
progress=progress)
|
|
107
|
+
job.update(phase="linking files", current="")
|
|
108
|
+
build_relations(db_path)
|
|
109
|
+
build_hierarchy(db_path)
|
|
110
|
+
try_build_fts(db_path)
|
|
111
|
+
rec = self.registry.upsert_catalog(
|
|
112
|
+
folder, stats["seen"], db_path.stat().st_size, snapshot)
|
|
113
|
+
job.update(status="done", phase="done",
|
|
114
|
+
catalog_id=rec["id"])
|
|
115
|
+
except Exception as exc: # surfaced on the home page, not a crash
|
|
116
|
+
job.update(status="error", message=str(exc))
|
|
117
|
+
finally:
|
|
118
|
+
if was_open and job["status"] == "done":
|
|
119
|
+
self.open_catalog(cid)
|
|
120
|
+
|
|
121
|
+
# -- catalog viewers ------------------------------------------------
|
|
122
|
+
def open_catalog(self, catalog_id: str) -> str:
|
|
123
|
+
rec = self.registry.get_catalog(catalog_id)
|
|
124
|
+
if rec is None:
|
|
125
|
+
raise KeyError("no such catalog")
|
|
126
|
+
if catalog_id in self.viewers:
|
|
127
|
+
_, port = self.viewers[catalog_id]
|
|
128
|
+
self.registry.touch_opened(catalog_id)
|
|
129
|
+
return f"http://{self.host}:{port}/"
|
|
130
|
+
folder = Path(rec["path"])
|
|
131
|
+
db_path = folder / CATALOG_NAME
|
|
132
|
+
if not db_path.exists():
|
|
133
|
+
raise KeyError("catalog file is missing — refresh to rebuild it")
|
|
134
|
+
server = create_server(db_path, folder, self.host, 0,
|
|
135
|
+
home_url=self.home_url)
|
|
136
|
+
port = server.server_address[1]
|
|
137
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
138
|
+
self.viewers[catalog_id] = (server, port)
|
|
139
|
+
self.registry.touch_opened(catalog_id)
|
|
140
|
+
return f"http://{self.host}:{port}/"
|
|
141
|
+
|
|
142
|
+
def _stop_viewer(self, catalog_id: str) -> None:
|
|
143
|
+
entry = self.viewers.pop(catalog_id, None)
|
|
144
|
+
if entry is not None:
|
|
145
|
+
server, _ = entry
|
|
146
|
+
server.shutdown()
|
|
147
|
+
server.server_close()
|
|
148
|
+
|
|
149
|
+
def delete_catalog(self, catalog_id: str) -> dict:
|
|
150
|
+
self._stop_viewer(catalog_id)
|
|
151
|
+
rec = self.registry.delete_catalog(catalog_id)
|
|
152
|
+
if rec is None:
|
|
153
|
+
raise KeyError("no such catalog")
|
|
154
|
+
db_path = Path(rec["path"]) / CATALOG_NAME
|
|
155
|
+
for p in (db_path, Path(str(db_path) + ".wal")):
|
|
156
|
+
try:
|
|
157
|
+
p.unlink()
|
|
158
|
+
except FileNotFoundError:
|
|
159
|
+
pass
|
|
160
|
+
return rec
|
|
161
|
+
|
|
162
|
+
# -- folder dialog (subprocess: tkinter needs its own main thread) --
|
|
163
|
+
@staticmethod
|
|
164
|
+
def browse_folder() -> str:
|
|
165
|
+
code = (
|
|
166
|
+
"import tkinter\n"
|
|
167
|
+
"from tkinter import filedialog\n"
|
|
168
|
+
"root = tkinter.Tk(); root.withdraw()\n"
|
|
169
|
+
"print(filedialog.askdirectory("
|
|
170
|
+
"title='Choose a folder for FolderLake'))\n"
|
|
171
|
+
"root.destroy()\n"
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
out = subprocess.run(
|
|
175
|
+
[sys.executable, "-c", code], capture_output=True,
|
|
176
|
+
text=True, timeout=600)
|
|
177
|
+
return out.stdout.strip().splitlines()[-1] if out.stdout.strip() else ""
|
|
178
|
+
except Exception:
|
|
179
|
+
return ""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
183
|
+
app: App
|
|
184
|
+
|
|
185
|
+
def log_message(self, *args):
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
def _send_json(self, payload, status: int = 200) -> None:
|
|
189
|
+
body = json.dumps(payload, ensure_ascii=False).encode()
|
|
190
|
+
self.send_response(status)
|
|
191
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
192
|
+
self.send_header("Content-Length", str(len(body)))
|
|
193
|
+
self.end_headers()
|
|
194
|
+
self.wfile.write(body)
|
|
195
|
+
|
|
196
|
+
def _send_html(self, text: str) -> None:
|
|
197
|
+
body = text.encode()
|
|
198
|
+
self.send_response(200)
|
|
199
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
200
|
+
self.send_header("Content-Length", str(len(body)))
|
|
201
|
+
self.end_headers()
|
|
202
|
+
self.wfile.write(body)
|
|
203
|
+
|
|
204
|
+
def _body(self) -> dict:
|
|
205
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
206
|
+
if not n:
|
|
207
|
+
return {}
|
|
208
|
+
try:
|
|
209
|
+
return json.loads(self.rfile.read(n) or b"{}")
|
|
210
|
+
except Exception:
|
|
211
|
+
return {}
|
|
212
|
+
|
|
213
|
+
def do_GET(self): # noqa: N802 - stdlib name
|
|
214
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
215
|
+
app = self.app
|
|
216
|
+
if parsed.path == "/":
|
|
217
|
+
self._send_html(HOME_PATH.read_text(encoding="utf-8"))
|
|
218
|
+
return
|
|
219
|
+
if parsed.path == "/api/catalogs":
|
|
220
|
+
self._send_json(app.registry.list_catalogs())
|
|
221
|
+
return
|
|
222
|
+
if parsed.path == "/api/settings":
|
|
223
|
+
self._send_json(app.registry.get_settings())
|
|
224
|
+
return
|
|
225
|
+
if parsed.path.startswith("/api/jobs/"):
|
|
226
|
+
job = app.jobs.get(parsed.path[len("/api/jobs/"):])
|
|
227
|
+
if job is None:
|
|
228
|
+
self._send_json({"error": "no such job"}, 404)
|
|
229
|
+
else:
|
|
230
|
+
self._send_json(job)
|
|
231
|
+
return
|
|
232
|
+
self._send_json({"error": "not found"}, 404)
|
|
233
|
+
|
|
234
|
+
def do_POST(self): # noqa: N802 - stdlib name
|
|
235
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
236
|
+
app = self.app
|
|
237
|
+
body = self._body()
|
|
238
|
+
try:
|
|
239
|
+
if parsed.path == "/api/scan":
|
|
240
|
+
folder = Path(str(body.get("path", ""))).expanduser()
|
|
241
|
+
if not folder.is_dir():
|
|
242
|
+
self._send_json(
|
|
243
|
+
{"error": f"not a folder: {folder}"}, 400)
|
|
244
|
+
return
|
|
245
|
+
use_llm = bool(body.get("use_llm"))
|
|
246
|
+
job = app.start_scan(folder.resolve(), use_llm)
|
|
247
|
+
self._send_json({"id": job["id"]}, 202)
|
|
248
|
+
return
|
|
249
|
+
if parsed.path == "/api/browse":
|
|
250
|
+
self._send_json({"path": app.browse_folder()})
|
|
251
|
+
return
|
|
252
|
+
if parsed.path == "/api/settings":
|
|
253
|
+
self._send_json(app.registry.save_settings(body))
|
|
254
|
+
return
|
|
255
|
+
if parsed.path.startswith("/api/catalogs/") and \
|
|
256
|
+
parsed.path.endswith("/open"):
|
|
257
|
+
cid = parsed.path.split("/")[3]
|
|
258
|
+
self._send_json({"url": app.open_catalog(cid)})
|
|
259
|
+
return
|
|
260
|
+
if parsed.path.startswith("/api/catalogs/") and \
|
|
261
|
+
parsed.path.endswith("/refresh"):
|
|
262
|
+
cid = parsed.path.split("/")[3]
|
|
263
|
+
rec = app.registry.get_catalog(cid)
|
|
264
|
+
if rec is None:
|
|
265
|
+
self._send_json({"error": "no such catalog"}, 404)
|
|
266
|
+
return
|
|
267
|
+
snap = rec["settings"] or {}
|
|
268
|
+
job = app.start_scan(
|
|
269
|
+
Path(rec["path"]), bool(snap.get("use_llm")))
|
|
270
|
+
self._send_json({"id": job["id"]}, 202)
|
|
271
|
+
return
|
|
272
|
+
except KeyError as exc:
|
|
273
|
+
self._send_json({"error": str(exc)}, 404)
|
|
274
|
+
return
|
|
275
|
+
except RuntimeError as exc:
|
|
276
|
+
self._send_json({"error": str(exc)}, 409)
|
|
277
|
+
return
|
|
278
|
+
self._send_json({"error": "not found"}, 404)
|
|
279
|
+
|
|
280
|
+
def do_DELETE(self): # noqa: N802 - stdlib name
|
|
281
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
282
|
+
if parsed.path.startswith("/api/catalogs/"):
|
|
283
|
+
cid = parsed.path.split("/")[3]
|
|
284
|
+
try:
|
|
285
|
+
self.app.delete_catalog(cid)
|
|
286
|
+
self._send_json({"deleted": cid})
|
|
287
|
+
return
|
|
288
|
+
except KeyError:
|
|
289
|
+
self._send_json({"error": "no such catalog"}, 404)
|
|
290
|
+
return
|
|
291
|
+
self._send_json({"error": "not found"}, 404)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def run_app(host: str = "127.0.0.1", port: int = 8770,
|
|
295
|
+
open_browser: bool = True) -> int:
|
|
296
|
+
app = App(host, port)
|
|
297
|
+
print(f"FolderLake home: {app.home_url}", flush=True)
|
|
298
|
+
print("Press Ctrl-C to stop.", flush=True)
|
|
299
|
+
if open_browser:
|
|
300
|
+
import webbrowser
|
|
301
|
+
|
|
302
|
+
webbrowser.open(app.home_url)
|
|
303
|
+
try:
|
|
304
|
+
app.server.serve_forever()
|
|
305
|
+
except KeyboardInterrupt:
|
|
306
|
+
print("\nStopped.")
|
|
307
|
+
return 0
|