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 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)