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.
Files changed (36) hide show
  1. studydesk/__init__.py +3 -0
  2. studydesk/__main__.py +3 -0
  3. studydesk/cli.py +312 -0
  4. studydesk/lookup.py +162 -0
  5. studydesk/static/app.js +1865 -0
  6. studydesk/static/index.html +40 -0
  7. studydesk/static/styles.css +459 -0
  8. studydesk/static/vendor/fonts/OFL-ibmplexmono.txt +93 -0
  9. studydesk/static/vendor/fonts/OFL-ibmplexsans.txt +93 -0
  10. studydesk/static/vendor/fonts/OFL-sourceserif4.txt +93 -0
  11. studydesk/static/vendor/fonts/fonts.css +151 -0
  12. studydesk/static/vendor/fonts/ibm-plex-mono-400-latin-ext.woff2 +0 -0
  13. studydesk/static/vendor/fonts/ibm-plex-mono-400-latin.woff2 +0 -0
  14. studydesk/static/vendor/fonts/ibm-plex-mono-500-latin-ext.woff2 +0 -0
  15. studydesk/static/vendor/fonts/ibm-plex-mono-500-latin.woff2 +0 -0
  16. studydesk/static/vendor/fonts/ibm-plex-sans-400-latin-ext.woff2 +0 -0
  17. studydesk/static/vendor/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  18. studydesk/static/vendor/fonts/ibm-plex-sans-500-latin-ext.woff2 +0 -0
  19. studydesk/static/vendor/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  20. studydesk/static/vendor/fonts/ibm-plex-sans-600-latin-ext.woff2 +0 -0
  21. studydesk/static/vendor/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  22. studydesk/static/vendor/fonts/source-serif-4-400-latin-ext.woff2 +0 -0
  23. studydesk/static/vendor/fonts/source-serif-4-400-latin.woff2 +0 -0
  24. studydesk/static/vendor/fonts/source-serif-4-600-latin-ext.woff2 +0 -0
  25. studydesk/static/vendor/fonts/source-serif-4-600-latin.woff2 +0 -0
  26. studydesk/static/vendor/fonts/source-serif-4-700-latin-ext.woff2 +0 -0
  27. studydesk/static/vendor/fonts/source-serif-4-700-latin.woff2 +0 -0
  28. studydesk/static/vendor/pdfjs/LICENSE +177 -0
  29. studydesk/static/vendor/pdfjs/pdf.min.js +22 -0
  30. studydesk/static/vendor/pdfjs/pdf.worker.min.js +22 -0
  31. studydesk/store.py +188 -0
  32. studydesk-1.2.0.dist-info/METADATA +260 -0
  33. studydesk-1.2.0.dist-info/RECORD +36 -0
  34. studydesk-1.2.0.dist-info/WHEEL +4 -0
  35. studydesk-1.2.0.dist-info/entry_points.txt +2 -0
  36. studydesk-1.2.0.dist-info/licenses/LICENSE +21 -0
studydesk/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """StudyDesk: a local-first workspace for reading research papers."""
2
+
3
+ __version__ = "1.2.0"
studydesk/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from studydesk.cli import main
2
+
3
+ raise SystemExit(main())
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 &amp;."""
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