lightkone 0.1.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.
- lightkone/__init__.py +23 -0
- lightkone/bibtex.py +376 -0
- lightkone/citations.py +527 -0
- lightkone/cli.py +1192 -0
- lightkone/library.py +463 -0
- lightkone/lookup.py +190 -0
- lightkone/merge.py +105 -0
- lightkone/query.py +162 -0
- lightkone/settings.py +162 -0
- lightkone/storage.py +410 -0
- lightkone-0.1.0.dist-info/METADATA +81 -0
- lightkone-0.1.0.dist-info/RECORD +16 -0
- lightkone-0.1.0.dist-info/WHEEL +4 -0
- lightkone-0.1.0.dist-info/entry_points.txt +2 -0
- lightkone-0.1.0.dist-info/licenses/LICENSE +73 -0
- lightkone-0.1.0.dist-info/licenses/NOTICE +27 -0
lightkone/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Read and write a Lightkone library from Python: the same files the web app keeps in your storage."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from lightkone.library import BIB_PATH, LIBRARY_PATH, Library
|
|
6
|
+
from lightkone.storage import (
|
|
7
|
+
FolderStorage,
|
|
8
|
+
GitHubStorage,
|
|
9
|
+
S3Storage,
|
|
10
|
+
Storage,
|
|
11
|
+
StorageError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Library",
|
|
16
|
+
"Storage",
|
|
17
|
+
"FolderStorage",
|
|
18
|
+
"S3Storage",
|
|
19
|
+
"GitHubStorage",
|
|
20
|
+
"StorageError",
|
|
21
|
+
"LIBRARY_PATH",
|
|
22
|
+
"BIB_PATH",
|
|
23
|
+
]
|
lightkone/bibtex.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""BibTeX reading and writing, the same way Lightkone does it.
|
|
2
|
+
|
|
3
|
+
A forgiving reader (braces, quotes, nested braces, numbers, ``@string``
|
|
4
|
+
macros, month names and ``#`` concatenation) and the plain writer Lightkone
|
|
5
|
+
uses for ``lightkone/library.bib``. Kept in step with Lightkone's
|
|
6
|
+
``src/core/bibtex.ts`` so a library saved by either tool reads the same.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
MONTHS = {
|
|
17
|
+
"jan": "January",
|
|
18
|
+
"feb": "February",
|
|
19
|
+
"mar": "March",
|
|
20
|
+
"apr": "April",
|
|
21
|
+
"may": "May",
|
|
22
|
+
"jun": "June",
|
|
23
|
+
"jul": "July",
|
|
24
|
+
"aug": "August",
|
|
25
|
+
"sep": "September",
|
|
26
|
+
"oct": "October",
|
|
27
|
+
"nov": "November",
|
|
28
|
+
"dec": "December",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class BibEntry:
|
|
34
|
+
type: str
|
|
35
|
+
key: str
|
|
36
|
+
fields: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_WORD_STOP = set(' \t\r\n,={}()"#')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_bibtex(text: str) -> list[BibEntry]:
|
|
43
|
+
"""Every entry in a BibTeX text, with lower-cased field names and raw values."""
|
|
44
|
+
entries: list[BibEntry] = []
|
|
45
|
+
macros = dict(MONTHS)
|
|
46
|
+
n = len(text)
|
|
47
|
+
i = 0
|
|
48
|
+
|
|
49
|
+
def skip_space() -> None:
|
|
50
|
+
nonlocal i
|
|
51
|
+
while i < n and text[i].isspace():
|
|
52
|
+
i += 1
|
|
53
|
+
|
|
54
|
+
def read_word() -> str:
|
|
55
|
+
nonlocal i
|
|
56
|
+
start = i
|
|
57
|
+
while i < n and text[i] not in _WORD_STOP and not text[i].isspace():
|
|
58
|
+
i += 1
|
|
59
|
+
return text[start:i]
|
|
60
|
+
|
|
61
|
+
def read_braced() -> str:
|
|
62
|
+
nonlocal i
|
|
63
|
+
depth = 0
|
|
64
|
+
start = i + 1
|
|
65
|
+
while i < n:
|
|
66
|
+
c = text[i]
|
|
67
|
+
if c == "\\":
|
|
68
|
+
i += 2
|
|
69
|
+
continue
|
|
70
|
+
if c == "{":
|
|
71
|
+
depth += 1
|
|
72
|
+
elif c == "}":
|
|
73
|
+
depth -= 1
|
|
74
|
+
if depth == 0:
|
|
75
|
+
i += 1
|
|
76
|
+
return text[start : i - 1]
|
|
77
|
+
i += 1
|
|
78
|
+
return text[start:]
|
|
79
|
+
|
|
80
|
+
def read_quoted() -> str:
|
|
81
|
+
nonlocal i
|
|
82
|
+
depth = 0
|
|
83
|
+
i += 1
|
|
84
|
+
start = i
|
|
85
|
+
while i < n:
|
|
86
|
+
c = text[i]
|
|
87
|
+
if c == "\\":
|
|
88
|
+
i += 2
|
|
89
|
+
continue
|
|
90
|
+
if c == "{":
|
|
91
|
+
depth += 1
|
|
92
|
+
elif c == "}":
|
|
93
|
+
depth -= 1
|
|
94
|
+
elif c == '"' and depth == 0:
|
|
95
|
+
i += 1
|
|
96
|
+
return text[start : i - 1]
|
|
97
|
+
i += 1
|
|
98
|
+
return text[start:]
|
|
99
|
+
|
|
100
|
+
def read_value() -> str:
|
|
101
|
+
nonlocal i
|
|
102
|
+
parts: list[str] = []
|
|
103
|
+
while True:
|
|
104
|
+
skip_space()
|
|
105
|
+
c = text[i] if i < n else ""
|
|
106
|
+
if c == "{":
|
|
107
|
+
parts.append(read_braced())
|
|
108
|
+
elif c == '"':
|
|
109
|
+
parts.append(read_quoted())
|
|
110
|
+
else:
|
|
111
|
+
w = read_word()
|
|
112
|
+
if not w:
|
|
113
|
+
break
|
|
114
|
+
parts.append(w if w.isdigit() else macros.get(w.lower(), w))
|
|
115
|
+
skip_space()
|
|
116
|
+
if i < n and text[i] == "#":
|
|
117
|
+
i += 1
|
|
118
|
+
continue
|
|
119
|
+
break
|
|
120
|
+
return "".join(parts)
|
|
121
|
+
|
|
122
|
+
while i < n:
|
|
123
|
+
at = text.find("@", i)
|
|
124
|
+
if at < 0:
|
|
125
|
+
break
|
|
126
|
+
i = at + 1
|
|
127
|
+
typ = read_word().lower()
|
|
128
|
+
skip_space()
|
|
129
|
+
opening = text[i] if i < n else ""
|
|
130
|
+
if opening not in ("{", "("):
|
|
131
|
+
continue
|
|
132
|
+
close = "}" if opening == "{" else ")"
|
|
133
|
+
i += 1
|
|
134
|
+
if typ == "comment":
|
|
135
|
+
i = at + 1
|
|
136
|
+
continue
|
|
137
|
+
if typ == "preamble":
|
|
138
|
+
read_value()
|
|
139
|
+
continue
|
|
140
|
+
if typ == "string":
|
|
141
|
+
skip_space()
|
|
142
|
+
name = read_word().lower()
|
|
143
|
+
skip_space()
|
|
144
|
+
if i < n and text[i] == "=":
|
|
145
|
+
i += 1
|
|
146
|
+
macros[name] = read_value()
|
|
147
|
+
continue
|
|
148
|
+
skip_space()
|
|
149
|
+
key_start = i
|
|
150
|
+
depth = 0
|
|
151
|
+
# Braces in a key ("Erd\H{o}s1959") are part of it, not the end of the entry.
|
|
152
|
+
while i < n and (depth > 0 or (text[i] != "," and text[i] != close)):
|
|
153
|
+
if text[i] == "{":
|
|
154
|
+
depth += 1
|
|
155
|
+
elif text[i] == "}":
|
|
156
|
+
depth -= 1
|
|
157
|
+
i += 1
|
|
158
|
+
key = text[key_start:i].strip()
|
|
159
|
+
fields: dict[str, str] = {}
|
|
160
|
+
while i < n and text[i] != close:
|
|
161
|
+
if text[i] == ",":
|
|
162
|
+
i += 1
|
|
163
|
+
skip_space()
|
|
164
|
+
if i >= n or text[i] == close:
|
|
165
|
+
break
|
|
166
|
+
name = read_word().lower()
|
|
167
|
+
skip_space()
|
|
168
|
+
if not name or i >= n or text[i] != "=":
|
|
169
|
+
# Malformed field: skip its values, then to the next comma or the end of the entry.
|
|
170
|
+
while i < n and text[i] != "," and text[i] != close:
|
|
171
|
+
if text[i] == "{":
|
|
172
|
+
read_braced()
|
|
173
|
+
elif text[i] == '"':
|
|
174
|
+
read_quoted()
|
|
175
|
+
else:
|
|
176
|
+
i += 1
|
|
177
|
+
continue
|
|
178
|
+
i += 1
|
|
179
|
+
fields[name] = re.sub(r"\s+", " ", read_value()).strip()
|
|
180
|
+
skip_space()
|
|
181
|
+
i += 1
|
|
182
|
+
if key or fields:
|
|
183
|
+
entries.append(BibEntry(typ, key, fields))
|
|
184
|
+
return entries
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
_ACCENTS = {
|
|
188
|
+
'"': "\u0308",
|
|
189
|
+
"'": "\u0301",
|
|
190
|
+
"`": "\u0300",
|
|
191
|
+
"^": "\u0302",
|
|
192
|
+
"~": "\u0303",
|
|
193
|
+
"=": "\u0304",
|
|
194
|
+
".": "\u0307",
|
|
195
|
+
"c": "\u0327",
|
|
196
|
+
"v": "\u030c",
|
|
197
|
+
"u": "\u0306",
|
|
198
|
+
"H": "\u030b",
|
|
199
|
+
}
|
|
200
|
+
_LETTERS = {
|
|
201
|
+
"ss": "ß",
|
|
202
|
+
"o": "ø",
|
|
203
|
+
"O": "Ø",
|
|
204
|
+
"ae": "æ",
|
|
205
|
+
"AE": "Æ",
|
|
206
|
+
"l": "ł",
|
|
207
|
+
"L": "Ł",
|
|
208
|
+
"i": "ı",
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
_ACCENT_RE = re.compile(
|
|
212
|
+
r"\{?\\([\"'`^~=.]|[cvuH](?=[\s{]))\s*\{?([A-Za-z]|\\[ij]\b)\}?\}?"
|
|
213
|
+
)
|
|
214
|
+
_LETTER_RE = re.compile(r"\{\\(ss|o|O|ae|AE|l|L|i)\}|\\(ss|o|O|ae|AE|l|L|i)\b ?")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def plain(value: str) -> str:
|
|
218
|
+
"""Remove protective braces and the most common LaTeX escapes, for display and search."""
|
|
219
|
+
v = _ACCENT_RE.sub(
|
|
220
|
+
lambda m: unicodedata.normalize(
|
|
221
|
+
"NFC", m.group(2).replace("\\", "") + _ACCENTS[m.group(1)]
|
|
222
|
+
),
|
|
223
|
+
value,
|
|
224
|
+
)
|
|
225
|
+
v = _LETTER_RE.sub(lambda m: _LETTERS[m.group(1) or m.group(2)], v)
|
|
226
|
+
v = re.sub(r"\\(?:emph|textit|textbf|mathrm|textrm)\{([^{}]*)\}", r"\1", v)
|
|
227
|
+
v = re.sub(r"\\([&%$#_{}])", r"\1", v)
|
|
228
|
+
v = re.sub(r"(?<!\\)[{}]", "", v)
|
|
229
|
+
v = v.replace("~", " ")
|
|
230
|
+
return re.sub(r"\s+", " ", v).strip()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
_ARXIV_IN_URL = re.compile(
|
|
234
|
+
r"arxiv\.org/(?:abs|pdf|html)/([^\s?#]+?)(?:v\d+)?(?:\.pdf)?(?:[?#]|$)", re.I
|
|
235
|
+
)
|
|
236
|
+
_ID_KEYWORD = re.compile(r"^(doi|url|arxiv|isbn|issn|pmid|pmc):|https?://", re.I)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def split_authors(value: str) -> list[str]:
|
|
240
|
+
"""BibTeX ``author`` field to "Last, First" strings."""
|
|
241
|
+
if not value.strip():
|
|
242
|
+
return []
|
|
243
|
+
out = []
|
|
244
|
+
for raw in re.split(r"\s+and\s+", value, flags=re.I):
|
|
245
|
+
raw = raw.strip()
|
|
246
|
+
a = plain(raw)
|
|
247
|
+
if not a:
|
|
248
|
+
continue
|
|
249
|
+
# "{LIGO Scientific Collaboration}" is a corporate name: never flip it.
|
|
250
|
+
if "," in a or a.lower() == "others" or re.match(r"^\{.*\}$", raw, re.S):
|
|
251
|
+
out.append(a)
|
|
252
|
+
continue
|
|
253
|
+
parts = a.split(" ")
|
|
254
|
+
out.append(f"{parts[-1]}, {' '.join(parts[:-1])}" if len(parts) > 1 else a)
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def entry_to_draft(entry: BibEntry) -> dict[str, Any]:
|
|
259
|
+
"""A BibTeX entry as a paper draft (a paper without id and added date)."""
|
|
260
|
+
f = dict(entry.fields)
|
|
261
|
+
|
|
262
|
+
def take(name: str) -> str | None:
|
|
263
|
+
return f.pop(name, None)
|
|
264
|
+
|
|
265
|
+
title = plain(take("title") or "")
|
|
266
|
+
author = take("author")
|
|
267
|
+
editor = take("editor")
|
|
268
|
+
authors = split_authors(author if author is not None else (editor or ""))
|
|
269
|
+
year_raw = take("year")
|
|
270
|
+
m = re.search(r"\d{4}", year_raw or "")
|
|
271
|
+
year = int(m.group(0)) if m else None
|
|
272
|
+
journal = take("journal")
|
|
273
|
+
booktitle = take("booktitle")
|
|
274
|
+
venue = (
|
|
275
|
+
plain(journal or "")
|
|
276
|
+
or plain(booktitle or "")
|
|
277
|
+
or plain(f.get("publisher") or "")
|
|
278
|
+
)
|
|
279
|
+
abstract = take("abstract")
|
|
280
|
+
doi = take("doi")
|
|
281
|
+
if doi:
|
|
282
|
+
doi = re.sub(r"^https?://(dx\.)?doi\.org/", "", doi, flags=re.I)
|
|
283
|
+
url = take("url")
|
|
284
|
+
eprint = take("eprint")
|
|
285
|
+
arxivid = take("arxivid")
|
|
286
|
+
prefix = (f.get("archiveprefix") or f.get("eprinttype") or "").lower()
|
|
287
|
+
arxiv = arxivid or (eprint if prefix == "arxiv" else None)
|
|
288
|
+
if not arxiv and url:
|
|
289
|
+
mm = _ARXIV_IN_URL.search(url)
|
|
290
|
+
arxiv = mm.group(1) if mm else None
|
|
291
|
+
if not arxiv and eprint and re.match(r"^\d{4}\.\d{4,5}", eprint):
|
|
292
|
+
arxiv = eprint
|
|
293
|
+
if arxiv:
|
|
294
|
+
for k in ("archiveprefix", "eprinttype", "primaryclass"):
|
|
295
|
+
f.pop(k, None)
|
|
296
|
+
elif eprint:
|
|
297
|
+
f["eprint"] = eprint
|
|
298
|
+
tags = [plain(t) for t in re.split(r"[;,]", take("keywords") or "")]
|
|
299
|
+
tags = [t for t in tags if t and not _ID_KEYWORD.search(t)]
|
|
300
|
+
file = take("file")
|
|
301
|
+
annote = take("annote")
|
|
302
|
+
note = take("note")
|
|
303
|
+
notes = annote if annote is not None else note
|
|
304
|
+
draft: dict[str, Any] = {
|
|
305
|
+
"key": entry.key,
|
|
306
|
+
"type": entry.type,
|
|
307
|
+
"title": title,
|
|
308
|
+
"authors": authors,
|
|
309
|
+
"year": year,
|
|
310
|
+
"venue": venue or None,
|
|
311
|
+
"abstract": plain(abstract) if abstract else None,
|
|
312
|
+
"doi": doi or None,
|
|
313
|
+
"arxiv": (
|
|
314
|
+
re.sub(r"v\d+$", "", re.sub(r"^arxiv:", "", arxiv, flags=re.I))
|
|
315
|
+
if arxiv
|
|
316
|
+
else None
|
|
317
|
+
),
|
|
318
|
+
"url": url,
|
|
319
|
+
"tags": tags,
|
|
320
|
+
"file": file,
|
|
321
|
+
"notes": notes,
|
|
322
|
+
"fields": f,
|
|
323
|
+
}
|
|
324
|
+
return {k: v for k, v in draft.items() if v is not None}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def parse_bibtex_to_drafts(text: str) -> list[dict[str, Any]]:
|
|
328
|
+
return [entry_to_draft(e) for e in parse_bibtex(text) if e.fields.get("title")]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _escape(v: str) -> str:
|
|
332
|
+
return re.sub(r"\\?([&%#])", r"\\\1", v)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def paper_to_bibtex(p: dict[str, Any]) -> str:
|
|
336
|
+
"""One paper as BibTeX, exactly as Lightkone writes it."""
|
|
337
|
+
lines: list[tuple[str, str]] = []
|
|
338
|
+
|
|
339
|
+
def add(name: str, value: Any) -> None:
|
|
340
|
+
if value is not None and value != "":
|
|
341
|
+
lines.append((name, str(value)))
|
|
342
|
+
|
|
343
|
+
add("title", "{" + _escape(p.get("title", "")) + "}")
|
|
344
|
+
add(
|
|
345
|
+
"author",
|
|
346
|
+
" and ".join(
|
|
347
|
+
a if ("," in a or a == "others") else "{" + a + "}"
|
|
348
|
+
for a in p.get("authors", [])
|
|
349
|
+
),
|
|
350
|
+
)
|
|
351
|
+
add("year", p.get("year"))
|
|
352
|
+
fields = p.get("fields") or {}
|
|
353
|
+
venue_field = (
|
|
354
|
+
"booktitle" if p.get("type") in ("inproceedings", "incollection") else "journal"
|
|
355
|
+
)
|
|
356
|
+
if p.get("venue") and not fields.get(venue_field):
|
|
357
|
+
add(venue_field, _escape(p["venue"]))
|
|
358
|
+
add("doi", p.get("doi"))
|
|
359
|
+
if p.get("arxiv"):
|
|
360
|
+
add("eprint", p["arxiv"])
|
|
361
|
+
add("archiveprefix", "arXiv")
|
|
362
|
+
add("url", p.get("url"))
|
|
363
|
+
add("abstract", _escape(p["abstract"]) if p.get("abstract") else None)
|
|
364
|
+
if p.get("tags"):
|
|
365
|
+
add("keywords", ";".join(p["tags"]))
|
|
366
|
+
add("file", p.get("file"))
|
|
367
|
+
for k, v in fields.items():
|
|
368
|
+
if not k.startswith("lightkone-"):
|
|
369
|
+
add(k, v)
|
|
370
|
+
width = max(len(k) for k, _ in lines)
|
|
371
|
+
body = ",\n".join(f" {k.ljust(width)} = {{{v}}}" for k, v in lines)
|
|
372
|
+
return f"@{p.get('type') or 'misc'}{{{p['key']},\n{body}\n}}\n"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def library_to_bibtex(papers: list[dict[str, Any]]) -> str:
|
|
376
|
+
return "\n".join(paper_to_bibtex(p) for p in papers)
|