folderlake 0.2.0__tar.gz → 0.2.1__tar.gz

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 (38) hide show
  1. {folderlake-0.2.0/src/folderlake.egg-info → folderlake-0.2.1}/PKG-INFO +7 -6
  2. {folderlake-0.2.0 → folderlake-0.2.1}/README.md +6 -5
  3. {folderlake-0.2.0 → folderlake-0.2.1}/pyproject.toml +1 -1
  4. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/appserver.py +126 -37
  5. {folderlake-0.2.0 → folderlake-0.2.1/src/folderlake.egg-info}/PKG-INFO +7 -6
  6. {folderlake-0.2.0 → folderlake-0.2.1}/tests/test_app.py +10 -1
  7. {folderlake-0.2.0 → folderlake-0.2.1}/LICENSE +0 -0
  8. {folderlake-0.2.0 → folderlake-0.2.1}/setup.cfg +0 -0
  9. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/__init__.py +0 -0
  10. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/assets/echarts.min.js +0 -0
  11. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/catalog.py +0 -0
  12. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/cli.py +0 -0
  13. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/cypher.py +0 -0
  14. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/extractors.py +0 -0
  15. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/hierarchy.py +0 -0
  16. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/home.html +0 -0
  17. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/llm.py +0 -0
  18. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/mcp_server.py +0 -0
  19. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/preview.py +0 -0
  20. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/registry.py +0 -0
  21. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/relations.py +0 -0
  22. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/server.py +0 -0
  23. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/topics.py +0 -0
  24. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake/viewer.html +0 -0
  25. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake.egg-info/SOURCES.txt +0 -0
  26. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake.egg-info/dependency_links.txt +0 -0
  27. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake.egg-info/entry_points.txt +0 -0
  28. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake.egg-info/requires.txt +0 -0
  29. {folderlake-0.2.0 → folderlake-0.2.1}/src/folderlake.egg-info/top_level.txt +0 -0
  30. {folderlake-0.2.0 → folderlake-0.2.1}/tests/test_cypher.py +0 -0
  31. {folderlake-0.2.0 → folderlake-0.2.1}/tests/test_extractors.py +0 -0
  32. {folderlake-0.2.0 → folderlake-0.2.1}/tests/test_hierarchy.py +0 -0
  33. {folderlake-0.2.0 → folderlake-0.2.1}/tests/test_incremental.py +0 -0
  34. {folderlake-0.2.0 → folderlake-0.2.1}/tests/test_mcp.py +0 -0
  35. {folderlake-0.2.0 → folderlake-0.2.1}/tests/test_phrases_llm.py +0 -0
  36. {folderlake-0.2.0 → folderlake-0.2.1}/tests/test_relations.py +0 -0
  37. {folderlake-0.2.0 → folderlake-0.2.1}/tests/test_server.py +0 -0
  38. {folderlake-0.2.0 → folderlake-0.2.1}/tests/test_topics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: folderlake
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Point it at a folder. Know what's in it. A local file catalog with topics, metadata and a relationship graph, stored in one DuckDB file.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -70,8 +70,7 @@ of shared topics, shared entities and explicit filename references.
70
70
  ## Quick start
71
71
 
72
72
  ```bash
73
- pip install "git+https://github.com/DaiZack/FolderLake"
74
- # (not on PyPI yet — GitHub is the source; or `pip install -e .` from a clone)
73
+ pip install folderlake # from PyPI (or: pip install -e . from a clone)
75
74
 
76
75
  folderlake app # opens the FolderLake home page
77
76
  ```
@@ -86,6 +85,9 @@ page (stored in a small SQLite registry in `~/.folderlake/`): reopen
86
85
  one in a click, **Refresh** it to pick up added/removed files, or
87
86
  **Delete** it. Each history entry shows the settings it was scanned
88
87
  with. Inside a catalog, a **← FolderLake Home** link brings you back.
88
+ Everything runs on a single port — each catalog lives under its own
89
+ `/c/…/` path — so the app works through SSH tunnels, Docker port
90
+ mappings and other single-port forwarding.
89
91
 
90
92
  The catalog viewer itself: filters and the topic tree on the left,
