zealpy 0.1.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.
- zealpy/__init__.py +36 -0
- zealpy/__main__.py +4 -0
- zealpy/cli.py +252 -0
- zealpy/docset.py +330 -0
- zealpy/feeds.py +226 -0
- zealpy/server.py +218 -0
- zealpy/web/index.html +785 -0
- zealpy/web/zeal.svg +8 -0
- zealpy-0.1.0.dist-info/METADATA +171 -0
- zealpy-0.1.0.dist-info/RECORD +14 -0
- zealpy-0.1.0.dist-info/WHEEL +5 -0
- zealpy-0.1.0.dist-info/entry_points.txt +2 -0
- zealpy-0.1.0.dist-info/licenses/COPYING +619 -0
- zealpy-0.1.0.dist-info/top_level.txt +1 -0
zealpy/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
zealpy
|
|
3
|
+
~~~~~~
|
|
4
|
+
|
|
5
|
+
Lightweight offline documentation browser and search engine for Dash / Zeal docsets.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__author__ = "Zealpy Contributors"
|
|
12
|
+
__license__ = "GPL-3.0-or-later"
|
|
13
|
+
|
|
14
|
+
from zealpy.docset import Docset, DocsetEntry, DocsetManager
|
|
15
|
+
from zealpy.server import run_server
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def search(query: str, docset: str = None, limit: int = 50):
|
|
19
|
+
"""Quick helper to search symbols across docsets."""
|
|
20
|
+
manager = DocsetManager()
|
|
21
|
+
return manager.search(query, docset_name=docset, total_limit=limit)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def serve(port: int = 8080, host: str = "127.0.0.1", open_browser: bool = True):
|
|
25
|
+
"""Starts the local documentation server."""
|
|
26
|
+
run_server(host=host, port=port, open_browser=open_browser)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Docset",
|
|
31
|
+
"DocsetEntry",
|
|
32
|
+
"DocsetManager",
|
|
33
|
+
"search",
|
|
34
|
+
"serve",
|
|
35
|
+
"__version__",
|
|
36
|
+
]
|
zealpy/__main__.py
ADDED
zealpy/cli.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
zealpy.cli
|
|
3
|
+
~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Command-line interface for zealpy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from zealpy import __version__
|
|
16
|
+
from zealpy.docset import DocsetManager
|
|
17
|
+
from zealpy.feeds import (
|
|
18
|
+
POPULAR_DOCSETS,
|
|
19
|
+
download_docset,
|
|
20
|
+
install_from_file,
|
|
21
|
+
list_available_docsets,
|
|
22
|
+
)
|
|
23
|
+
from zealpy.server import run_server
|
|
24
|
+
|
|
25
|
+
# ANSI Colors for terminal output
|
|
26
|
+
BOLD = "\033[1m"
|
|
27
|
+
DIM = "\033[2m"
|
|
28
|
+
CYAN = "\033[36m"
|
|
29
|
+
GREEN = "\033[32m"
|
|
30
|
+
YELLOW = "\033[33m"
|
|
31
|
+
BLUE = "\033[34m"
|
|
32
|
+
MAGENTA = "\033[35m"
|
|
33
|
+
RESET = "\033[0m"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_type_badge(type_str: str) -> str:
|
|
37
|
+
"""Returns colorized type badge."""
|
|
38
|
+
t = type_str.lower()
|
|
39
|
+
if "class" in t:
|
|
40
|
+
return f"{MAGENTA}[Class]{RESET}"
|
|
41
|
+
if "method" in t:
|
|
42
|
+
return f"{BLUE}[Method]{RESET}"
|
|
43
|
+
if "func" in t:
|
|
44
|
+
return f"{GREEN}[Function]{RESET}"
|
|
45
|
+
if "mod" in t or "pkg" in t:
|
|
46
|
+
return f"{YELLOW}[Module]{RESET}"
|
|
47
|
+
return f"{CYAN}[{type_str}]{RESET}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_serve(args: argparse.Namespace) -> None:
|
|
51
|
+
"""Starts the local documentation server."""
|
|
52
|
+
run_server(
|
|
53
|
+
host=args.host,
|
|
54
|
+
port=args.port,
|
|
55
|
+
docset_dir=args.docset_dir,
|
|
56
|
+
open_browser=not args.no_browser,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_search(args: argparse.Namespace) -> None:
|
|
61
|
+
"""Searches symbols in terminal."""
|
|
62
|
+
extra = [args.docset_dir] if args.docset_dir else None
|
|
63
|
+
manager = DocsetManager(extra_paths=extra)
|
|
64
|
+
|
|
65
|
+
results = manager.search(
|
|
66
|
+
query=args.query,
|
|
67
|
+
docset_name=args.docset,
|
|
68
|
+
entry_type=args.type,
|
|
69
|
+
total_limit=args.limit,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if not results:
|
|
73
|
+
print(f"No symbols found matching '{args.query}'.")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
print(f"\nFound {BOLD}{len(results)}{RESET} symbol(s) for '{args.query}':\n")
|
|
77
|
+
for r in results:
|
|
78
|
+
badge = format_type_badge(r.type)
|
|
79
|
+
print(f" {badge:<18} {BOLD}{r.name:<32}{RESET} {DIM}({r.docset}){RESET}")
|
|
80
|
+
if args.verbose:
|
|
81
|
+
print(f" {DIM}Path: {r.path}{RESET}")
|
|
82
|
+
print()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_list(args: argparse.Namespace) -> None:
|
|
86
|
+
"""Lists all installed docsets."""
|
|
87
|
+
extra = [args.docset_dir] if args.docset_dir else None
|
|
88
|
+
manager = DocsetManager(extra_paths=extra)
|
|
89
|
+
|
|
90
|
+
if not manager.docsets:
|
|
91
|
+
print("\nNo docsets found locally.")
|
|
92
|
+
print("To download docsets, run: zealpy available")
|
|
93
|
+
print("Or: zealpy download <name>\n")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
print(f"\n{BOLD}Installed Docsets ({len(manager.docsets)}):{RESET}\n")
|
|
97
|
+
for name, ds in sorted(manager.docsets.items()):
|
|
98
|
+
entries = ds.count_entries()
|
|
99
|
+
print(f" * {BOLD}{ds.name:<24}{RESET} [{entries:>6} symbols] {DIM}{ds.path}{RESET}")
|
|
100
|
+
print()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_available(args: argparse.Namespace) -> None:
|
|
104
|
+
"""Lists popular docsets available for download."""
|
|
105
|
+
items = list_available_docsets()
|
|
106
|
+
print(f"\n{BOLD}Available Official Docsets ({len(items)}):{RESET}\n")
|
|
107
|
+
print(f" {'Name':<18} {'Description'}")
|
|
108
|
+
print(f" {'-'*18} {'-'*45}")
|
|
109
|
+
for item in items:
|
|
110
|
+
print(f" {CYAN}{item['name']:<18}{RESET} {item['title']}")
|
|
111
|
+
print(f"\nInstall any docset using: {BOLD}zealpy download <name>{RESET}\n")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_download(args: argparse.Namespace) -> None:
|
|
115
|
+
"""Downloads a docset from official mirrors."""
|
|
116
|
+
name = args.name.strip()
|
|
117
|
+
print(f"\nDownloading docset {BOLD}{name}{RESET}...")
|
|
118
|
+
|
|
119
|
+
def progress(read_bytes: int, total_bytes: int) -> None:
|
|
120
|
+
mb_read = read_bytes / (1024 * 1024)
|
|
121
|
+
mb_total = total_bytes / (1024 * 1024)
|
|
122
|
+
percent = (read_bytes / total_bytes) * 100 if total_bytes > 0 else 0
|
|
123
|
+
sys.stdout.write(f"\r Progress: {mb_read:.1f} MB / {mb_total:.1f} MB ({percent:.0f}%)")
|
|
124
|
+
sys.stdout.flush()
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
path = download_docset(name, target_dir=args.docset_dir, progress_callback=progress)
|
|
128
|
+
print(f"\n\n{GREEN}Successfully installed {name}!{RESET}")
|
|
129
|
+
print(f"Installed at: {path}\n")
|
|
130
|
+
except Exception as e:
|
|
131
|
+
print(f"\n{YELLOW}Failed to download docset:{RESET} {e}\n", file=sys.stderr)
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_add(args: argparse.Namespace) -> None:
|
|
136
|
+
"""Imports a local docset or archive."""
|
|
137
|
+
src = Path(args.path)
|
|
138
|
+
print(f"\nImporting docset from {src}...")
|
|
139
|
+
try:
|
|
140
|
+
path = install_from_file(src, target_dir=args.docset_dir)
|
|
141
|
+
print(f"{GREEN}Successfully imported!{RESET}")
|
|
142
|
+
print(f"Location: {path}\n")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
print(f"Failed to import: {e}", file=sys.stderr)
|
|
145
|
+
sys.exit(1)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cmd_info(args: argparse.Namespace) -> None:
|
|
149
|
+
"""Shows detailed information about a docset."""
|
|
150
|
+
extra = [args.docset_dir] if args.docset_dir else None
|
|
151
|
+
manager = DocsetManager(extra_paths=extra)
|
|
152
|
+
ds = manager.get(args.name)
|
|
153
|
+
|
|
154
|
+
if not ds:
|
|
155
|
+
print(f"Docset '{args.name}' not found.", file=sys.stderr)
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
|
|
158
|
+
print(f"\n{BOLD}Docset: {ds.name}{RESET}")
|
|
159
|
+
print(f" Identifier: {ds.identifier}")
|
|
160
|
+
print(f" Path: {ds.path}")
|
|
161
|
+
print(f" Database: {ds.db_path}")
|
|
162
|
+
print(f" Total: {ds.count_entries()} symbols\n")
|
|
163
|
+
|
|
164
|
+
types = ds.get_types()
|
|
165
|
+
if types:
|
|
166
|
+
print(f" {BOLD}Symbol Types:{RESET}")
|
|
167
|
+
for t, count in types[:12]:
|
|
168
|
+
print(f" * {t:<20} {count:>6}")
|
|
169
|
+
print()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
173
|
+
"""Builds argument parser for zealpy CLI."""
|
|
174
|
+
parser = argparse.ArgumentParser(
|
|
175
|
+
prog="zealpy",
|
|
176
|
+
description="Zealpy: Lightweight Offline Documentation Browser and Search Engine for Dash / Zeal docsets.",
|
|
177
|
+
epilog="Examples:\n"
|
|
178
|
+
" zealpy serve # Start local documentation server\n"
|
|
179
|
+
" zealpy search QComboBox # Search symbol across docsets\n"
|
|
180
|
+
" zealpy search groupby -d Pandas\n"
|
|
181
|
+
" zealpy download Python # Download official Python docs\n"
|
|
182
|
+
" zealpy list # List installed docsets\n",
|
|
183
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
parser.add_argument("-v", "--version", action="version", version=f"zealpy {__version__}")
|
|
187
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
188
|
+
|
|
189
|
+
# serve
|
|
190
|
+
p_serve = subparsers.add_parser("serve", help="Start local web server and documentation viewer")
|
|
191
|
+
p_serve.add_argument("-p", "--port", type=int, default=8080, help="Port to listen on (default: 8080)")
|
|
192
|
+
p_serve.add_argument("-H", "--host", default="127.0.0.1", help="Host address (default: 127.0.0.1)")
|
|
193
|
+
p_serve.add_argument("--no-browser", action="store_true", help="Do not open browser automatically")
|
|
194
|
+
p_serve.add_argument("-d", "--docset-dir", help="Path to custom docsets directory")
|
|
195
|
+
p_serve.set_defaults(func=cmd_serve)
|
|
196
|
+
|
|
197
|
+
# search
|
|
198
|
+
p_search = subparsers.add_parser("search", help="Search symbol/function across docsets")
|
|
199
|
+
p_search.add_argument("query", help="Symbol or function name to search for")
|
|
200
|
+
p_search.add_argument("-d", "--docset", help="Filter by docset name")
|
|
201
|
+
p_search.add_argument("-t", "--type", help="Filter by symbol type (Class, Method, Function, etc.)")
|
|
202
|
+
p_search.add_argument("-n", "--limit", type=int, default=25, help="Maximum number of results")
|
|
203
|
+
p_search.add_argument("-V", "--verbose", action="store_true", help="Show full entry paths")
|
|
204
|
+
p_search.add_argument("--docset-dir", help="Path to custom docsets directory")
|
|
205
|
+
p_search.set_defaults(func=cmd_search)
|
|
206
|
+
|
|
207
|
+
# list
|
|
208
|
+
p_list = subparsers.add_parser("list", help="List all locally installed docsets")
|
|
209
|
+
p_list.add_argument("-d", "--docset-dir", help="Path to custom docsets directory")
|
|
210
|
+
p_list.set_defaults(func=cmd_list)
|
|
211
|
+
|
|
212
|
+
# available
|
|
213
|
+
p_avail = subparsers.add_parser("available", help="List popular docsets available for download")
|
|
214
|
+
p_avail.set_defaults(func=cmd_available)
|
|
215
|
+
|
|
216
|
+
# download
|
|
217
|
+
p_down = subparsers.add_parser("download", help="Download and install an official docset")
|
|
218
|
+
p_down.add_argument("name", help="Name of docset (e.g. Python, Qt, Pandas, Flask)")
|
|
219
|
+
p_down.add_argument("-d", "--docset-dir", help="Target installation directory")
|
|
220
|
+
p_down.set_defaults(func=cmd_download)
|
|
221
|
+
|
|
222
|
+
# add
|
|
223
|
+
p_add = subparsers.add_parser("add", help="Import a local .docset folder or archive (.tgz, .zip)")
|
|
224
|
+
p_add.add_argument("path", help="Path to .docset folder or archive")
|
|
225
|
+
p_add.add_argument("-d", "--docset-dir", help="Target installation directory")
|
|
226
|
+
p_add.set_defaults(func=cmd_add)
|
|
227
|
+
|
|
228
|
+
# info
|
|
229
|
+
p_info = subparsers.add_parser("info", help="Show metadata and statistics for a docset")
|
|
230
|
+
p_info.add_argument("name", help="Name of docset")
|
|
231
|
+
p_info.add_argument("-d", "--docset-dir", help="Path to custom docsets directory")
|
|
232
|
+
p_info.set_defaults(func=cmd_info)
|
|
233
|
+
|
|
234
|
+
return parser
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def main() -> None:
|
|
238
|
+
"""Main CLI entrypoint."""
|
|
239
|
+
parser = build_parser()
|
|
240
|
+
if len(sys.argv) == 1:
|
|
241
|
+
parser.print_help()
|
|
242
|
+
sys.exit(0)
|
|
243
|
+
|
|
244
|
+
args = parser.parse_args()
|
|
245
|
+
if hasattr(args, "func"):
|
|
246
|
+
args.func(args)
|
|
247
|
+
else:
|
|
248
|
+
parser.print_help()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
main()
|
zealpy/docset.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""
|
|
2
|
+
zealpy.docset
|
|
3
|
+
~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Docset parser and search engine for Dash / Zeal documentation sets.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import plistlib
|
|
12
|
+
import sqlite3
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, Iterator, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DocsetEntry:
|
|
21
|
+
"""Represents an indexed symbol / entry inside a docset."""
|
|
22
|
+
name: str
|
|
23
|
+
type: str
|
|
24
|
+
path: str
|
|
25
|
+
docset: str
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def url(self) -> str:
|
|
29
|
+
"""URL path for local HTTP server serving."""
|
|
30
|
+
# Clean up possible anchor or leading slashes
|
|
31
|
+
clean_path = self.path.lstrip("/")
|
|
32
|
+
return f"/docs/{self.docset}/{clean_path}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Docset:
|
|
36
|
+
"""Represents a single .docset bundle."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, path: Path | str) -> None:
|
|
39
|
+
self.path = Path(path).resolve()
|
|
40
|
+
if not self.path.is_dir() or not self.path.name.endswith(".docset"):
|
|
41
|
+
raise ValueError(f"Not a valid .docset directory: {self.path}")
|
|
42
|
+
|
|
43
|
+
self.contents_dir = self.path / "Contents"
|
|
44
|
+
self.resources_dir = self.contents_dir / "Resources"
|
|
45
|
+
self.documents_dir = self.resources_dir / "Documents"
|
|
46
|
+
self.db_path = self.resources_dir / "docSet.dsidx"
|
|
47
|
+
self.plist_path = self.contents_dir / "Info.plist"
|
|
48
|
+
|
|
49
|
+
self.identifier = self.path.stem
|
|
50
|
+
self.name = self.path.stem
|
|
51
|
+
self.feed_url: Optional[str] = None
|
|
52
|
+
self._table_type: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
self._load_plist()
|
|
55
|
+
|
|
56
|
+
def _load_plist(self) -> None:
|
|
57
|
+
"""Loads metadata from Info.plist if present."""
|
|
58
|
+
if self.plist_path.is_file():
|
|
59
|
+
try:
|
|
60
|
+
with open(self.plist_path, "rb") as fp:
|
|
61
|
+
data = plistlib.load(fp)
|
|
62
|
+
self.identifier = data.get("CFBundleIdentifier", self.identifier)
|
|
63
|
+
self.name = (
|
|
64
|
+
data.get("DocSetPlatformFamily")
|
|
65
|
+
or data.get("CFBundleName")
|
|
66
|
+
or data.get("CFBundleIdentifier")
|
|
67
|
+
or self.name
|
|
68
|
+
)
|
|
69
|
+
self.feed_url = data.get("DashDocSetFeedURL")
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def _get_connection(self) -> sqlite3.Connection:
|
|
74
|
+
"""Opens a read-only SQLite connection to the index."""
|
|
75
|
+
if not self.db_path.is_file():
|
|
76
|
+
raise FileNotFoundError(f"Database not found: {self.db_path}")
|
|
77
|
+
uri = f"file:{self.db_path.as_posix()}?mode=ro"
|
|
78
|
+
conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
|
|
79
|
+
conn.row_factory = sqlite3.Row
|
|
80
|
+
return conn
|
|
81
|
+
|
|
82
|
+
def _detect_schema(self, conn: sqlite3.Connection) -> str:
|
|
83
|
+
"""Detects whether index uses searchIndex or CoreData format."""
|
|
84
|
+
if self._table_type:
|
|
85
|
+
return self._table_type
|
|
86
|
+
|
|
87
|
+
cursor = conn.cursor()
|
|
88
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
89
|
+
tables = {row[0].lower() for row in cursor.fetchall()}
|
|
90
|
+
|
|
91
|
+
if "searchindex" in tables:
|
|
92
|
+
self._table_type = "searchIndex"
|
|
93
|
+
elif "zloadingentry" in tables:
|
|
94
|
+
self._table_type = "zloadingentry"
|
|
95
|
+
elif "ztoken" in tables:
|
|
96
|
+
self._table_type = "ztoken"
|
|
97
|
+
else:
|
|
98
|
+
self._table_type = "unknown"
|
|
99
|
+
|
|
100
|
+
return self._table_type
|
|
101
|
+
|
|
102
|
+
def count_entries(self) -> int:
|
|
103
|
+
"""Returns the total number of indexed symbols."""
|
|
104
|
+
if not self.db_path.is_file():
|
|
105
|
+
return 0
|
|
106
|
+
try:
|
|
107
|
+
with self._get_connection() as conn:
|
|
108
|
+
schema = self._detect_schema(conn)
|
|
109
|
+
cursor = conn.cursor()
|
|
110
|
+
if schema == "searchIndex":
|
|
111
|
+
cursor.execute("SELECT COUNT(*) FROM searchIndex")
|
|
112
|
+
elif schema == "zloadingentry":
|
|
113
|
+
cursor.execute("SELECT COUNT(*) FROM zloadingentry")
|
|
114
|
+
elif schema == "ztoken":
|
|
115
|
+
cursor.execute("SELECT COUNT(*) FROM ztoken")
|
|
116
|
+
else:
|
|
117
|
+
return 0
|
|
118
|
+
return cursor.fetchone()[0]
|
|
119
|
+
except Exception:
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
def get_types(self) -> List[Tuple[str, int]]:
|
|
123
|
+
"""Returns list of (type, count) found in the docset."""
|
|
124
|
+
if not self.db_path.is_file():
|
|
125
|
+
return []
|
|
126
|
+
try:
|
|
127
|
+
with self._get_connection() as conn:
|
|
128
|
+
schema = self._detect_schema(conn)
|
|
129
|
+
cursor = conn.cursor()
|
|
130
|
+
if schema == "searchIndex":
|
|
131
|
+
cursor.execute(
|
|
132
|
+
"SELECT type, COUNT(*) FROM searchIndex GROUP BY type ORDER BY COUNT(*) DESC"
|
|
133
|
+
)
|
|
134
|
+
elif schema == "zloadingentry":
|
|
135
|
+
cursor.execute(
|
|
136
|
+
"SELECT ZTYPE, COUNT(*) FROM zloadingentry GROUP BY ZTYPE ORDER BY COUNT(*) DESC"
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
return []
|
|
140
|
+
return [(row[0] or "Unknown", row[1]) for row in cursor.fetchall()]
|
|
141
|
+
except Exception:
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
def search(
|
|
145
|
+
self,
|
|
146
|
+
query: str,
|
|
147
|
+
entry_type: Optional[str] = None,
|
|
148
|
+
limit: int = 50,
|
|
149
|
+
) -> List[DocsetEntry]:
|
|
150
|
+
"""Searches symbols matching the query with intelligent ranking."""
|
|
151
|
+
if not self.db_path.is_file() or not query.strip():
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
query = query.strip()
|
|
155
|
+
results: List[DocsetEntry] = []
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
with self._get_connection() as conn:
|
|
159
|
+
schema = self._detect_schema(conn)
|
|
160
|
+
cursor = conn.cursor()
|
|
161
|
+
|
|
162
|
+
if schema == "searchIndex":
|
|
163
|
+
sql = """
|
|
164
|
+
SELECT name, type, path,
|
|
165
|
+
CASE
|
|
166
|
+
WHEN name = ? THEN 1
|
|
167
|
+
WHEN name LIKE ? || '%' THEN 2
|
|
168
|
+
WHEN name LIKE '%' || ? || '%' THEN 3
|
|
169
|
+
ELSE 4
|
|
170
|
+
END AS rank
|
|
171
|
+
FROM searchIndex
|
|
172
|
+
WHERE name LIKE '%' || ? || '%'
|
|
173
|
+
"""
|
|
174
|
+
params: list = [query, query, query, query]
|
|
175
|
+
if entry_type:
|
|
176
|
+
sql += " AND type = ?"
|
|
177
|
+
params.append(entry_type)
|
|
178
|
+
sql += " ORDER BY rank ASC, LENGTH(name) ASC, name ASC LIMIT ?"
|
|
179
|
+
params.append(limit)
|
|
180
|
+
|
|
181
|
+
cursor.execute(sql, params)
|
|
182
|
+
for row in cursor.fetchall():
|
|
183
|
+
results.append(
|
|
184
|
+
DocsetEntry(
|
|
185
|
+
name=row["name"],
|
|
186
|
+
type=row["type"] or "Unknown",
|
|
187
|
+
path=row["path"],
|
|
188
|
+
docset=self.identifier,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
elif schema == "zloadingentry":
|
|
193
|
+
sql = """
|
|
194
|
+
SELECT ZNAME AS name, ZTYPE AS type, ZPATH AS path,
|
|
195
|
+
CASE
|
|
196
|
+
WHEN ZNAME = ? THEN 1
|
|
197
|
+
WHEN ZNAME LIKE ? || '%' THEN 2
|
|
198
|
+
WHEN ZNAME LIKE '%' || ? || '%' THEN 3
|
|
199
|
+
ELSE 4
|
|
200
|
+
END AS rank
|
|
201
|
+
FROM zloadingentry
|
|
202
|
+
WHERE ZNAME LIKE '%' || ? || '%'
|
|
203
|
+
"""
|
|
204
|
+
params = [query, query, query, query]
|
|
205
|
+
if entry_type:
|
|
206
|
+
sql += " AND ZTYPE = ?"
|
|
207
|
+
params.append(entry_type)
|
|
208
|
+
sql += " ORDER BY rank ASC, LENGTH(ZNAME) ASC, ZNAME ASC LIMIT ?"
|
|
209
|
+
params.append(limit)
|
|
210
|
+
|
|
211
|
+
cursor.execute(sql, params)
|
|
212
|
+
for row in cursor.fetchall():
|
|
213
|
+
results.append(
|
|
214
|
+
DocsetEntry(
|
|
215
|
+
name=row["name"],
|
|
216
|
+
type=row["type"] or "Unknown",
|
|
217
|
+
path=row["path"],
|
|
218
|
+
docset=self.identifier,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
# Fallback for unexpected DB error
|
|
223
|
+
print(f"Error querying {self.name}: {e}", file=sys.stderr)
|
|
224
|
+
|
|
225
|
+
return results
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class DocsetManager:
|
|
229
|
+
"""Discovers and manages docsets across system and custom directories."""
|
|
230
|
+
|
|
231
|
+
def __init__(self, extra_paths: Optional[List[Path | str]] = None) -> None:
|
|
232
|
+
self.extra_paths = [Path(p).resolve() for p in (extra_paths or [])]
|
|
233
|
+
self.docsets: Dict[str, Docset] = {}
|
|
234
|
+
self.rescan()
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def get_default_storage_dir() -> Path:
|
|
238
|
+
"""Returns the primary storage directory for zealpy docsets (~/.zealpy/docsets)."""
|
|
239
|
+
base = Path.home() / ".zealpy" / "docsets"
|
|
240
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
return base
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def get_search_paths(cls, extra_paths: Optional[List[Path]] = None) -> List[Path]:
|
|
245
|
+
"""Returns all directories checked for docsets, including native Zeal paths."""
|
|
246
|
+
paths: List[Path] = []
|
|
247
|
+
|
|
248
|
+
if extra_paths:
|
|
249
|
+
paths.extend(extra_paths)
|
|
250
|
+
|
|
251
|
+
# Local workspace paths
|
|
252
|
+
paths.append(Path.cwd() / "docsets")
|
|
253
|
+
paths.append(Path.cwd())
|
|
254
|
+
|
|
255
|
+
# zealpy storage
|
|
256
|
+
paths.append(cls.get_default_storage_dir())
|
|
257
|
+
|
|
258
|
+
# Native Zeal paths for cross-compatibility
|
|
259
|
+
if sys.platform == "win32":
|
|
260
|
+
local_appdata = os.environ.get("LOCALAPPDATA")
|
|
261
|
+
if local_appdata:
|
|
262
|
+
paths.append(Path(local_appdata) / "Zeal" / "Zeal" / "docsets")
|
|
263
|
+
elif sys.platform == "darwin":
|
|
264
|
+
paths.append(Path.home() / "Library" / "Application Support" / "Zeal" / "Zeal" / "docsets")
|
|
265
|
+
else:
|
|
266
|
+
paths.append(Path.home() / ".local" / "share" / "Zeal" / "Zeal" / "docsets")
|
|
267
|
+
|
|
268
|
+
# Return existing unique directories
|
|
269
|
+
unique_paths: List[Path] = []
|
|
270
|
+
for p in paths:
|
|
271
|
+
if p.is_dir() and p not in unique_paths:
|
|
272
|
+
unique_paths.append(p)
|
|
273
|
+
return unique_paths
|
|
274
|
+
|
|
275
|
+
def rescan(self) -> None:
|
|
276
|
+
"""Scans all search paths and registers found docsets."""
|
|
277
|
+
self.docsets.clear()
|
|
278
|
+
for folder in self.get_search_paths(self.extra_paths):
|
|
279
|
+
if folder.name.endswith(".docset") and folder.is_dir():
|
|
280
|
+
try:
|
|
281
|
+
ds = Docset(folder)
|
|
282
|
+
self.docsets[ds.identifier] = ds
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
# Check subdirectories
|
|
288
|
+
for entry in folder.iterdir():
|
|
289
|
+
if entry.is_dir() and entry.name.endswith(".docset"):
|
|
290
|
+
try:
|
|
291
|
+
ds = Docset(entry)
|
|
292
|
+
# Keep latest or first seen
|
|
293
|
+
if ds.identifier not in self.docsets:
|
|
294
|
+
self.docsets[ds.identifier] = ds
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
|
|
298
|
+
def get(self, name: str) -> Optional[Docset]:
|
|
299
|
+
"""Gets docset by identifier or name (case-insensitive)."""
|
|
300
|
+
if name in self.docsets:
|
|
301
|
+
return self.docsets[name]
|
|
302
|
+
lower_name = name.lower()
|
|
303
|
+
for k, v in self.docsets.items():
|
|
304
|
+
if k.lower() == lower_name or v.name.lower() == lower_name:
|
|
305
|
+
return v
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
def search(
|
|
309
|
+
self,
|
|
310
|
+
query: str,
|
|
311
|
+
docset_name: Optional[str] = None,
|
|
312
|
+
entry_type: Optional[str] = None,
|
|
313
|
+
limit_per_docset: int = 25,
|
|
314
|
+
total_limit: int = 100,
|
|
315
|
+
) -> List[DocsetEntry]:
|
|
316
|
+
"""Searches across all docsets or a specific docset."""
|
|
317
|
+
results: List[DocsetEntry] = []
|
|
318
|
+
if docset_name:
|
|
319
|
+
ds = self.get(docset_name)
|
|
320
|
+
if ds:
|
|
321
|
+
return ds.search(query, entry_type=entry_type, limit=total_limit)
|
|
322
|
+
return []
|
|
323
|
+
|
|
324
|
+
for ds in self.docsets.values():
|
|
325
|
+
ds_results = ds.search(query, entry_type=entry_type, limit=limit_per_docset)
|
|
326
|
+
results.extend(ds_results)
|
|
327
|
+
if len(results) >= total_limit:
|
|
328
|
+
break
|
|
329
|
+
|
|
330
|
+
return results[:total_limit]
|