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