91
93
  the file list and the relationship graph side by side in the middle
@@ -117,8 +119,7 @@ variables needed; the environment variables below remain for the
117
119
  CLI and scripts:
118
120
 
119
121
  ```bash
120
- pip install "folderlake[llm] @ git+https://github.com/DaiZack/FolderLake"
121
- # (adds LiteLLM as the transport)
122
+ pip install "folderlake[llm]" # adds LiteLLM as the transport
122
123
 
123
124
  # Local model via Ollama (default provider)
124
125
  FOLDERLAKE_LLM_MODEL=qwen3 folderlake scan ~/notes --llm
@@ -149,7 +150,7 @@ client (Claude Code, Cursor, your own agent) can ask questions about a
149
150
  catalog without opening files:
150
151
 
151
152
  ```bash
152
- pip install "folderlake[mcp] @ git+https://github.com/DaiZack/FolderLake"
153
+ pip install "folderlake[mcp]"
153
154
  folderlake mcp ~/Documents/notes # stdio MCP server
154
155
  # or the folderlake-mcp entry point
155
156
  ```
@@ -53,8 +53,7 @@ of shared topics, shared entities and explicit filename references.
53
53
  ## Quick start
54
54
 
55
55
  ```bash
56
- pip install "git+https://github.com/DaiZack/FolderLake"
57
- # (not on PyPI yet — GitHub is the source; or `pip install -e .` from a clone)
56
+ pip install folderlake # from PyPI (or: pip install -e . from a clone)
58
57
 
59
58
  folderlake app # opens the FolderLake home page
60
59
  ```
@@ -69,6 +68,9 @@ page (stored in a small SQLite registry in `~/.folderlake/`): reopen
69
68
  one in a click, **Refresh** it to pick up added/removed files, or
70
69
  **Delete** it. Each history entry shows the settings it was scanned
71
70
  with. Inside a catalog, a **← FolderLake Home** link brings you back.
71
+ Everything runs on a single port — each catalog lives under its own
72
+ `/c/…/` path — so the app works through SSH tunnels, Docker port
73
+ mappings and other single-port forwarding.
72
74
 
73
75
  The catalog viewer itself: filters and the topic tree on the left,
74
76
  the file list and the relationship graph side by side in the middle
@@ -100,8 +102,7 @@ variables needed; the environment variables below remain for the
100
102
  CLI and scripts:
101
103
 
102
104
  ```bash
103
- pip install "folderlake[llm] @ git+https://github.com/DaiZack/FolderLake"
104
- # (adds LiteLLM as the transport)
105
+ pip install "folderlake[llm]" # adds LiteLLM as the transport
105
106
 
106
107
  # Local model via Ollama (default provider)
107
108
  FOLDERLAKE_LLM_MODEL=qwen3 folderlake scan ~/notes --llm
@@ -132,7 +133,7 @@ client (Claude Code, Cursor, your own agent) can ask questions about a
132
133
  catalog without opening files:
133
134
 
