studydesk 1.2.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.
- studydesk/__init__.py +3 -0
- studydesk/__main__.py +3 -0
- studydesk/cli.py +312 -0
- studydesk/lookup.py +162 -0
- studydesk/static/app.js +1865 -0
- studydesk/static/index.html +40 -0
- studydesk/static/styles.css +459 -0
- studydesk/static/vendor/fonts/OFL-ibmplexmono.txt +93 -0
- studydesk/static/vendor/fonts/OFL-ibmplexsans.txt +93 -0
- studydesk/static/vendor/fonts/OFL-sourceserif4.txt +93 -0
- studydesk/static/vendor/fonts/fonts.css +151 -0
- studydesk/static/vendor/fonts/ibm-plex-mono-400-latin-ext.woff2 +0 -0
- studydesk/static/vendor/fonts/ibm-plex-mono-400-latin.woff2 +0 -0
- studydesk/static/vendor/fonts/ibm-plex-mono-500-latin-ext.woff2 +0 -0
- studydesk/static/vendor/fonts/ibm-plex-mono-500-latin.woff2 +0 -0
- studydesk/static/vendor/fonts/ibm-plex-sans-400-latin-ext.woff2 +0 -0
- studydesk/static/vendor/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
- studydesk/static/vendor/fonts/ibm-plex-sans-500-latin-ext.woff2 +0 -0
- studydesk/static/vendor/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
- studydesk/static/vendor/fonts/ibm-plex-sans-600-latin-ext.woff2 +0 -0
- studydesk/static/vendor/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
- studydesk/static/vendor/fonts/source-serif-4-400-latin-ext.woff2 +0 -0
- studydesk/static/vendor/fonts/source-serif-4-400-latin.woff2 +0 -0
- studydesk/static/vendor/fonts/source-serif-4-600-latin-ext.woff2 +0 -0
- studydesk/static/vendor/fonts/source-serif-4-600-latin.woff2 +0 -0
- studydesk/static/vendor/fonts/source-serif-4-700-latin-ext.woff2 +0 -0
- studydesk/static/vendor/fonts/source-serif-4-700-latin.woff2 +0 -0
- studydesk/static/vendor/pdfjs/LICENSE +177 -0
- studydesk/static/vendor/pdfjs/pdf.min.js +22 -0
- studydesk/static/vendor/pdfjs/pdf.worker.min.js +22 -0
- studydesk/store.py +188 -0
- studydesk-1.2.0.dist-info/METADATA +260 -0
- studydesk-1.2.0.dist-info/RECORD +36 -0
- studydesk-1.2.0.dist-info/WHEEL +4 -0
- studydesk-1.2.0.dist-info/entry_points.txt +2 -0
- studydesk-1.2.0.dist-info/licenses/LICENSE +21 -0
studydesk/__init__.py
ADDED
studydesk/__main__.py
ADDED
studydesk/cli.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""`studydesk` command: serve the bundled browser app on localhost and open it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import errno
|
|
7
|
+
import functools
|
|
8
|
+
import http.server
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import socket
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import webbrowser
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from urllib.parse import unquote, urlsplit
|
|
19
|
+
|
|
20
|
+
from studydesk import __version__, lookup
|
|
21
|
+
from studydesk.store import Conflict, Store, default_dir
|
|
22
|
+
|
|
23
|
+
DEFAULT_PORT = 5173
|
|
24
|
+
AUTHOR = "Subham Divakar"
|
|
25
|
+
ISSUES = "https://github.com/shubham10divakar/studydesk/issues"
|
|
26
|
+
MAX_LIBRARY = 256 << 20 # 256 MB of JSON
|
|
27
|
+
MAX_PDF = 2 << 30 # 2 GB per PDF
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def say(text: str, ascii_fallback: str) -> None:
|
|
31
|
+
"""Print text, or a plain-ASCII version on consoles that can't encode it (older Windows code pages)."""
|
|
32
|
+
try:
|
|
33
|
+
print(text, flush=True)
|
|
34
|
+
except UnicodeEncodeError:
|
|
35
|
+
print(ascii_fallback, flush=True)
|
|
36
|
+
|
|
37
|
+
# Windows can map .js to text/plain through the registry, which browsers refuse to run.
|
|
38
|
+
TYPES = {
|
|
39
|
+
".html": "text/html; charset=utf-8",
|
|
40
|
+
".js": "text/javascript; charset=utf-8",
|
|
41
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
42
|
+
".css": "text/css; charset=utf-8",
|
|
43
|
+
".json": "application/json; charset=utf-8",
|
|
44
|
+
".svg": "image/svg+xml",
|
|
45
|
+
".png": "image/png",
|
|
46
|
+
".pdf": "application/pdf",
|
|
47
|
+
".woff2": "font/woff2",
|
|
48
|
+
".txt": "text/plain; charset=utf-8",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def open_folder(path: Path) -> None:
|
|
53
|
+
"""Show the folder in the system file manager (File Explorer, Finder, or the Linux default)."""
|
|
54
|
+
if sys.platform == "win32":
|
|
55
|
+
os.startfile(str(path)) # type: ignore[attr-defined]
|
|
56
|
+
elif sys.platform == "darwin":
|
|
57
|
+
subprocess.Popen(["open", str(path)])
|
|
58
|
+
else:
|
|
59
|
+
subprocess.Popen(["xdg-open", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def static_dir() -> Path:
|
|
63
|
+
"""The app files: bundled inside the wheel, or prototype/ when run from a checkout."""
|
|
64
|
+
bundled = Path(__file__).parent / "static"
|
|
65
|
+
if (bundled / "index.html").is_file():
|
|
66
|
+
return bundled
|
|
67
|
+
checkout = Path(__file__).parent.parent / "prototype"
|
|
68
|
+
if (checkout / "index.html").is_file():
|
|
69
|
+
return checkout
|
|
70
|
+
raise SystemExit("studydesk: app files are missing from this install. Reinstall with `pip install --force-reinstall studydesk`.")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
74
|
+
"""Static app files, plus the library API under /api/ (spec: docs/disk-storage.md)."""
|
|
75
|
+
|
|
76
|
+
extensions_map = {**http.server.SimpleHTTPRequestHandler.extensions_map, **TYPES}
|
|
77
|
+
|
|
78
|
+
def end_headers(self) -> None:
|
|
79
|
+
# Revalidate every load, so an upgraded install never runs a stale app.js.
|
|
80
|
+
self.send_header("Cache-Control", "no-cache")
|
|
81
|
+
super().end_headers()
|
|
82
|
+
|
|
83
|
+
# ---------- request checks ----------
|
|
84
|
+
def _allowed(self) -> bool:
|
|
85
|
+
"""Only this machine's browser, at this server's own address, may talk to us.
|
|
86
|
+
|
|
87
|
+
The Host check blocks DNS rebinding (a web page whose domain resolves to 127.0.0.1); the Origin
|
|
88
|
+
check blocks other sites. CORS preflights are never answered, so no other origin can PUT/DELETE.
|
|
89
|
+
"""
|
|
90
|
+
srv = self.server
|
|
91
|
+
host = (self.headers.get("Host") or "").lower()
|
|
92
|
+
origin = self.headers.get("Origin")
|
|
93
|
+
if host not in srv.allowed_hosts or (origin is not None and origin.lower() not in srv.allowed_origins): # type: ignore[attr-defined]
|
|
94
|
+
self.send_error(403, "Forbidden")
|
|
95
|
+
return False
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
def _json(self, code: int, obj) -> None:
|
|
99
|
+
body = json.dumps(obj, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
100
|
+
self.send_response(code)
|
|
101
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
102
|
+
self.send_header("Content-Length", str(len(body)))
|
|
103
|
+
self.end_headers()
|
|
104
|
+
self.wfile.write(body)
|
|
105
|
+
|
|
106
|
+
def _length(self, limit: int) -> int | None:
|
|
107
|
+
try:
|
|
108
|
+
n = int(self.headers.get("Content-Length", ""))
|
|
109
|
+
except ValueError:
|
|
110
|
+
self._json(411, {"error": "Content-Length required"})
|
|
111
|
+
return None
|
|
112
|
+
if n < 0 or n > limit:
|
|
113
|
+
self._json(413, {"error": "too large"})
|
|
114
|
+
return None
|
|
115
|
+
return n
|
|
116
|
+
|
|
117
|
+
def _api_path(self) -> list[str] | None:
|
|
118
|
+
path = unquote(urlsplit(self.path).path)
|
|
119
|
+
if not path.startswith("/api/"):
|
|
120
|
+
return None
|
|
121
|
+
return path[len("/api/"):].strip("/").split("/")
|
|
122
|
+
|
|
123
|
+
# ---------- methods ----------
|
|
124
|
+
def do_GET(self) -> None:
|
|
125
|
+
if not self._allowed():
|
|
126
|
+
return
|
|
127
|
+
parts = self._api_path()
|
|
128
|
+
if parts is None:
|
|
129
|
+
return super().do_GET()
|
|
130
|
+
store: Store = self.server.store # type: ignore[attr-defined]
|
|
131
|
+
if parts == ["info"]:
|
|
132
|
+
return self._json(200, {"app": "studydesk", "version": __version__, "dataDir": str(store.root), "rev": store.rev(), "platform": sys.platform})
|
|
133
|
+
if parts == ["library"]:
|
|
134
|
+
rev, papers = store.read()
|
|
135
|
+
return self._json(200, {"rev": rev, "papers": papers})
|
|
136
|
+
if len(parts) == 2 and parts[0] == "pdf":
|
|
137
|
+
try:
|
|
138
|
+
data = store.read_pdf(parts[1])
|
|
139
|
+
except KeyError:
|
|
140
|
+
return self._json(400, {"error": "bad id"})
|
|
141
|
+
if data is None:
|
|
142
|
+
return self._json(404, {"error": "no such PDF"})
|
|
143
|
+
self.send_response(200)
|
|
144
|
+
self.send_header("Content-Type", "application/pdf")
|
|
145
|
+
self.send_header("Content-Length", str(len(data)))
|
|
146
|
+
self.end_headers()
|
|
147
|
+
self.wfile.write(data)
|
|
148
|
+
return
|
|
149
|
+
self._json(404, {"error": "not found"})
|
|
150
|
+
|
|
151
|
+
def do_HEAD(self) -> None:
|
|
152
|
+
if not self._allowed():
|
|
153
|
+
return
|
|
154
|
+
if self._api_path() is not None:
|
|
155
|
+
return self._json(405, {"error": "use GET"})
|
|
156
|
+
super().do_HEAD()
|
|
157
|
+
|
|
158
|
+
def do_PUT(self) -> None:
|
|
159
|
+
if not self._allowed():
|
|
160
|
+
return
|
|
161
|
+
parts = self._api_path()
|
|
162
|
+
store: Store = self.server.store # type: ignore[attr-defined]
|
|
163
|
+
if parts == ["library"]:
|
|
164
|
+
n = self._length(MAX_LIBRARY)
|
|
165
|
+
if n is None:
|
|
166
|
+
return
|
|
167
|
+
try:
|
|
168
|
+
body = json.loads(self.rfile.read(n).decode("utf-8"))
|
|
169
|
+
rev = store.write(str(body["baseRev"]), body["papers"])
|
|
170
|
+
except Conflict as c:
|
|
171
|
+
return self._json(409, {"rev": c.rev, "papers": c.papers})
|
|
172
|
+
except (ValueError, KeyError, TypeError, UnicodeDecodeError):
|
|
173
|
+
return self._json(400, {"error": "expected {baseRev, papers}"})
|
|
174
|
+
return self._json(200, {"rev": rev})
|
|
175
|
+
if parts and len(parts) == 2 and parts[0] == "pdf":
|
|
176
|
+
n = self._length(MAX_PDF)
|
|
177
|
+
if n is None:
|
|
178
|
+
return
|
|
179
|
+
try:
|
|
180
|
+
store.write_pdf(parts[1], self.rfile, n)
|
|
181
|
+
except KeyError:
|
|
182
|
+
return self._json(400, {"error": "bad id"})
|
|
183
|
+
except ValueError:
|
|
184
|
+
return self._json(400, {"error": "upload ended early"})
|
|
185
|
+
return self._json(200, {"ok": True})
|
|
186
|
+
self._json(404, {"error": "not found"})
|
|
187
|
+
|
|
188
|
+
def do_DELETE(self) -> None:
|
|
189
|
+
if not self._allowed():
|
|
190
|
+
return
|
|
191
|
+
parts = self._api_path()
|
|
192
|
+
if parts and len(parts) == 2 and parts[0] == "pdf":
|
|
193
|
+
try:
|
|
194
|
+
self.server.store.delete_pdf(parts[1]) # type: ignore[attr-defined]
|
|
195
|
+
except KeyError:
|
|
196
|
+
return self._json(400, {"error": "bad id"})
|
|
197
|
+
return self._json(200, {"ok": True})
|
|
198
|
+
self._json(404, {"error": "not found"})
|
|
199
|
+
|
|
200
|
+
def _from_app(self) -> bool:
|
|
201
|
+
"""For actions with effects outside the library (opening a program, going online): require the app's own
|
|
202
|
+
Origin and a JSON content type, which other sites can't send without a CORS preflight we never answer."""
|
|
203
|
+
if self.headers.get("Origin") is None or not (self.headers.get("Content-Type") or "").startswith("application/json"):
|
|
204
|
+
self.send_error(403, "Forbidden")
|
|
205
|
+
return False
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
def do_POST(self) -> None:
|
|
209
|
+
if not self._allowed():
|
|
210
|
+
return
|
|
211
|
+
parts = self._api_path()
|
|
212
|
+
if parts == ["open-folder"]:
|
|
213
|
+
if not self._from_app():
|
|
214
|
+
return
|
|
215
|
+
try:
|
|
216
|
+
open_folder(self.server.store.root) # type: ignore[attr-defined]
|
|
217
|
+
except OSError as e:
|
|
218
|
+
return self._json(500, {"error": str(e.strerror or e)})
|
|
219
|
+
return self._json(200, {"ok": True})
|
|
220
|
+
if parts == ["lookup"]:
|
|
221
|
+
if not self._from_app():
|
|
222
|
+
return
|
|
223
|
+
n = self._length(64 << 10)
|
|
224
|
+
if n is None:
|
|
225
|
+
return
|
|
226
|
+
try:
|
|
227
|
+
body = json.loads(self.rfile.read(n).decode("utf-8"))
|
|
228
|
+
query, pid = str(body["query"]), str(body["id"])
|
|
229
|
+
self.server.store.pdf_path(pid) # type: ignore[attr-defined] # validates the id
|
|
230
|
+
except (ValueError, KeyError, TypeError, UnicodeDecodeError):
|
|
231
|
+
return self._json(400, {"error": "expected {query, id}"})
|
|
232
|
+
try:
|
|
233
|
+
meta, pdf = lookup.look_up(query)
|
|
234
|
+
except lookup.LookupError_ as e:
|
|
235
|
+
return self._json(e.status, {"error": str(e)})
|
|
236
|
+
if pdf is not None:
|
|
237
|
+
self.server.store.write_pdf(pid, io.BytesIO(pdf), len(pdf)) # type: ignore[attr-defined]
|
|
238
|
+
meta.pop("pdfUrl", None)
|
|
239
|
+
return self._json(200, {"paper": meta, "hasFile": pdf is not None})
|
|
240
|
+
self._json(405, {"error": "not supported"})
|
|
241
|
+
|
|
242
|
+
def do_OPTIONS(self) -> None:
|
|
243
|
+
# Never answer CORS preflights: other websites must not be able to write to the library.
|
|
244
|
+
self.send_error(403, "Forbidden")
|
|
245
|
+
|
|
246
|
+
def log_message(self, format: str, *args) -> None: # quiet by default
|
|
247
|
+
if self.server.verbose: # type: ignore[attr-defined]
|
|
248
|
+
super().log_message(format, *args)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class Server(http.server.ThreadingHTTPServer):
|
|
252
|
+
# On Windows SO_REUSEADDR lets a second server bind the same port silently, so two copies would
|
|
253
|
+
# "run" at once. Claim the port exclusively there instead; elsewhere keep fast restarts.
|
|
254
|
+
allow_reuse_address = sys.platform != "win32"
|
|
255
|
+
daemon_threads = True
|
|
256
|
+
|
|
257
|
+
def server_bind(self) -> None:
|
|
258
|
+
if sys.platform == "win32" and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
|
259
|
+
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
|
260
|
+
super().server_bind()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def main(argv: list[str] | None = None) -> int:
|
|
264
|
+
parser = argparse.ArgumentParser(
|
|
265
|
+
prog="studydesk",
|
|
266
|
+
description="Start StudyDesk on this computer and open it in your browser.",
|
|
267
|
+
)
|
|
268
|
+
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"port to serve on (default {DEFAULT_PORT})")
|
|
269
|
+
parser.add_argument("--data-dir", type=Path, default=None,
|
|
270
|
+
help="folder for your library (default: $STUDYDESK_DATA, or StudyDesk in your home folder; an existing PDFDesk folder is used as it is)")
|
|
271
|
+
parser.add_argument("--no-browser", action="store_true", help="don't open a browser window")
|
|
272
|
+
parser.add_argument("--verbose", action="store_true", help="log every request")
|
|
273
|
+
parser.add_argument("--version", action="version", version=f"studydesk {__version__}")
|
|
274
|
+
args = parser.parse_args(argv)
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
store = Store(args.data_dir or default_dir())
|
|
278
|
+
except OSError as e:
|
|
279
|
+
print(f"studydesk: can't use {args.data_dir or default_dir()} as the library folder: {e.strerror or e}", file=sys.stderr)
|
|
280
|
+
return 1
|
|
281
|
+
handler = functools.partial(Handler, directory=str(static_dir()))
|
|
282
|
+
try:
|
|
283
|
+
server = Server(("127.0.0.1", args.port), handler)
|
|
284
|
+
except OSError as e:
|
|
285
|
+
if e.errno in (errno.EADDRINUSE, errno.EACCES) or getattr(e, "winerror", None) in (10048, 10013):
|
|
286
|
+
print(
|
|
287
|
+
f"studydesk: port {args.port} is already in use. StudyDesk may already be running at http://localhost:{args.port}\n"
|
|
288
|
+
"Open that address, or start on another port: studydesk --port <number>\n"
|
|
289
|
+
f"(This command would use the library folder {store.root}; the server already running may use a different one.)",
|
|
290
|
+
file=sys.stderr,
|
|
291
|
+
)
|
|
292
|
+
return 1
|
|
293
|
+
raise
|
|
294
|
+
server.verbose = args.verbose # type: ignore[attr-defined]
|
|
295
|
+
server.store = store # type: ignore[attr-defined]
|
|
296
|
+
server.allowed_hosts = {f"localhost:{args.port}", f"127.0.0.1:{args.port}"} # type: ignore[attr-defined]
|
|
297
|
+
server.allowed_origins = {f"http://{h}" for h in server.allowed_hosts} # type: ignore[attr-defined]
|
|
298
|
+
|
|
299
|
+
url = f"http://localhost:{args.port}/"
|
|
300
|
+
print(f"StudyDesk {__version__} running at {url} (Ctrl+C to stop)")
|
|
301
|
+
print(f"Library folder: {store.root}", flush=True)
|
|
302
|
+
say(f"Built by {AUTHOR} with ♥", f"Built by {AUTHOR} with <3")
|
|
303
|
+
print(f"Feedback and questions: {ISSUES}", flush=True)
|
|
304
|
+
if not args.no_browser:
|
|
305
|
+
threading.Timer(0.3, webbrowser.open, args=(url,)).start()
|
|
306
|
+
try:
|
|
307
|
+
server.serve_forever()
|
|
308
|
+
except KeyboardInterrupt:
|
|
309
|
+
print("\nStopped.")
|
|
310
|
+
finally:
|
|
311
|
+
server.server_close()
|
|
312
|
+
return 0
|
studydesk/lookup.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Look up a paper by arXiv id or DOI and fetch its details (and, for arXiv, its PDF). Standard library only.
|
|
2
|
+
|
|
3
|
+
Only three hosts are ever contacted: export.arxiv.org (metadata), arxiv.org (PDFs) and api.crossref.org (DOIs).
|
|
4
|
+
The id is validated before it goes into a URL, so this can't be used to fetch arbitrary addresses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import html
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
import xml.etree.ElementTree as ET
|
|
16
|
+
|
|
17
|
+
from studydesk import __version__
|
|
18
|
+
|
|
19
|
+
USER_AGENT = f"StudyDesk/{__version__} (+https://github.com/shubham10divakar/studydesk)"
|
|
20
|
+
TIMEOUT = 25
|
|
21
|
+
MAX_PDF = 150 << 20 # 150 MB
|
|
22
|
+
|
|
23
|
+
ARXIV_NEW = re.compile(r"(\d{4}\.\d{4,5})(v\d+)?", re.I)
|
|
24
|
+
ARXIV_OLD = re.compile(r"([a-z][a-z.\-]*/\d{7})(v\d+)?", re.I)
|
|
25
|
+
DOI = re.compile(r"(10\.\d{4,9}/[^\s\"<>]+)")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LookupError_(Exception):
|
|
29
|
+
"""A lookup that failed for a reason worth telling the user. `status` is the HTTP status to answer with."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, status: int = 400):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.status = status
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_query(q: str) -> tuple[str, str]:
|
|
37
|
+
"""('arxiv', '1706.03762v5') or ('doi', '10.1145/3292500.3330701') from what someone pasted."""
|
|
38
|
+
s = (q or "").strip()
|
|
39
|
+
s = re.sub(r"^(https?://)?(www\.)?", "", s, flags=re.I)
|
|
40
|
+
low = s.lower()
|
|
41
|
+
# arXiv DOIs (10.48550/arXiv.1706.03762) are arXiv papers
|
|
42
|
+
m = re.match(r"(doi\.org/|dx\.doi\.org/|doi:\s*)?10\.48550/arxiv\.(.+)$", low)
|
|
43
|
+
if m:
|
|
44
|
+
s = m.group(2)
|
|
45
|
+
low = s.lower()
|
|
46
|
+
if low.startswith(("arxiv.org/abs/", "arxiv.org/pdf/", "export.arxiv.org/abs/")):
|
|
47
|
+
s = s.split("/", 2)[2]
|
|
48
|
+
s = re.sub(r"\.pdf$", "", s, flags=re.I)
|
|
49
|
+
s = re.sub(r"^arxiv:\s*", "", s, flags=re.I)
|
|
50
|
+
for rx in (ARXIV_NEW, ARXIV_OLD):
|
|
51
|
+
m = rx.fullmatch(s)
|
|
52
|
+
if m:
|
|
53
|
+
return "arxiv", m.group(1) + (m.group(2) or "")
|
|
54
|
+
s2 = re.sub(r"^(doi\.org/|dx\.doi\.org/|doi:\s*)", "", s, flags=re.I)
|
|
55
|
+
m = DOI.fullmatch(s2.rstrip(".,;"))
|
|
56
|
+
if m:
|
|
57
|
+
return "doi", m.group(1)
|
|
58
|
+
raise LookupError_("That doesn't look like an arXiv id (like 1706.03762) or a DOI (like 10.1145/3292500.3330701).")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def fetch(url: str, accept: str = "*/*", limit: int = 5 << 20) -> tuple[bytes, str]:
|
|
62
|
+
"""GET url and return (body, content type). Replaced in tests."""
|
|
63
|
+
host = urllib.parse.urlsplit(url).hostname
|
|
64
|
+
if host not in ("export.arxiv.org", "arxiv.org", "api.crossref.org"):
|
|
65
|
+
raise LookupError_("refusing to contact " + str(host), 500)
|
|
66
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": accept})
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
|
|
69
|
+
body = r.read(limit + 1)
|
|
70
|
+
if len(body) > limit:
|
|
71
|
+
raise LookupError_("The file is too large to download.", 413)
|
|
72
|
+
return body, r.headers.get("Content-Type", "")
|
|
73
|
+
except urllib.error.HTTPError as e:
|
|
74
|
+
if e.code == 404:
|
|
75
|
+
raise LookupError_("Not found.", 404) from e
|
|
76
|
+
raise LookupError_(f"The server answered {e.code}. Try again in a minute.", 502) from e
|
|
77
|
+
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
78
|
+
raise LookupError_("Couldn't reach the internet to look this up. Check your connection and try again.", 502) from e
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def clean(s) -> str:
|
|
82
|
+
"""Crossref text can carry HTML: tags like <i>…</i> and entities like &."""
|
|
83
|
+
return " ".join(html.unescape(re.sub(r"<[^>]+>", "", str(s or ""))).split())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def authors_line(names: list[str]) -> str:
|
|
87
|
+
"""Surnames, comma separated, like the rest of the library ("Vaswani, Shazeer, et al.")."""
|
|
88
|
+
sur = [n.split()[-1] if n.split() else n for n in names if n and n.strip()]
|
|
89
|
+
return ", ".join(sur[:6] + ["et al."]) if len(sur) > 7 else ", ".join(sur)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
NS = {"a": "http://www.w3.org/2005/Atom", "arxiv": "http://arxiv.org/schemas/atom"}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def arxiv(aid: str) -> dict:
|
|
96
|
+
body, _ = fetch("https://export.arxiv.org/api/query?" + urllib.parse.urlencode({"id_list": aid}), "application/atom+xml")
|
|
97
|
+
try:
|
|
98
|
+
root = ET.fromstring(body)
|
|
99
|
+
except ET.ParseError as e:
|
|
100
|
+
raise LookupError_("arXiv sent something StudyDesk couldn't read. Try again in a minute.", 502) from e
|
|
101
|
+
entry = root.find("a:entry", NS)
|
|
102
|
+
title = entry.findtext("a:title", "", NS) if entry is not None else ""
|
|
103
|
+
if entry is None or not title.strip() or "/api/errors" in (entry.findtext("a:id", "", NS) or ""):
|
|
104
|
+
raise LookupError_(f"arXiv has no paper {aid}.", 404)
|
|
105
|
+
names = [a.findtext("a:name", "", NS).strip() for a in entry.findall("a:author", NS)]
|
|
106
|
+
published = entry.findtext("a:published", "", NS)
|
|
107
|
+
journal = (entry.findtext("arxiv:journal_ref", "", NS) or "").strip()
|
|
108
|
+
base = re.sub(r"v\d+$", "", aid)
|
|
109
|
+
return {
|
|
110
|
+
"source": "arxiv",
|
|
111
|
+
"arxiv": base,
|
|
112
|
+
"doi": (entry.findtext("arxiv:doi", "", NS) or "").strip(),
|
|
113
|
+
"title": " ".join(title.split()),
|
|
114
|
+
"authors": authors_line(names),
|
|
115
|
+
"year": int(published[:4]) if published[:4].isdigit() else "",
|
|
116
|
+
"venue": journal or "arXiv",
|
|
117
|
+
"url": f"https://arxiv.org/abs/{base}",
|
|
118
|
+
"pdfUrl": f"https://arxiv.org/pdf/{aid}",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def arxiv_pdf(meta: dict) -> bytes:
|
|
123
|
+
body, ctype = fetch(meta["pdfUrl"], "application/pdf", MAX_PDF)
|
|
124
|
+
if not body.startswith(b"%PDF"):
|
|
125
|
+
raise LookupError_("arXiv didn't send a PDF for this paper (it may still be processing). Try again later.", 502)
|
|
126
|
+
return body
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def crossref(doi: str) -> dict:
|
|
130
|
+
body, _ = fetch("https://api.crossref.org/works/" + urllib.parse.quote(doi, safe="/:;()"), "application/json")
|
|
131
|
+
try:
|
|
132
|
+
msg = json.loads(body.decode("utf-8"))["message"]
|
|
133
|
+
except (ValueError, KeyError) as e:
|
|
134
|
+
raise LookupError_("Crossref sent something StudyDesk couldn't read. Try again in a minute.", 502) from e
|
|
135
|
+
title = clean((msg.get("title") or [""])[0])
|
|
136
|
+
sub = clean((msg.get("subtitle") or [""])[0])
|
|
137
|
+
if sub and sub.lower() not in title.lower():
|
|
138
|
+
title = f"{title}: {sub}"
|
|
139
|
+
if not title:
|
|
140
|
+
raise LookupError_(f"Crossref has no title for {doi}.", 404)
|
|
141
|
+
names = [clean(" ".join(x for x in (a.get("given"), a.get("family")) if x) or a.get("name", "")) for a in msg.get("author") or []]
|
|
142
|
+
parts = (msg.get("issued") or msg.get("published") or {}).get("date-parts") or [[None]]
|
|
143
|
+
year = parts[0][0] if parts and parts[0] else ""
|
|
144
|
+
return {
|
|
145
|
+
"source": "doi",
|
|
146
|
+
"arxiv": "",
|
|
147
|
+
"doi": msg.get("DOI", doi),
|
|
148
|
+
"title": title,
|
|
149
|
+
"authors": authors_line(names),
|
|
150
|
+
"year": year or "",
|
|
151
|
+
"venue": clean((msg.get("container-title") or [""])[0] or msg.get("publisher") or ""),
|
|
152
|
+
"url": "https://doi.org/" + msg.get("DOI", doi),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def look_up(query: str) -> tuple[dict, bytes | None]:
|
|
157
|
+
"""Details for the paper, and its PDF when one can be downloaded (arXiv)."""
|
|
158
|
+
kind, ident = parse_query(query)
|
|
159
|
+
if kind == "arxiv":
|
|
160
|
+
meta = arxiv(ident)
|
|
161
|
+
return meta, arxiv_pdf(meta)
|
|
162
|
+
return crossref(ident), None
|