Nymbo commited on
Commit
e63577a
·
verified ·
1 Parent(s): d6e3242

Create Obsidian_Vault.py

Browse files
Files changed (1) hide show
  1. Modules/Obsidian_Vault.py +501 -0
Modules/Obsidian_Vault.py ADDED
@@ -0,0 +1,501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import stat
7
+ from datetime import datetime
8
+ from typing import Annotated, Optional
9
+
10
+ import gradio as gr
11
+
12
+ from app import _log_call_end, _log_call_start, _truncate_for_log
13
+ from ._docstrings import autodoc
14
+
15
+
16
+ TOOL_SUMMARY = (
17
+ "Browse and search the Obsidian vault in read-only mode. "
18
+ "Actions: list, read, info, search, help. "
19
+ "All paths resolve within the vault root."
20
+ )
21
+
22
+ HELP_TEXT = (
23
+ "Obsidian Vault — actions and usage\n\n"
24
+ "Root: Nymbo-Tools/Obsidian (override with OBSIDIAN_VAULT_ROOT). "
25
+ "Absolute paths are disabled unless UNSAFE_ALLOW_ABS_PATHS=1.\n\n"
26
+ "Actions and fields:\n"
27
+ "- list: path='.' (default), recursive=false, show_hidden=false, max_entries=20\n"
28
+ "- read: path, offset=0, max_chars=4000 (shows next_cursor when truncated)\n"
29
+ "- info: path\n"
30
+ "- search: path (note or folder), query text in the Search field, recursive=false, show_hidden=false, max_entries=20, case_sensitive=false, offset=0\n"
31
+ "- help: show this guide\n\n"
32
+ "Errors are returned as JSON with fields: {status:'error', code, message, path?, hint?, data?}.\n\n"
33
+ "Examples:\n"
34
+ "- list current: action=list, path='.'\n"
35
+ "- read note: action=read, path='Projects/note.md', max_chars=500\n"
36
+ "- show metadata: action=info, path='Inbox'\n"
37
+ "- search notes: action=search, path='Projects', query='deadline', recursive=true, max_entries=100\n"
38
+ "- case-sensitive search: action=search, query='TODO', case_sensitive=true\n"
39
+ "- page search results: action=search, query='TODO', offset=20\n"
40
+ )
41
+
42
+
43
+ def _default_root() -> str:
44
+ env_root = os.getenv("OBSIDIAN_VAULT_ROOT")
45
+ if env_root and env_root.strip():
46
+ return os.path.abspath(os.path.expanduser(env_root.strip()))
47
+ try:
48
+ here = os.path.abspath(__file__)
49
+ tools_dir = os.path.dirname(os.path.dirname(here))
50
+ return os.path.abspath(os.path.join(tools_dir, "Obsidian"))
51
+ except Exception:
52
+ return os.path.abspath(os.getcwd())
53
+
54
+
55
+ ROOT_DIR = _default_root()
56
+ try:
57
+ os.makedirs(ROOT_DIR, exist_ok=True)
58
+ except Exception:
59
+ pass
60
+ ALLOW_ABS = bool(int(os.getenv("UNSAFE_ALLOW_ABS_PATHS", "0")))
61
+
62
+
63
+ def _safe_err(exc: Exception | str) -> str:
64
+ """Return an error string with any absolute root replaced by '/' and slashes normalized."""
65
+ s = str(exc)
66
+ s_norm = s.replace("\\", "/")
67
+ root_fwd = ROOT_DIR.replace("\\", "/")
68
+ root_variants = {ROOT_DIR, root_fwd, re.sub(r"/+", "/", root_fwd)}
69
+ for variant in root_variants:
70
+ if variant:
71
+ s_norm = s_norm.replace(variant, "/")
72
+ s_norm = re.sub(r"/+", "/", s_norm)
73
+ return s_norm
74
+
75
+
76
+ def _err(code: str, message: str, *, path: str | None = None, hint: str | None = None, data: dict | None = None) -> str:
77
+ payload = {
78
+ "status": "error",
79
+ "code": code,
80
+ "message": message,
81
+ "root": "/",
82
+ }
83
+ if path:
84
+ payload["path"] = path
85
+ if hint:
86
+ payload["hint"] = hint
87
+ if data:
88
+ payload["data"] = data
89
+ return json.dumps(payload, ensure_ascii=False)
90
+
91
+
92
+ def _display_path(abs_path: str) -> str:
93
+ try:
94
+ norm_root = os.path.normpath(ROOT_DIR)
95
+ norm_abs = os.path.normpath(abs_path)
96
+ common = os.path.commonpath([norm_root, norm_abs])
97
+ if os.path.normcase(common) == os.path.normcase(norm_root):
98
+ rel = os.path.relpath(norm_abs, norm_root)
99
+ if rel == ".":
100
+ return "/"
101
+ return "/" + rel.replace("\\", "/")
102
+ except Exception:
103
+ pass
104
+ return abs_path.replace("\\", "/")
105
+
106
+
107
+ def _resolve_path(path: str) -> tuple[str, str]:
108
+ try:
109
+ user_input = (path or ".").strip()
110
+ raw = os.path.expanduser(user_input)
111
+ if os.path.isabs(raw):
112
+ if not ALLOW_ABS:
113
+ return "", _err(
114
+ "absolute_path_disabled",
115
+ "Absolute paths are disabled in safe mode.",
116
+ path=raw.replace("\\", "/"),
117
+ hint="Use a path relative to / (e.g., Notes/index.md).",
118
+ )
119
+ abs_path = os.path.abspath(raw)
120
+ else:
121
+ abs_path = os.path.abspath(os.path.join(ROOT_DIR, raw))
122
+ if not ALLOW_ABS:
123
+ try:
124
+ common = os.path.commonpath([os.path.normpath(ROOT_DIR), os.path.normpath(abs_path)])
125
+ except Exception:
126
+ root_cmp = os.path.normcase(os.path.normpath(ROOT_DIR))
127
+ abs_cmp = os.path.normcase(os.path.normpath(abs_path))
128
+ if not abs_cmp.startswith(root_cmp):
129
+ return "", _err(
130
+ "path_outside_root",
131
+ "Path not allowed outside root.",
132
+ path=user_input.replace("\\", "/"),
133
+ hint="Use a path under / (the vault root).",
134
+ )
135
+ else:
136
+ root_cmp = os.path.normcase(os.path.normpath(ROOT_DIR))
137
+ common_cmp = os.path.normcase(os.path.normpath(common))
138
+ if common_cmp != root_cmp:
139
+ return "", _err(
140
+ "path_outside_root",
141
+ "Path not allowed outside root.",
142
+ path=user_input.replace("\\", "/"),
143
+ hint="Use a path under / (the vault root).",
144
+ )
145
+ return abs_path, ""
146
+ except Exception as exc:
147
+ return "", _err(
148
+ "resolve_path_failed",
149
+ "Failed to resolve path.",
150
+ path=(path or ""),
151
+ data={"error": _safe_err(exc)},
152
+ )
153
+
154
+
155
+ def _fmt_size(num_bytes: int) -> str:
156
+ units = ["B", "KB", "MB", "GB", "TB"]
157
+ size = float(num_bytes)
158
+ for unit in units:
159
+ if size < 1024.0:
160
+ return f"{size:.1f} {unit}"
161
+ size /= 1024.0
162
+ return f"{size:.1f} PB"
163
+
164
+
165
+ def _list_dir(abs_path: str, *, show_hidden: bool, recursive: bool, max_entries: int) -> str:
166
+ lines: list[str] = []
167
+ total = 0
168
+ listing_display = _display_path(abs_path)
169
+ for root, dirs, files in os.walk(abs_path):
170
+ if not show_hidden:
171
+ dirs[:] = [d for d in dirs if not d.startswith('.')]
172
+ files = [f for f in files if not f.startswith('.')]
173
+ try:
174
+ rel_root = os.path.relpath(root, ROOT_DIR)
175
+ except Exception:
176
+ rel_root = root
177
+ rel_root_disp = "/" if rel_root == "." else "/" + rel_root.replace("\\", "/")
178
+ lines.append(f"\n📂 {rel_root_disp}")
179
+ dirs.sort()
180
+ files.sort()
181
+ for d in dirs:
182
+ p = os.path.join(root, d)
183
+ try:
184
+ mtime = datetime.fromtimestamp(os.path.getmtime(p)).isoformat(sep=' ', timespec='seconds')
185
+ except Exception:
186
+ mtime = "?"
187
+ lines.append(f" • [DIR] {d} (modified {mtime})")
188
+ total += 1
189
+ if total >= max_entries:
190
+ lines.append(f"\n… Truncated at {max_entries} entries.")
191
+ return "\n".join(lines).strip()
192
+ for f in files:
193
+ p = os.path.join(root, f)
194
+ try:
195
+ size = _fmt_size(os.path.getsize(p))
196
+ mtime = datetime.fromtimestamp(os.path.getmtime(p)).isoformat(sep=' ', timespec='seconds')
197
+ except Exception:
198
+ size, mtime = "?", "?"
199
+ lines.append(f" • {f} ({size}, modified {mtime})")
200
+ total += 1
201
+ if total >= max_entries:
202
+ lines.append(f"\n… Truncated at {max_entries} entries.")
203
+ return "\n".join(lines).strip()
204
+ if not recursive:
205
+ break
206
+ header = f"Listing of {listing_display}\nRoot: /\nEntries: {total}"
207
+ return (header + "\n" + "\n".join(lines)).strip()
208
+
209
+
210
+ def _search_text(
211
+ abs_path: str,
212
+ query: str,
213
+ *,
214
+ recursive: bool,
215
+ show_hidden: bool,
216
+ max_results: int,
217
+ case_sensitive: bool,
218
+ start_index: int,
219
+ ) -> str:
220
+ if not os.path.exists(abs_path):
221
+ return _err("path_not_found", f"Path not found: {_display_path(abs_path)}", path=_display_path(abs_path))
222
+
223
+ query = query or ""
224
+ normalized_query = query if case_sensitive else query.lower()
225
+ if normalized_query == "":
226
+ return _err(
227
+ "missing_search_query",
228
+ "Search query is required for the search action.",
229
+ hint="Provide text in the Search field to look for.",
230
+ )
231
+
232
+ max_results = max(1, int(max_results) if max_results is not None else 20)
233
+ start_index = max(0, int(start_index) if start_index is not None else 0)
234
+ matches: list[tuple[str, int, str]] = []
235
+ errors: list[str] = []
236
+ files_scanned = 0
237
+ truncated = False
238
+ total_matches = 0
239
+
240
+ def _should_skip(name: str) -> bool:
241
+ return not show_hidden and name.startswith('.')
242
+
243
+ def _handle_match(file_path: str, line_no: int, line_text: str) -> bool:
244
+ nonlocal truncated, total_matches
245
+ total_matches += 1
246
+ if total_matches <= start_index:
247
+ return False
248
+ if len(matches) < max_results:
249
+ snippet = line_text.strip()
250
+ if len(snippet) > 200:
251
+ snippet = snippet[:197] + "…"
252
+ matches.append((_display_path(file_path), line_no, snippet))
253
+ return False
254
+ truncated = True
255
+ return True
256
+
257
+ def _search_file(file_path: str) -> bool:
258
+ nonlocal files_scanned
259
+ files_scanned += 1
260
+ try:
261
+ with open(file_path, 'r', encoding='utf-8', errors='replace') as handle:
262
+ for line_no, line in enumerate(handle, start=1):
263
+ haystack = line if case_sensitive else line.lower()
264
+ if normalized_query in haystack:
265
+ if _handle_match(file_path, line_no, line):
266
+ return True
267
+ except Exception as exc:
268
+ errors.append(f"{_display_path(file_path)} ({_safe_err(exc)})")
269
+ return truncated
270
+
271
+ if os.path.isfile(abs_path):
272
+ _search_file(abs_path)
273
+ else:
274
+ for root, dirs, files in os.walk(abs_path):
275
+ dirs[:] = [d for d in dirs if not _should_skip(d)]
276
+ visible_files = [f for f in files if show_hidden or not f.startswith('.')]
277
+ for name in visible_files:
278
+ file_path = os.path.join(root, name)
279
+ if _search_file(file_path):
280
+ break
281
+ if truncated:
282
+ break
283
+ if not recursive:
284
+ break
285
+
286
+ header_lines = [
287
+ f"Search results for {query!r}",
288
+ f"Scope: {_display_path(abs_path)}",
289
+ f"Recursive: {'yes' if recursive else 'no'}, Hidden: {'yes' if show_hidden else 'no'}, Case-sensitive: {'yes' if case_sensitive else 'no'}",
290
+ f"Start offset: {start_index}",
291
+ f"Matches returned: {len(matches)}" + (" (truncated)" if truncated else ""),
292
+ f"Files scanned: {files_scanned}",
293
+ ]
294
+
295
+ next_cursor = start_index + len(matches) if truncated else None
296
+
297
+ if truncated:
298
+ header_lines.append(f"Matches encountered before truncation: {total_matches}")
299
+ header_lines.append(f"Truncated: yes — re-run with offset={next_cursor} to continue.")
300
+ header_lines.append(f"Next cursor: {next_cursor}")
301
+ else:
302
+ header_lines.append(f"Total matches found: {total_matches}")
303
+ header_lines.append("Truncated: no — end of results.")
304
+ header_lines.append("Next cursor: None")
305
+
306
+ if not matches:
307
+ if total_matches > 0 and start_index >= total_matches:
308
+ hint_limit = max(total_matches - 1, 0)
309
+ body_lines = [
310
+ f"No matches found at or after offset {start_index}. Total matches available: {total_matches}.",
311
+ (f"Try a smaller offset (≤ {hint_limit})." if hint_limit >= 0 else ""),
312
+ ]
313
+ body_lines = [line for line in body_lines if line]
314
+ else:
315
+ body_lines = [
316
+ "No matches found.",
317
+ (f"Total matches encountered: {total_matches}." if total_matches else ""),
318
+ ]
319
+ body_lines = [line for line in body_lines if line]
320
+ else:
321
+ body_lines = [f"{idx}. {path}:{line_no}: {text}" for idx, (path, line_no, text) in enumerate(matches, start=1)]
322
+
323
+ if errors:
324
+ shown = errors[:5]
325
+ body_lines.extend(["", "Warnings:"])
326
+ body_lines.extend(shown)
327
+ if len(errors) > len(shown):
328
+ body_lines.append(f"… {len(errors) - len(shown)} additional files could not be read.")
329
+
330
+ return "\n".join(header_lines) + "\n\n" + "\n".join(body_lines)
331
+
332
+
333
+ def _read_file(abs_path: str, *, offset: int, max_chars: int) -> str:
334
+ if not os.path.exists(abs_path):
335
+ return _err("file_not_found", f"File not found: {_display_path(abs_path)}", path=_display_path(abs_path))
336
+ if os.path.isdir(abs_path):
337
+ return _err(
338
+ "is_directory",
339
+ f"Path is a directory, not a file: {_display_path(abs_path)}",
340
+ path=_display_path(abs_path),
341
+ hint="Provide a file path.",
342
+ )
343
+ try:
344
+ with open(abs_path, 'r', encoding='utf-8', errors='replace') as f:
345
+ data = f.read()
346
+ except Exception as exc:
347
+ return _err("read_failed", "Failed to read file.", path=_display_path(abs_path), data={"error": _safe_err(exc)})
348
+ total = len(data)
349
+ start = max(0, min(offset, total))
350
+ end = total if max_chars <= 0 else min(total, start + max_chars)
351
+ chunk = data[start:end]
352
+ next_cursor = end if end < total else None
353
+ header = (
354
+ f"Reading {_display_path(abs_path)}\n"
355
+ f"Offset {start}, returned {len(chunk)} of {total}."
356
+ + (f"\nNext cursor: {next_cursor}" if next_cursor is not None else "")
357
+ )
358
+ return header + "\n\n---\n\n" + chunk
359
+
360
+
361
+ def _info(abs_path: str) -> str:
362
+ try:
363
+ st = os.stat(abs_path)
364
+ except Exception as exc:
365
+ return _err("stat_failed", "Failed to stat path.", path=_display_path(abs_path), data={"error": _safe_err(exc)})
366
+ info = {
367
+ "path": _display_path(abs_path),
368
+ "type": "directory" if stat.S_ISDIR(st.st_mode) else "file",
369
+ "size": st.st_size,
370
+ "modified": datetime.fromtimestamp(st.st_mtime).isoformat(sep=' ', timespec='seconds'),
371
+ "created": datetime.fromtimestamp(st.st_ctime).isoformat(sep=' ', timespec='seconds'),
372
+ "mode": oct(st.st_mode),
373
+ "root": "/",
374
+ }
375
+ return json.dumps(info, indent=2)
376
+
377
+
378
+ @autodoc(summary=TOOL_SUMMARY)
379
+ def Obsidian_Vault(
380
+ action: Annotated[str, "Operation to perform: 'list', 'read', 'info', 'search', 'help'."],
381
+ path: Annotated[str, "Target path, relative to the vault root." ] = ".",
382
+ query: Annotated[Optional[str], "Text to search for when action=search."] = None,
383
+ recursive: Annotated[bool, "Recurse into subfolders when listing/searching."] = False,
384
+ show_hidden: Annotated[bool, "Include hidden files when listing/searching."] = False,
385
+ max_entries: Annotated[int, "Max entries to list or matches to return (for list/search)."] = 20,
386
+ offset: Annotated[int, "Start offset when reading files."] = 0,
387
+ max_chars: Annotated[int, "Max characters to return when reading (0 = full file)."] = 4000,
388
+ case_sensitive: Annotated[bool, "Match case when searching text."] = False,
389
+ ) -> str:
390
+ _log_call_start(
391
+ "Obsidian_Vault",
392
+ action=action,
393
+ path=path,
394
+ query=query,
395
+ recursive=recursive,
396
+ show_hidden=show_hidden,
397
+ max_entries=max_entries,
398
+ offset=offset,
399
+ max_chars=max_chars,
400
+ case_sensitive=case_sensitive,
401
+ )
402
+ action = (action or "").strip().lower()
403
+ if action not in {"list", "read", "info", "search", "help"}:
404
+ result = _err(
405
+ "invalid_action",
406
+ "Invalid action.",
407
+ hint="Choose from: list, read, info, search, help.",
408
+ )
409
+ _log_call_end("Obsidian_Vault", _truncate_for_log(result))
410
+ return result
411
+
412
+ if action == "help":
413
+ result = HELP_TEXT
414
+ _log_call_end("Obsidian_Vault", _truncate_for_log(result))
415
+ return result
416
+
417
+ abs_path, err = _resolve_path(path)
418
+ if err:
419
+ _log_call_end("Obsidian_Vault", _truncate_for_log(err))
420
+ return err
421
+
422
+ try:
423
+ if action == "list":
424
+ if not os.path.exists(abs_path):
425
+ result = _err("path_not_found", f"Path not found: {_display_path(abs_path)}", path=_display_path(abs_path))
426
+ else:
427
+ result = _list_dir(abs_path, show_hidden=show_hidden, recursive=recursive, max_entries=max_entries)
428
+ elif action == "read":
429
+ result = _read_file(abs_path, offset=offset, max_chars=max_chars)
430
+ elif action == "search":
431
+ query_text = query or ""
432
+ if query_text.strip() == "":
433
+ result = _err(
434
+ "missing_search_query",
435
+ "Search query is required for the search action.",
436
+ hint="Provide text in the Search field to look for.",
437
+ )
438
+ else:
439
+ result = _search_text(
440
+ abs_path,
441
+ query_text,
442
+ recursive=recursive,
443
+ show_hidden=show_hidden,
444
+ max_results=max_entries,
445
+ case_sensitive=case_sensitive,
446
+ start_index=offset,
447
+ )
448
+ else: # info
449
+ result = _info(abs_path)
450
+ except Exception as exc:
451
+ result = _err("exception", "Unhandled error during operation.", data={"error": _safe_err(exc)})
452
+
453
+ _log_call_end("Obsidian_Vault", _truncate_for_log(result))
454
+ return result
455
+
456
+
457
+ def build_interface() -> gr.Interface:
458
+ return gr.Interface(
459
+ fn=Obsidian_Vault,
460
+ inputs=[
461
+ gr.Radio(
462
+ label="Action",
463
+ choices=["list", "read", "info", "search", "help"],
464
+ value="help",
465
+ ),
466
+ gr.Textbox(label="Path", placeholder=". or Notes/todo.md", max_lines=1, value="."),
467
+ gr.Textbox(label="Search text (search)", lines=3, placeholder="Text to search for..."),
468
+ gr.Checkbox(label="Recursive (list/search)", value=False),
469
+ gr.Checkbox(label="Show hidden (list/search)", value=False),
470
+ gr.Slider(minimum=10, maximum=5000, step=10, value=20, label="Max entries / matches"),
471
+ gr.Slider(minimum=0, maximum=1_000_000, step=100, value=0, label="Offset (read/search start)"),
472
+ gr.Slider(minimum=0, maximum=100_000, step=500, value=4000, label="Max chars (read, 0=all)"),
473
+ gr.Checkbox(label="Case sensitive search", value=False),
474
+ ],
475
+ outputs=gr.Textbox(label="Result", lines=20),
476
+ title="Obsidian Vault",
477
+ description=(
478
+ "<div style=\"text-align:center; overflow:hidden;\">Explore and search notes in the vault without modifying them." "</div>"
479
+ ),
480
+ api_description=TOOL_SUMMARY,
481
+ flagging_mode="never",
482
+ submit_btn="Run",
483
+ css=(
484
+ """
485
+ article.prose, .prose, .gr-prose {
486
+ overflow: visible !important;
487
+ max-height: none !important;
488
+ -ms-overflow-style: none !important;
489
+ scrollbar-width: none !important;
490
+ }
491
+ article.prose::-webkit-scrollbar,
492
+ .prose::-webkit-scrollbar,
493
+ .gr-prose::-webkit-scrollbar {
494
+ display: none !important;
495
+ }
496
+ """
497
+ ),
498
+ )
499
+
500
+
501
+ __all__ = ["Obsidian_Vault", "build_interface"]