134
135
  ```bash
135
- pip install "folderlake[mcp] @ git+https://github.com/DaiZack/FolderLake"
136
+ pip install "folderlake[mcp]"
136
137
  folderlake mcp ~/Documents/notes # stdio MCP server
137
138
  # or the folderlake-mcp entry point
138
139
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "folderlake"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  readme = "README.md"
9
9
  description = "Point it at a folder. Know what's in it. A local file catalog with topics, metadata and a relationship graph, stored in one DuckDB file."
10
10
  requires-python = ">=3.11"
@@ -4,9 +4,14 @@
4
4
  folder picker, no scan before the page appears. The home page lists
5
5
  every catalog built so far (from the SQLite registry), starts new
6
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.
7
+ the LLM settings.
8
+
9
+ Everything is served from this one port: each catalog's viewer lives
10
+ under ``/c/<catalog-id>/`` with its API at ``/c/<catalog-id>/api/…``
11
+ (the viewer HTML is rewritten to the prefix when served). A single
12
+ port keeps the app usable through SSH tunnels, Docker port mappings
13
+ and other one-port forwarding — an earlier design ran each catalog
14
+ viewer on its own random port, which broke exactly there.
10
15
  """
11
16
 
12
17
  from __future__ import annotations
@@ -21,12 +26,13 @@ import uuid
21
26
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
22
27
  from pathlib import Path
23
28
 
24
- from .catalog import CATALOG_NAME, scan
29
+ from .catalog import CATALOG_NAME, scan, try_build_fts
30
+ from .cypher import build_cypher
25
31
  from .hierarchy import build_hierarchy
26
- from .registry import Registry, catalog_id_for
32
+ from .registry import Registry
27
33
  from .relations import build_relations
28
- from .server import create_server
29
- from .catalog import try_build_fts
34
+ from .server import (ECHARTS_PATH, VIEWER_PATH, facets, file_detail,
35
+ graph, list_files, topics_tree)
30
36
 
31
37
  HOME_PATH = Path(__file__).with_name("home.html")
32
38
 
@@ -38,7 +44,6 @@ class App:
38
44
  self.registry = registry or Registry()
39
45
  self.jobs: dict[str, dict] = {}
40
46
  self._job_lock = threading.Lock()
41
- self.viewers: dict[str, tuple[ThreadingHTTPServer, int]] = {}
42
47
  handler = type("AppHandler", (_Handler,), {"app": self})
43
48
  try:
44
49
  self.server = ThreadingHTTPServer((host, port), handler)
@@ -90,11 +95,7 @@ class App:
90
95
  return snapshot
91
96
 
92
97
  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
98
  try:
96
- if was_open:
97
- self._stop_viewer(cid) # DuckDB allows one writer
98
99
  snapshot = self._apply_llm_env(use_llm)
99
100
  db_path = folder / CATALOG_NAME
100
101
 
@@ -114,40 +115,43 @@ class App:
114
115
  catalog_id=rec["id"])
115
116
  except Exception as exc: # surfaced on the home page, not a crash
116
117
  job.update(status="error", message=str(exc))
117
- finally:
118
- if was_open and job["status"] == "done":
119
- self.open_catalog(cid)
120
118
 
121
- # -- catalog viewers ------------------------------------------------
122
- def open_catalog(self, catalog_id: str) -> str:
119
+ # -- catalogs under /c/<id>/ ----------------------------------------
120
+ def catalog_rec(self, catalog_id: str) -> dict:
123
121
  rec = self.registry.get_catalog(catalog_id)
124
122
  if rec is None:
125
123
  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():
124
+ return rec
125
+
126
+ def open_catalog(self, catalog_id: str) -> str:
127
+ rec = self.catalog_rec(catalog_id)
128
+ if not (Path(rec["path"]) / CATALOG_NAME).exists():
133
129
  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
130
  self.registry.touch_opened(catalog_id)
140
- return f"http://{self.host}:{port}/"
131
+ return f"/c/{catalog_id}/"
141
132
 
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()
133
+ def catalog_viewer_html(self, catalog_id: str) -> str:
134
+ """The viewer page with its API/asset paths rewritten to this
135
+ catalog's prefix, plus the back-to-home link."""
136
+ rec = self.catalog_rec(catalog_id)
137
+ if not (Path(rec["path"]) / CATALOG_NAME).exists():
138
+ raise KeyError("catalog file is missing — refresh to rebuild it")
139
+ prefix = f"/c/{catalog_id}"
140
+ page = VIEWER_PATH.read_text(encoding="utf-8")
141
+ page = page.replace("/api/", prefix + "/api/")
142
+ page = page.replace("/assets/", prefix + "/assets/")
143
+ link = (
144
+ '<a href="/" style="margin-left:auto;margin-right:12px;'
145
+ 'color:#4a80d0;text-decoration:none;font-size:13px;'
146
+ 'white-space:nowrap">← FolderLake Home</a>'
147
+ )
148
+ return page.replace("</header>", link + "</header>", 1)
148
149
 
149
150
  def delete_catalog(self, catalog_id: str) -> dict:
150
- self._stop_viewer(catalog_id)
151
+ running = self.running_job()
152
+ rec = self.catalog_rec(catalog_id)
153
+ if running is not None and running["path"] == rec["path"]:
154
+ raise RuntimeError("a scan is running for this catalog")
151
155
  rec = self.registry.delete_catalog(catalog_id)
152
156
  if rec is None:
153
157
  raise KeyError("no such catalog")
@@ -193,6 +197,16 @@ class _Handler(BaseHTTPRequestHandler):
193
197
  self.end_headers()
194
198
  self.wfile.write(body)
195
199
 
200
+ def _send_bytes(self, body: bytes, content_type: str,
201
+ disposition: str | None = None) -> None:
202
+ self.send_response(200)
203
+ self.send_header("Content-Type", content_type)
204
+ self.send_header("Content-Length", str(len(body)))
205
+ if disposition:
206
+ self.send_header("Content-Disposition", disposition)
207
+ self.end_headers()
208
+ self.wfile.write(body)
209
+
196
210
  def _send_html(self, text: str) -> None:
197
211
  body = text.encode()
198
212
  self.send_response(200)
@@ -210,12 +224,84 @@ class _Handler(BaseHTTPRequestHandler):
210
224
  except Exception:
211
225
  return {}
212
226
 
227
+ def _serve_catalog(self, parsed) -> None:
228
+ """GET /c/<id>/… — the catalog viewer and its API, same port."""
229
+ app = self.app
230
+ parts = parsed.path.split("/") # ['', 'c', '<id>', <rest...>]
231
+ cid = parts[2] if len(parts) > 2 else ""
232
+ rest = "/".join(parts[3:])
233
+ try:
234
+ rec = app.catalog_rec(cid)
235
+ except KeyError:
236
+ self._send_json({"error": "no such catalog"}, 404)
237
+ return
238
+ db_path = Path(rec["path"]) / CATALOG_NAME
239
+ query = urllib.parse.parse_qs(parsed.query)
240
+ try:
241
+ if rest in ("", "/"):
242
+ self._send_html(app.catalog_viewer_html(cid))
243
+ return
244
+ if rest == "assets/echarts.min.js":
245
+ if ECHARTS_PATH.exists():
246
+ self._send_bytes(
247
+ ECHARTS_PATH.read_bytes(),
248
+ "application/javascript; charset=utf-8")
249
+ else:
250
+ self._send_json({"error": "echarts asset missing"}, 404)
251
+ return
252
+ if rest == "api/export.cypher":
253
+ self._send_bytes(build_cypher(db_path).encode(),
254
+ "text/plain; charset=utf-8",
255
+ 'attachment; filename="folderlake.cypher"')
256
+ return
257
+ if rest == "api/meta":
258
+ self._send_json({
259
+ "folder": rec["path"],
260
+ "catalog": str(db_path),
261
+ "catalog_bytes": (db_path.stat().st_size
262
+ if db_path.exists() else 0),
263
+ })
264
+ return
265
+ if rest == "api/files":
266
+ kinds = query.get("kind", [None])[0]
267
+ self._send_json(list_files(
268
+ db_path,
269
+ kinds=kinds.split(",") if kinds else None,
270
+ topic=query.get("topic", [None])[0],
271
+ query=query.get("q", [None])[0],
272
+ node=query.get("node", [None])[0]))
273
+ return
274
+ if rest == "api/facets":
275
+ self._send_json(facets(db_path))
276
+ return
277
+ if rest == "api/topics":
278
+ self._send_json(topics_tree(db_path))
279
+ return
280
+ if rest == "api/graph":
281
+ self._send_json(graph(db_path))
282
+ return
283
+ if rest.startswith("api/file/"):
284
+ detail = file_detail(
285
+ db_path, urllib.parse.unquote(rest[len("api/file/"):]))
286
+ if detail is None:
287
+ self._send_json({"error": "file not found"}, 404)
288
+ else:
289
+ self._send_json(detail)
290
+ return
291
+ except KeyError as exc:
292
+ self._send_json({"error": str(exc)}, 404)
293
+ return
294
+ self._send_json({"error": "not found"}, 404)
295
+
213
296
  def do_GET(self): # noqa: N802 - stdlib name
214
297
  parsed = urllib.parse.urlparse(self.path)
215
298
  app = self.app
216
299
  if parsed.path == "/":
217
300
  self._send_html(HOME_PATH.read_text(encoding="utf-8"))
218
301
  return
302
+ if parsed.path.startswith("/c/"):
303
+ self._serve_catalog(parsed)
304
+ return
219
305
  if parsed.path == "/api/catalogs":
220
306
  self._send_json(app.registry.list_catalogs())
221
307
  return
@@ -288,6 +374,9 @@ class _Handler(BaseHTTPRequestHandler):
288
374
  except KeyError:
289
375
  self._send_json({"error": "no such catalog"}, 404)
290
376
  return
377
+ except RuntimeError as exc:
378
+ self._send_json({"error": str(exc)}, 409)
379
+ return
291
380
  self._send_json({"error": "not found"}, 404)
292
381
 
293
382
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: folderlake
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Point it at a folder. Know what's in it. A local file catalog with topics, metadata and a relationship graph, stored in one DuckDB file.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -70,8 +70,7 @@ of shared topics, shared entities and explicit filename references.
70
70
  ## Quick start
71
71
 
72
72
  ```bash
