bookmark-research 0.5.0-beta.1
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.
- package/.agents/plugins/marketplace.json +20 -0
- package/.codex-plugin/plugin.json +46 -0
- package/LICENSE +674 -0
- package/README.md +128 -0
- package/README.zh.md +126 -0
- package/bin/bookmark-research.js +30 -0
- package/config/providers.json +98 -0
- package/docs/bookmark-research-architecture.md +124 -0
- package/docs/cli-launcher-plan.md +58 -0
- package/docs/details.en.md +272 -0
- package/docs/details.md +93 -0
- package/docs/harness-compatibility.md +304 -0
- package/docs/harness-sources.json +342 -0
- package/docs/host-validation.json +298 -0
- package/docs/install-design-0.2.0.md +57 -0
- package/docs/installation.en.md +197 -0
- package/docs/installation.md +316 -0
- package/docs/instructions.en.md +42 -0
- package/docs/instructions.md +42 -0
- package/docs/mcp-aggregation-0.2.0.md +92 -0
- package/docs/multi-host-installation-plan.md +163 -0
- package/docs/prompt-reference.en.md +104 -0
- package/docs/prompt-reference.md +104 -0
- package/docs/provider-smoke-0.2.0.json +235 -0
- package/docs/release-notes-0.5.0-beta.1.md +33 -0
- package/docs/research-0.2.0.md +140 -0
- package/docs/research-sources-0.2.0.json +9360 -0
- package/docs/user-guide.en.md +74 -0
- package/docs/user-guide.md +74 -0
- package/docs/validation-0.2.0.md +126 -0
- package/docs/validation-0.4.0.md +154 -0
- package/docs/wiki-quality.md +105 -0
- package/docs/wiki-quality.zh.md +105 -0
- package/hosts/claude/runtime.js +13 -0
- package/hosts/codex/delegate.md +14 -0
- package/hosts/codex/prepare.py +61 -0
- package/hosts/dsh/plugin.js +36 -0
- package/hosts/dsh/runtime.js +14 -0
- package/hosts/dsh/workflow-call.py +45 -0
- package/hosts/pi/register-workflow.py +72 -0
- package/hosts/pi/runtime.js +18 -0
- package/hosts/shared/research-call.py +76 -0
- package/hosts/shared/research-flow.js +208 -0
- package/hosts/shared/workflow.json +15 -0
- package/install.sh +310 -0
- package/package.json +12 -0
- package/scripts/build_zip.py +143 -0
- package/scripts/export_bundle.py +496 -0
- package/scripts/host_assets.py +70 -0
- package/scripts/host_clients.py +196 -0
- package/scripts/host_install.py +277 -0
- package/scripts/install.py +593 -0
- package/scripts/launcher.py +186 -0
- package/scripts/verify_fixture.py +220 -0
- package/scripts/verify_live.py +62 -0
- package/scripts/verify_quality.py +87 -0
- package/skills/bookmark-research/SKILL.md +80 -0
- package/skills/bookmark-research/agents/openai.yaml +3 -0
- package/skills/bookmark-research/references/cli.md +150 -0
- package/skills/bookmark-research/references/deep-research.md +164 -0
- package/skills/bookmark-research/references/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/host-workflows.md +101 -0
- package/skills/bookmark-research/references/package-semantics.md +78 -0
- package/skills/bookmark-research/references/research-methods.md +51 -0
- package/skills/bookmark-research/references/research-services.md +72 -0
- package/skills/bookmark-research/references/research-workflow.md +57 -0
- package/skills/bookmark-research/references/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
- package/skills/bookmark-research/references/zh/cli.md +150 -0
- package/skills/bookmark-research/references/zh/deep-research.md +164 -0
- package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
- package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
- package/skills/bookmark-research/references/zh/research-methods.md +51 -0
- package/skills/bookmark-research/references/zh/research-services.md +70 -0
- package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
- package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
- package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
- package/src/archive.py +302 -0
- package/src/bookmark_index.py +913 -0
- package/src/cli.py +435 -0
- package/src/credentials.py +82 -0
- package/src/evaluation.py +337 -0
- package/src/jina.py +96 -0
- package/src/mcp_server.py +641 -0
- package/src/onboarding.py +352 -0
- package/src/provider_adapters.py +152 -0
- package/src/readiness.py +283 -0
- package/src/remote_mcp.py +295 -0
- package/src/research.py +1334 -0
- package/src/research_coverage.py +160 -0
- package/src/research_services.py +684 -0
- package/src/routing.py +202 -0
- package/src/search_results.py +244 -0
- package/src/service_http.py +139 -0
- package/src/settings.py +247 -0
- package/src/source_inputs.py +201 -0
- package/src/source_manager.py +348 -0
- package/src/source_watcher.py +68 -0
- package/src/textio.py +9 -0
- package/src/tui.py +586 -0
- package/src/web_search.py +603 -0
- package/src/wiki.py +383 -0
package/src/settings.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Persistent user preferences, stored separately from plugins and canvas packages."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import errno
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Settings:
|
|
15
|
+
"""Load on each operation so MCP clients can change preferences without restart."""
|
|
16
|
+
|
|
17
|
+
PROVIDERS = ("exa", "parallel", "tavily", "jina")
|
|
18
|
+
|
|
19
|
+
def __init__(self, path=None):
|
|
20
|
+
config_home = self._xdg_home("XDG_CONFIG_HOME", Path.home() / ".config")
|
|
21
|
+
selected = path or os.environ.get("BOOKMARK_RESEARCH_CONFIG") or config_home / "bookmark-research/settings.json"
|
|
22
|
+
self.path = Path(selected).expanduser().resolve()
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def _xdg_home(variable, fallback):
|
|
26
|
+
value = os.environ.get(variable)
|
|
27
|
+
# XDG base directories must be absolute; empty or relative values use
|
|
28
|
+
# the specification's home-directory defaults, never the process cwd.
|
|
29
|
+
return Path(value) if value and Path(value).is_absolute() else fallback
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def data_directory():
|
|
33
|
+
selected = os.environ.get("BOOKMARK_RESEARCH_DATA_DIR")
|
|
34
|
+
if selected:
|
|
35
|
+
return Path(selected).expanduser().resolve()
|
|
36
|
+
return (Settings._xdg_home("XDG_DATA_HOME", Path.home() / ".local/share") / "bookmark-research").resolve()
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def external_path(value, purpose):
|
|
40
|
+
path = Path(value).expanduser()
|
|
41
|
+
if not path.is_absolute():
|
|
42
|
+
raise ValueError(purpose + " must be an absolute path (or start with ~)")
|
|
43
|
+
path = path.resolve()
|
|
44
|
+
plugin = Path(__file__).resolve().parents[1]
|
|
45
|
+
for parent in (path, *path.parents):
|
|
46
|
+
if (parent == plugin or (parent / ".codex-plugin/plugin.json").is_file()
|
|
47
|
+
or (parent / ".claude-plugin/plugin.json").is_file()):
|
|
48
|
+
raise ValueError(purpose + " must be outside plugin directories")
|
|
49
|
+
if ((parent / "永久栏目").is_dir() or (parent / "临时栏目").is_dir()
|
|
50
|
+
or any(parent.glob("*.canvas"))):
|
|
51
|
+
raise ValueError(purpose + " must be outside canvas package directories")
|
|
52
|
+
return path
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def defaults(cls):
|
|
56
|
+
return {"schema_version": 1, "timeout_seconds": 30,
|
|
57
|
+
"search": {"providers": ["exa", "parallel"],
|
|
58
|
+
"fallback_providers": ["tavily", "jina"], "limit_per_target": 5},
|
|
59
|
+
"fetch": {"provider": "exa", "providers": ["exa", "parallel", "jina"], "max_characters": 12000},
|
|
60
|
+
"archive": {"enabled": True, "directory": str(cls.data_directory() / "knowledge")},
|
|
61
|
+
"research": {"depth": "auto", "prefer_host_workflows": True, "response_language": "auto",
|
|
62
|
+
"methods": ["comparative_analysis", "fact_check", "benchmark_review"]},
|
|
63
|
+
"readiness": {"mode": "cached", "ttl_seconds": 900, "timeout_seconds": 10},
|
|
64
|
+
"professional_research": {"enabled": False, "provider": None,
|
|
65
|
+
"openai": {"model": "o4-mini-deep-research", "max_tool_calls": 24},
|
|
66
|
+
"parallel": {"processor": "pro"}},
|
|
67
|
+
"wiki": {"directory": str(cls.data_directory() / "wiki")}}
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _merge(base, patch):
|
|
71
|
+
if not isinstance(patch, dict):
|
|
72
|
+
raise ValueError("Settings must be a JSON object")
|
|
73
|
+
merged = copy.deepcopy(base)
|
|
74
|
+
for key, value in patch.items():
|
|
75
|
+
if isinstance(merged.get(key), dict) and isinstance(value, dict):
|
|
76
|
+
merged[key] = Settings._merge(merged[key], value)
|
|
77
|
+
else:
|
|
78
|
+
merged[key] = copy.deepcopy(value)
|
|
79
|
+
return merged
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def _validate(cls, value):
|
|
83
|
+
defaults = cls.defaults()
|
|
84
|
+
if set(value) - set(defaults):
|
|
85
|
+
raise ValueError("Unknown settings fields; credentials belong in the environment or private credential file")
|
|
86
|
+
for section in ("search", "fetch", "archive", "research", "readiness", "professional_research", "wiki"):
|
|
87
|
+
if not isinstance(value.get(section), dict) or set(value[section]) != set(defaults[section]):
|
|
88
|
+
raise ValueError("Invalid settings fields in " + section)
|
|
89
|
+
for label, number, minimum, maximum in (
|
|
90
|
+
("schema_version", value["schema_version"], 1, 1),
|
|
91
|
+
("timeout_seconds", value["timeout_seconds"], 1, 60),
|
|
92
|
+
("search.limit_per_target", value["search"]["limit_per_target"], 1, 20),
|
|
93
|
+
("fetch.max_characters", value["fetch"]["max_characters"], 100, 100000),
|
|
94
|
+
("readiness.ttl_seconds", value["readiness"]["ttl_seconds"], 60, 86400),
|
|
95
|
+
("readiness.timeout_seconds", value["readiness"]["timeout_seconds"], 1, 30)):
|
|
96
|
+
if type(number) is not int or not minimum <= number <= maximum:
|
|
97
|
+
raise ValueError("%s must be an integer between %s and %s" % (label, minimum, maximum))
|
|
98
|
+
for section, field, minimum in (("search", "providers", 1), ("fetch", "providers", 1),
|
|
99
|
+
("search", "fallback_providers", 0)):
|
|
100
|
+
providers = value[section][field]
|
|
101
|
+
if (not isinstance(providers, list) or not minimum <= len(providers) <= len(cls.PROVIDERS)
|
|
102
|
+
or any(p not in cls.PROVIDERS for p in providers) or len(set(providers)) != len(providers)):
|
|
103
|
+
raise ValueError(section + "." + field + " must select supported providers without duplicates")
|
|
104
|
+
if value["fetch"]["provider"] not in cls.PROVIDERS:
|
|
105
|
+
raise ValueError("fetch.provider must select a supported provider")
|
|
106
|
+
if type(value["archive"]["enabled"]) is not bool:
|
|
107
|
+
raise ValueError("archive.enabled must be a boolean")
|
|
108
|
+
directory = value["archive"]["directory"]
|
|
109
|
+
if not isinstance(directory, str) or not directory.strip() or "\x00" in directory:
|
|
110
|
+
raise ValueError("archive.directory must be a nonempty path")
|
|
111
|
+
value["archive"]["directory"] = str(cls.external_path(directory, "Archive directory"))
|
|
112
|
+
research = value["research"]
|
|
113
|
+
if research["depth"] not in ("auto", "quick", "agentic", "deep"):
|
|
114
|
+
raise ValueError("research.depth must be auto, quick, agentic or deep")
|
|
115
|
+
if type(research["prefer_host_workflows"]) is not bool:
|
|
116
|
+
raise ValueError("research.prefer_host_workflows must be a boolean")
|
|
117
|
+
if research["response_language"] not in ("auto", "en", "zh"):
|
|
118
|
+
raise ValueError("research.response_language must be auto, en or zh; per-request instructions override it")
|
|
119
|
+
if value["readiness"]["mode"] not in ("cached", "always", "manual"):
|
|
120
|
+
raise ValueError("readiness.mode must be cached, always or manual")
|
|
121
|
+
methods = research["methods"]
|
|
122
|
+
allowed_methods = ("comparative_analysis", "fact_check", "benchmark_review", "wiki_synthesis")
|
|
123
|
+
if (not isinstance(methods, list) or not 1 <= len(methods) <= len(allowed_methods)
|
|
124
|
+
or any(not isinstance(method, str) or method not in allowed_methods for method in methods)
|
|
125
|
+
or len(set(methods)) != len(methods)):
|
|
126
|
+
raise ValueError("research.methods must select distinct supported analysis methods")
|
|
127
|
+
services = value["professional_research"]
|
|
128
|
+
if type(services["enabled"]) is not bool or services["provider"] not in (None, "openai", "parallel"):
|
|
129
|
+
raise ValueError("professional_research requires enabled boolean and an openai/parallel provider or null")
|
|
130
|
+
for provider in ("openai", "parallel"):
|
|
131
|
+
if (not isinstance(services[provider], dict)
|
|
132
|
+
or set(services[provider]) != set(defaults["professional_research"][provider])):
|
|
133
|
+
raise ValueError("Invalid professional_research fields for " + provider)
|
|
134
|
+
if services["openai"]["model"] not in ("o3-deep-research", "o4-mini-deep-research"):
|
|
135
|
+
raise ValueError("OpenAI research model must be o3-deep-research or o4-mini-deep-research")
|
|
136
|
+
limit = services["openai"]["max_tool_calls"]
|
|
137
|
+
if type(limit) is not int or not 1 <= limit <= 1000:
|
|
138
|
+
raise ValueError("professional_research.openai.max_tool_calls must be between 1 and 1000")
|
|
139
|
+
processor = services["parallel"]["processor"]
|
|
140
|
+
if not isinstance(processor, str) or not re.fullmatch(r"[a-z][a-z0-9-]{0,63}", processor):
|
|
141
|
+
raise ValueError("professional_research.parallel.processor must be a processor name")
|
|
142
|
+
wiki_directory = value["wiki"]["directory"]
|
|
143
|
+
if not isinstance(wiki_directory, str) or not wiki_directory.strip() or "\x00" in wiki_directory:
|
|
144
|
+
raise ValueError("wiki.directory must be a nonempty path")
|
|
145
|
+
value["wiki"]["directory"] = str(cls.external_path(wiki_directory, "Wiki directory"))
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def fetch_providers(preferences):
|
|
150
|
+
return list(dict.fromkeys([preferences["fetch"]["provider"]] + preferences["fetch"]["providers"]))
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def fetch_call_count(provider, urls):
|
|
154
|
+
# Reader has one HTTP request per URL; the other providers batch URLs
|
|
155
|
+
# in one MCP tool call. Reserve this before any network work begins.
|
|
156
|
+
return len(urls) if provider == "jina" else 1
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _unique_object(pairs):
|
|
160
|
+
result = {}
|
|
161
|
+
for key, value in pairs:
|
|
162
|
+
if key in result:
|
|
163
|
+
raise ValueError("Duplicate settings field")
|
|
164
|
+
result[key] = value
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
def _read(self):
|
|
168
|
+
if not self.path.exists():
|
|
169
|
+
return {}
|
|
170
|
+
value = json.loads(self.path.read_text(encoding="utf-8"), object_pairs_hook=self._unique_object)
|
|
171
|
+
if not isinstance(value, dict):
|
|
172
|
+
raise ValueError("Settings file must contain a JSON object")
|
|
173
|
+
return value
|
|
174
|
+
|
|
175
|
+
def load(self):
|
|
176
|
+
stored = self._read()
|
|
177
|
+
merged = self._merge(self.defaults(), stored)
|
|
178
|
+
search = stored.get("search", {})
|
|
179
|
+
if isinstance(search, dict) and "providers" in search and "fallback_providers" not in search:
|
|
180
|
+
# An earlier explicit provider list must not silently gain services
|
|
181
|
+
# on upgrade. Users can opt into a separate fallback list.
|
|
182
|
+
merged["search"]["fallback_providers"] = []
|
|
183
|
+
return self._validate(merged)
|
|
184
|
+
|
|
185
|
+
def describe(self):
|
|
186
|
+
return {"config_path": str(self.path), "config_exists": self.path.is_file(), "settings": self.load()}
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
@contextmanager
|
|
190
|
+
def _update_lock(path):
|
|
191
|
+
# Keep the lock inode across updates: replacing or deleting it would let
|
|
192
|
+
# another CLI/MCP process lock a different file and lose an update.
|
|
193
|
+
lock_path = path.with_name("." + path.name + ".lock")
|
|
194
|
+
descriptor = os.open(lock_path, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
195
|
+
with os.fdopen(descriptor, "r+b") as lock:
|
|
196
|
+
if os.name == "nt":
|
|
197
|
+
import msvcrt
|
|
198
|
+
if lock.seek(0, os.SEEK_END) == 0:
|
|
199
|
+
lock.write(b"\x00")
|
|
200
|
+
lock.flush()
|
|
201
|
+
lock.seek(0)
|
|
202
|
+
acquire = lambda: msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1)
|
|
203
|
+
release = lambda: msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1)
|
|
204
|
+
else:
|
|
205
|
+
import fcntl
|
|
206
|
+
acquire = lambda: fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
207
|
+
release = lambda: fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
208
|
+
deadline = time.monotonic() + 10
|
|
209
|
+
while True:
|
|
210
|
+
try:
|
|
211
|
+
acquire()
|
|
212
|
+
break
|
|
213
|
+
except OSError as error:
|
|
214
|
+
if error.errno not in (errno.EACCES, errno.EAGAIN, errno.EDEADLK):
|
|
215
|
+
raise
|
|
216
|
+
if time.monotonic() >= deadline:
|
|
217
|
+
raise TimeoutError("Settings are being updated by another process") from error
|
|
218
|
+
time.sleep(0.05)
|
|
219
|
+
try:
|
|
220
|
+
yield
|
|
221
|
+
finally:
|
|
222
|
+
release()
|
|
223
|
+
|
|
224
|
+
def update(self, changes):
|
|
225
|
+
if not isinstance(changes, dict) or not changes:
|
|
226
|
+
raise ValueError("Provide at least one settings change")
|
|
227
|
+
stored = self._merge(self._read(), changes)
|
|
228
|
+
self._validate(self._merge(self.defaults(), stored))
|
|
229
|
+
path = self.external_path(str(self.path), "Settings file")
|
|
230
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
# Persist only overrides; inherited defaults still follow the user's environment.
|
|
232
|
+
with self._update_lock(path):
|
|
233
|
+
stored = self._merge(self._read(), changes)
|
|
234
|
+
self._validate(self._merge(self.defaults(), stored))
|
|
235
|
+
temporary = None
|
|
236
|
+
try:
|
|
237
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="", dir=path.parent,
|
|
238
|
+
prefix=".settings-", delete=False) as stream:
|
|
239
|
+
temporary = Path(stream.name)
|
|
240
|
+
stream.write(json.dumps(stored, ensure_ascii=False, allow_nan=False, indent=2) + "\n")
|
|
241
|
+
stream.flush()
|
|
242
|
+
os.fsync(stream.fileno())
|
|
243
|
+
temporary.replace(path)
|
|
244
|
+
finally:
|
|
245
|
+
if temporary is not None:
|
|
246
|
+
temporary.unlink(missing_ok=True)
|
|
247
|
+
return self.describe()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Read directory, ZIP and single-section exports without changing their files."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import stat
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
|
|
9
|
+
from bookmark_index import BookmarkIndex, _dump, _reject_constant, _unique_object
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
PROTOCOL_DIRS = ("永久栏目", "临时栏目")
|
|
13
|
+
MAX_FILE_BYTES = 64 * 1024 * 1024
|
|
14
|
+
MAX_TOTAL_BYTES = 256 * 1024 * 1024
|
|
15
|
+
MAX_ENTRIES = 10000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def hashes(files):
|
|
19
|
+
return {name: hashlib.sha256(raw).hexdigest() for name, raw in sorted(files.items())}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fingerprint(file_hashes):
|
|
23
|
+
return hashlib.sha256(_dump(file_hashes).encode("utf-8")).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def json_object(raw):
|
|
27
|
+
value = json.loads(raw.decode("utf-8-sig"), object_pairs_hook=_unique_object, parse_constant=_reject_constant)
|
|
28
|
+
if not isinstance(value, dict):
|
|
29
|
+
raise ValueError("An export file must contain one JSON object")
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _safe_name(name):
|
|
34
|
+
parts = PurePosixPath(name).parts
|
|
35
|
+
if (not name or "\x00" in name or "\\" in name or name.startswith("/")
|
|
36
|
+
or any(part in (".", "..") or ":" in part for part in name.rstrip("/").split("/"))):
|
|
37
|
+
raise ValueError("Unsafe archive path: " + name[:200])
|
|
38
|
+
return parts
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _section_name(name, raw, section_paths):
|
|
42
|
+
obj = json_object(raw)
|
|
43
|
+
prefix = "永久栏目" if obj.get("sectionType") == "permanent" else "临时栏目"
|
|
44
|
+
relative = prefix + "/" + Path(name).name
|
|
45
|
+
section_id, section = BookmarkIndex._section(relative, obj)
|
|
46
|
+
BookmarkIndex._items(section_id, section, obj)
|
|
47
|
+
# A card downloaded under a new filename still updates its known card.
|
|
48
|
+
return section_paths.get(section_id, relative)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _read_file(path, root):
|
|
52
|
+
if path.is_symlink() or root not in path.resolve().parents:
|
|
53
|
+
raise ValueError("Export contains an external symlink: " + str(path))
|
|
54
|
+
if path.stat().st_size > MAX_FILE_BYTES:
|
|
55
|
+
raise ValueError("Export file exceeds the 64 MiB limit: " + path.name)
|
|
56
|
+
raw = path.read_bytes()
|
|
57
|
+
if len(raw) > MAX_FILE_BYTES:
|
|
58
|
+
raise ValueError("Export file exceeds the 64 MiB limit: " + path.name)
|
|
59
|
+
return raw
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _directory(path):
|
|
63
|
+
paths = list(path.glob("*.canvas"))
|
|
64
|
+
for loose in path.glob("*.json"):
|
|
65
|
+
raw = _read_file(loose, path)
|
|
66
|
+
try:
|
|
67
|
+
obj = json_object(raw)
|
|
68
|
+
except ValueError:
|
|
69
|
+
continue
|
|
70
|
+
if obj.get("format") == "bookmark-canvas-section":
|
|
71
|
+
raise ValueError("Loose section JSON at package root; import the card file directly or use protocol directories")
|
|
72
|
+
for directory in PROTOCOL_DIRS:
|
|
73
|
+
branch = path / directory
|
|
74
|
+
if branch.is_symlink():
|
|
75
|
+
raise ValueError("Export contains a symlinked protocol directory")
|
|
76
|
+
for entry in branch.rglob("*"):
|
|
77
|
+
if entry.is_symlink():
|
|
78
|
+
raise ValueError("Export contains an external symlink: " + str(entry))
|
|
79
|
+
if entry.is_file() and entry.suffix == ".json":
|
|
80
|
+
paths.append(entry)
|
|
81
|
+
if len(paths) > MAX_ENTRIES:
|
|
82
|
+
raise ValueError("Export contains too many protocol files")
|
|
83
|
+
files, total = {}, 0
|
|
84
|
+
for entry in sorted(paths):
|
|
85
|
+
raw = _read_file(entry, path)
|
|
86
|
+
total += len(raw)
|
|
87
|
+
if total > MAX_TOTAL_BYTES:
|
|
88
|
+
raise ValueError("Export exceeds the 256 MiB limit")
|
|
89
|
+
files[entry.relative_to(path).as_posix()] = raw
|
|
90
|
+
return files
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _zip(path, section_paths):
|
|
94
|
+
try:
|
|
95
|
+
with zipfile.ZipFile(path) as archive:
|
|
96
|
+
entries = archive.infolist()
|
|
97
|
+
if len(entries) > MAX_ENTRIES:
|
|
98
|
+
raise ValueError("Archive contains too many entries")
|
|
99
|
+
selected, roots, seen, loose = [], set(), set(), []
|
|
100
|
+
total = 0
|
|
101
|
+
for entry in entries:
|
|
102
|
+
parts = _safe_name(entry.filename)
|
|
103
|
+
normalized = "/".join(parts)
|
|
104
|
+
if normalized in seen:
|
|
105
|
+
raise ValueError("Duplicate archive entry: " + normalized)
|
|
106
|
+
seen.add(normalized)
|
|
107
|
+
mode = entry.external_attr >> 16
|
|
108
|
+
if stat.S_ISLNK(mode) or (stat.S_IFMT(mode) and not (stat.S_ISREG(mode) or stat.S_ISDIR(mode))):
|
|
109
|
+
raise ValueError("Archive links and special files are unsupported")
|
|
110
|
+
if entry.flag_bits & 1:
|
|
111
|
+
raise ValueError("Encrypted archives are unsupported")
|
|
112
|
+
if entry.is_dir() or parts[0] == "__MACOSX" or parts[-1].startswith("."):
|
|
113
|
+
continue
|
|
114
|
+
suffix = PurePosixPath(normalized).suffix
|
|
115
|
+
root = None
|
|
116
|
+
if suffix == ".canvas":
|
|
117
|
+
root = parts[:-1]
|
|
118
|
+
elif suffix == ".json":
|
|
119
|
+
for position, part in enumerate(parts[:-1]):
|
|
120
|
+
if part in PROTOCOL_DIRS:
|
|
121
|
+
root = parts[:position]
|
|
122
|
+
break
|
|
123
|
+
if root is None:
|
|
124
|
+
loose.append(entry)
|
|
125
|
+
if root is not None:
|
|
126
|
+
roots.add(root)
|
|
127
|
+
selected.append((entry, "/".join(parts[len(root):])))
|
|
128
|
+
# Bound all decompressed sizes, including ignored files.
|
|
129
|
+
total += entry.file_size
|
|
130
|
+
if entry.file_size > MAX_FILE_BYTES or total > MAX_TOTAL_BYTES:
|
|
131
|
+
raise ValueError("Archive exceeds export size limits")
|
|
132
|
+
if len(roots) > 1:
|
|
133
|
+
raise ValueError("Archive contains multiple package roots; import each canvas as a separate source")
|
|
134
|
+
if selected:
|
|
135
|
+
for entry in loose:
|
|
136
|
+
try:
|
|
137
|
+
obj = json_object(archive.read(entry))
|
|
138
|
+
except ValueError:
|
|
139
|
+
continue
|
|
140
|
+
if obj.get("format") == "bookmark-canvas-section":
|
|
141
|
+
raise ValueError("Archive mixes a package with loose card JSON; import the inputs separately")
|
|
142
|
+
if not selected and len(loose) == 1:
|
|
143
|
+
raw = archive.read(loose[0])
|
|
144
|
+
return {_section_name(loose[0].filename, raw, section_paths): raw}, True
|
|
145
|
+
files = {name: archive.read(entry) for entry, name in selected}
|
|
146
|
+
return files, False
|
|
147
|
+
except (zipfile.BadZipFile, NotImplementedError, RuntimeError) as exc:
|
|
148
|
+
raise ValueError("Cannot read ZIP export: " + str(exc)) from exc
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def read_input(path, section_paths=None):
|
|
152
|
+
path = Path(path).expanduser().resolve()
|
|
153
|
+
section_paths = section_paths or {}
|
|
154
|
+
single_section = False
|
|
155
|
+
if path.is_dir():
|
|
156
|
+
kind, files = "directory", _directory(path)
|
|
157
|
+
elif path.is_file() and path.suffix.lower() == ".zip":
|
|
158
|
+
kind = "zip"
|
|
159
|
+
files, single_section = _zip(path, section_paths)
|
|
160
|
+
elif path.is_file() and path.suffix.lower() == ".json":
|
|
161
|
+
kind, single_section = "section", True
|
|
162
|
+
raw = _read_file(path, path.parent)
|
|
163
|
+
files = {_section_name(path.name, raw, section_paths): raw}
|
|
164
|
+
else:
|
|
165
|
+
raise ValueError("Input must be an existing package directory, ZIP, or section JSON: " + str(path))
|
|
166
|
+
if not files:
|
|
167
|
+
raise ValueError("No protocol JSON or .canvas files in export; the saved index was retained")
|
|
168
|
+
if sum(name.endswith(".canvas") for name in files) > 1:
|
|
169
|
+
raise ValueError("An export must have at most one .canvas entry")
|
|
170
|
+
file_hashes = hashes(files)
|
|
171
|
+
return {"path": str(path), "kind": kind, "single_section": single_section,
|
|
172
|
+
"files": files, "hashes": file_hashes, "fingerprint": fingerprint(file_hashes)}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def git_directory(path):
|
|
176
|
+
"""Locate local Git metadata, including worktrees; never invoke Git or fetch."""
|
|
177
|
+
path = Path(path)
|
|
178
|
+
for parent in (path, *path.parents):
|
|
179
|
+
marker = parent / ".git"
|
|
180
|
+
if marker.is_dir():
|
|
181
|
+
return marker
|
|
182
|
+
if marker.is_file():
|
|
183
|
+
if marker.stat().st_size > 4096:
|
|
184
|
+
raise ValueError("Invalid Git worktree marker")
|
|
185
|
+
text = marker.read_text(encoding="utf-8").strip()
|
|
186
|
+
if not text.startswith("gitdir: "):
|
|
187
|
+
raise ValueError("Invalid Git worktree marker")
|
|
188
|
+
return (parent / text[8:]).resolve()
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def git_busy(path):
|
|
193
|
+
directory = git_directory(path)
|
|
194
|
+
if directory is None:
|
|
195
|
+
return False
|
|
196
|
+
directories = [directory]
|
|
197
|
+
common = directory / "commondir"
|
|
198
|
+
if common.is_file() and common.stat().st_size <= 4096:
|
|
199
|
+
directories.append((directory / common.read_text(encoding="utf-8").strip()).resolve())
|
|
200
|
+
return any((root / name).exists() for root in directories
|
|
201
|
+
for name in ("index.lock", "HEAD.lock", "packed-refs.lock", "shallow.lock"))
|