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.
Files changed (106) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.codex-plugin/plugin.json +46 -0
  3. package/LICENSE +674 -0
  4. package/README.md +128 -0
  5. package/README.zh.md +126 -0
  6. package/bin/bookmark-research.js +30 -0
  7. package/config/providers.json +98 -0
  8. package/docs/bookmark-research-architecture.md +124 -0
  9. package/docs/cli-launcher-plan.md +58 -0
  10. package/docs/details.en.md +272 -0
  11. package/docs/details.md +93 -0
  12. package/docs/harness-compatibility.md +304 -0
  13. package/docs/harness-sources.json +342 -0
  14. package/docs/host-validation.json +298 -0
  15. package/docs/install-design-0.2.0.md +57 -0
  16. package/docs/installation.en.md +197 -0
  17. package/docs/installation.md +316 -0
  18. package/docs/instructions.en.md +42 -0
  19. package/docs/instructions.md +42 -0
  20. package/docs/mcp-aggregation-0.2.0.md +92 -0
  21. package/docs/multi-host-installation-plan.md +163 -0
  22. package/docs/prompt-reference.en.md +104 -0
  23. package/docs/prompt-reference.md +104 -0
  24. package/docs/provider-smoke-0.2.0.json +235 -0
  25. package/docs/release-notes-0.5.0-beta.1.md +33 -0
  26. package/docs/research-0.2.0.md +140 -0
  27. package/docs/research-sources-0.2.0.json +9360 -0
  28. package/docs/user-guide.en.md +74 -0
  29. package/docs/user-guide.md +74 -0
  30. package/docs/validation-0.2.0.md +126 -0
  31. package/docs/validation-0.4.0.md +154 -0
  32. package/docs/wiki-quality.md +105 -0
  33. package/docs/wiki-quality.zh.md +105 -0
  34. package/hosts/claude/runtime.js +13 -0
  35. package/hosts/codex/delegate.md +14 -0
  36. package/hosts/codex/prepare.py +61 -0
  37. package/hosts/dsh/plugin.js +36 -0
  38. package/hosts/dsh/runtime.js +14 -0
  39. package/hosts/dsh/workflow-call.py +45 -0
  40. package/hosts/pi/register-workflow.py +72 -0
  41. package/hosts/pi/runtime.js +18 -0
  42. package/hosts/shared/research-call.py +76 -0
  43. package/hosts/shared/research-flow.js +208 -0
  44. package/hosts/shared/workflow.json +15 -0
  45. package/install.sh +310 -0
  46. package/package.json +12 -0
  47. package/scripts/build_zip.py +143 -0
  48. package/scripts/export_bundle.py +496 -0
  49. package/scripts/host_assets.py +70 -0
  50. package/scripts/host_clients.py +196 -0
  51. package/scripts/host_install.py +277 -0
  52. package/scripts/install.py +593 -0
  53. package/scripts/launcher.py +186 -0
  54. package/scripts/verify_fixture.py +220 -0
  55. package/scripts/verify_live.py +62 -0
  56. package/scripts/verify_quality.py +87 -0
  57. package/skills/bookmark-research/SKILL.md +80 -0
  58. package/skills/bookmark-research/agents/openai.yaml +3 -0
  59. package/skills/bookmark-research/references/cli.md +150 -0
  60. package/skills/bookmark-research/references/deep-research.md +164 -0
  61. package/skills/bookmark-research/references/github-and-sync.md +30 -0
  62. package/skills/bookmark-research/references/host-workflows.md +101 -0
  63. package/skills/bookmark-research/references/package-semantics.md +78 -0
  64. package/skills/bookmark-research/references/research-methods.md +51 -0
  65. package/skills/bookmark-research/references/research-services.md +72 -0
  66. package/skills/bookmark-research/references/research-workflow.md +57 -0
  67. package/skills/bookmark-research/references/settings-and-archive.md +104 -0
  68. package/skills/bookmark-research/references/source-lifecycle.md +75 -0
  69. package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
  70. package/skills/bookmark-research/references/zh/cli.md +150 -0
  71. package/skills/bookmark-research/references/zh/deep-research.md +164 -0
  72. package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
  73. package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
  74. package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
  75. package/skills/bookmark-research/references/zh/research-methods.md +51 -0
  76. package/skills/bookmark-research/references/zh/research-services.md +70 -0
  77. package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
  78. package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
  79. package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
  80. package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
  81. package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
  82. package/src/archive.py +302 -0
  83. package/src/bookmark_index.py +913 -0
  84. package/src/cli.py +435 -0
  85. package/src/credentials.py +82 -0
  86. package/src/evaluation.py +337 -0
  87. package/src/jina.py +96 -0
  88. package/src/mcp_server.py +641 -0
  89. package/src/onboarding.py +352 -0
  90. package/src/provider_adapters.py +152 -0
  91. package/src/readiness.py +283 -0
  92. package/src/remote_mcp.py +295 -0
  93. package/src/research.py +1334 -0
  94. package/src/research_coverage.py +160 -0
  95. package/src/research_services.py +684 -0
  96. package/src/routing.py +202 -0
  97. package/src/search_results.py +244 -0
  98. package/src/service_http.py +139 -0
  99. package/src/settings.py +247 -0
  100. package/src/source_inputs.py +201 -0
  101. package/src/source_manager.py +348 -0
  102. package/src/source_watcher.py +68 -0
  103. package/src/textio.py +9 -0
  104. package/src/tui.py +586 -0
  105. package/src/web_search.py +603 -0
  106. package/src/wiki.py +383 -0
@@ -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"))