73
- pip install "git+https://github.com/DaiZack/FolderLake"
74
- # (not on PyPI yet — GitHub is the source; or `pip install -e .` from a clone)
73
+ pip install folderlake # from PyPI (or: pip install -e . from a clone)
75
74
 
76
75
  folderlake app # opens the FolderLake home page
77
76
  ```
@@ -86,6 +85,9 @@ page (stored in a small SQLite registry in `~/.folderlake/`): reopen
86
85
  one in a click, **Refresh** it to pick up added/removed files, or
87
86
  **Delete** it. Each history entry shows the settings it was scanned
88
87
  with. Inside a catalog, a **← FolderLake Home** link brings you back.
88
+ Everything runs on a single port — each catalog lives under its own
89
+ `/c/…/` path — so the app works through SSH tunnels, Docker port
90
+ mappings and other single-port forwarding.
89
91
 
90
92
  The catalog viewer itself: filters and the topic tree on the left,
91
93
  the file list and the relationship graph side by side in the middle
@@ -117,8 +119,7 @@ variables needed; the environment variables below remain for the
117
119
  CLI and scripts:
118
120
 
119
121
  ```bash
120
- pip install "folderlake[llm] @ git+https://github.com/DaiZack/FolderLake"
121
- # (adds LiteLLM as the transport)
122
+ pip install "folderlake[llm]" # adds LiteLLM as the transport
122
123
 
