docs-search 1.0.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ninjasin-labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: docs-search
3
+ Version: 1.0.0
4
+ Summary: Zero-dependency local document search engine — SQLite index, millisecond retrieval, CLI + Web UI with upload
5
+ Author: ninjasin-labs
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 ninjasin-labs
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/ninjasln-labs/docs-search
29
+ Project-URL: Repository, https://github.com/ninjasln-labs/docs-search
30
+ Project-URL: Issues, https://github.com/ninjasln-labs/docs-search/issues
31
+ Keywords: search,sqlite,markdown,docs,cli
32
+ Classifier: Development Status :: 5 - Production/Stable
33
+ Classifier: Environment :: Console
34
+ Classifier: Environment :: Web Environment
35
+ Classifier: Intended Audience :: Developers
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3.10
40
+ Classifier: Programming Language :: Python :: 3.11
41
+ Classifier: Programming Language :: Python :: 3.12
42
+ Classifier: Programming Language :: Python :: 3.13
43
+ Classifier: Topic :: Text Processing :: Indexing
44
+ Requires-Python: >=3.10
45
+ Description-Content-Type: text/markdown
46
+ License-File: LICENSE
47
+ Provides-Extra: test
48
+ Requires-Dist: pytest>=8; extra == "test"
49
+ Requires-Dist: ruff>=0.6; extra == "test"
50
+ Dynamic: license-file
51
+
52
+ # docs-search
53
+
54
+ 零依赖的本地文档搜索引擎。纯 Python 标准库,SQLite 索引,毫秒级检索。
55
+
56
+ A zero-dependency local document search engine. Pure Python stdlib, SQLite index, millisecond-level retrieval.
57
+
58
+ ## 特性 / Features
59
+
60
+ - **零依赖** — 纯 Python 标准库(3.10+),无需 pip install
61
+ - **快** — SQLite FTS 索引,检索 < 50ms
62
+ - **自动索引** — 搜索前自动检测文件变更并增量重建
63
+ - **Web UI** — 内置搜索界面 + 拖拽上传 .md 文档
64
+ - **多库隔离** — 不同文档目录各自独立索引,可并存
65
+ - **不绑定路径** — 文档目录由参数/环境变量指定,不写死任何本地路径
66
+ - **跨平台** — Windows / macOS / Linux
67
+
68
+ ## 快速开始 / Quick Start
69
+
70
+ ```bash
71
+ # 1. 索引一个文档目录(默认 ./docs,也可用 --dir 指定)
72
+ python scripts/docs-search.py index --dir /path/to/your/docs
73
+
74
+ # 2. 搜索
75
+ python scripts/docs-search.py search "关键词" --dir /path/to/your/docs
76
+
77
+ # 3. 启动 Web UI(含上传接口)
78
+ python scripts/docs-search-web.py /path/to/your/docs
79
+ # 访问 http://127.0.0.1:8765
80
+ ```
81
+
82
+ ## 路径解析规则
83
+
84
+ | 目标 | 优先级 |
85
+ |------|--------|
86
+ | 文档目录 | `--dir` 参数 > 环境变量 `DOCS_SEARCH_DIR` > `./docs` |
87
+ | 索引库 | `--db` 参数(CLI)> 环境变量 `DOCS_SEARCH_DB` > `~/.docs-search/<目录哈希>/index.db` |
88
+
89
+ 索引库按文档目录哈希隔离——多个文档目录可以各自拥有独立索引,互不干扰。
90
+
91
+ ## CLI
92
+
93
+ ```bash
94
+ python scripts/docs-search.py index [--dir DIR] # 重建索引
95
+ python scripts/docs-search.py search "关键词" [--dir DIR] [-n 8] # 多关键词 AND 搜索
96
+ python scripts/docs-search.py list [--dir DIR] # 列出所有文档
97
+ python scripts/docs-search.py show <path> [--dir DIR] # 显示文档内容
98
+ python scripts/docs-search.py status [--dir DIR] # 查看索引状态
99
+ python scripts/docs-search.py upload <file.md> [--dir DIR] # 复制 .md 到文档库 uploads/ 并重建索引
100
+ python scripts/docs-search.py open <path> [--dir DIR] # 用系统默认程序打开
101
+ ```
102
+
103
+ Windows 下可用 `scripts/docs-search.bat`。
104
+
105
+ ## Web API
106
+
107
+ 启动:`python scripts/docs-search-web.py [DIR] [--port 8765] [--host 127.0.0.1] [--no-browser]`
108
+
109
+ | 方法 | 端点 | 说明 |
110
+ |------|------|------|
111
+ | GET | `/api/stats` | 统计信息 `{count, updated, categories}` |
112
+ | GET | `/api/search?q=关键词&cat=` | 搜索(多关键词 AND) |
113
+ | GET | `/api/list?cat=` | 列出文档 |
114
+ | GET | `/api/show?path=x.md` | 文档内容 |
115
+ | POST | `/api/upload?filename=x.md` | 上传文档(raw body = UTF-8 文本) |
116
+ | POST | `/api/delete?path=uploads/x.md` | 删除 `uploads/` 下已上传文档 |
117
+
118
+ 上传示例:
119
+
120
+ ```bash
121
+ curl -X POST "http://127.0.0.1:8765/api/upload?filename=notes.md" \
122
+ --data-binary @notes.md
123
+ ```
124
+
125
+ 上传的文件存入 `<文档目录>/uploads/` 并自动进入索引;同名自动加 `-1`、`-2` 后缀。
126
+
127
+ ## 安全说明 / Security
128
+
129
+ - 服务默认仅监听 `127.0.0.1`,**请勿用 `--host 0.0.0.0` 暴露到公网**(接口无鉴权)
130
+ - 上传仅接受 `.md` 文件、单文件 ≤ 10MB、文件名经过消毒(防路径穿越)
131
+ - 删除接口仅允许操作 `uploads/` 目录内的文件
132
+
133
+ ## 开发 / Development
134
+
135
+ ```bash
136
+ pip install -r requirements.lock -e . # 可编辑安装 + 锁定的开发工具链
137
+ python scripts/verify.py # 验证链单源:ruff + pytest(单元 + CLI E2E + Web API)
138
+ ruff check src/ tests/ scripts/ # lint
139
+ git config core.hooksPath .githooks # 启用 pre-commit 验证链
140
+ ```
141
+
142
+ 工程结构见 [DEVELOPMENT.md](DEVELOPMENT.md),贡献规范见 [CONTRIBUTING.md](CONTRIBUTING.md),AI 协作纪律见 [AGENTS.md](AGENTS.md)。English docs: [README.en.md](README.en.md)
143
+
144
+ ## 许可证 / License
145
+
146
+ MIT — 见 [LICENSE](LICENSE)
@@ -0,0 +1,95 @@
1
+ # docs-search
2
+
3
+ 零依赖的本地文档搜索引擎。纯 Python 标准库,SQLite 索引,毫秒级检索。
4
+
5
+ A zero-dependency local document search engine. Pure Python stdlib, SQLite index, millisecond-level retrieval.
6
+
7
+ ## 特性 / Features
8
+
9
+ - **零依赖** — 纯 Python 标准库(3.10+),无需 pip install
10
+ - **快** — SQLite FTS 索引,检索 < 50ms
11
+ - **自动索引** — 搜索前自动检测文件变更并增量重建
12
+ - **Web UI** — 内置搜索界面 + 拖拽上传 .md 文档
13
+ - **多库隔离** — 不同文档目录各自独立索引,可并存
14
+ - **不绑定路径** — 文档目录由参数/环境变量指定,不写死任何本地路径
15
+ - **跨平台** — Windows / macOS / Linux
16
+
17
+ ## 快速开始 / Quick Start
18
+
19
+ ```bash
20
+ # 1. 索引一个文档目录(默认 ./docs,也可用 --dir 指定)
21
+ python scripts/docs-search.py index --dir /path/to/your/docs
22
+
23
+ # 2. 搜索
24
+ python scripts/docs-search.py search "关键词" --dir /path/to/your/docs
25
+
26
+ # 3. 启动 Web UI(含上传接口)
27
+ python scripts/docs-search-web.py /path/to/your/docs
28
+ # 访问 http://127.0.0.1:8765
29
+ ```
30
+
31
+ ## 路径解析规则
32
+
33
+ | 目标 | 优先级 |
34
+ |------|--------|
35
+ | 文档目录 | `--dir` 参数 > 环境变量 `DOCS_SEARCH_DIR` > `./docs` |
36
+ | 索引库 | `--db` 参数(CLI)> 环境变量 `DOCS_SEARCH_DB` > `~/.docs-search/<目录哈希>/index.db` |
37
+
38
+ 索引库按文档目录哈希隔离——多个文档目录可以各自拥有独立索引,互不干扰。
39
+
40
+ ## CLI
41
+
42
+ ```bash
43
+ python scripts/docs-search.py index [--dir DIR] # 重建索引
44
+ python scripts/docs-search.py search "关键词" [--dir DIR] [-n 8] # 多关键词 AND 搜索
45
+ python scripts/docs-search.py list [--dir DIR] # 列出所有文档
46
+ python scripts/docs-search.py show <path> [--dir DIR] # 显示文档内容
47
+ python scripts/docs-search.py status [--dir DIR] # 查看索引状态
48
+ python scripts/docs-search.py upload <file.md> [--dir DIR] # 复制 .md 到文档库 uploads/ 并重建索引
49
+ python scripts/docs-search.py open <path> [--dir DIR] # 用系统默认程序打开
50
+ ```
51
+
52
+ Windows 下可用 `scripts/docs-search.bat`。
53
+
54
+ ## Web API
55
+
56
+ 启动:`python scripts/docs-search-web.py [DIR] [--port 8765] [--host 127.0.0.1] [--no-browser]`
57
+
58
+ | 方法 | 端点 | 说明 |
59
+ |------|------|------|
60
+ | GET | `/api/stats` | 统计信息 `{count, updated, categories}` |
61
+ | GET | `/api/search?q=关键词&cat=` | 搜索(多关键词 AND) |
62
+ | GET | `/api/list?cat=` | 列出文档 |
63
+ | GET | `/api/show?path=x.md` | 文档内容 |
64
+ | POST | `/api/upload?filename=x.md` | 上传文档(raw body = UTF-8 文本) |
65
+ | POST | `/api/delete?path=uploads/x.md` | 删除 `uploads/` 下已上传文档 |
66
+
67
+ 上传示例:
68
+
69
+ ```bash
70
+ curl -X POST "http://127.0.0.1:8765/api/upload?filename=notes.md" \
71
+ --data-binary @notes.md
72
+ ```
73
+
74
+ 上传的文件存入 `<文档目录>/uploads/` 并自动进入索引;同名自动加 `-1`、`-2` 后缀。
75
+
76
+ ## 安全说明 / Security
77
+
78
+ - 服务默认仅监听 `127.0.0.1`,**请勿用 `--host 0.0.0.0` 暴露到公网**(接口无鉴权)
79
+ - 上传仅接受 `.md` 文件、单文件 ≤ 10MB、文件名经过消毒(防路径穿越)
80
+ - 删除接口仅允许操作 `uploads/` 目录内的文件
81
+
82
+ ## 开发 / Development
83
+
84
+ ```bash
85
+ pip install -r requirements.lock -e . # 可编辑安装 + 锁定的开发工具链
86
+ python scripts/verify.py # 验证链单源:ruff + pytest(单元 + CLI E2E + Web API)
87
+ ruff check src/ tests/ scripts/ # lint
88
+ git config core.hooksPath .githooks # 启用 pre-commit 验证链
89
+ ```
90
+
91
+ 工程结构见 [DEVELOPMENT.md](DEVELOPMENT.md),贡献规范见 [CONTRIBUTING.md](CONTRIBUTING.md),AI 协作纪律见 [AGENTS.md](AGENTS.md)。English docs: [README.en.md](README.en.md)
92
+
93
+ ## 许可证 / License
94
+
95
+ MIT — 见 [LICENSE](LICENSE)
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "docs-search"
7
+ version = "1.0.0"
8
+ description = "Zero-dependency local document search engine — SQLite index, millisecond retrieval, CLI + Web UI with upload"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "ninjasin-labs" }]
13
+ keywords = ["search", "sqlite", "markdown", "docs", "cli"]
14
+ classifiers = [
15
+ "Development Status :: 5 - Production/Stable",
16
+ "Environment :: Console",
17
+ "Environment :: Web Environment",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Text Processing :: Indexing",
27
+ ]
28
+ # 运行时零依赖(卖点):不声明任何 install_requires
29
+ dependencies = []
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/ninjasln-labs/docs-search"
33
+ Repository = "https://github.com/ninjasln-labs/docs-search"
34
+ Issues = "https://github.com/ninjasln-labs/docs-search/issues"
35
+
36
+ [project.scripts]
37
+ docs-search = "docs_search.cli:main"
38
+ docs-search-web = "docs_search.web:main"
39
+
40
+ [project.optional-dependencies]
41
+ test = ["pytest>=8", "ruff>=0.6"]
42
+
43
+ [dependency-groups]
44
+ test = ["pytest>=8", "ruff>=0.6"]
45
+
46
+ [tool.setuptools]
47
+ package-dir = { "" = "src" }
48
+ packages = ["docs_search"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ addopts = "-q"
53
+
54
+ [tool.ruff]
55
+ line-length = 120
56
+ target-version = "py310"
57
+ exclude = ["audit-report"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,25 @@
1
+ """docs_search: 零依赖本地文档搜索引擎(SQLite 索引,CLI + Web UI)"""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ from .core import ( # noqa: F401
6
+ ENV_DB_PATH,
7
+ ENV_DOCS_DIR,
8
+ MAX_UPLOAD_BYTES,
9
+ build_db,
10
+ dedupe_target,
11
+ ensure_index,
12
+ files_hash,
13
+ get_conn,
14
+ load_meta,
15
+ meta_path,
16
+ need_reindex,
17
+ rebuild_index,
18
+ resolve_db_path,
19
+ resolve_docs_dir,
20
+ sanitize_filename,
21
+ save_meta,
22
+ scan_docs,
23
+ scan_meta,
24
+ win_utf8,
25
+ )
@@ -0,0 +1,231 @@
1
+ """docs-search: 本地文档搜索引擎(零依赖,SQLite,毫秒级检索)
2
+
3
+ 用法:
4
+ python scripts/docs-search.py index [--dir 目录]
5
+ python scripts/docs-search.py search "关键词" [--dir 目录]
6
+ python scripts/docs-search.py list [--dir 目录]
7
+ python scripts/docs-search.py show <path> [--dir 目录]
8
+ python scripts/docs-search.py status [--dir 目录]
9
+ python scripts/docs-search.py upload <file.md> [--dir 目录] # 上传(复制)文档
10
+
11
+ 路径规则(不绑定任何本地路径):
12
+ 文档目录: --dir > 环境变量 DOCS_SEARCH_DIR > ./docs
13
+ 索引库: ~/.docs-search/<目录哈希>/index.db(按目录隔离,多库并存)
14
+
15
+ 跨平台: Windows / macOS / Linux
16
+ """
17
+
18
+ import argparse
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from .core import (
24
+ MAX_UPLOAD_BYTES,
25
+ dedupe_target,
26
+ ensure_index,
27
+ get_conn,
28
+ load_meta,
29
+ resolve_db_path,
30
+ resolve_docs_dir,
31
+ sanitize_filename,
32
+ win_utf8,
33
+ )
34
+
35
+ # 短命令别名
36
+ ALIAS = {"i": "index", "s": "search", "st": "status", "l": "list", "sh": "show", "u": "upload"}
37
+
38
+
39
+ def _paths(args):
40
+ docs_dir = resolve_docs_dir(getattr(args, "dir", None))
41
+ db_path = resolve_db_path(docs_dir, getattr(args, "db", None))
42
+ return docs_dir, db_path
43
+
44
+
45
+ def cmd_index(args):
46
+ win_utf8()
47
+ docs_dir, db_path = _paths(args)
48
+ from .core import rebuild_index
49
+
50
+ n, dt = rebuild_index(docs_dir, db_path)
51
+ print(f"indexed {n} docs in {dt:.0f}ms")
52
+ print(f" docs: {docs_dir}")
53
+ print(f" db: {db_path}")
54
+
55
+
56
+ def cmd_search(args):
57
+ win_utf8()
58
+ docs_dir, db_path = _paths(args)
59
+ import time
60
+
61
+ t0 = time.time()
62
+ n, rebuilt = ensure_index(docs_dir, db_path)
63
+ if rebuilt:
64
+ print(f"[auto] reindexed {n} docs")
65
+ c = get_conn(db_path)
66
+ q = args.query.strip()
67
+ keywords = q.split()
68
+ if not keywords:
69
+ print('no results for ""')
70
+ return
71
+ conditions, params = [], []
72
+ for kw in keywords:
73
+ conditions.append("(title LIKE ? OR body LIKE ?)")
74
+ params.extend([f"%{kw}%", f"%{kw}%"])
75
+ sql = f"SELECT path, cat, title, body FROM docs WHERE {' AND '.join(conditions)} LIMIT ?"
76
+ params.append(args.limit)
77
+ rows = c.execute(sql, params).fetchall()
78
+ c.close()
79
+ dt = (time.time() - t0) * 1000
80
+ if not rows:
81
+ print(f'no results for "{q}"')
82
+ return
83
+ print(f'"{q}" -> {len(rows)} results ({dt:.0f}ms)\n')
84
+ for path, cat, title, body in rows:
85
+ print(f"[{path}] {title}")
86
+ print(f" {body[:120].replace(chr(10), ' ')}...")
87
+ print()
88
+
89
+
90
+ def cmd_list(args):
91
+ win_utf8()
92
+ _docs_dir, db_path = _paths(args)
93
+ c = get_conn(db_path)
94
+ rows = c.execute("SELECT cat, path, title, size FROM docs ORDER BY cat, title").fetchall()
95
+ c.close()
96
+ if not rows:
97
+ print("empty")
98
+ return
99
+ print(f"total: {len(rows)} docs\n")
100
+ cur = None
101
+ for cat, path, title, size in rows:
102
+ if cat != cur:
103
+ cur = cat
104
+ print(f"\n### {cat}/")
105
+ print(f" . {path} -- {title} ({size // 1024}KB)")
106
+
107
+
108
+ def cmd_show(args):
109
+ win_utf8()
110
+ _docs_dir, db_path = _paths(args)
111
+ c = get_conn(db_path)
112
+ row = c.execute("SELECT title, body, size FROM docs WHERE path=?", (args.path,)).fetchone()
113
+ c.close()
114
+ if not row:
115
+ print(f"not found: {args.path}")
116
+ return
117
+ title, body, size = row
118
+ print(f"\n{title} [{args.path}] ({size // 1024}KB)\n{body[:2000]}")
119
+ if len(body) > 2000:
120
+ print(f"\n...(total {len(body) // 1024}KB)")
121
+ print()
122
+
123
+
124
+ def cmd_status(args):
125
+ win_utf8()
126
+ docs_dir, db_path = _paths(args)
127
+ from .core import need_reindex
128
+
129
+ meta = load_meta(db_path)
130
+ if not db_path.exists():
131
+ print(f"docs: {docs_dir}")
132
+ print("no index yet. run: docs-search index")
133
+ return
134
+ c = get_conn(db_path)
135
+ count = c.execute("SELECT COUNT(*) FROM docs").fetchone()[0]
136
+ c.close()
137
+ stale = need_reindex(meta, db_path, docs_dir)
138
+ print(f"docs: {docs_dir}")
139
+ print(f"db: {db_path}")
140
+ print(f"records: {count}")
141
+ if meta:
142
+ print(f"updated: {meta.get('updated', '?')}")
143
+ print(f"stale: {'yes (search will auto-reindex)' if stale else 'no'}")
144
+
145
+
146
+ def cmd_upload(args):
147
+ """把一个 .md 文件复制到文档库 uploads/ 并重建索引"""
148
+ win_utf8()
149
+ docs_dir, db_path = _paths(args)
150
+ src = Path(args.file).expanduser().resolve()
151
+ if not src.exists():
152
+ print(f"not found: {src}")
153
+ sys.exit(1)
154
+ if src.stat().st_size > MAX_UPLOAD_BYTES:
155
+ print(f"too large (max {MAX_UPLOAD_BYTES // 1024 // 1024}MB): {src.name}")
156
+ sys.exit(1)
157
+ name = sanitize_filename(src.name)
158
+ if not name:
159
+ print(f"invalid filename: {src.name}")
160
+ sys.exit(1)
161
+ target = dedupe_target(docs_dir, name)
162
+ if not target:
163
+ print("upload failed: cannot allocate target name")
164
+ sys.exit(1)
165
+ target.write_bytes(src.read_bytes())
166
+ from .core import rebuild_index
167
+
168
+ n, dt = rebuild_index(docs_dir, db_path)
169
+ print(f"uploaded: {target.name} -> uploads/{target.name}")
170
+ print(f"reindexed {n} docs in {dt:.0f}ms")
171
+
172
+
173
+ def cmd_open(args):
174
+ win_utf8()
175
+ docs_dir, _ = _paths(args)
176
+ p = docs_dir / args.path.replace("\\", "/")
177
+ if not p.exists():
178
+ print(f"not found: {args.path}")
179
+ return
180
+ if sys.platform == "win32":
181
+ subprocess.Popen(["start", "/B", "", str(p)], shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
182
+ elif sys.platform == "darwin":
183
+ subprocess.Popen(["open", str(p)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
184
+ else:
185
+ subprocess.Popen(["xdg-open", str(p)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
186
+
187
+
188
+ def main():
189
+ p = argparse.ArgumentParser(prog="docs-search", description="docs-search: 本地文档搜索引擎(零依赖)")
190
+ sub = p.add_subparsers(dest="cmd")
191
+
192
+ def add(name, aliases, help_text):
193
+ sp = sub.add_parser(name, aliases=aliases, help=help_text)
194
+ sp.add_argument("--dir", default=None, help="文档根目录(默认 ./docs 或 $DOCS_SEARCH_DIR)")
195
+ sp.add_argument("--db", default=None, help="索引库路径(默认 ~/.docs-search/<目录哈希>/index.db)")
196
+ return sp
197
+
198
+ add("index", ["i"], "重建索引")
199
+ sp_search = add("search", ["s"], "搜索文档")
200
+ sp_search.add_argument("query", help="搜索关键词(多关键词 AND)")
201
+ sp_search.add_argument("--limit", "-n", type=int, default=8, help="结果条数(默认 8)")
202
+ add("list", ["l"], "列出所有文档")
203
+ sp_show = add("show", ["sh"], "显示文档内容")
204
+ sp_show.add_argument("path", help="文档相对路径(如 infra/mcp.md)")
205
+ add("status", ["st"], "查看索引状态")
206
+ sp_up = add("upload", ["u"], "上传(复制).md 文档到 uploads/ 并重建索引")
207
+ sp_up.add_argument("file", help="要上传的 .md 文件路径")
208
+ sp_open = add("open", ["o"], "用系统默认程序打开文档")
209
+ sp_open.add_argument("path", help="文档相对路径")
210
+
211
+ a = p.parse_args()
212
+ if not a.cmd:
213
+ p.print_help()
214
+ return
215
+ fn = {
216
+ "index": cmd_index,
217
+ "search": cmd_search,
218
+ "list": cmd_list,
219
+ "show": cmd_show,
220
+ "status": cmd_status,
221
+ "upload": cmd_upload,
222
+ "open": cmd_open,
223
+ }.get(ALIAS.get(a.cmd, a.cmd))
224
+ if fn:
225
+ fn(a)
226
+ else:
227
+ p.print_help()
228
+
229
+
230
+ if __name__ == "__main__":
231
+ main()