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/catalog.py ADDED
@@ -0,0 +1,184 @@
1
+ """The DuckDB catalog: five tables, incremental scanning.
2
+
3
+ Incremental rule: a file whose (size, mtime) matches the stored row is
4
+ skipped on stat alone — no hashing, no extraction. Only new or changed
5
+ files are hashed and processed. That is what makes re-opening a folder
6
+ near-instant.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from pathlib import Path
14
+
15
+ import duckdb
16
+
17
+ from .extractors import extract
18
+ from .topics import compute_topics, term_counts_for
19
+
20
+ SCHEMA = """
21
+ CREATE TABLE IF NOT EXISTS files (
22
+ id VARCHAR PRIMARY KEY,
23
+ path VARCHAR, name VARCHAR, ext VARCHAR, kind VARCHAR,
24
+ size_bytes BIGINT, mtime DOUBLE, hash VARCHAR,
25
+ title VARCHAR, author VARCHAR, extra_json VARCHAR
26
+ );
27
+ CREATE TABLE IF NOT EXISTS file_topics (
28
+ file_id VARCHAR, tag VARCHAR, weight DOUBLE, source VARCHAR
29
+ );
30
+ CREATE TABLE IF NOT EXISTS file_entities (
31
+ file_id VARCHAR, entity VARCHAR
32
+ );
33
+ CREATE TABLE IF NOT EXISTS relations (
34
+ file_a VARCHAR, file_b VARCHAR, kind VARCHAR, detail VARCHAR
35
+ );
36
+ CREATE TABLE IF NOT EXISTS file_text (
37
+ file_id VARCHAR, summary VARCHAR, text VARCHAR
38
+ );
39
+ CREATE TABLE IF NOT EXISTS file_terms (
40
+ file_id VARCHAR, term VARCHAR, cnt BIGINT
41
+ );
42
+ CREATE TABLE IF NOT EXISTS file_topic_meta (
43
+ file_id VARCHAR PRIMARY KEY, hash VARCHAR, method VARCHAR
44
+ );
45
+ CREATE TABLE IF NOT EXISTS topic_nodes (
46
+ id VARCHAR PRIMARY KEY, parent_id VARCHAR, kind VARCHAR,
47
+ label VARCHAR, depth INTEGER, file_count INTEGER, cohesion DOUBLE
48
+ );
49
+ CREATE TABLE IF NOT EXISTS topic_node_files (
50
+ node_id VARCHAR, file_id VARCHAR
51
+ );
52
+ """
53
+
54
+ CATALOG_NAME = "folderlake.duckdb"
55
+
56
+
57
+ def file_id_for(path: Path) -> str:
58
+ return hashlib.md5(str(path).encode()).hexdigest()[:16]
59
+
60
+
61
+ def _hash_file(path: Path) -> str:
62
+ h = hashlib.sha256()
63
+ with open(path, "rb") as fh:
64
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
65
+ h.update(chunk)
66
+ return h.hexdigest()
67
+
68
+
69
+ def connect(db_path: Path) -> duckdb.DuckDBPyConnection:
70
+ con = duckdb.connect(str(db_path))
71
+ con.execute(SCHEMA)
72
+ try: # catalogs built before the source column existed
73
+ con.execute("ALTER TABLE file_topics ADD COLUMN IF NOT EXISTS source VARCHAR")
74
+ except Exception:
75
+ pass
76
+ return con
77
+
78
+
79
+ def _iter_files(root: Path, catalog_path: Path):
80
+ # The catalog itself and the generated Neo4j export are not content.
81
+ catalog_names = {catalog_path.name, catalog_path.name + ".wal", "folderlake.cypher"}
82
+ for path in sorted(root.rglob("*")):
83
+ if not path.is_file():
84
+ continue
85
+ if path.name in catalog_names:
86
+ continue
87
+ if any(part.startswith(".") for part in path.relative_to(root).parts):
88
+ continue
89
+ yield path
90
+
91
+
92
+ def scan(root: Path, db_path: Path, use_llm: bool = False,
93
+ progress=None) -> dict:
94
+ """Scan ``root`` into the catalog. ``progress``, when given, is
95
+ called as progress(done, total, current_name) while files are
96
+ walked, so a UI can show a live bar instead of a frozen wait."""
97
+ root = root.resolve()
98
+ con = connect(db_path)
99
+ stats = {"seen": 0, "added": 0, "updated": 0, "skipped": 0, "removed": 0}
100
+ existing = {
101
+ row[0]: (row[1], row[2])
102
+ for row in con.execute("SELECT path, size_bytes, mtime FROM files").fetchall()
103
+ }
104
+ seen_paths: set[str] = set()
105
+ changed_ids: set[str] = set()
106
+ try:
107
+ # One transaction for the whole scan: committing per file costs a
108
+ # disk sync per statement and made a 1,000-file scan take ~194s;
109
+ # batched, the same scan is extraction-bound instead.
110
+ con.execute("BEGIN TRANSACTION")
111
+ paths = list(_iter_files(root, db_path))
112
+ total = len(paths)
113
+ for idx, path in enumerate(paths):
114
+ if progress is not None:
115
+ progress(idx, total, path.name)
116
+ stats["seen"] += 1
117
+ key = str(path)
118
+ seen_paths.add(key)
119
+ st = path.stat()
120
+ prev = existing.get(key)
121
+ if prev is not None and prev[0] == st.st_size and abs(prev[1] - st.st_mtime) < 1e-6:
122
+ stats["skipped"] += 1
123
+ continue
124
+ fid = file_id_for(path)
125
+ result = extract(path)
126
+ con.execute("DELETE FROM files WHERE id = ?", [fid])
127
+ con.execute("DELETE FROM file_topics WHERE file_id = ?", [fid])
128
+ con.execute("DELETE FROM file_entities WHERE file_id = ?", [fid])
129
+ con.execute("DELETE FROM file_text WHERE file_id = ?", [fid])
130
+ con.execute("DELETE FROM file_terms WHERE file_id = ?", [fid])
131
+ con.execute("DELETE FROM file_topic_meta WHERE file_id = ?", [fid])
132
+ con.execute(
133
+ "INSERT INTO files VALUES (?,?,?,?,?,?,?,?,?,?,?)",
134
+ [
135
+ fid, key, path.name, path.suffix.lower(), result["kind"],
136
+ st.st_size, st.st_mtime, _hash_file(path),
137
+ result["title"], result["author"],
138
+ json.dumps(result["extra"], ensure_ascii=False),
139
+ ],
140
+ )
141
+ # Term counts are the incremental base for the post-scan
142
+ # TF-IDF step: unchanged files keep theirs, so adding a
143
+ # file never re-reads the rest of the folder.
144
+ for term, cnt in term_counts_for(result, path.name).items():
145
+ con.execute("INSERT INTO file_terms VALUES (?,?,?)", [fid, term, cnt])
146
+ con.execute(
147
+ "INSERT INTO file_text VALUES (?,?,?)",
148
+ [fid, "", result["text"]],
149
+ )
150
+ changed_ids.add(fid)
151
+ stats["updated" if prev is not None else "added"] += 1
152
+ for old_path, _ in [(p, None) for p in existing if p not in seen_paths]:
153
+ fid = file_id_for(Path(old_path))
154
+ for tbl, col in (("files", "id"), ("file_topics", "file_id"),
155
+ ("file_entities", "file_id"), ("file_text", "file_id"),
156
+ ("file_terms", "file_id"), ("file_topic_meta", "file_id")):
157
+ con.execute(f"DELETE FROM {tbl} WHERE {col} = ?", [fid])
158
+ stats["removed"] += 1
159
+ con.execute("COMMIT")
160
+ if progress is not None:
161
+ progress(total, total, "")
162
+ # Post-scan topic pass on the same connection: TF-IDF over
163
+ # stored term counts (no file I/O for unchanged files); the
164
+ # LLM, when enabled, is called only for files added or updated
165
+ # in this scan.
166
+ stats["topics"] = compute_topics(
167
+ use_llm=use_llm, llm_file_ids=changed_ids, con=con
168
+ )
169
+ finally:
170
+ con.close()
171
+ return stats
172
+
173
+
174
+ def try_build_fts(db_path: Path) -> bool:
175
+ """Best-effort full-text index over extracted text (used by search later)."""
176
+ con = duckdb.connect(str(db_path))
177
+ try:
178
+ con.execute("INSTALL fts; LOAD fts;")
179
+ con.execute("PRAGMA create_fts_index('file_text', 'file_id', 'text', overwrite=1)")
180
+ return True
181
+ except Exception:
182
+ return False
183
+ finally:
184
+ con.close()
folderlake/cli.py ADDED
@@ -0,0 +1,209 @@
1
+ """FolderLake command line.
2
+
3
+ folderlake scan <folder> [--db PATH] [--llm]
4
+ folderlake list [--db PATH]
5
+ folderlake preview <file-id-or-name> [--db PATH]
6
+ folderlake serve <folder> [--db PATH] [--port N] [--open]
7
+
8
+ ``serve`` is the one-command workflow from the README: it scans the
9
+ folder (incremental — unchanged files are skipped), builds relations,
10
+ and opens the three-pane viewer (filters | list/graph | preview).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ import time
19
+ import webbrowser
20
+ from pathlib import Path
21
+
22
+ from .catalog import CATALOG_NAME, connect, scan, try_build_fts
23
+ from .cypher import build_cypher
24
+ from .hierarchy import build_hierarchy
25
+ from .preview import build_preview
26
+ from .relations import build_relations
27
+ from .server import create_server
28
+
29
+
30
+ def _default_db(folder: Path) -> Path:
31
+ return folder / CATALOG_NAME
32
+
33
+
34
+ def _run_server(folder: Path, db_path: Path, use_llm: bool, host: str,
35
+ port: int, open_browser: bool) -> int:
36
+ stats = scan(folder, db_path, use_llm=use_llm)
37
+ build_relations(db_path)
38
+ build_hierarchy(db_path)
39
+ try_build_fts(db_path)
40
+ server = create_server(db_path, folder.resolve(), host, port)
41
+ url = f"http://{host}:{server.server_address[1]}/"
42
+ print(f"scanned: {stats}", flush=True)
43
+ print(f"FolderLake viewer: {url} (folder: {folder}, catalog: {db_path})", flush=True)
44
+ print("Press Ctrl-C to stop.", flush=True)
45
+ if open_browser:
46
+ webbrowser.open(url)
47
+ try:
48
+ server.serve_forever()
49
+ except KeyboardInterrupt:
50
+ print("\nStopped.")
51
+ return 0
52
+
53
+
54
+ def main(argv: list[str] | None = None) -> int:
55
+ parser = argparse.ArgumentParser(prog="folderlake")
56
+ sub = parser.add_subparsers(dest="cmd", required=True)
57
+
58
+ p_scan = sub.add_parser("scan", help="scan a folder into a DuckDB catalog")
59
+ p_scan.add_argument("folder")
60
+ p_scan.add_argument("--db", default=None)
61
+ p_scan.add_argument("--llm", action="store_true",
62
+ help="use a local LLM (Ollama) for topics instead of rules")
63
+
64
+ p_list = sub.add_parser("list", help="list catalogued files with topics")
65
+ p_list.add_argument("--db", required=True)
66
+
67
+ p_prev = sub.add_parser("preview", help="preview one file by id or name")
68
+ p_prev.add_argument("target")
69
+ p_prev.add_argument("--db", required=True)
70
+
71
+ p_serve = sub.add_parser(
72
+ "serve", help="scan a folder and open the local viewer")
73
+ p_serve.add_argument("folder")
74
+ p_serve.add_argument("--db", default=None)
75
+ p_serve.add_argument("--llm", action="store_true",
76
+ help="use a local LLM (Ollama) for topics instead of rules")
77
+ p_serve.add_argument("--port", type=int, default=0,
78
+ help="port to listen on (0 = pick a free port)")
79
+ p_serve.add_argument("--host", default="127.0.0.1")
80
+ p_serve.add_argument("--open", action="store_true",
81
+ help="open the viewer in the default browser")
82
+
83
+ p_cypher = sub.add_parser(
84
+ "cypher", help="export the catalog as a Neo4j Cypher script")
85
+ p_cypher.add_argument("folder")
86
+ p_cypher.add_argument("--db", default=None)
87
+ p_cypher.add_argument("-o", "--output", default=None,
88
+ help="output file (default: <folder>/folderlake.cypher)")
89
+
90
+ p_app = sub.add_parser(
91
+ "app", help="web-app mode: open the FolderLake home page "
92
+ "(history, new scans, settings) in your browser")
93
+ p_app.add_argument("--port", type=int, default=8770,
94
+ help="port to listen on (default 8770)")
95
+ p_app.add_argument("--host", default="127.0.0.1")
96
+
97
+ p_mcp = sub.add_parser(
98
+ "mcp", help="serve the catalog to other agents over MCP (stdio)")
99
+ p_mcp.add_argument("folder", nargs="?", default=None,
100
+ help="folder whose catalog to serve")
101
+ p_mcp.add_argument("--db", default=None,
102
+ help="catalog path (overrides the folder default)")
103
+
104
+ args = parser.parse_args(argv)
105
+
106
+ if args.cmd == "scan":
107
+ folder = Path(args.folder)
108
+ if not folder.is_dir():
109
+ print(f"not a folder: {folder}", file=sys.stderr)
110
+ return 2
111
+ db_path = Path(args.db) if args.db else _default_db(folder)
112
+ started = time.perf_counter()
113
+ stats = scan(folder, db_path, use_llm=args.llm)
114
+ elapsed = time.perf_counter() - started
115
+ rel = build_relations(db_path)
116
+ hier = build_hierarchy(db_path)
117
+ fts = try_build_fts(db_path)
118
+ print(json.dumps({
119
+ **stats, "relations": rel, "hierarchy": hier, "fts_index": fts,
120
+ "seconds": round(elapsed, 3),
121
+ "catalog": str(db_path),
122
+ "catalog_bytes": db_path.stat().st_size,
123
+ }, indent=2))
124
+ return 0
125
+
126
+ if args.cmd == "list":
127
+ con = connect(Path(args.db))
128
+ try:
129
+ rows = con.execute(
130
+ """
131
+ SELECT f.name, f.kind,
132
+ coalesce(string_agg(t.tag, ', '), '') AS tags
133
+ FROM files f LEFT JOIN file_topics t ON t.file_id = f.id
134
+ GROUP BY f.name, f.kind ORDER BY f.name
135
+ """
136
+ ).fetchall()
137
+ for name, kind, tags in rows:
138
+ print(f"{name}\t{kind}\t{tags}")
139
+ print(f"-- {len(rows)} files")
140
+ finally:
141
+ con.close()
142
+ return 0
143
+
144
+ if args.cmd == "preview":
145
+ con = connect(Path(args.db))
146
+ try:
147
+ row = con.execute(
148
+ "SELECT id FROM files WHERE id = ? OR name = ? LIMIT 1",
149
+ [args.target, args.target],
150
+ ).fetchone()
151
+ finally:
152
+ con.close()
153
+ if row is None:
154
+ print(f"not found: {args.target}", file=sys.stderr)
155
+ return 2
156
+ print(json.dumps(build_preview(Path(args.db), row[0]), indent=2, ensure_ascii=False))
157
+ return 0
158
+
159
+ if args.cmd == "cypher":
160
+ folder = Path(args.folder)
161
+ db_path = Path(args.db) if args.db else _default_db(folder)
162
+ if not db_path.exists():
163
+ print(f"no catalog found: {db_path} (run scan first)", file=sys.stderr)
164
+ return 2
165
+ script = build_cypher(db_path)
166
+ out = Path(args.output) if args.output else folder / "folderlake.cypher"
167
+ out.write_text(script, encoding="utf-8")
168
+ print(json.dumps({
169
+ "cypher": str(out),
170
+ "bytes": out.stat().st_size,
171
+ "statements": script.count(";\n") + (1 if script.rstrip().endswith(";") else 0),
172
+ }, indent=2))
173
+ return 0
174
+
175
+ if args.cmd == "serve":
176
+ folder = Path(args.folder)
177
+ if not folder.is_dir():
178
+ print(f"not a folder: {folder}", file=sys.stderr)
179
+ return 2
180
+ db_path = Path(args.db) if args.db else _default_db(folder)
181
+ return _run_server(folder, db_path, args.llm, args.host, args.port,
182
+ args.open)
183
+
184
+ if args.cmd == "mcp":
185
+ from .mcp_server import main as mcp_main
186
+
187
+ if args.db:
188
+ db_path = Path(args.db)
189
+ elif args.folder:
190
+ db_path = _default_db(Path(args.folder))
191
+ else:
192
+ print("folderlake mcp: give a folder or --db", file=sys.stderr)
193
+ return 2
194
+ if not db_path.exists():
195
+ print(f"catalog not found: {db_path} (scan the folder first)",
196
+ file=sys.stderr)
197
+ return 2
198
+ return mcp_main(["--db", str(db_path)])
199
+
200
+ if args.cmd == "app":
201
+ from .appserver import run_app
202
+
203
+ return run_app(args.host, args.port, open_browser=True)
204
+
205
+ return 0
206
+
207
+
208
+ if __name__ == "__main__":
209
+ raise SystemExit(main())
folderlake/cypher.py ADDED
@@ -0,0 +1,100 @@
1
+ """Export the catalog as a Neo4j Cypher script.
2
+
3
+ Graph model:
4
+ (:File {id, name, path, kind, ext, size_bytes, title})
5
+ (:Tag {name}) (:Entity {name})
6
+ (File)-[:TAGGED {weight, source}]->(Tag)
7
+ (File)-[:MENTIONS]->(Entity)
8
+ (File)-[:RELATED {kind, detail}]->(File)
9
+
10
+ The script is idempotent (MERGE everywhere) and self-contained: run it with
11
+ cypher-shell or paste it into Neo4j Browser. Constraints use Neo4j 5 syntax.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import duckdb
19
+
20
+
21
+ def _q(value) -> str:
22
+ """Quote a value as a Cypher literal."""
23
+ if value is None:
24
+ return "null"
25
+ if isinstance(value, bool):
26
+ return "true" if value else "false"
27
+ if isinstance(value, (int, float)):
28
+ return repr(value)
29
+ text = str(value).replace("\\", "\\\\").replace("'", "\\'")
30
+ return f"'{text}'"
31
+
32
+
33
+ def build_cypher(db_path: Path) -> str:
34
+ con = duckdb.connect(str(db_path), read_only=True)
35
+ try:
36
+ files = con.execute(
37
+ "SELECT id, name, path, kind, ext, size_bytes, title FROM files ORDER BY name"
38
+ ).fetchall()
39
+ tags = con.execute(
40
+ "SELECT file_id, tag, weight, source FROM file_topics ORDER BY file_id, weight DESC"
41
+ ).fetchall()
42
+ entities = con.execute(
43
+ "SELECT file_id, entity FROM file_entities ORDER BY file_id, entity"
44
+ ).fetchall()
45
+ relations = con.execute(
46
+ "SELECT file_a, file_b, kind, detail FROM relations ORDER BY file_a, file_b"
47
+ ).fetchall()
48
+ finally:
49
+ con.close()
50
+
51
+ lines = [
52
+ "// FolderLake catalog export for Neo4j",
53
+ f"// {len(files)} files, {len(tags)} tag links, "
54
+ f"{len(entities)} entity links, {len(relations)} file relations",
55
+ "// Run with cypher-shell or paste into Neo4j Browser. Safe to re-run (MERGE).",
56
+ "",
57
+ "CREATE CONSTRAINT file_id_unique IF NOT EXISTS FOR (f:File) REQUIRE f.id IS UNIQUE;",
58
+ "CREATE CONSTRAINT tag_name_unique IF NOT EXISTS FOR (t:Tag) REQUIRE t.name IS UNIQUE;",
59
+ "CREATE CONSTRAINT entity_name_unique IF NOT EXISTS FOR (e:Entity) REQUIRE e.name IS UNIQUE;",
60
+ "",
61
+ "// ---- Files ----",
62
+ ]
63
+ for fid, name, path, kind, ext, size, title in files:
64
+ props = (
65
+ f"id: {_q(fid)}, name: {_q(name)}, path: {_q(path)}, kind: {_q(kind)}, "
66
+ f"ext: {_q(ext)}, size_bytes: {_q(size)}, title: {_q(title)}"
67
+ )
68
+ lines.append(f"MERGE (f:File {{id: {_q(fid)}}}) SET f += {{{props}}};")
69
+
70
+ lines.append("")
71
+ lines.append("// ---- Tags ----")
72
+ tag_names = sorted({t[1] for t in tags})
73
+ for name in tag_names:
74
+ lines.append(f"MERGE (:Tag {{name: {_q(name)}}});")
75
+ for fid, tag, weight, source in tags:
76
+ lines.append(
77
+ f"MATCH (f:File {{id: {_q(fid)}}}), (t:Tag {{name: {_q(tag)}}}) "
78
+ f"MERGE (f)-[:TAGGED {{weight: {_q(round(weight, 4))}, source: {_q(source)}}}]->(t);"
79
+ )
80
+
81
+ lines.append("")
82
+ lines.append("// ---- Entities ----")
83
+ entity_names = sorted({e[1] for e in entities})
84
+ for name in entity_names:
85
+ lines.append(f"MERGE (:Entity {{name: {_q(name)}}});")
86
+ for fid, entity in entities:
87
+ lines.append(
88
+ f"MATCH (f:File {{id: {_q(fid)}}}), (e:Entity {{name: {_q(entity)}}}) "
89
+ "MERGE (f)-[:MENTIONS]->(e);"
90
+ )
91
+
92
+ lines.append("")
93
+ lines.append("// ---- File relations ----")
94
+ for a, b, kind, detail in relations:
95
+ lines.append(
96
+ f"MATCH (a:File {{id: {_q(a)}}}), (b:File {{id: {_q(b)}}}) "
97
+ f"MERGE (a)-[:RELATED {{kind: {_q(kind)}, detail: {_q(detail)}}}]->(b);"
98
+ )
99
+ lines.append("")
100
+ return "\n".join(lines)