123
124
  # Local model via Ollama (default provider)
124
125
  FOLDERLAKE_LLM_MODEL=qwen3 folderlake scan ~/notes --llm
@@ -149,7 +150,7 @@ client (Claude Code, Cursor, your own agent) can ask questions about a
149
150
  catalog without opening files:
150
151
 
151
152
  ```bash
152
- pip install "folderlake[mcp] @ git+https://github.com/DaiZack/FolderLake"
153
+ pip install "folderlake[mcp]"
153
154
  folderlake mcp ~/Documents/notes # stdio MCP server
154
155
  # or the folderlake-mcp entry point
155
156
  ```
@@ -122,8 +122,17 @@ def test_app_server_flow(tmp_path):
122
122
  status, opened = client.call(
123
123
  "POST", f"/api/catalogs/{cat['id']}/open")
124
124
  assert status == 200
125
- viewer = client.get_text(opened["url"])
125
+ # Same port, path-based: no second server is involved, so the
126
+ # app works through single-port forwarding (SSH tunnels etc.).
127
+ assert opened["url"] == f"/c/{cat['id']}/"
128
+ viewer = client.get_text(client.base + opened["url"])
126
129
  assert "FolderLake Home" in viewer # back-to-home link injected
130
+ assert f"/c/{cat['id']}/api/" in viewer # API paths prefixed
131
+ _, files = client.call("GET", opened["url"] + "api/files")
132
+ assert len(files) == 2
133
+ cypher = client.get_text(client.base + opened["url"]
134
+ + "api/export.cypher")
135
+ assert cypher.startswith("// FolderLake")
127
136
 
128
137
  # refresh picks up a new file
129
138
  (folder / "more.txt").write_text("workshop schedule update",
File without changes
File without changes
File without changes