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
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
r"""Serialization of `Entry` objects to bibtex text ("export" snippets).
|
|
2
|
+
|
|
3
|
+
Provides {any}`export_entries`, which renders one or more
|
|
4
|
+
{any}`bibdeskparser.entry.Entry` objects to a bibtex snippet in one of
|
|
5
|
+
three formats:
|
|
6
|
+
|
|
7
|
+
- `"default"`: every stored field except the BibDesk bookkeeping fields
|
|
8
|
+
(`date-added`/`date-modified`) and the `bdsk-*` fields -- i.e., the
|
|
9
|
+
`Entry` dict interface plus `keywords` -- Unicode and detexified,
|
|
10
|
+
plus the entry's `bdsk-file-N`/`bdsk-url-N` fields rendered as plain
|
|
11
|
+
relative paths/URLs. This is the format used to build the temporary
|
|
12
|
+
file shown to `$EDITOR` (see the later `editing.py` module): it is
|
|
13
|
+
re-parseable and its fields can be merged back into an `Entry` (the
|
|
14
|
+
`bdsk-file`/`bdsk-url` and `keywords` lines are merged via their
|
|
15
|
+
dedicated properties, not the dict interface, since those fields are
|
|
16
|
+
not part of it).
|
|
17
|
+
- `"raw"`: the same field set/order as `"default"`, but field values are
|
|
18
|
+
the literal TeX-encoded text as stored (i.e., exactly what would end
|
|
19
|
+
up in a `.bib` file on disk), rather than the decoded Unicode dict
|
|
20
|
+
values.
|
|
21
|
+
- `"minimal"`: a small, LaTeX-bibliography-oriented whitelist of fields
|
|
22
|
+
per entry type, covering `article`, `inproceedings`, `incollection`,
|
|
23
|
+
`mastersthesis`, and `phdthesis`, with a best-effort
|
|
24
|
+
`author, title, year` fallback for every other entry type.
|
|
25
|
+
|
|
26
|
+
For `"default"` and `"raw"`, an optional `strings` mapping of macro name
|
|
27
|
+
to Unicode value (e.g., `library.strings`) is used to prepend `@string`
|
|
28
|
+
definitions for every macro name that is actually referenced (as a bare
|
|
29
|
+
field value) by the selected entries, so the exported snippet is
|
|
30
|
+
self-contained. `"minimal"` never emits `@string` definitions. The
|
|
31
|
+
`keywords` field is always literal text, never a macro reference: its
|
|
32
|
+
value is always rendered braced, and it never pulls in an `@string`
|
|
33
|
+
definition, even when a keyword happens to match a macro name.
|
|
34
|
+
|
|
35
|
+
This module intentionally does not import `bibdeskparser.library`
|
|
36
|
+
(which imports this module), to avoid a circular dependency.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
>>> from bibdeskparser.entry import Entry
|
|
40
|
+
>>> from bibdeskparser.exporting import export_entries
|
|
41
|
+
>>> entry = Entry(
|
|
42
|
+
... "article",
|
|
43
|
+
... "Key2024",
|
|
44
|
+
... fields={"author": "Jane Doe", "title": "A Title", "year": "2024"},
|
|
45
|
+
... )
|
|
46
|
+
>>> text = export_entries([entry])
|
|
47
|
+
>>> text == (
|
|
48
|
+
... "@article{Key2024,\n"
|
|
49
|
+
... "\tauthor = {Jane Doe},\n"
|
|
50
|
+
... "\ttitle = {A Title},\n"
|
|
51
|
+
... "\tyear = {2024}\n"
|
|
52
|
+
... "}\n"
|
|
53
|
+
... )
|
|
54
|
+
True
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
import re
|
|
60
|
+
|
|
61
|
+
from .bdskfile import BibDeskFile
|
|
62
|
+
from .macros import is_valid_macro_name, normalize_macro_name
|
|
63
|
+
from .writer import bibdesk_field_order
|
|
64
|
+
|
|
65
|
+
__all__ = []
|
|
66
|
+
|
|
67
|
+
# All members whose name does not start with an underscore must be listed
|
|
68
|
+
# either in __all__ or in __private__
|
|
69
|
+
__private__ = ["export_entries"]
|
|
70
|
+
|
|
71
|
+
_FORMATS = frozenset(("default", "raw", "minimal"))
|
|
72
|
+
|
|
73
|
+
#: Non-normal field keys (never part of any exported format's field
|
|
74
|
+
#: list; `bdsk-file-N`/`bdsk-url-N` are handled separately).
|
|
75
|
+
_DATE_KEYS = frozenset(("date-added", "date-modified"))
|
|
76
|
+
|
|
77
|
+
_BDSK_FILE_RE = re.compile(r"bdsk-file-(\d+)$", re.IGNORECASE)
|
|
78
|
+
_BDSK_URL_RE = re.compile(r"bdsk-url-(\d+)$", re.IGNORECASE)
|
|
79
|
+
|
|
80
|
+
#: Per-entry-type field whitelist for the `"minimal"` format.
|
|
81
|
+
_MINIMAL_FIELDS = {
|
|
82
|
+
"article": (
|
|
83
|
+
"author",
|
|
84
|
+
"title",
|
|
85
|
+
"journal",
|
|
86
|
+
"year",
|
|
87
|
+
"doi",
|
|
88
|
+
"pages",
|
|
89
|
+
"volume",
|
|
90
|
+
"number",
|
|
91
|
+
),
|
|
92
|
+
"inproceedings": (
|
|
93
|
+
"author",
|
|
94
|
+
"title",
|
|
95
|
+
"booktitle",
|
|
96
|
+
"year",
|
|
97
|
+
"doi",
|
|
98
|
+
"pages",
|
|
99
|
+
"address",
|
|
100
|
+
"editor",
|
|
101
|
+
),
|
|
102
|
+
"incollection": (
|
|
103
|
+
"author",
|
|
104
|
+
"title",
|
|
105
|
+
"booktitle",
|
|
106
|
+
"year",
|
|
107
|
+
"doi",
|
|
108
|
+
"pages",
|
|
109
|
+
"editor",
|
|
110
|
+
"publisher",
|
|
111
|
+
"volume",
|
|
112
|
+
),
|
|
113
|
+
"mastersthesis": ("author", "title", "school", "year"),
|
|
114
|
+
"phdthesis": ("author", "title", "school", "year"),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#: Best-effort fallback whitelist for any entry type not covered by
|
|
118
|
+
#: `_MINIMAL_FIELDS` (e.g. `book`, `misc`, `techreport`,
|
|
119
|
+
#: `unpublished`, ...). Not tuned per type -- just enough for a minimal
|
|
120
|
+
#: citation.
|
|
121
|
+
_MINIMAL_FALLBACK = ("author", "title", "year")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_normal_field(key):
|
|
125
|
+
"""Whether `key` is exported as a regular field: not a
|
|
126
|
+
`date-added`/`date-modified`/`bdsk-*` field. This includes
|
|
127
|
+
`keywords`, which `Entry`'s dict interface hides."""
|
|
128
|
+
lkey = key.lower()
|
|
129
|
+
return lkey not in _DATE_KEYS and not lkey.startswith("bdsk-")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _normal_fields(entry):
|
|
133
|
+
"""The "normal" `Field` objects of `entry`, in BibDesk's field
|
|
134
|
+
order."""
|
|
135
|
+
fields = [f for f in entry._entry.fields if _is_normal_field(f.key)]
|
|
136
|
+
return bibdesk_field_order(fields)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _strip_braces(value):
|
|
140
|
+
"""Strip one matching pair of enclosing `{...}`/`"..."`, if
|
|
141
|
+
present."""
|
|
142
|
+
if isinstance(value, str) and len(value) >= 2:
|
|
143
|
+
if (value[0] == "{" and value[-1] == "}") or (
|
|
144
|
+
value[0] == '"' and value[-1] == '"'
|
|
145
|
+
):
|
|
146
|
+
return value[1:-1]
|
|
147
|
+
return value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _render_value(value):
|
|
151
|
+
"""Render a Unicode field value: bare if it's a normalized macro
|
|
152
|
+
name, else braced."""
|
|
153
|
+
if is_valid_macro_name(value, normalized=True):
|
|
154
|
+
return value
|
|
155
|
+
return "{" + value + "}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _bdsk_bodies(entry):
|
|
159
|
+
"""`"name = value"` bodies (no trailing comma/newline) for
|
|
160
|
+
`entry`'s `bdsk-file-N`/`bdsk-url-N` fields, in numeric order:
|
|
161
|
+
files (as plain relative paths) before URLs (brace-stripped)."""
|
|
162
|
+
file_fields = []
|
|
163
|
+
url_fields = []
|
|
164
|
+
for field in entry._entry.fields:
|
|
165
|
+
match = _BDSK_FILE_RE.match(field.key)
|
|
166
|
+
if match:
|
|
167
|
+
file_fields.append((int(match.group(1)), field))
|
|
168
|
+
continue
|
|
169
|
+
match = _BDSK_URL_RE.match(field.key)
|
|
170
|
+
if match:
|
|
171
|
+
url_fields.append((int(match.group(1)), field))
|
|
172
|
+
file_fields.sort(key=lambda item: item[0])
|
|
173
|
+
url_fields.sort(key=lambda item: item[0])
|
|
174
|
+
bodies = []
|
|
175
|
+
for index, field in file_fields:
|
|
176
|
+
value = field.value
|
|
177
|
+
if isinstance(value, BibDeskFile):
|
|
178
|
+
path = value.relative_path
|
|
179
|
+
else:
|
|
180
|
+
path = BibDeskFile.from_field_value(value).relative_path
|
|
181
|
+
bodies.append(f"bdsk-file-{index} = {{{path}}}")
|
|
182
|
+
for index, field in url_fields:
|
|
183
|
+
url = _strip_braces(field.value)
|
|
184
|
+
bodies.append(f"bdsk-url-{index} = {{{url}}}")
|
|
185
|
+
return bodies
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _entry_bodies(entry, fmt):
|
|
189
|
+
"""`"name = value"` bodies (no trailing comma/newline), in order,
|
|
190
|
+
for `entry` in the `"default"`/`"raw"` formats."""
|
|
191
|
+
bodies = []
|
|
192
|
+
for field in _normal_fields(entry):
|
|
193
|
+
name = field.key.lower()
|
|
194
|
+
if fmt == "raw":
|
|
195
|
+
bodies.append(f"{name} = {field.value}")
|
|
196
|
+
else:
|
|
197
|
+
# `entry._decode` rather than `entry[field.key]`: the
|
|
198
|
+
# decoding is identical, but the dict interface hides
|
|
199
|
+
# `keywords`, which is still exported.
|
|
200
|
+
value = entry._decode(field.key, field.value)
|
|
201
|
+
if name == "keywords":
|
|
202
|
+
# Keywords are always literal text, never a macro
|
|
203
|
+
# reference: a one-word macro-shaped keyword must not
|
|
204
|
+
# be rendered bare.
|
|
205
|
+
bodies.append(f"{name} = {{{value}}}")
|
|
206
|
+
else:
|
|
207
|
+
bodies.append(f"{name} = {_render_value(value)}")
|
|
208
|
+
bodies.extend(_bdsk_bodies(entry))
|
|
209
|
+
return bodies
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _render_entry(entry, fmt):
|
|
213
|
+
"""Render a single `entry` (`"default"`/`"raw"`/`"minimal"`)."""
|
|
214
|
+
if fmt == "minimal":
|
|
215
|
+
return _render_minimal_entry(entry)
|
|
216
|
+
bodies = _entry_bodies(entry, fmt)
|
|
217
|
+
lines = [f"@{entry.entry_type}{{{entry.key},\n"]
|
|
218
|
+
last = len(bodies) - 1
|
|
219
|
+
for i, body in enumerate(bodies):
|
|
220
|
+
tail = "" if i == last else ","
|
|
221
|
+
lines.append(f"\t{body}{tail}\n")
|
|
222
|
+
lines.append("}\n")
|
|
223
|
+
return "".join(lines)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _render_minimal_entry(entry):
|
|
227
|
+
"""Render a single `entry` in the `"minimal"` format."""
|
|
228
|
+
whitelist = _MINIMAL_FIELDS.get(entry.entry_type.lower())
|
|
229
|
+
if whitelist is None:
|
|
230
|
+
whitelist = _MINIMAL_FALLBACK
|
|
231
|
+
lines = [f"@{entry.entry_type}{{{entry.key},\n"]
|
|
232
|
+
for name in whitelist:
|
|
233
|
+
value = entry.get(name)
|
|
234
|
+
if not value:
|
|
235
|
+
continue
|
|
236
|
+
lines.append(f" {name.capitalize()} = {_render_value(value)},\n")
|
|
237
|
+
lines.append("}\n")
|
|
238
|
+
return "".join(lines)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _is_bare(value):
|
|
242
|
+
"""Whether `value` is a string with no enclosing `{...}`/`"..."`
|
|
243
|
+
(a bare macro reference, per BibDesk's convention)."""
|
|
244
|
+
return isinstance(value, str) and bool(value) and value[0] not in '{"'
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _referenced_macro_names(entries):
|
|
248
|
+
"""The set of (normalized) macro names referenced as a bare field
|
|
249
|
+
value by any entry in `entries`.
|
|
250
|
+
|
|
251
|
+
The `keywords` field never counts as a macro reference (keywords
|
|
252
|
+
are always literal text), so it never pulls in an `@string`
|
|
253
|
+
definition."""
|
|
254
|
+
names = set()
|
|
255
|
+
for entry in entries:
|
|
256
|
+
for field in entry._entry.fields:
|
|
257
|
+
value = field.value
|
|
258
|
+
if (
|
|
259
|
+
_is_bare(value)
|
|
260
|
+
and field.key.lower() != "keywords"
|
|
261
|
+
and is_valid_macro_name(value, normalized=False)
|
|
262
|
+
):
|
|
263
|
+
names.add(normalize_macro_name(value))
|
|
264
|
+
return names
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _string_lines(entries, strings):
|
|
268
|
+
"""`@string{name = {value}}` lines for every macro referenced by
|
|
269
|
+
`entries` that is defined in `strings`, sorted by name."""
|
|
270
|
+
if not strings:
|
|
271
|
+
return []
|
|
272
|
+
names = sorted(
|
|
273
|
+
name for name in _referenced_macro_names(entries) if name in strings
|
|
274
|
+
)
|
|
275
|
+
return [
|
|
276
|
+
"@string{" + name + " = {" + strings[name] + "}}\n" for name in names
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def export_entries(
|
|
281
|
+
entries, strings=None, format="default", outfile=None
|
|
282
|
+
): # pylint: disable=redefined-builtin
|
|
283
|
+
"""Serialize `entries` (an iterable of `Entry`) to bibtex text.
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
export_entries(entries, strings=None, format="default", outfile=None)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Returns the text as a `str` if `outfile` is `None`, else writes it
|
|
290
|
+
(UTF-8) to `outfile` and returns `None`.
|
|
291
|
+
|
|
292
|
+
# Arguments
|
|
293
|
+
|
|
294
|
+
* `entries`: an iterable of {any}`bibdeskparser.entry.Entry`.
|
|
295
|
+
* `strings`: an optional `dict` mapping macro name to its Unicode
|
|
296
|
+
value (e.g. `library.strings`), used to prepend `@string`
|
|
297
|
+
definitions for every macro actually referenced by `entries`
|
|
298
|
+
(see the module docstring); ignored for `format="minimal"`. A
|
|
299
|
+
referenced macro not found in `strings` is silently skipped (not
|
|
300
|
+
an error -- this is a best-effort self-containment feature, not
|
|
301
|
+
a validator).
|
|
302
|
+
* `format`: one of `"default"`, `"raw"`, `"minimal"` (see the
|
|
303
|
+
module docstring); raises {any}`ValueError` for any other value.
|
|
304
|
+
* `outfile`: if given, a path (`str`/`pathlib.Path`) or an
|
|
305
|
+
already-open text file object (anything with a `write` method;
|
|
306
|
+
if already open, it is written to but not closed).
|
|
307
|
+
|
|
308
|
+
Multiple entries are separated by a single blank line; the returned
|
|
309
|
+
(or written) text always ends with exactly one trailing newline.
|
|
310
|
+
"""
|
|
311
|
+
if format not in _FORMATS:
|
|
312
|
+
raise ValueError(
|
|
313
|
+
f"format must be one of {sorted(_FORMATS)}, not {format!r}"
|
|
314
|
+
)
|
|
315
|
+
entries = list(entries)
|
|
316
|
+
pieces = []
|
|
317
|
+
if format != "minimal":
|
|
318
|
+
string_lines = _string_lines(entries, strings)
|
|
319
|
+
if string_lines:
|
|
320
|
+
pieces.append("".join(string_lines))
|
|
321
|
+
pieces.append("\n")
|
|
322
|
+
pieces.append("\n".join(_render_entry(entry, format) for entry in entries))
|
|
323
|
+
text = "".join(pieces)
|
|
324
|
+
if outfile is None:
|
|
325
|
+
return text
|
|
326
|
+
if hasattr(outfile, "write"):
|
|
327
|
+
outfile.write(text)
|
|
328
|
+
return None
|
|
329
|
+
with open(outfile, "w", encoding="utf-8") as fh:
|
|
330
|
+
fh.write(text)
|
|
331
|
+
return None
|
bibdeskparser/groups.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""BibDesk static groups (the `BibDesk Static Groups` `@comment`).
|
|
2
|
+
|
|
3
|
+
BibDesk stores manually curated ("static") groups in an `@comment` block
|
|
4
|
+
at the end of the `.bib` file. The body of that comment is the literal
|
|
5
|
+
prefix `BibDesk Static Groups{`, a newline, an Apple XML plist, and a
|
|
6
|
+
closing `}`. The plist is an `<array>` of `<dict>`s with two keys:
|
|
7
|
+
`group name` (the group's display name) and `keys` (the citation keys of
|
|
8
|
+
the group's members, comma-joined). Python's `plistlib` reproduces
|
|
9
|
+
BibDesk's exact XML layout (tab indentation, entity escaping), so a
|
|
10
|
+
parsed block re-serializes byte-for-byte.
|
|
11
|
+
|
|
12
|
+
This module holds only pure (de)serialization functions. The decoded
|
|
13
|
+
form is a plain `dict` mapping each group name to a `tuple` of citation
|
|
14
|
+
keys, in file order; all group *state* (and every mutation of it) lives
|
|
15
|
+
in {any}`bibdeskparser.library.Library`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import plistlib
|
|
19
|
+
|
|
20
|
+
__all__ = []
|
|
21
|
+
|
|
22
|
+
# All members whose name does not start with an underscore must be listed
|
|
23
|
+
# either in __all__ or in __private__
|
|
24
|
+
__private__ = [
|
|
25
|
+
"is_static_groups_comment",
|
|
26
|
+
"parse_static_groups",
|
|
27
|
+
"render_static_groups",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
_STATIC_GROUPS_HEAD = "BibDesk Static Groups{"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_static_groups_comment(comment):
|
|
34
|
+
"""Check whether a comment body holds a static-groups block.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
is_static_groups_comment(comment)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Return `True` if `comment` (the string body of an `@comment` block,
|
|
41
|
+
e.g. the `comment` attribute of a bibtexparser `ExplicitComment`) is
|
|
42
|
+
a `BibDesk Static Groups` block that can be parsed with
|
|
43
|
+
`parse_static_groups`. Non-string input yields `False`.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
>>> from bibdeskparser.groups import is_static_groups_comment
|
|
47
|
+
>>> is_static_groups_comment("BibDesk Static Groups{...}")
|
|
48
|
+
True
|
|
49
|
+
>>> is_static_groups_comment("some other comment")
|
|
50
|
+
False
|
|
51
|
+
>>> is_static_groups_comment(None)
|
|
52
|
+
False
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
"""
|
|
56
|
+
return isinstance(comment, str) and comment.startswith(_STATIC_GROUPS_HEAD)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_static_groups(comment):
|
|
60
|
+
"""Parse an `@comment` body of the form `BibDesk Static Groups{...}`.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
parse_static_groups(comment)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The `...` is a newline, an Apple XML plist, and a final newline; the
|
|
67
|
+
trailing `}` closes the head's opening brace. Return a `dict`
|
|
68
|
+
mapping each group name to a `tuple` of the group's citation keys,
|
|
69
|
+
with both the groups and the keys within each group in file order.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
>>> from bibdeskparser.groups import (
|
|
73
|
+
... parse_static_groups,
|
|
74
|
+
... render_static_groups,
|
|
75
|
+
... )
|
|
76
|
+
>>> comment = render_static_groups({"My Papers": ("key1", "key2")})
|
|
77
|
+
>>> parse_static_groups(comment)
|
|
78
|
+
{'My Papers': ('key1', 'key2')}
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
"""
|
|
82
|
+
# After the head comes `\\n<plist xml>\\n}`; drop the head and the
|
|
83
|
+
# trailing `}`. plistlib requires the `<?xml` declaration at offset
|
|
84
|
+
# 0, so strip the leading newline (render_static_groups re-adds it).
|
|
85
|
+
body = comment[len(_STATIC_GROUPS_HEAD) : comment.rindex("}")]
|
|
86
|
+
array = plistlib.loads(body.lstrip().encode("utf-8"))
|
|
87
|
+
return {
|
|
88
|
+
item["group name"]: (
|
|
89
|
+
tuple(item["keys"].split(",")) if item["keys"] else ()
|
|
90
|
+
)
|
|
91
|
+
for item in array
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def render_static_groups(groups):
|
|
96
|
+
"""Serialize `groups` to the exact `@comment` body BibDesk writes.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
render_static_groups(groups)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
* `groups`: a `dict` mapping each group name to an iterable of the
|
|
103
|
+
group's citation keys (as returned by `parse_static_groups`).
|
|
104
|
+
|
|
105
|
+
The result of parsing a BibDesk-written comment with
|
|
106
|
+
`parse_static_groups` and serializing it again is byte-identical to
|
|
107
|
+
the original.
|
|
108
|
+
"""
|
|
109
|
+
array = [
|
|
110
|
+
{"group name": name, "keys": ",".join(keys)}
|
|
111
|
+
for name, keys in groups.items()
|
|
112
|
+
]
|
|
113
|
+
xml = plistlib.dumps(
|
|
114
|
+
array,
|
|
115
|
+
fmt=plistlib.FMT_XML, # pylint: disable=no-member
|
|
116
|
+
).decode("utf-8")
|
|
117
|
+
# plistlib output ends in `</plist>\n`; the head supplies the
|
|
118
|
+
# leading `\n`.
|
|
119
|
+
return f"{_STATIC_GROUPS_HEAD}\n{xml}}}"
|
bibdeskparser/header.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Handling of the header comment in BibDesk `.bib` files.
|
|
2
|
+
|
|
3
|
+
Every `.bib` file saved by BibDesk starts with this header (`NAME` is
|
|
4
|
+
the macOS user's full name, and there is a single trailing space after
|
|
5
|
+
the date and after `(UTF-8)`):
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
%% This BibTeX bibliography file was created using BibDesk.
|
|
9
|
+
%% http://bibdesk.sourceforge.net/
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
%% Created for NAME at YYYY-MM-DD HH:MM:SS ±ZZZZ
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
%% Saved with string encoding Unicode (UTF-8)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The date on the `Created for` line is the time of the last save;
|
|
19
|
+
BibDesk updates it in place on every save. `bibtexparser` parses the
|
|
20
|
+
whole header as a single `ImplicitComment` block (internal blank lines
|
|
21
|
+
and trailing spaces are preserved verbatim, but the trailing space of
|
|
22
|
+
the *last* line is rstripped away; see `restore_trailing_space`).
|
|
23
|
+
|
|
24
|
+
This module extracts data from that header (`parse_header`,
|
|
25
|
+
`peek_timestamp`), updates it in place (`update_header`), and
|
|
26
|
+
synthesizes it for new files (`make_header`).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import datetime
|
|
30
|
+
import re
|
|
31
|
+
|
|
32
|
+
__all__ = []
|
|
33
|
+
|
|
34
|
+
# All members whose name does not start with an underscore must be listed
|
|
35
|
+
# either in __all__ or in __private__
|
|
36
|
+
__private__ = [
|
|
37
|
+
"parse_header",
|
|
38
|
+
"make_header",
|
|
39
|
+
"peek_timestamp",
|
|
40
|
+
"update_header",
|
|
41
|
+
"restore_trailing_space",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
#: `strptime`/`strftime` format of the date in the `Created for` line.
|
|
46
|
+
_TIMESTAMP_FMT = "%Y-%m-%d %H:%M:%S %z"
|
|
47
|
+
|
|
48
|
+
#: Regex matching the `Created for` line of a BibDesk header.
|
|
49
|
+
_CREATED_RE = re.compile(
|
|
50
|
+
r"^%% Created for (?P<creator>.+?) at "
|
|
51
|
+
r"(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{4})",
|
|
52
|
+
flags=re.MULTILINE,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
#: First line of the last section of a BibDesk header.
|
|
56
|
+
_SAVED_WITH_PREFIX = "%% Saved with string encoding "
|
|
57
|
+
|
|
58
|
+
#: Template for `make_header` (mind the trailing spaces).
|
|
59
|
+
_HEADER_TEMPLATE = (
|
|
60
|
+
"%% This BibTeX bibliography file was created using BibDesk.\n"
|
|
61
|
+
"%% http://bibdesk.sourceforge.net/\n"
|
|
62
|
+
"\n"
|
|
63
|
+
"\n"
|
|
64
|
+
"%% Created for {creator} at {timestamp} \n"
|
|
65
|
+
"\n"
|
|
66
|
+
"\n"
|
|
67
|
+
"%% Saved with string encoding Unicode (UTF-8) "
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_header(comment_text):
|
|
72
|
+
"""Extract the creator and save time from a BibDesk header.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
parse_header(comment_text)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Returns a tuple `(creator, timestamp)` with the creator name (str)
|
|
79
|
+
and the last-save time (a timezone-aware
|
|
80
|
+
{any}`datetime.datetime`), extracted from the
|
|
81
|
+
`%% Created for NAME at YYYY-MM-DD HH:MM:SS ±ZZZZ` line of
|
|
82
|
+
`comment_text` (the body of the comment block at the top of the
|
|
83
|
+
`.bib` file). Returns `(None, None)` if no such line is found,
|
|
84
|
+
indicating that the file was not written by BibDesk.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
>>> from bibdeskparser.header import parse_header
|
|
88
|
+
>>> header = (
|
|
89
|
+
... "%% This BibTeX bibliography file was created using "
|
|
90
|
+
... "BibDesk.\\n"
|
|
91
|
+
... "%% http://bibdesk.sourceforge.net/\\n\\n\\n"
|
|
92
|
+
... "%% Created for Michael Goerz at 2026-07-04 13:45:42 -0400 "
|
|
93
|
+
... )
|
|
94
|
+
>>> creator, timestamp = parse_header(header)
|
|
95
|
+
>>> creator
|
|
96
|
+
'Michael Goerz'
|
|
97
|
+
>>> print(timestamp)
|
|
98
|
+
2026-07-04 13:45:42-04:00
|
|
99
|
+
>>> parse_header("% not a BibDesk header")
|
|
100
|
+
(None, None)
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
"""
|
|
104
|
+
match = _CREATED_RE.search(comment_text)
|
|
105
|
+
if match is None:
|
|
106
|
+
return (None, None)
|
|
107
|
+
timestamp = datetime.datetime.strptime(
|
|
108
|
+
match.group("timestamp"), _TIMESTAMP_FMT
|
|
109
|
+
)
|
|
110
|
+
return (match.group("creator"), timestamp)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def update_header(comment_text, timestamp):
|
|
114
|
+
"""Replace the save time in a BibDesk header.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
update_header(comment_text, timestamp)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Returns a copy of `comment_text` in which only the
|
|
121
|
+
`YYYY-MM-DD HH:MM:SS ±ZZZZ` substring of the `Created for` line is
|
|
122
|
+
replaced with `timestamp` (a timezone-aware
|
|
123
|
+
{any}`datetime.datetime`). Everything else, including trailing
|
|
124
|
+
spaces, is preserved byte-for-byte, mirroring how BibDesk updates
|
|
125
|
+
the date in place on every save.
|
|
126
|
+
|
|
127
|
+
Raises {any}`ValueError` if `comment_text` contains no
|
|
128
|
+
`Created for` line (see `parse_header`).
|
|
129
|
+
"""
|
|
130
|
+
match = _CREATED_RE.search(comment_text)
|
|
131
|
+
if match is None:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"Cannot update header: no BibDesk 'Created for' line in "
|
|
134
|
+
f"{comment_text!r}"
|
|
135
|
+
)
|
|
136
|
+
start, end = match.span("timestamp")
|
|
137
|
+
return (
|
|
138
|
+
comment_text[:start]
|
|
139
|
+
+ timestamp.strftime(_TIMESTAMP_FMT)
|
|
140
|
+
+ comment_text[end:]
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def make_header(creator, timestamp):
|
|
145
|
+
"""Synthesize the header comment for a new BibDesk `.bib` file.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
make_header(creator, timestamp)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Returns the canonical BibDesk header exactly as BibDesk writes it
|
|
152
|
+
(including the single trailing space after the date and after
|
|
153
|
+
`(UTF-8)`, and the two blank lines between sections), without a
|
|
154
|
+
trailing newline.
|
|
155
|
+
|
|
156
|
+
* `creator`: the name to put on the `Created for` line.
|
|
157
|
+
* `timestamp`: the save time, as a timezone-aware
|
|
158
|
+
{any}`datetime.datetime`.
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
>>> import datetime
|
|
162
|
+
>>> from bibdeskparser.header import make_header, parse_header
|
|
163
|
+
>>> timestamp = datetime.datetime(
|
|
164
|
+
... 2026, 7, 4, 13, 45, 42,
|
|
165
|
+
... tzinfo=datetime.timezone(datetime.timedelta(hours=-4)),
|
|
166
|
+
... )
|
|
167
|
+
>>> header = make_header("Michael Goerz", timestamp)
|
|
168
|
+
>>> for line in header.split("\\n"):
|
|
169
|
+
... print(repr(line))
|
|
170
|
+
'%% This BibTeX bibliography file was created using BibDesk.'
|
|
171
|
+
'%% http://bibdesk.sourceforge.net/'
|
|
172
|
+
''
|
|
173
|
+
''
|
|
174
|
+
'%% Created for Michael Goerz at 2026-07-04 13:45:42 -0400 '
|
|
175
|
+
''
|
|
176
|
+
''
|
|
177
|
+
'%% Saved with string encoding Unicode (UTF-8) '
|
|
178
|
+
>>> parse_header(header) == ("Michael Goerz", timestamp)
|
|
179
|
+
True
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
"""
|
|
183
|
+
return _HEADER_TEMPLATE.format(
|
|
184
|
+
creator=creator, timestamp=timestamp.strftime(_TIMESTAMP_FMT)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def peek_timestamp(path):
|
|
189
|
+
"""Cheaply extract the save time from a `.bib` file on disk.
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
peek_timestamp(path)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Reads only the first 20 lines of the file at `path` (without
|
|
196
|
+
parsing it as BibTeX) and returns the timezone-aware
|
|
197
|
+
{any}`datetime.datetime` from the header's `Created for` line, or
|
|
198
|
+
`None` if the file has no BibDesk header. This is intended for
|
|
199
|
+
detecting whether a file changed on disk (e.g., was re-saved by
|
|
200
|
+
BibDesk) since it was read.
|
|
201
|
+
"""
|
|
202
|
+
lines = []
|
|
203
|
+
with open(path, encoding="utf-8", errors="replace") as bibfile:
|
|
204
|
+
for line in bibfile:
|
|
205
|
+
lines.append(line)
|
|
206
|
+
if len(lines) >= 20:
|
|
207
|
+
break
|
|
208
|
+
_, timestamp = parse_header("".join(lines))
|
|
209
|
+
return timestamp
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def restore_trailing_space(comment_text):
|
|
213
|
+
"""Re-append the trailing space to a parsed BibDesk header.
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
restore_trailing_space(comment_text)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
BibDesk writes the last header line as
|
|
220
|
+
`%% Saved with string encoding Unicode (UTF-8) `, with a single
|
|
221
|
+
trailing space. When `bibtexparser` parses the header into an
|
|
222
|
+
`ImplicitComment`, it preserves *internal* trailing spaces (such as
|
|
223
|
+
the one after the date) but rstrips the whitespace at the very end
|
|
224
|
+
of the comment, so that one space is lost. This function re-appends
|
|
225
|
+
it, so that writing the parsed header back to disk is byte-exact.
|
|
226
|
+
|
|
227
|
+
The space is appended only if the last line of `comment_text`
|
|
228
|
+
starts with `%% Saved with string encoding` and does not already
|
|
229
|
+
end in whitespace; any other text is returned unchanged.
|
|
230
|
+
"""
|
|
231
|
+
last_line = comment_text.rsplit("\n", 1)[-1]
|
|
232
|
+
if last_line.startswith(_SAVED_WITH_PREFIX.rstrip()) and (
|
|
233
|
+
last_line == last_line.rstrip()
|
|
234
|
+
):
|
|
235
|
+
return comment_text + " "
|
|
236
|
+
return comment_text
|