bibdeskparser 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.
- bibdeskparser/__init__.py +23 -0
- bibdeskparser/bdskfile.py +351 -0
- bibdeskparser/editing.py +673 -0
- bibdeskparser/entry.py +619 -0
- bibdeskparser/exporting.py +331 -0
- bibdeskparser/groups.py +119 -0
- bibdeskparser/header.py +236 -0
- bibdeskparser/library.py +1565 -0
- bibdeskparser/macros.py +109 -0
- bibdeskparser/middleware.py +174 -0
- bibdeskparser/names.py +96 -0
- bibdeskparser/render.py +666 -0
- bibdeskparser/texmap.py +467 -0
- bibdeskparser/writer.py +234 -0
- bibdeskparser-0.1.0.dist-info/METADATA +144 -0
- bibdeskparser-0.1.0.dist-info/RECORD +17 -0
- bibdeskparser-0.1.0.dist-info/WHEEL +4 -0
bibdeskparser/entry.py
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
r"""The `Entry` class: a single BibDesk `.bib` record.
|
|
2
|
+
|
|
3
|
+
`Entry` wraps a `bibtexparser.model.Entry` (`entry._entry`, private) via
|
|
4
|
+
*composition*, presenting BibDesk's view of a bibliography record:
|
|
5
|
+
|
|
6
|
+
- A case-insensitive `dict`-like interface (`Entry` is a
|
|
7
|
+
{py:class}`collections.abc.MutableMapping`) over "normal" fields, i.e.
|
|
8
|
+
every field except `date-added`, `date-modified`, `keywords`, and any
|
|
9
|
+
field whose key starts with `bdsk-` (case-insensitively). Values are
|
|
10
|
+
Unicode strings without their enclosing `{...}`/`"..."` delimiters,
|
|
11
|
+
matching what BibDesk's UI editor displays; a bare value that is a
|
|
12
|
+
valid BibDesk macro name is returned in its normalized (lowercase)
|
|
13
|
+
form instead, since it is a reference to a `@string` macro rather
|
|
14
|
+
than literal text.
|
|
15
|
+
- {any}`Entry.date_added` / {any}`Entry.date_modified`: read-only
|
|
16
|
+
`datetime.datetime` views of the BibDesk-managed `date-added` /
|
|
17
|
+
`date-modified` fields (not accessible through the `dict` interface).
|
|
18
|
+
- {any}`Entry.keywords`: a read-only tuple view of the `keywords`
|
|
19
|
+
field (also not accessible through the `dict` interface). Keywords
|
|
20
|
+
are edited through the owning {any}`bibdeskparser.library.Library`
|
|
21
|
+
(`add_to_keyword`, `remove_from_keyword`, or the
|
|
22
|
+
`Library.keywords` mapping), which is what keeps that mapping and
|
|
23
|
+
the entries consistent at all times. The `keywords` field is always
|
|
24
|
+
literal text: unlike other fields, a bare stored value that looks
|
|
25
|
+
like a macro name is never treated as a `@string` reference.
|
|
26
|
+
- {any}`Entry.groups`: a read-only tuple of the names of the BibDesk
|
|
27
|
+
static groups the entry belongs to, maintained by the owning
|
|
28
|
+
{any}`bibdeskparser.library.Library` (group data lives in the
|
|
29
|
+
library, not in the entry).
|
|
30
|
+
- {any}`Entry.files` / {any}`Entry.urls`: structured views of the
|
|
31
|
+
`bdsk-file-N` / `bdsk-url-N` fields (also not accessible through the
|
|
32
|
+
`dict` interface). `files` is read-only: the stored paths are
|
|
33
|
+
relative to the library's `.bib` file, which the entry itself does
|
|
34
|
+
not know, so attachments are modified through the owning
|
|
35
|
+
{any}`bibdeskparser.library.Library` (`add_file`, `replace_file`,
|
|
36
|
+
`unlink_file`, `rename_file`) instead.
|
|
37
|
+
- {any}`Entry.author` / {any}`Entry.editor`: read-only structured views
|
|
38
|
+
of the `author`/`editor` fields.
|
|
39
|
+
|
|
40
|
+
Every mutation (`__setitem__`, `__delitem__`, the `urls` setter, and
|
|
41
|
+
the `entry_type` setter) updates `date-modified` and marks the entry
|
|
42
|
+
{any}`Entry.dirty` (BibDesk itself does this for ordinary fields and
|
|
43
|
+
the entry type, but not for `bdsk-*` changes; `bibdeskparser` stamps
|
|
44
|
+
those too, since the entry's stored fields do change). `key` (see
|
|
45
|
+
{any}`Entry.key`) is the one exception: it is read-only.
|
|
46
|
+
|
|
47
|
+
Field values are TeX-encoded on write and decoded back to Unicode on
|
|
48
|
+
read, except for URL-like fields, which are stored/returned verbatim,
|
|
49
|
+
matching how BibDesk itself treats them -- so a field set through
|
|
50
|
+
`Entry` and one loaded from a `.bib` file behave identically.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
import datetime
|
|
54
|
+
import re
|
|
55
|
+
import urllib.parse
|
|
56
|
+
import warnings
|
|
57
|
+
from collections.abc import MutableMapping
|
|
58
|
+
|
|
59
|
+
from bibtexparser import model
|
|
60
|
+
|
|
61
|
+
from .bdskfile import BibDeskFile
|
|
62
|
+
from .macros import is_valid_macro_name, normalize_macro_name
|
|
63
|
+
from .names import structured_names
|
|
64
|
+
from .texmap import detexify, skip_texify, texify
|
|
65
|
+
|
|
66
|
+
__all__ = ["Entry", "Value"]
|
|
67
|
+
|
|
68
|
+
# All members whose name does not start with an underscore must be listed
|
|
69
|
+
# either in __all__ or in __private__
|
|
70
|
+
__private__ = []
|
|
71
|
+
|
|
72
|
+
#: `strptime`/`strftime` format of the `date-added`/`date-modified`
|
|
73
|
+
#: fields, e.g. `"2026-07-04 09:04:26 -0400"`.
|
|
74
|
+
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S %z"
|
|
75
|
+
|
|
76
|
+
_DATE_KEYS = frozenset(("date-added", "date-modified"))
|
|
77
|
+
|
|
78
|
+
_BDSK_FILE_RE = re.compile(r"bdsk-file-(\d+)$", re.IGNORECASE)
|
|
79
|
+
_BDSK_URL_RE = re.compile(r"bdsk-url-(\d+)$", re.IGNORECASE)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_normal_key(key):
|
|
83
|
+
"""Whether `key` belongs to the `Entry` `dict` interface.
|
|
84
|
+
|
|
85
|
+
`False` for `date-added`, `date-modified`, `keywords`, and any
|
|
86
|
+
`bdsk-` prefixed key (case-insensitively); `True` for everything
|
|
87
|
+
else.
|
|
88
|
+
"""
|
|
89
|
+
lkey = key.lower()
|
|
90
|
+
return (
|
|
91
|
+
lkey not in _DATE_KEYS
|
|
92
|
+
and lkey != "keywords"
|
|
93
|
+
and not lkey.startswith("bdsk-")
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _split_keywords(raw):
|
|
98
|
+
"""Split a comma-separated `keywords` field value into a list of
|
|
99
|
+
stripped, non-empty keywords."""
|
|
100
|
+
return [keyword.strip() for keyword in raw.split(",") if keyword.strip()]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _strip_enclosing(value):
|
|
104
|
+
"""Strip one matching pair of `{...}`/`"..."` from `value`, if
|
|
105
|
+
present."""
|
|
106
|
+
if len(value) >= 2 and (
|
|
107
|
+
(value[0] == "{" and value[-1] == "}")
|
|
108
|
+
or (value[0] == '"' and value[-1] == '"')
|
|
109
|
+
):
|
|
110
|
+
return value[1:-1]
|
|
111
|
+
return value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_date(value):
|
|
115
|
+
"""Parse a `date-added`/`date-modified` field value."""
|
|
116
|
+
if not isinstance(value, str):
|
|
117
|
+
return None
|
|
118
|
+
return datetime.datetime.strptime(_strip_enclosing(value), _DATE_FORMAT)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Value(str):
|
|
122
|
+
r"""Force a field value to be stored as a braced BibTeX string.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
Value(value)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
A plain `str` value assigned via `entry[field] = value` is stored
|
|
129
|
+
bare (no enclosing `{...}`) when it happens to be a valid,
|
|
130
|
+
normalized BibDesk macro name, since such a value is ambiguous
|
|
131
|
+
with a reference to a `@string` macro of that name. Wrap the
|
|
132
|
+
value in `Value` to force it to be treated as literal text
|
|
133
|
+
instead, even though it would otherwise pass as a macro name:
|
|
134
|
+
|
|
135
|
+
Both forms return the same value through the `dict` interface --
|
|
136
|
+
the difference is only in how the value is *stored* (as a literal
|
|
137
|
+
braced string vs. a bare macro reference), visible in the `"raw"`
|
|
138
|
+
export format (see {any}`Library.export`):
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
>>> import warnings
|
|
142
|
+
>>> from bibdeskparser import Library
|
|
143
|
+
>>> from bibdeskparser.entry import Entry, Value
|
|
144
|
+
>>> bib = Library()
|
|
145
|
+
>>> entry = Entry("article", "Key2024")
|
|
146
|
+
>>> entry["journal"] = Value("prl") # forced literal text
|
|
147
|
+
>>> entry["journal"]
|
|
148
|
+
'prl'
|
|
149
|
+
>>> bib["Key2024"] = entry
|
|
150
|
+
>>> bib.export("Key2024", format="raw") == (
|
|
151
|
+
... "@article{Key2024,\n\tjournal = {prl}\n}\n"
|
|
152
|
+
... )
|
|
153
|
+
True
|
|
154
|
+
>>> with warnings.catch_warnings():
|
|
155
|
+
... warnings.simplefilter("ignore")
|
|
156
|
+
... entry["journal"] = "prl" # bare str: treated as a macro ref
|
|
157
|
+
>>> entry["journal"]
|
|
158
|
+
'prl'
|
|
159
|
+
>>> bib.export("Key2024", format="raw") == (
|
|
160
|
+
... "@article{Key2024,\n\tjournal = prl\n}\n"
|
|
161
|
+
... )
|
|
162
|
+
True
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
__slots__ = ()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Entry(MutableMapping):
|
|
171
|
+
"""A single BibDesk `.bib` entry.
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
Entry(entry_type, key, fields=None)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Creates a new entry that is not (yet) part of any `Library`; add
|
|
178
|
+
it to one with `library[key] = entry`. `fields` (if given, a
|
|
179
|
+
`dict` mapping field name to a `str` or {any}`Value`) is applied
|
|
180
|
+
field by field, the same way as `entry[field_key] = value` -- so a
|
|
181
|
+
`keywords` field is rejected with `KeyError`, like any other key
|
|
182
|
+
outside the `dict` interface: keywords are edited only through the
|
|
183
|
+
owning `Library`, after the entry has been added to one.
|
|
184
|
+
`date-added` and `date-modified` are set to the current time (see
|
|
185
|
+
{any}`dirty`).
|
|
186
|
+
|
|
187
|
+
An entry obtained from a `Library` (e.g. `library[key]`, or by
|
|
188
|
+
iterating {any}`Library.entries`) is not constructed this way: it
|
|
189
|
+
keeps the `date-added`/`date-modified` values and `dirty` state it
|
|
190
|
+
had when the library was loaded, rather than being reset to "just
|
|
191
|
+
created".
|
|
192
|
+
|
|
193
|
+
See the module docstring for the full behavior of the `dict`
|
|
194
|
+
interface and the other properties.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(self, entry_type, key, fields=None):
|
|
198
|
+
self._entry = model.Entry(entry_type=entry_type, key=key, fields=[])
|
|
199
|
+
self._groups = ()
|
|
200
|
+
self._dirty = False
|
|
201
|
+
for field_key, value in (fields or {}).items():
|
|
202
|
+
self[field_key] = value
|
|
203
|
+
self._touch()
|
|
204
|
+
|
|
205
|
+
@classmethod
|
|
206
|
+
def _wrap(cls, model_entry):
|
|
207
|
+
"""Wrap an already-parsed `bibtexparser.model.Entry` (internal).
|
|
208
|
+
|
|
209
|
+
Unlike the constructor, this does not touch `model_entry`'s
|
|
210
|
+
fields or dates: a freshly loaded entry is pristine (`dirty` is
|
|
211
|
+
`False`) until it is modified. Used by the library loader.
|
|
212
|
+
"""
|
|
213
|
+
self = object.__new__(cls)
|
|
214
|
+
self._entry = model_entry
|
|
215
|
+
self._groups = ()
|
|
216
|
+
self._dirty = False
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
# -- internal helpers -------------------------------------------- #
|
|
220
|
+
|
|
221
|
+
def _find_field(self, key):
|
|
222
|
+
"""Return the `Field` matching `key` case-insensitively, or
|
|
223
|
+
`None`."""
|
|
224
|
+
lkey = key.lower()
|
|
225
|
+
for field in self._entry.fields:
|
|
226
|
+
if field.key.lower() == lkey:
|
|
227
|
+
return field
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
def _check_writable(self, key):
|
|
231
|
+
"""Raise `KeyError` if `key` is not writable via the `dict`
|
|
232
|
+
interface."""
|
|
233
|
+
lkey = key.lower()
|
|
234
|
+
if lkey in _DATE_KEYS:
|
|
235
|
+
raise KeyError(
|
|
236
|
+
f"{key!r} is read-only; use the date_added/"
|
|
237
|
+
"date_modified properties"
|
|
238
|
+
)
|
|
239
|
+
if lkey == "keywords":
|
|
240
|
+
raise KeyError(
|
|
241
|
+
"'keywords' is not accessible via the dict interface; "
|
|
242
|
+
"use the read-only Entry.keywords property, or the "
|
|
243
|
+
"Library methods add_to_keyword/remove_from_keyword"
|
|
244
|
+
)
|
|
245
|
+
if lkey.startswith("bdsk-"):
|
|
246
|
+
raise KeyError(
|
|
247
|
+
f"{key!r} is not accessible via the dict interface; "
|
|
248
|
+
"use the urls property, or the Library methods for "
|
|
249
|
+
"file attachments (add_file etc.)"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def _set_raw_field(self, key, rendered_value):
|
|
253
|
+
"""Set the raw (encoded) value of `key`, preserving the
|
|
254
|
+
existing `Field` object (and its original key spelling) if one
|
|
255
|
+
exists, else appending a new field."""
|
|
256
|
+
field = self._find_field(key)
|
|
257
|
+
if field is not None:
|
|
258
|
+
field.value = rendered_value
|
|
259
|
+
else:
|
|
260
|
+
self._entry.fields.append(
|
|
261
|
+
model.Field(key=key, value=rendered_value)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _touch(self):
|
|
265
|
+
"""Set `date-modified` (and `date-added`, if unset) to now, and
|
|
266
|
+
mark the entry {any}`dirty`."""
|
|
267
|
+
now = datetime.datetime.now().astimezone()
|
|
268
|
+
rendered = "{" + now.strftime(_DATE_FORMAT) + "}"
|
|
269
|
+
self._set_raw_field("date-modified", rendered)
|
|
270
|
+
if self._find_field("date-added") is None:
|
|
271
|
+
self._set_raw_field("date-added", rendered)
|
|
272
|
+
self._dirty = True
|
|
273
|
+
|
|
274
|
+
def _decode(self, key, value):
|
|
275
|
+
"""Decode a raw stored field value for `__getitem__`."""
|
|
276
|
+
if not isinstance(value, str):
|
|
277
|
+
return value
|
|
278
|
+
if len(value) >= 2 and (
|
|
279
|
+
(value[0] == "{" and value[-1] == "}")
|
|
280
|
+
or (value[0] == '"' and value[-1] == '"')
|
|
281
|
+
):
|
|
282
|
+
inner = value[1:-1]
|
|
283
|
+
return inner if skip_texify(key) else detexify(inner)
|
|
284
|
+
# A bare macro-shaped `keywords` value is *not* a macro
|
|
285
|
+
# reference: keywords are always literal text (BibDesk's own
|
|
286
|
+
# keyword machinery expands any macro and writes back a plain
|
|
287
|
+
# string on the first keyword edit).
|
|
288
|
+
if key.lower() != "keywords" and is_valid_macro_name(
|
|
289
|
+
value, normalized=True
|
|
290
|
+
):
|
|
291
|
+
return normalize_macro_name(value)
|
|
292
|
+
# bare and not a valid macro name: shouldn't normally happen for
|
|
293
|
+
# well-formed data, but be defensive and return it as-is
|
|
294
|
+
return value if skip_texify(key) else detexify(value)
|
|
295
|
+
|
|
296
|
+
# -- MutableMapping interface (normal fields only) ---------------- #
|
|
297
|
+
|
|
298
|
+
def __getitem__(self, key):
|
|
299
|
+
field = self._find_field(key)
|
|
300
|
+
if field is None or not _is_normal_key(field.key):
|
|
301
|
+
raise KeyError(key)
|
|
302
|
+
return self._decode(field.key, field.value)
|
|
303
|
+
|
|
304
|
+
def __setitem__(self, key, value):
|
|
305
|
+
self._check_writable(key)
|
|
306
|
+
if isinstance(value, Value):
|
|
307
|
+
text = str(value)
|
|
308
|
+
text = text if skip_texify(key) else texify(text)
|
|
309
|
+
rendered = "{" + text + "}"
|
|
310
|
+
elif isinstance(value, str):
|
|
311
|
+
if is_valid_macro_name(value, normalized=True):
|
|
312
|
+
warnings.warn(
|
|
313
|
+
f"field {key!r} set to macro reference {value!r}; "
|
|
314
|
+
"ensure it is defined in library.strings",
|
|
315
|
+
UserWarning,
|
|
316
|
+
stacklevel=2,
|
|
317
|
+
)
|
|
318
|
+
rendered = value
|
|
319
|
+
else:
|
|
320
|
+
text = value if skip_texify(key) else texify(value)
|
|
321
|
+
rendered = "{" + text + "}"
|
|
322
|
+
else:
|
|
323
|
+
raise TypeError(
|
|
324
|
+
f"field value must be a str or Value, not {type(value)!r}"
|
|
325
|
+
)
|
|
326
|
+
self._set_raw_field(key, rendered)
|
|
327
|
+
self._touch()
|
|
328
|
+
|
|
329
|
+
def __delitem__(self, key):
|
|
330
|
+
self._check_writable(key)
|
|
331
|
+
field = self._find_field(key)
|
|
332
|
+
if field is None or not _is_normal_key(field.key):
|
|
333
|
+
raise KeyError(key)
|
|
334
|
+
self._entry.fields.remove(field)
|
|
335
|
+
self._touch()
|
|
336
|
+
|
|
337
|
+
def __iter__(self):
|
|
338
|
+
for field in self._entry.fields:
|
|
339
|
+
if _is_normal_key(field.key):
|
|
340
|
+
yield field.key
|
|
341
|
+
|
|
342
|
+
def __len__(self):
|
|
343
|
+
return sum(
|
|
344
|
+
1 for field in self._entry.fields if _is_normal_key(field.key)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# -- dates --------------------------------------------------------- #
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def date_added(self):
|
|
351
|
+
"""`datetime.datetime` of the `date-added` field (read-only).
|
|
352
|
+
|
|
353
|
+
`None` if the field is absent (shouldn't normally happen: the
|
|
354
|
+
constructor always sets it)."""
|
|
355
|
+
field = self._find_field("date-added")
|
|
356
|
+
return None if field is None else _parse_date(field.value)
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def date_modified(self):
|
|
360
|
+
"""`datetime.datetime` of the `date-modified` field
|
|
361
|
+
(read-only)."""
|
|
362
|
+
field = self._find_field("date-modified")
|
|
363
|
+
return None if field is None else _parse_date(field.value)
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def dirty(self):
|
|
367
|
+
"""Whether the entry has been modified since it was loaded
|
|
368
|
+
(always `True` for a freshly constructed entry)."""
|
|
369
|
+
return self._dirty
|
|
370
|
+
|
|
371
|
+
# -- key / entry_type ------------------------------------------------ #
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def key(self):
|
|
375
|
+
"""The BibTeX citation key (read-only).
|
|
376
|
+
|
|
377
|
+
Set at construction time and immutable afterwards -- an
|
|
378
|
+
`Entry` cannot rename itself. An entry already in a
|
|
379
|
+
{any}`bibdeskparser.library.Library` is renamed through
|
|
380
|
+
{any}`bibdeskparser.library.Library.rekey`, which keeps the
|
|
381
|
+
library's key-based lookups consistent.
|
|
382
|
+
"""
|
|
383
|
+
return self._entry.key
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def entry_type(self):
|
|
387
|
+
"""The BibTeX entry type, e.g. `"article"`."""
|
|
388
|
+
return self._entry.entry_type
|
|
389
|
+
|
|
390
|
+
@entry_type.setter
|
|
391
|
+
def entry_type(self, value):
|
|
392
|
+
self._entry.entry_type = value
|
|
393
|
+
self._touch()
|
|
394
|
+
|
|
395
|
+
# -- groups ------------------------------------------------------ #
|
|
396
|
+
|
|
397
|
+
@property
|
|
398
|
+
def groups(self):
|
|
399
|
+
"""Names of the BibDesk static groups this entry belongs to
|
|
400
|
+
(a tuple, read-only).
|
|
401
|
+
|
|
402
|
+
Maintained by the owning `Library`: group data lives in the
|
|
403
|
+
library, and every mutation of it (`library.groups[name] =
|
|
404
|
+
...`, `del library.groups[name]`,
|
|
405
|
+
{any}`bibdeskparser.library.Library.add_to_group`,
|
|
406
|
+
{any}`bibdeskparser.library.Library.remove_from_group`)
|
|
407
|
+
immediately refreshes this property for every affected entry,
|
|
408
|
+
so it is always consistent with `library.groups`. `()` if the
|
|
409
|
+
entry is not (yet) attached to a `Library`, or is a member of
|
|
410
|
+
no group.
|
|
411
|
+
"""
|
|
412
|
+
return tuple(self._groups)
|
|
413
|
+
|
|
414
|
+
# -- keywords ------------------------------------------------------ #
|
|
415
|
+
|
|
416
|
+
@property
|
|
417
|
+
def keywords(self):
|
|
418
|
+
"""The entry's keywords (a tuple, read-only).
|
|
419
|
+
|
|
420
|
+
Parsed on access from the stored `keywords` field (a
|
|
421
|
+
comma-separated list, not accessible through the `dict`
|
|
422
|
+
interface). Keywords are edited through the owning
|
|
423
|
+
{any}`bibdeskparser.library.Library`
|
|
424
|
+
({any}`bibdeskparser.library.Library.add_to_keyword`,
|
|
425
|
+
{any}`bibdeskparser.library.Library.remove_from_keyword`, or
|
|
426
|
+
the `Library.keywords` mapping), which is what keeps that
|
|
427
|
+
mapping and the entries consistent. Unlike {any}`groups`,
|
|
428
|
+
keywords are stored in the entry itself, so they are preserved
|
|
429
|
+
by {any}`copy` and readable on a detached entry.
|
|
430
|
+
"""
|
|
431
|
+
field = self._find_field("keywords")
|
|
432
|
+
if field is None:
|
|
433
|
+
return ()
|
|
434
|
+
return tuple(_split_keywords(self._decode(field.key, field.value)))
|
|
435
|
+
|
|
436
|
+
def _set_keywords(self, keywords):
|
|
437
|
+
"""Replace the stored `keywords` field with the comma-joined
|
|
438
|
+
`keywords` (an iterable of strings), removing the field
|
|
439
|
+
entirely if `keywords` is empty, and `_touch`.
|
|
440
|
+
|
|
441
|
+
The public `keywords` property is read-only; this may only be
|
|
442
|
+
called by the owning `Library` (and by the `editing` module,
|
|
443
|
+
on the library's behalf), so that `Library.keywords` and the
|
|
444
|
+
entries can never disagree."""
|
|
445
|
+
keywords = list(keywords)
|
|
446
|
+
field = self._find_field("keywords")
|
|
447
|
+
if keywords:
|
|
448
|
+
text = texify(", ".join(keywords))
|
|
449
|
+
self._set_raw_field("keywords", "{" + text + "}")
|
|
450
|
+
elif field is not None:
|
|
451
|
+
self._entry.fields.remove(field)
|
|
452
|
+
else:
|
|
453
|
+
return # no keywords before or after: nothing to change
|
|
454
|
+
self._touch()
|
|
455
|
+
|
|
456
|
+
# -- files -------------------------------------------------------- #
|
|
457
|
+
|
|
458
|
+
def _bdsk_file_fields(self):
|
|
459
|
+
"""`(index, field)` pairs for all `bdsk-file-N` fields, sorted
|
|
460
|
+
by `index`."""
|
|
461
|
+
items = []
|
|
462
|
+
for field in self._entry.fields:
|
|
463
|
+
match = _BDSK_FILE_RE.match(field.key)
|
|
464
|
+
if match:
|
|
465
|
+
items.append((int(match.group(1)), field))
|
|
466
|
+
items.sort(key=lambda item: item[0])
|
|
467
|
+
return items
|
|
468
|
+
|
|
469
|
+
@property
|
|
470
|
+
def files(self):
|
|
471
|
+
"""Relative paths of attached files (the `bdsk-file-N` fields),
|
|
472
|
+
in numeric order (1, 2, 3, ...); read-only.
|
|
473
|
+
|
|
474
|
+
The paths are relative to the directory of the library's
|
|
475
|
+
`.bib` file, which the entry itself does not know, so
|
|
476
|
+
attachments can only be modified through the owning
|
|
477
|
+
{any}`bibdeskparser.library.Library`: see
|
|
478
|
+
{any}`bibdeskparser.library.Library.add_file`,
|
|
479
|
+
{any}`bibdeskparser.library.Library.replace_file`,
|
|
480
|
+
{any}`bibdeskparser.library.Library.unlink_file`, and
|
|
481
|
+
{any}`bibdeskparser.library.Library.rename_file` (an entry
|
|
482
|
+
must be added to a library before files can be attached to
|
|
483
|
+
it).
|
|
484
|
+
"""
|
|
485
|
+
return [f.relative_path for f in self._file_objects()]
|
|
486
|
+
|
|
487
|
+
def _file_objects(self):
|
|
488
|
+
"""The entry's attachments as `BibDeskFile` objects, in
|
|
489
|
+
`files` order (decoding raw field values as needed)."""
|
|
490
|
+
result = []
|
|
491
|
+
for _, field in self._bdsk_file_fields():
|
|
492
|
+
value = field.value
|
|
493
|
+
if not isinstance(value, BibDeskFile):
|
|
494
|
+
value = BibDeskFile.from_field_value(value)
|
|
495
|
+
result.append(value)
|
|
496
|
+
return result
|
|
497
|
+
|
|
498
|
+
def _set_files(self, bdsk_files):
|
|
499
|
+
"""Replace all `bdsk-file-N` fields with `bdsk_files` (a list
|
|
500
|
+
of `BibDeskFile`), renumbering from 1, and `_touch`.
|
|
501
|
+
|
|
502
|
+
The public `files` property is read-only; this may only be
|
|
503
|
+
called by the owning `Library` (and by the `editing` module,
|
|
504
|
+
on the library's behalf), which resolves paths relative to
|
|
505
|
+
the library's `.bib` directory."""
|
|
506
|
+
for _, field in self._bdsk_file_fields():
|
|
507
|
+
self._entry.fields.remove(field)
|
|
508
|
+
for i, bdsk_file in enumerate(bdsk_files, start=1):
|
|
509
|
+
self._entry.fields.append(
|
|
510
|
+
model.Field(key=f"bdsk-file-{i}", value=bdsk_file)
|
|
511
|
+
)
|
|
512
|
+
self._touch()
|
|
513
|
+
|
|
514
|
+
# -- urls ---------------------------------------------------------- #
|
|
515
|
+
|
|
516
|
+
def _bdsk_url_fields(self):
|
|
517
|
+
"""`(index, field)` pairs for all `bdsk-url-N` fields, sorted
|
|
518
|
+
by `index`."""
|
|
519
|
+
items = []
|
|
520
|
+
for field in self._entry.fields:
|
|
521
|
+
match = _BDSK_URL_RE.match(field.key)
|
|
522
|
+
if match:
|
|
523
|
+
items.append((int(match.group(1)), field))
|
|
524
|
+
items.sort(key=lambda item: item[0])
|
|
525
|
+
return items
|
|
526
|
+
|
|
527
|
+
@property
|
|
528
|
+
def urls(self):
|
|
529
|
+
"""URLs of attached links (the `bdsk-url-N` fields), in numeric
|
|
530
|
+
order (1, 2, 3, ...), without their enclosing braces.
|
|
531
|
+
|
|
532
|
+
Assign a list of URL strings to replace them; each one must
|
|
533
|
+
include both a scheme and a host (e.g.
|
|
534
|
+
`https://example.com/paper.pdf`), or `ValueError` is raised.
|
|
535
|
+
"""
|
|
536
|
+
return [
|
|
537
|
+
_strip_enclosing(field.value)
|
|
538
|
+
for _, field in self._bdsk_url_fields()
|
|
539
|
+
]
|
|
540
|
+
|
|
541
|
+
@urls.setter
|
|
542
|
+
def urls(self, urls):
|
|
543
|
+
urls = list(urls)
|
|
544
|
+
for url in urls:
|
|
545
|
+
parsed = urllib.parse.urlparse(url)
|
|
546
|
+
if not parsed.scheme or not parsed.netloc:
|
|
547
|
+
raise ValueError(f"not a valid URL: {url!r}")
|
|
548
|
+
for _, field in self._bdsk_url_fields():
|
|
549
|
+
self._entry.fields.remove(field)
|
|
550
|
+
for i, url in enumerate(urls, start=1):
|
|
551
|
+
self._entry.fields.append(
|
|
552
|
+
model.Field(key=f"bdsk-url-{i}", value="{" + url + "}")
|
|
553
|
+
)
|
|
554
|
+
self._touch()
|
|
555
|
+
|
|
556
|
+
# -- structured names ---------------------------------------------- #
|
|
557
|
+
|
|
558
|
+
@property
|
|
559
|
+
def author(self):
|
|
560
|
+
"""Structured view of the `author` field (read-only).
|
|
561
|
+
|
|
562
|
+
A `list` of `NameParts` (each with `.first`, `.von`, `.last`,
|
|
563
|
+
`.jr` attributes), or `[]` if the field is absent."""
|
|
564
|
+
return structured_names(self.get("author", ""))
|
|
565
|
+
|
|
566
|
+
@property
|
|
567
|
+
def editor(self):
|
|
568
|
+
"""Structured view of the `editor` field (read-only), like
|
|
569
|
+
{any}`author`."""
|
|
570
|
+
return structured_names(self.get("editor", ""))
|
|
571
|
+
|
|
572
|
+
def __repr__(self):
|
|
573
|
+
return f"Entry({self.entry_type!r}, {self.key!r})"
|
|
574
|
+
|
|
575
|
+
def _repr_pretty_(self, p, cycle):
|
|
576
|
+
# IPython/Jupyter pretty-printing hook: a fuller, multi-line
|
|
577
|
+
# rendering (including fields) for interactive display, while
|
|
578
|
+
# __repr__ stays the compact, eval-able form.
|
|
579
|
+
prefix = f"Entry({self.entry_type!r}, {self.key!r}"
|
|
580
|
+
if cycle:
|
|
581
|
+
p.text(prefix + ", {...})")
|
|
582
|
+
return
|
|
583
|
+
items = list(self.items())
|
|
584
|
+
if not items:
|
|
585
|
+
p.text(prefix + ")")
|
|
586
|
+
return
|
|
587
|
+
with p.group(4, prefix + ", {", "})"):
|
|
588
|
+
for i, (field_key, value) in enumerate(items):
|
|
589
|
+
if i:
|
|
590
|
+
p.text(",")
|
|
591
|
+
p.breakable()
|
|
592
|
+
p.pretty(field_key)
|
|
593
|
+
p.text(": ")
|
|
594
|
+
p.pretty(value)
|
|
595
|
+
|
|
596
|
+
# -- copying -------------------------------------------------------- #
|
|
597
|
+
|
|
598
|
+
def copy(self):
|
|
599
|
+
"""Return an independent copy of this entry.
|
|
600
|
+
|
|
601
|
+
Mutating the copy (or its fields) never affects the original.
|
|
602
|
+
The copy is not a member of any `Library` (its `groups` is
|
|
603
|
+
`()`) until you add it to one, and starts with `dirty` set to
|
|
604
|
+
`False`: its fields (including `date-added`, `date-modified`,
|
|
605
|
+
and `keywords`, which travels with the entry, unlike `groups`)
|
|
606
|
+
are copied verbatim from the original, so the copy is a
|
|
607
|
+
faithful snapshot of the original's current state, not a
|
|
608
|
+
fresh, empty entry.
|
|
609
|
+
"""
|
|
610
|
+
new_fields = [
|
|
611
|
+
model.Field(key=field.key, value=field.value)
|
|
612
|
+
for field in self._entry.fields
|
|
613
|
+
]
|
|
614
|
+
new_entry = model.Entry(
|
|
615
|
+
entry_type=self._entry.entry_type,
|
|
616
|
+
key=self._entry.key,
|
|
617
|
+
fields=new_fields,
|
|
618
|
+
)
|
|
619
|
+
return Entry._wrap(new_entry)
|