cite2site 1.0.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.
- c2s/__init__.py +24 -0
- c2s/__main__.py +4 -0
- c2s/_context_menu.py +44 -0
- c2s/adapter.py +436 -0
- c2s/cli.py +167 -0
- c2s/core.py +1186 -0
- cite2site-1.0.0.dist-info/METADATA +156 -0
- cite2site-1.0.0.dist-info/RECORD +12 -0
- cite2site-1.0.0.dist-info/WHEEL +5 -0
- cite2site-1.0.0.dist-info/entry_points.txt +2 -0
- cite2site-1.0.0.dist-info/licenses/LICENSE +21 -0
- cite2site-1.0.0.dist-info/top_level.txt +1 -0
c2s/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Cite2Site first-slice implementation."""
|
|
2
|
+
|
|
3
|
+
__all__ = [
|
|
4
|
+
"__version__",
|
|
5
|
+
"BaseAdapter",
|
|
6
|
+
"FilesystemTextAdapter",
|
|
7
|
+
"MarkdownAdapter",
|
|
8
|
+
"AdapterArtifact",
|
|
9
|
+
"get_adapter",
|
|
10
|
+
"adapter_for_uri",
|
|
11
|
+
"adapter_diagnostics",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
__version__ = "1.0.0"
|
|
15
|
+
|
|
16
|
+
from .adapter import ( # noqa: E402 — re-export after version
|
|
17
|
+
AdapterArtifact,
|
|
18
|
+
BaseAdapter,
|
|
19
|
+
FilesystemTextAdapter,
|
|
20
|
+
MarkdownAdapter,
|
|
21
|
+
adapter_diagnostics,
|
|
22
|
+
adapter_for_uri,
|
|
23
|
+
get_adapter,
|
|
24
|
+
)
|
c2s/__main__.py
ADDED
c2s/_context_menu.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Windows context-menu integration for Cite2Site.
|
|
2
|
+
|
|
3
|
+
Provides `c2s install-context-menu` and `c2s uninstall-context-menu` CLI
|
|
4
|
+
commands. Registers Cite2Site entries in the Windows Explorer right-click
|
|
5
|
+
menu so users can look up citations and cite selections directly from any
|
|
6
|
+
file.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _menu_entries() -> list[dict[str, str]]:
|
|
12
|
+
"""Return the context-menu entries to register."""
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
"name": "Look up citations here",
|
|
16
|
+
"command": 'cmd /k c2s lookup-actions --artifact "%1" --start 0 --end 0',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "Cite selection here",
|
|
20
|
+
"command": 'cmd /k c2s cite-selection --artifact "%1" --start 0 --end 0',
|
|
21
|
+
},
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def install() -> None:
|
|
26
|
+
"""Register Cite2Site in the Windows Explorer context menu."""
|
|
27
|
+
from context_menu import menus
|
|
28
|
+
|
|
29
|
+
entries = [
|
|
30
|
+
menus.ContextCommand(name=e["name"], command=e["command"])
|
|
31
|
+
for e in _menu_entries()
|
|
32
|
+
]
|
|
33
|
+
menu = menus.ContextMenu(name="Cite2Site", type="FILES", entries=entries)
|
|
34
|
+
menu.compile()
|
|
35
|
+
print("Cite2Site added to Windows right-click menu.")
|
|
36
|
+
print("Right-click any file -> Cite2Site -> Look up citations here / Cite selection here")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def uninstall() -> None:
|
|
40
|
+
"""Remove Cite2Site from the Windows Explorer context menu."""
|
|
41
|
+
from context_menu import menus
|
|
42
|
+
|
|
43
|
+
menus.ContextMenu(name="Cite2Site", type="FILES", entries=[]).remove()
|
|
44
|
+
print("Cite2Site removed from Windows right-click menu.")
|
c2s/adapter.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import dataclasses
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .core import (
|
|
9
|
+
C2SError,
|
|
10
|
+
Repo,
|
|
11
|
+
canonical_text,
|
|
12
|
+
evidence_for_text,
|
|
13
|
+
line_hashes,
|
|
14
|
+
line_number_at,
|
|
15
|
+
make_locator,
|
|
16
|
+
normalize_uri,
|
|
17
|
+
resolve_artifact_path,
|
|
18
|
+
sha256_json,
|
|
19
|
+
TEXT_CANON,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Adapter diagnostics — stable structured codes for adapter-level failures
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
_ADAPTER_DIAGNOSTICS = {
|
|
27
|
+
"E_ADAPTER_UNSUPPORTED": "the requested adapter is not installed or recognized",
|
|
28
|
+
"E_ADAPTER_UTF8": "artifact content is not valid UTF-8 text",
|
|
29
|
+
"E_ADAPTER_MISSING": "artifact file does not exist at the resolved path",
|
|
30
|
+
"E_ADAPTER_EMPTY_SELECTION": "selection range captured zero characters",
|
|
31
|
+
"E_ADAPTER_RANGE_INVALID": "selection range is outside artifact bounds",
|
|
32
|
+
"E_ADAPTER_AMBIGUOUS": "adapter cannot resolve a single authoritative evidence",
|
|
33
|
+
"E_ADAPTER_UNAVAILABLE": "artifact is temporarily unavailable (network, timeout, or converter failure)",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Adapter contract types
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclasses.dataclass(frozen=True)
|
|
42
|
+
class AdapterArtifact:
|
|
43
|
+
"""Stable artifact identity produced by an adapter."""
|
|
44
|
+
|
|
45
|
+
adapter: str
|
|
46
|
+
uri: str
|
|
47
|
+
artifact_id: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Abstract adapter protocol
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BaseAdapter(abc.ABC):
|
|
56
|
+
"""Protocol every Cite2Site adapter must fulfil.
|
|
57
|
+
|
|
58
|
+
Concrete adapters implement eight contract methods:
|
|
59
|
+
identify – stable artifact identity
|
|
60
|
+
canonicalize – read artifact and return canonical text
|
|
61
|
+
evidence – produce canonical evidence from selected text
|
|
62
|
+
locate – produce unambiguous locator coordinates
|
|
63
|
+
observe – read current artifact bytes at a locator (no mutation)
|
|
64
|
+
compare – deterministic resolved/changed status
|
|
65
|
+
summarize – metadata-safe human-readable range summary
|
|
66
|
+
privacy – comply with the active publication privacy mode
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
# Subclasses override this.
|
|
70
|
+
name: str = "base"
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# Contract methods (must be implemented)
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
@abc.abstractmethod
|
|
77
|
+
def identify(self, repo: Repo, uri: str) -> AdapterArtifact:
|
|
78
|
+
"""Return stable artifact identity for *uri*."""
|
|
79
|
+
|
|
80
|
+
@abc.abstractmethod
|
|
81
|
+
def canonicalize(self, repo: Repo, uri: str) -> str:
|
|
82
|
+
"""Read artifact and return canonical text.
|
|
83
|
+
|
|
84
|
+
Raises structured C2SError with an adapter-level diagnostic on failure.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
@abc.abstractmethod
|
|
88
|
+
def evidence(self, selected_text: str) -> dict[str, Any]:
|
|
89
|
+
"""Produce canonical evidence dict for *selected_text*."""
|
|
90
|
+
|
|
91
|
+
@abc.abstractmethod
|
|
92
|
+
def locate(self, source_text: str, start: int, end: int) -> dict[str, Any]:
|
|
93
|
+
"""Produce an unambiguous locator from *start*/*end* scalar offsets."""
|
|
94
|
+
|
|
95
|
+
@abc.abstractmethod
|
|
96
|
+
def observe(self, repo: Repo, artifact: dict[str, Any], locator: dict[str, Any]) -> dict[str, Any]:
|
|
97
|
+
"""Read current artifact content at *locator* without mutation.
|
|
98
|
+
|
|
99
|
+
Returns an evidence dict for the currently observed selection.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
@abc.abstractmethod
|
|
103
|
+
def compare(self, accepted_evidence: dict[str, Any], observed_evidence: dict[str, Any]) -> str:
|
|
104
|
+
"""Return 'resolved' or 'changed'. 'missing' and 'unsupported' are
|
|
105
|
+
determined externally by core.observe_status()."""
|
|
106
|
+
|
|
107
|
+
@abc.abstractmethod
|
|
108
|
+
def summarize(self, locator: dict[str, Any]) -> str:
|
|
109
|
+
"""Return a metadata-safe human-readable range summary."""
|
|
110
|
+
|
|
111
|
+
@abc.abstractmethod
|
|
112
|
+
def privacy(self, citation: dict[str, Any], mode: str, policy: dict[str, Any]) -> None:
|
|
113
|
+
"""Mutate *citation* in-place to comply with *mode*/*policy*.
|
|
114
|
+
|
|
115
|
+
The default implementation delegates to the core apply_privacy,
|
|
116
|
+
which is adapter-agnostic. Adapters that need specialised
|
|
117
|
+
redaction (e.g. stripping structured fields) override this.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# Derived helpers (not part of the conformance surface)
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def citation_id_for(self, artifact: AdapterArtifact, locator: dict[str, Any], evidence: dict[str, Any]) -> str:
|
|
125
|
+
return sha256_json(
|
|
126
|
+
{
|
|
127
|
+
"kind": "c2s.citation",
|
|
128
|
+
"artifact": {"adapter": artifact.adapter, "uri": artifact.uri, "artifact_id": artifact.artifact_id},
|
|
129
|
+
"locator": locator,
|
|
130
|
+
"accepted_evidence_hash": evidence["content_hash"],
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def validate_selection(self, source_text: str, start: int, end: int) -> str:
|
|
135
|
+
"""Return the selected text, or raise a structured diagnostic."""
|
|
136
|
+
if start < 0 or end < start or end > len(source_text):
|
|
137
|
+
raise C2SError(
|
|
138
|
+
"E_ADAPTER_RANGE_INVALID",
|
|
139
|
+
_ADAPTER_DIAGNOSTICS["E_ADAPTER_RANGE_INVALID"],
|
|
140
|
+
start=start,
|
|
141
|
+
end=end,
|
|
142
|
+
length=len(source_text),
|
|
143
|
+
)
|
|
144
|
+
selected = source_text[start:end]
|
|
145
|
+
if selected == "":
|
|
146
|
+
raise C2SError(
|
|
147
|
+
"E_ADAPTER_EMPTY_SELECTION",
|
|
148
|
+
_ADAPTER_DIAGNOSTICS["E_ADAPTER_EMPTY_SELECTION"],
|
|
149
|
+
start=start,
|
|
150
|
+
end=end,
|
|
151
|
+
)
|
|
152
|
+
return selected
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Filesystem text adapter — the canonical plain-text adapter
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class FilesystemTextAdapter(BaseAdapter):
|
|
161
|
+
"""Plain-text filesystem adapter (UTF-8, LF-normalised)."""
|
|
162
|
+
|
|
163
|
+
name = "filesystem-text"
|
|
164
|
+
|
|
165
|
+
# -- contract -------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def identify(self, repo: Repo, uri: str) -> AdapterArtifact:
|
|
168
|
+
path = resolve_artifact_path(repo, uri)
|
|
169
|
+
identity = {"adapter": self.name, "uri": normalize_uri(uri), "path": str(path)}
|
|
170
|
+
return AdapterArtifact(adapter=self.name, uri=normalize_uri(uri), artifact_id=sha256_json(identity))
|
|
171
|
+
|
|
172
|
+
def canonicalize(self, repo: Repo, uri: str) -> str:
|
|
173
|
+
path = resolve_artifact_path(repo, uri)
|
|
174
|
+
try:
|
|
175
|
+
raw = path.read_text(encoding="utf-8")
|
|
176
|
+
except FileNotFoundError as exc:
|
|
177
|
+
raise C2SError(
|
|
178
|
+
"E_ADAPTER_MISSING",
|
|
179
|
+
_ADAPTER_DIAGNOSTICS["E_ADAPTER_MISSING"],
|
|
180
|
+
artifact=uri,
|
|
181
|
+
) from exc
|
|
182
|
+
except UnicodeDecodeError as exc:
|
|
183
|
+
raise C2SError(
|
|
184
|
+
"E_ADAPTER_UTF8",
|
|
185
|
+
_ADAPTER_DIAGNOSTICS["E_ADAPTER_UTF8"],
|
|
186
|
+
artifact=uri,
|
|
187
|
+
) from exc
|
|
188
|
+
return canonical_text(raw)
|
|
189
|
+
|
|
190
|
+
def evidence(self, selected_text: str) -> dict[str, Any]:
|
|
191
|
+
return evidence_for_text(selected_text)
|
|
192
|
+
|
|
193
|
+
def locate(self, source_text: str, start: int, end: int) -> dict[str, Any]:
|
|
194
|
+
selected = self.validate_selection(source_text, start, end)
|
|
195
|
+
return make_locator(source_text, start, end)
|
|
196
|
+
|
|
197
|
+
def observe(self, repo: Repo, artifact: dict[str, Any], locator: dict[str, Any]) -> dict[str, Any]:
|
|
198
|
+
uri = str(artifact["uri"])
|
|
199
|
+
source = self.canonicalize(repo, uri)
|
|
200
|
+
start, end = int(locator["start"]), int(locator["end"])
|
|
201
|
+
if end > len(source):
|
|
202
|
+
raise C2SError(
|
|
203
|
+
"E_ADAPTER_RANGE_INVALID",
|
|
204
|
+
_ADAPTER_DIAGNOSTICS["E_ADAPTER_RANGE_INVALID"],
|
|
205
|
+
artifact=uri,
|
|
206
|
+
start=start,
|
|
207
|
+
end=end,
|
|
208
|
+
length=len(source),
|
|
209
|
+
)
|
|
210
|
+
selected = source[start:end]
|
|
211
|
+
if selected == "":
|
|
212
|
+
raise C2SError(
|
|
213
|
+
"E_ADAPTER_EMPTY_SELECTION",
|
|
214
|
+
_ADAPTER_DIAGNOSTICS["E_ADAPTER_EMPTY_SELECTION"],
|
|
215
|
+
artifact=uri,
|
|
216
|
+
start=start,
|
|
217
|
+
end=end,
|
|
218
|
+
)
|
|
219
|
+
return self.evidence(selected)
|
|
220
|
+
|
|
221
|
+
def compare(self, accepted_evidence: dict[str, Any], observed_evidence: dict[str, Any]) -> str:
|
|
222
|
+
if accepted_evidence["content_hash"] == observed_evidence["content_hash"]:
|
|
223
|
+
return "resolved"
|
|
224
|
+
return "changed"
|
|
225
|
+
|
|
226
|
+
def summarize(self, locator: dict[str, Any]) -> str:
|
|
227
|
+
if "start_line" in locator and "end_line" in locator:
|
|
228
|
+
return f"lines {locator['start_line']}-{locator['end_line']}"
|
|
229
|
+
return f"{locator['start']}-{locator['end']}"
|
|
230
|
+
|
|
231
|
+
def privacy(self, citation: dict[str, Any], mode: str, policy: dict[str, Any]) -> None:
|
|
232
|
+
# Delegate to core.apply_privacy — adapter-agnostic transform.
|
|
233
|
+
from .core import apply_privacy as _apply_privacy
|
|
234
|
+
|
|
235
|
+
_apply_privacy(citation, mode, policy)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# Markdown adapter — text semantics with explicit Markdown awareness marker
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class MarkdownAdapter(FilesystemTextAdapter):
|
|
244
|
+
"""Markdown adapter using text-line semantics.
|
|
245
|
+
|
|
246
|
+
Block-aware locators, summaries, and observation are not yet
|
|
247
|
+
implemented. This adapter intentionally inherits the plain-text
|
|
248
|
+
contract until the block-level spec and test suite are delivered.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
name = "markdown"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# Converter adapter — shells out to external format converters
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
#
|
|
258
|
+
# One generic adapter, configured per file extension, replaces three
|
|
259
|
+
# separate per-format adapters (DOCX, PDF, XLSX). canonicalize() shells
|
|
260
|
+
# out to a registered converter and treats stdout as canonical text;
|
|
261
|
+
# evidence() / locate() / compare() reuse the filesystem-text code path.
|
|
262
|
+
# identify() records converter name + version so a converter upgrade that
|
|
263
|
+
# changes output is detectable, not a silent replay failure.
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class ConverterAdapter(FilesystemTextAdapter):
|
|
267
|
+
"""Generic adapter backed by an external format converter.
|
|
268
|
+
|
|
269
|
+
Configured with a *name*, a list of *extensions* (e.g. ``[".docx"]``),
|
|
270
|
+
a *convert_cmd* list where ``{path}`` is replaced with the artifact
|
|
271
|
+
path, and a *version_cmd* list that prints the converter version.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
name: str
|
|
275
|
+
extensions: tuple[str, ...]
|
|
276
|
+
convert_cmd: list[str]
|
|
277
|
+
version_cmd: list[str]
|
|
278
|
+
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
name: str,
|
|
282
|
+
extensions: tuple[str, ...],
|
|
283
|
+
convert_cmd: list[str],
|
|
284
|
+
version_cmd: list[str],
|
|
285
|
+
):
|
|
286
|
+
self.name = name
|
|
287
|
+
self.extensions = extensions
|
|
288
|
+
self.convert_cmd = convert_cmd
|
|
289
|
+
self.version_cmd = version_cmd
|
|
290
|
+
self._converter_version: str | None = None
|
|
291
|
+
|
|
292
|
+
def _detect_version(self) -> str:
|
|
293
|
+
"""Run version_cmd and return stripped stdout, caching the result."""
|
|
294
|
+
if self._converter_version is None:
|
|
295
|
+
import subprocess
|
|
296
|
+
try:
|
|
297
|
+
r = subprocess.run(self.version_cmd, capture_output=True, text=True, timeout=10)
|
|
298
|
+
self._converter_version = r.stdout.strip() or r.stderr.strip() or "unknown"
|
|
299
|
+
except Exception:
|
|
300
|
+
self._converter_version = "unknown"
|
|
301
|
+
return self._converter_version
|
|
302
|
+
|
|
303
|
+
# -- contract overrides --------------------------------------------
|
|
304
|
+
|
|
305
|
+
def identify(self, repo: Repo, uri: str) -> AdapterArtifact:
|
|
306
|
+
path = resolve_artifact_path(repo, uri)
|
|
307
|
+
version = self._detect_version()
|
|
308
|
+
identity = {
|
|
309
|
+
"adapter": self.name,
|
|
310
|
+
"uri": normalize_uri(uri),
|
|
311
|
+
"path": str(path),
|
|
312
|
+
"converter": self.name,
|
|
313
|
+
"converter_version": version,
|
|
314
|
+
}
|
|
315
|
+
return AdapterArtifact(
|
|
316
|
+
adapter=self.name,
|
|
317
|
+
uri=normalize_uri(uri),
|
|
318
|
+
artifact_id=sha256_json(identity),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def canonicalize(self, repo: Repo, uri: str) -> str:
|
|
322
|
+
path = resolve_artifact_path(repo, uri)
|
|
323
|
+
if not path.exists():
|
|
324
|
+
raise C2SError(
|
|
325
|
+
"E_ADAPTER_MISSING",
|
|
326
|
+
_ADAPTER_DIAGNOSTICS["E_ADAPTER_MISSING"],
|
|
327
|
+
artifact=uri,
|
|
328
|
+
)
|
|
329
|
+
import subprocess
|
|
330
|
+
cmd = [str(path) if arg == "{path}" else arg for arg in self.convert_cmd]
|
|
331
|
+
try:
|
|
332
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
333
|
+
except FileNotFoundError as exc:
|
|
334
|
+
raise C2SError(
|
|
335
|
+
"E_ADAPTER_UNSUPPORTED",
|
|
336
|
+
f"converter '{self.convert_cmd[0]}' not found on PATH",
|
|
337
|
+
adapter=self.name,
|
|
338
|
+
) from exc
|
|
339
|
+
except subprocess.TimeoutExpired as exc:
|
|
340
|
+
raise C2SError(
|
|
341
|
+
"E_ADAPTER_UNAVAILABLE",
|
|
342
|
+
f"converter '{self.convert_cmd[0]}' timed out",
|
|
343
|
+
adapter=self.name,
|
|
344
|
+
) from exc
|
|
345
|
+
if r.returncode != 0:
|
|
346
|
+
raise C2SError(
|
|
347
|
+
"E_ADAPTER_UTF8",
|
|
348
|
+
f"converter '{self.convert_cmd[0]}' failed: {r.stderr.strip() or 'exit ' + str(r.returncode)}",
|
|
349
|
+
adapter=self.name,
|
|
350
|
+
)
|
|
351
|
+
# Normalize line endings, same as filesystem-text
|
|
352
|
+
return canonical_text(r.stdout)
|
|
353
|
+
|
|
354
|
+
# evidence(), locate(), observe(), compare(), summarize(), privacy()
|
|
355
|
+
# are all inherited from FilesystemTextAdapter.
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# Adapter registry
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
_CONVERTER_ADAPTERS: dict[str, BaseAdapter] = {}
|
|
364
|
+
|
|
365
|
+
# Register pandoc adapter for .docx files (optional — converter must be on PATH)
|
|
366
|
+
try:
|
|
367
|
+
_CONVERTER_ADAPTERS["pandoc"] = ConverterAdapter(
|
|
368
|
+
name="pandoc",
|
|
369
|
+
extensions=(".docx",),
|
|
370
|
+
convert_cmd=["pandoc", "-f", "docx", "-t", "plain", "--wrap=none", "{path}"],
|
|
371
|
+
version_cmd=["pandoc", "--version"],
|
|
372
|
+
)
|
|
373
|
+
except Exception:
|
|
374
|
+
pass
|
|
375
|
+
|
|
376
|
+
# Register pdftotext adapter for .pdf files (optional — converter must be on PATH)
|
|
377
|
+
try:
|
|
378
|
+
_CONVERTER_ADAPTERS["pdftotext"] = ConverterAdapter(
|
|
379
|
+
name="pdftotext",
|
|
380
|
+
extensions=(".pdf",),
|
|
381
|
+
convert_cmd=["pdftotext", "-layout", "{path}", "-"],
|
|
382
|
+
version_cmd=["pdftotext", "-v"],
|
|
383
|
+
)
|
|
384
|
+
except Exception:
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
# Register passthrough text-converter for testing (uses OS cat/type equivalent)
|
|
388
|
+
_cat_cmd = ["python", "-c", "import sys; sys.stdout.write(open(sys.argv[1]).read())"]
|
|
389
|
+
_cat_ver = ["python", "--version"]
|
|
390
|
+
_CONVERTER_ADAPTERS["text-converter"] = ConverterAdapter(
|
|
391
|
+
name="text-converter",
|
|
392
|
+
extensions=(".txt", ".csv"),
|
|
393
|
+
convert_cmd=_cat_cmd + ["{path}"],
|
|
394
|
+
version_cmd=_cat_ver,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
_SUPPORTED: dict[str, BaseAdapter] = {
|
|
398
|
+
"filesystem-text": FilesystemTextAdapter(),
|
|
399
|
+
"markdown": MarkdownAdapter(),
|
|
400
|
+
**_CONVERTER_ADAPTERS,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def get_adapter(name: str) -> BaseAdapter:
|
|
405
|
+
"""Return the adapter instance for *name*.
|
|
406
|
+
|
|
407
|
+
Raises E_ADAPTER_UNSUPPORTED when *name* is not a supported adapter.
|
|
408
|
+
"""
|
|
409
|
+
if name not in _SUPPORTED:
|
|
410
|
+
raise C2SError(
|
|
411
|
+
"E_ADAPTER_UNSUPPORTED",
|
|
412
|
+
_ADAPTER_DIAGNOSTICS["E_ADAPTER_UNSUPPORTED"],
|
|
413
|
+
adapter=name,
|
|
414
|
+
supported=sorted(_SUPPORTED.keys()),
|
|
415
|
+
)
|
|
416
|
+
return _SUPPORTED[name]
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def adapter_for_uri(uri: str, requested: str | None = None) -> str:
|
|
420
|
+
"""Resolve the adapter name for *uri*, optionally overriding with *requested*."""
|
|
421
|
+
if requested:
|
|
422
|
+
get_adapter(requested) # validates
|
|
423
|
+
return requested
|
|
424
|
+
lower = uri.lower()
|
|
425
|
+
if lower.endswith((".md", ".markdown")):
|
|
426
|
+
return "markdown"
|
|
427
|
+
if lower.endswith(".docx") and "pandoc" in _SUPPORTED:
|
|
428
|
+
return "pandoc"
|
|
429
|
+
if lower.endswith(".pdf") and "pdftotext" in _SUPPORTED:
|
|
430
|
+
return "pdftotext"
|
|
431
|
+
return "filesystem-text"
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def adapter_diagnostics() -> dict[str, str]:
|
|
435
|
+
"""Return the stable adapter diagnostic code→message map (for docs/tests)."""
|
|
436
|
+
return dict(_ADAPTER_DIAGNOSTICS)
|
c2s/cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from . import core
|
|
9
|
+
from .adapter import _SUPPORTED as _ADAPTER_CHOICES # noqa: F811 — registry
|
|
10
|
+
|
|
11
|
+
_ADAPTER_NAMES = sorted(_ADAPTER_CHOICES.keys())
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JsonArgumentParser(argparse.ArgumentParser):
|
|
15
|
+
def error(self, message: str) -> None:
|
|
16
|
+
raise core.C2SError("E_USAGE", message)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def emit(value: dict[str, Any]) -> int:
|
|
20
|
+
print(json.dumps(value, indent=2, sort_keys=True))
|
|
21
|
+
return 0 if value.get("ok", False) else 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def emit_jsonl(value: dict[str, Any]) -> int:
|
|
25
|
+
for citation in value["citations"]:
|
|
26
|
+
print(json.dumps(citation, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
|
|
27
|
+
return 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(argv: list[str] | None = None) -> int:
|
|
31
|
+
parser = JsonArgumentParser(prog="c2s")
|
|
32
|
+
parser.add_argument("--repo", default=".c2s", help="citation repository path")
|
|
33
|
+
sub = parser.add_subparsers(dest="command", required=True, parser_class=JsonArgumentParser)
|
|
34
|
+
|
|
35
|
+
p_init = sub.add_parser("init")
|
|
36
|
+
p_init.add_argument("--force", action="store_true")
|
|
37
|
+
p_init.set_defaults(func=lambda args: core.init_repo(core.repo_from_arg(args.repo), force=args.force))
|
|
38
|
+
|
|
39
|
+
def add_actor(p: argparse.ArgumentParser) -> None:
|
|
40
|
+
p.add_argument("--actor-kind", default="user", choices=["user", "agent", "machine"])
|
|
41
|
+
p.add_argument("--actor-id")
|
|
42
|
+
|
|
43
|
+
p_cite = sub.add_parser("cite-selection")
|
|
44
|
+
p_cite.add_argument("--artifact", required=True)
|
|
45
|
+
p_cite.add_argument("--adapter", choices=_ADAPTER_NAMES)
|
|
46
|
+
p_cite.add_argument("--start", required=True, type=int)
|
|
47
|
+
p_cite.add_argument("--end", required=True, type=int)
|
|
48
|
+
p_cite.add_argument("--expected-content-hash")
|
|
49
|
+
p_cite.add_argument("--handle")
|
|
50
|
+
p_cite.add_argument("--label")
|
|
51
|
+
p_cite.add_argument("--tag", action="append", default=[])
|
|
52
|
+
p_cite.add_argument("--note")
|
|
53
|
+
p_cite.add_argument("--handle-from-first-line", action="store_true")
|
|
54
|
+
add_actor(p_cite)
|
|
55
|
+
p_cite.set_defaults(func=core.cite_selection)
|
|
56
|
+
|
|
57
|
+
p_preflight = sub.add_parser("preflight-selection")
|
|
58
|
+
p_preflight.add_argument("--artifact", required=True)
|
|
59
|
+
p_preflight.add_argument("--adapter", choices=_ADAPTER_NAMES)
|
|
60
|
+
p_preflight.add_argument("--start", required=True, type=int)
|
|
61
|
+
p_preflight.add_argument("--end", required=True, type=int)
|
|
62
|
+
p_preflight.add_argument("--expected-content-hash")
|
|
63
|
+
p_preflight.add_argument("--handle")
|
|
64
|
+
p_preflight.add_argument("--label")
|
|
65
|
+
p_preflight.add_argument("--tag", action="append", default=[])
|
|
66
|
+
p_preflight.add_argument("--note")
|
|
67
|
+
p_preflight.add_argument("--handle-from-first-line", action="store_true")
|
|
68
|
+
p_preflight.set_defaults(func=core.preflight_selection)
|
|
69
|
+
|
|
70
|
+
p_batch = sub.add_parser("cite-batch")
|
|
71
|
+
p_batch.add_argument("--request", required=True)
|
|
72
|
+
add_actor(p_batch)
|
|
73
|
+
p_batch.set_defaults(func=core.cite_batch)
|
|
74
|
+
|
|
75
|
+
p_handle = sub.add_parser("set-handle")
|
|
76
|
+
p_handle.add_argument("--citation-id", required=True)
|
|
77
|
+
p_handle.add_argument("--handle", required=True)
|
|
78
|
+
p_handle.add_argument("--action", default="bind", choices=["bind", "rename", "alias", "retire"])
|
|
79
|
+
p_handle.add_argument("--previous-handle")
|
|
80
|
+
add_actor(p_handle)
|
|
81
|
+
p_handle.set_defaults(func=core.set_handle)
|
|
82
|
+
|
|
83
|
+
p_accept = sub.add_parser("accept-current")
|
|
84
|
+
p_accept.add_argument("--citation-id", required=True)
|
|
85
|
+
p_accept.add_argument("--expected-content-hash")
|
|
86
|
+
add_actor(p_accept)
|
|
87
|
+
p_accept.set_defaults(func=core.accept_current)
|
|
88
|
+
|
|
89
|
+
for command, func in [("retract", core.retract), ("restore", core.restore)]:
|
|
90
|
+
action = sub.add_parser(command)
|
|
91
|
+
action.add_argument("--citation-id", required=True)
|
|
92
|
+
add_actor(action)
|
|
93
|
+
action.set_defaults(func=func)
|
|
94
|
+
|
|
95
|
+
p_relocate = sub.add_parser("relocate")
|
|
96
|
+
p_relocate.add_argument("--citation-id", required=True)
|
|
97
|
+
p_relocate.add_argument("--artifact")
|
|
98
|
+
p_relocate.add_argument("--adapter", choices=_ADAPTER_NAMES)
|
|
99
|
+
p_relocate.add_argument("--start", required=True, type=int)
|
|
100
|
+
p_relocate.add_argument("--end", required=True, type=int)
|
|
101
|
+
p_relocate.add_argument("--expected-content-hash")
|
|
102
|
+
add_actor(p_relocate)
|
|
103
|
+
p_relocate.set_defaults(func=core.relocate)
|
|
104
|
+
|
|
105
|
+
p_note = sub.add_parser("note")
|
|
106
|
+
p_note.add_argument("--citation-id", required=True)
|
|
107
|
+
p_note.add_argument("--note", required=True)
|
|
108
|
+
add_actor(p_note)
|
|
109
|
+
p_note.set_defaults(func=core.note)
|
|
110
|
+
|
|
111
|
+
p_lookup = sub.add_parser("lookup-actions")
|
|
112
|
+
p_lookup.add_argument("--artifact", required=True)
|
|
113
|
+
p_lookup.add_argument("--start", required=True, type=int)
|
|
114
|
+
p_lookup.add_argument("--end", required=True, type=int)
|
|
115
|
+
p_lookup.add_argument("--privacy", default=None, choices=sorted(core.PRIVACY_MODES))
|
|
116
|
+
p_lookup.set_defaults(func=core.lookup_actions)
|
|
117
|
+
|
|
118
|
+
p_status = sub.add_parser("status")
|
|
119
|
+
p_status.add_argument("--privacy", default=None, choices=sorted(core.PRIVACY_MODES))
|
|
120
|
+
p_status.set_defaults(func=core.status)
|
|
121
|
+
|
|
122
|
+
p_citations = sub.add_parser("citations")
|
|
123
|
+
p_citations.add_argument("--artifact")
|
|
124
|
+
p_citations.add_argument("--handle")
|
|
125
|
+
p_citations.add_argument("--tag")
|
|
126
|
+
p_citations.add_argument("--status", choices=sorted(core.VALID_STATUSES))
|
|
127
|
+
p_citations.add_argument("--batch")
|
|
128
|
+
p_citations.add_argument("--privacy", default=None, choices=sorted(core.PRIVACY_MODES))
|
|
129
|
+
p_citations.add_argument("--format", default="json", choices=["json", "jsonl"])
|
|
130
|
+
p_citations.set_defaults(func=core.citations)
|
|
131
|
+
|
|
132
|
+
p_export = sub.add_parser("export")
|
|
133
|
+
p_export.add_argument("--privacy", default=None, choices=sorted(core.PRIVACY_MODES))
|
|
134
|
+
p_export.set_defaults(func=core.export)
|
|
135
|
+
|
|
136
|
+
p_check = sub.add_parser("check")
|
|
137
|
+
p_check.set_defaults(func=core.check)
|
|
138
|
+
|
|
139
|
+
# -- context-menu integration --
|
|
140
|
+
def _install_context_menu(_args: argparse.Namespace) -> dict[str, Any]:
|
|
141
|
+
from ._context_menu import install
|
|
142
|
+
install()
|
|
143
|
+
return {"ok": True}
|
|
144
|
+
|
|
145
|
+
def _uninstall_context_menu(_args: argparse.Namespace) -> dict[str, Any]:
|
|
146
|
+
from ._context_menu import uninstall
|
|
147
|
+
uninstall()
|
|
148
|
+
return {"ok": True}
|
|
149
|
+
|
|
150
|
+
p_cm_install = sub.add_parser("install-context-menu")
|
|
151
|
+
p_cm_install.set_defaults(func=_install_context_menu)
|
|
152
|
+
|
|
153
|
+
p_cm_uninstall = sub.add_parser("uninstall-context-menu")
|
|
154
|
+
p_cm_uninstall.set_defaults(func=_uninstall_context_menu)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
args = parser.parse_args(argv)
|
|
158
|
+
func: Callable[[argparse.Namespace], dict[str, Any]] = args.func
|
|
159
|
+
value = func(args)
|
|
160
|
+
return emit_jsonl(value) if args.command == "citations" and args.format == "jsonl" else emit(value)
|
|
161
|
+
except core.C2SError as exc:
|
|
162
|
+
print(json.dumps(exc.to_json(), indent=2, sort_keys=True), file=sys.stderr)
|
|
163
|
+
return 1
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
raise SystemExit(main())
|