File size: 7,231 Bytes
e63577a
 
 
 
 
 
 
 
 
a83aca4
e63577a
 
 
 
 
f908fa0
e63577a
 
 
 
e42284c
f908fa0
e63577a
 
f908fa0
 
e63577a
 
 
 
 
f908fa0
 
 
 
e63577a
 
 
 
 
a83aca4
 
 
e63577a
 
a83aca4
e63577a
a83aca4
e63577a
 
a83aca4
 
e63577a
 
a83aca4
 
e63577a
 
 
 
 
f908fa0
e63577a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a83aca4
e63577a
a83aca4
e63577a
 
 
 
 
 
 
 
 
a83aca4
e63577a
 
 
 
 
 
 
 
 
a83aca4
e63577a
a83aca4
e63577a
 
 
 
 
 
 
 
 
 
 
 
 
93bb3e0
e63577a
93bb3e0
 
 
 
 
 
 
 
e63577a
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
from __future__ import annotations

import os
from typing import Annotated, Optional

import gradio as gr

from app import _log_call_end, _log_call_start, _truncate_for_log
from ._docstrings import autodoc
from ._core import obsidian_sandbox, OBSIDIAN_ROOT


TOOL_SUMMARY = (
    "Browse and search the Obsidian vault in read-only mode. "
    "Actions: list, read, info, search, help. "
    "All paths resolve within the vault root. Start paths with '/' (e.g., /Notes)."
)

HELP_TEXT = (
    "Obsidian Vault — actions and usage\n\n"
    "Root: Tools/Obsidian (override with OBSIDIAN_VAULT_ROOT). "
    "Start paths with '/' to reference the vault root (e.g., /Projects/note.md). "
    "Absolute paths are disabled unless UNSAFE_ALLOW_ABS_PATHS=1.\n\n"
    "Actions and fields:\n"
    "- list: path='/' (default), recursive=false, show_hidden=false, max_entries=20\n"
    "- read: path (e.g., /Projects/note.md), offset=0, max_chars=4000 (shows next_cursor when truncated)\n"
    "- info: path\n"
    "- 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"
    "- help: show this guide\n\n"
    "Errors are returned as JSON with fields: {status:'error', code, message, path?, hint?, data?}.\n\n"
    "Examples:\n"
    "- list current: action=list, path='/'\n"
    "- read note: action=read, path='/Projects/note.md', max_chars=500\n"
    "- show metadata: action=info, path='/Inbox'\n"
    "- search notes: action=search, path='/Projects', query='deadline', recursive=true, max_entries=100\n"
    "- case-sensitive search: action=search, query='TODO', case_sensitive=true\n"
    "- page search results: action=search, query='TODO', offset=20\n"
)


# Use the pre-configured Obsidian sandbox
_sandbox = obsidian_sandbox
ROOT_DIR = OBSIDIAN_ROOT


# Convenience wrappers
def _resolve_path(path: str) -> tuple[str, str]:
    return _sandbox.resolve_path(path)


def _display_path(abs_path: str) -> str:
    return _sandbox.display_path(abs_path)


def _err(code: str, message: str, *, path: str | None = None, hint: str | None = None, data: dict | None = None) -> str:
    return _sandbox.err(code, message, path=path, hint=hint, data=data)


@autodoc(summary=TOOL_SUMMARY)
def Obsidian_Vault(
    action: Annotated[str, "Operation to perform: 'list', 'read', 'info', 'search', 'help'."],
    path: Annotated[str, "Target path, relative to the vault root." ] = "/",
    query: Annotated[Optional[str], "Text to search for when action=search."] = None,
    recursive: Annotated[bool, "Recurse into subfolders when listing/searching."] = False,
    show_hidden: Annotated[bool, "Include hidden files when listing/searching."] = False,
    max_entries: Annotated[int, "Max entries to list or matches to return (for list/search)."] = 20,
    offset: Annotated[int, "Start offset when reading files."] = 0,
    max_chars: Annotated[int, "Max characters to return when reading (0 = full file)."] = 4000,
    case_sensitive: Annotated[bool, "Match case when searching text."] = False,
) -> str:
    _log_call_start(
        "Obsidian_Vault",
        action=action,
        path=path,
        query=query,
        recursive=recursive,
        show_hidden=show_hidden,
        max_entries=max_entries,
        offset=offset,
        max_chars=max_chars,
        case_sensitive=case_sensitive,
    )
    action = (action or "").strip().lower()
    if action not in {"list", "read", "info", "search", "help"}:
        result = _err(
            "invalid_action",
            "Invalid action.",
            hint="Choose from: list, read, info, search, help.",
        )
        _log_call_end("Obsidian_Vault", _truncate_for_log(result))
        return result

    if action == "help":
        result = HELP_TEXT
        _log_call_end("Obsidian_Vault", _truncate_for_log(result))
        return result

    abs_path, err = _resolve_path(path)
    if err:
        _log_call_end("Obsidian_Vault", _truncate_for_log(err))
        return err

    try:
        if action == "list":
            if not os.path.exists(abs_path):
                result = _err("path_not_found", f"Path not found: {_display_path(abs_path)}", path=_display_path(abs_path))
            else:
                result = _sandbox.list_dir(abs_path, show_hidden=show_hidden, recursive=recursive, max_entries=max_entries)
        elif action == "read":
            result = _sandbox.read_file(abs_path, offset=offset, max_chars=max_chars)
        elif action == "search":
            query_text = query or ""
            if query_text.strip() == "":
                result = _err(
                    "missing_search_query",
                    "Search query is required for the search action.",
                    hint="Provide text in the Search field to look for.",
                )
            else:
                result = _sandbox.search_text(
                    abs_path,
                    query_text,
                    recursive=recursive,
                    show_hidden=show_hidden,
                    max_results=max_entries,
                    case_sensitive=case_sensitive,
                    start_index=offset,
                )
        else:  # info
            result = _sandbox.info(abs_path)
    except Exception as exc:
        result = _err("exception", "Unhandled error during operation.", data={"error": _sandbox.safe_err(exc)})

    _log_call_end("Obsidian_Vault", _truncate_for_log(result))
    return result


def build_interface() -> gr.Interface:
    return gr.Interface(
        fn=Obsidian_Vault,
        inputs=[
            gr.Radio(
                label="Action",
                choices=["list", "read", "info", "search", "help"],
                value="help",
                info="Operation to perform",
            ),
            gr.Textbox(label="Path", placeholder="/ or /Notes/todo.md", max_lines=1, value="/", info="Target path (relative to vault root)"),
            gr.Textbox(label="Search text", lines=3, placeholder="Text to search for...", info="Text to search for (Search only)"),
            gr.Checkbox(label="Recursive", value=False, info="Recurse into subfolders (List/Search)"),
            gr.Checkbox(label="Show hidden", value=False, info="Include hidden files (List/Search)"),
            gr.Slider(minimum=10, maximum=5000, step=10, value=20, label="Max entries / matches", info="Max entries to list or matches to return (List/Search)"),
            gr.Slider(minimum=0, maximum=1_000_000, step=100, value=0, label="Offset", info="Start offset (Read/Search)"),
            gr.Slider(minimum=0, maximum=100_000, step=500, value=4000, label="Max chars", info="Max characters to return (Read, 0=all)"),
            gr.Checkbox(label="Case sensitive search", value=False, info="Match case (Search)"),
        ],
        outputs=gr.Textbox(label="Result", lines=20),
        title="Obsidian Vault",
        description=(
            "<div style=\"text-align:center; overflow:hidden;\">Explore and search notes in the vault without modifying them." "</div>"
        ),
        api_description=TOOL_SUMMARY,
        flagging_mode="never",
        submit_btn="Run",
    )


__all__ = ["Obsidian_Vault", "build_interface"]