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.
@@ -0,0 +1,673 @@
1
+ r"""`$EDITOR` round-trip editing of `Entry` objects and `Library` strings.
2
+
3
+ Provides {any}`edit_entries` and {any}`edit_strings`: pure functions that
4
+ export data to a temporary `.bib`-like file, open it in `$EDITOR`, wait
5
+ for the editor to exit, re-parse the (possibly edited) result, validate
6
+ it, and merge any changes back into the *original*
7
+ {any}`bibdeskparser.entry.Entry` objects (and, optionally, a
8
+ {any}`bibdeskparser.library.Library`'s `.strings`). This includes an
9
+ entry's `keywords = {...}` line, which merges back through the entry's
10
+ keywords accessors ({any}`bibdeskparser.entry.Entry.keywords` is not
11
+ part of the dict interface). Neither function is
12
+ a method on `Library` -- wiring those up (e.g. `Library.edit()`) is
13
+ out of scope for this module.
14
+
15
+ Only the `"default"` export format can be round-tripped this way (see
16
+ "Known limitations" below): re-parsing and merging back relies on its
17
+ exact text shape.
18
+
19
+ ## Known limitations
20
+
21
+ * {any}`edit_entries` can only edit the fields of *existing* entries
22
+ matched by citation key: it cannot add or remove entries. An
23
+ original entry whose key does not appear in the edited text is left
24
+ untouched (with a `UserWarning`); an entry block in the edited text
25
+ whose key does not match any original entry is silently ignored
26
+ (also with a `UserWarning`). Renaming a citekey is not supported
27
+ either, since that has wider implications (e.g. `Library` dict-key
28
+ consistency) that are out of scope here.
29
+ * Only `format="default"` is accepted (see {any}`edit_entries`'s
30
+ docstring for why); the parameter still exists for API symmetry with
31
+ `export_entries`/`render_entry`.
32
+ * In {any}`edit_strings`, a macro whose deletion fails (because it is
33
+ still referenced by an entry) is reported as a validation problem
34
+ for that round and can be fixed by reopening the editor; however,
35
+ *other* changes from that same round (new/redefined/renamed macros)
36
+ are **not** rolled back if a later deletion in the same round fails
37
+ -- only failed deletions are retried. This is a deliberate
38
+ simplification (deletions are rare and usually singular); see the
39
+ function's docstring.
40
+
41
+ This module intentionally has no doctest: exercising it means
42
+ launching a real subprocess and waiting on an interactive editor,
43
+ which cannot be done safely inside a doctest. See `tests/test_editing.py`
44
+ for coverage using a scripted fake editor instead.
45
+ """
46
+
47
+ import os
48
+ import re
49
+ import shlex
50
+ import subprocess
51
+ import tempfile
52
+ import warnings
53
+ from pathlib import Path
54
+
55
+ import bibtexparser
56
+
57
+ from .bdskfile import BibDeskFile
58
+ from .exporting import export_entries
59
+ from .macros import is_valid_macro_name, normalize_macro_name
60
+
61
+ __all__ = []
62
+
63
+ # All members whose name does not start with an underscore must be listed
64
+ # either in __all__ or in __private__
65
+ __private__ = ["edit_entries", "edit_strings"]
66
+
67
+ _DATE_KEYS = frozenset(("date-added", "date-modified"))
68
+ _BDSK_FILE_RE = re.compile(r"bdsk-file-(\d+)$", re.IGNORECASE)
69
+ _BDSK_URL_RE = re.compile(r"bdsk-url-(\d+)$", re.IGNORECASE)
70
+
71
+
72
+ # -- small local helpers (do not import the private originals) ------- #
73
+
74
+
75
+ def _strip_enclosing(value):
76
+ """Strip one matching pair of enclosing `{...}`/`"..."` from
77
+ `value`, if present; else return it unchanged (a bare token stays
78
+ bare)."""
79
+ if (
80
+ isinstance(value, str)
81
+ and len(value) >= 2
82
+ and (
83
+ (value[0] == "{" and value[-1] == "}")
84
+ or (value[0] == '"' and value[-1] == '"')
85
+ )
86
+ ):
87
+ return value[1:-1]
88
+ return value
89
+
90
+
91
+ def _is_bare(value):
92
+ """Whether `value` is a non-empty string with no enclosing
93
+ `{...}`/`"..."` (a candidate bare macro reference)."""
94
+ return isinstance(value, str) and bool(value) and value[0] not in '{"'
95
+
96
+
97
+ def _is_bdsk_field(key):
98
+ """Whether `key` is a `bdsk-file-N`/`bdsk-url-N` field name."""
99
+ return bool(_BDSK_FILE_RE.match(key) or _BDSK_URL_RE.match(key))
100
+
101
+
102
+ def _split_keywords(raw):
103
+ """Split a comma-separated `keywords` field value into a tuple of
104
+ stripped, non-empty keywords."""
105
+ return tuple(kw.strip() for kw in raw.split(",") if kw.strip())
106
+
107
+
108
+ # -- editor invocation ------------------------------------------------- #
109
+
110
+
111
+ def _write_temp_file(text):
112
+ """Write `text` to a new, closed temporary `.bib` file; return its
113
+ `Path`."""
114
+ with tempfile.NamedTemporaryFile(
115
+ mode="w", suffix=".bib", delete=False, encoding="utf-8"
116
+ ) as tmp:
117
+ tmp.write(text)
118
+ path = Path(tmp.name)
119
+ return path
120
+
121
+
122
+ def _run_editor(editor, path):
123
+ """Resolve and run the editor command on `path`, waiting for it to
124
+ exit.
125
+
126
+ Resolution order: the explicit `editor` argument, else
127
+ `$EDITOR`, else `"vi"`. Any exception raised by a missing command
128
+ or a non-zero exit status (`subprocess.CalledProcessError`)
129
+ propagates to the caller unchanged.
130
+ """
131
+ command = editor or os.environ.get("EDITOR") or "vi"
132
+ if os.name == "nt":
133
+ # shlex.split is POSIX-only: it treats backslashes as escape
134
+ # characters and mangles Windows paths. Use posix=False and strip
135
+ # the surrounding quotes that non-POSIX mode preserves. posix=False
136
+ # rejects a quote mid-token (a valid way to quote just a space in a
137
+ # Windows path), so fall back to treating the whole command as a
138
+ # single program path in that case.
139
+ try:
140
+ tokens = shlex.split(command, posix=False)
141
+ except ValueError:
142
+ tokens = [command]
143
+ args = []
144
+ for token in tokens:
145
+ if (
146
+ len(token) >= 2
147
+ and token[0] == token[-1]
148
+ and token[0] in ('"', "'")
149
+ ):
150
+ token = token[1:-1]
151
+ args.append(token)
152
+ else:
153
+ args = shlex.split(command)
154
+ subprocess.run(args + [str(path)], check=True)
155
+
156
+
157
+ def _prompt_reopen_or_abandon(problems):
158
+ """Warn about `problems` (a list of human-readable strings) and
159
+ interactively ask whether to reopen the editor or abandon the
160
+ edit.
161
+
162
+ Returns `True` to reopen, `False` to abandon. If `input()` raises
163
+ `EOFError`/`OSError` (non-interactive stdin, e.g. under pytest/CI),
164
+ or the user's answer is anything other than `"r"`
165
+ (case-insensitively; empty/EOF defaults to abandon), abandonment is
166
+ chosen and an additional `"edit abandoned"` warning is emitted.
167
+ """
168
+ message = "Validation failed:\n" + "\n".join(problems)
169
+ warnings.warn(message, UserWarning, stacklevel=3)
170
+ try:
171
+ choice = input(f"{message}\n[r]eopen editor to fix, [a]bandon edit? ")
172
+ except (EOFError, OSError):
173
+ choice = "a"
174
+ choice = (choice or "a").strip().lower()
175
+ if choice == "r":
176
+ return True
177
+ warnings.warn("edit abandoned", UserWarning, stacklevel=3)
178
+ return False
179
+
180
+
181
+ # -- validation --------------------------------------------------------- #
182
+
183
+
184
+ def _failed_block_problems(parsed):
185
+ """Problem strings for every block of `parsed`
186
+ (`bibtexparser.Library`) that failed to parse.
187
+
188
+ `bibtexparser.parse_string` does not raise an exception for a
189
+ syntactically broken block (e.g. unbalanced braces, or a duplicate
190
+ citation key): it silently turns it into a `ParsingFailedBlock` in
191
+ `parsed.failed_blocks` instead. Surfacing those here is what makes
192
+ such editor mistakes trigger the warn-and-reopen-or-abandon flow,
193
+ rather than silently vanishing (as an entry simply "not found in
194
+ the edited text").
195
+ """
196
+ return [
197
+ f"could not parse block: {block.raw!r}"
198
+ for block in parsed.failed_blocks
199
+ ]
200
+
201
+
202
+ def _validate_parsed_entries(parsed, library):
203
+ """Collect human-readable validation problems for a `parsed`
204
+ `bibtexparser.Library` (from `parse_string(text, parse_stack=[])`)
205
+ representing edited `Entry` blocks.
206
+
207
+ * Every entry must have a non-empty `entry_type` and `key`.
208
+ * If `library` is given, every bare field value shaped like a
209
+ macro name must resolve to a name already in `library.strings`
210
+ or newly defined by an `@string` block in the edited text
211
+ itself (so a rename/new-definition is not rejected just because
212
+ it is not yet in `library.strings`). The `keywords` field is
213
+ exempt: keywords are always literal text, never a macro
214
+ reference.
215
+ """
216
+ problems = list(_failed_block_problems(parsed))
217
+ for entry in parsed.entries:
218
+ if not entry.entry_type:
219
+ problems.append(f"entry {entry.key!r}: missing entry type")
220
+ if not entry.key:
221
+ problems.append("an entry is missing its citation key")
222
+ if library is not None:
223
+ defined = set(dict(library.strings))
224
+ for string in parsed.strings:
225
+ if is_valid_macro_name(string.key, normalized=False):
226
+ defined.add(normalize_macro_name(string.key))
227
+ for entry in parsed.entries:
228
+ for field in entry.fields:
229
+ lkey = field.key.lower()
230
+ if (
231
+ _is_bdsk_field(field.key)
232
+ or lkey in _DATE_KEYS
233
+ or lkey == "keywords"
234
+ ):
235
+ continue
236
+ value = field.value
237
+ if not _is_bare(value):
238
+ continue
239
+ if not is_valid_macro_name(value, normalized=False):
240
+ continue
241
+ if normalize_macro_name(value) not in defined:
242
+ problems.append(
243
+ f"entry {entry.key!r}: undefined macro "
244
+ f"{value!r} referenced by field {field.key!r}"
245
+ )
246
+ return problems
247
+
248
+
249
+ def _file_problems(parsed, entries, base_dir):
250
+ """Problem strings for the edited `bdsk-file-N` paths of `parsed`.
251
+
252
+ A path that does not match one of the target entry's existing
253
+ attachments (`entry.files`) is a *changed* attachment: it must
254
+ exist as a file relative to `base_dir` (the library's `.bib`
255
+ directory, or the CWD when editing without a library). If the
256
+ library has no file path at all (`base_dir` is `None`),
257
+ attachments cannot be changed at all."""
258
+ problems = []
259
+ originals = {entry.key: entry for entry in entries}
260
+ for parsed_entry in parsed.entries:
261
+ original = originals.get(parsed_entry.key)
262
+ if original is None:
263
+ continue
264
+ existing = set(original.files)
265
+ for field in parsed_entry.fields:
266
+ if not _BDSK_FILE_RE.match(field.key):
267
+ continue
268
+ path = _strip_enclosing(field.value)
269
+ if path in existing:
270
+ continue
271
+ if base_dir is None:
272
+ problems.append(
273
+ f"entry {parsed_entry.key!r}: cannot change "
274
+ f"linked file {path!r}: the library has no file "
275
+ "path yet (save it first)"
276
+ )
277
+ elif not (base_dir / path).exists():
278
+ problems.append(
279
+ f"entry {parsed_entry.key!r}: linked file does "
280
+ f"not exist: {path!r} (relative to {base_dir})"
281
+ )
282
+ return problems
283
+
284
+
285
+ def _parse_and_validate_entries(text, library, entries, base_dir):
286
+ """Parse `text` (edited `Entry` blocks) and validate it.
287
+
288
+ Returns `(parsed_library_or_None, problems)`: a parse failure is
289
+ caught and reported as a single problem, `parsed_library` is then
290
+ `None`."""
291
+ try:
292
+ # An empty parse_stack (no middleware) keeps braces verbatim,
293
+ # matching what export_entries(..., format="default") wrote:
294
+ # in particular, bdsk-file-N fields are plain path strings
295
+ # here, not the base64 blob the normal read middleware stack
296
+ # expects to decode, so running that stack over edited text
297
+ # would fail or corrupt the paths. Field values are then
298
+ # brace-stripped locally (_strip_enclosing) and fed into the
299
+ # target Entry's normal __setitem__, which re-texifies/
300
+ # re-braces/re-validates a plain Unicode string exactly as if
301
+ # the user had set it directly in Python.
302
+ parsed = bibtexparser.parse_string(text, parse_stack=[])
303
+ except Exception as exc: # pylint: disable=broad-except
304
+ return None, [f"parse error: {exc}"]
305
+ problems = _validate_parsed_entries(parsed, library)
306
+ problems.extend(_file_problems(parsed, entries, base_dir))
307
+ return parsed, problems
308
+
309
+
310
+ def _parse_and_validate_strings(text):
311
+ """Parse `text` (edited `@string` blocks only) and validate it.
312
+
313
+ Returns `(parsed_library_or_None, problems)`, like
314
+ `_parse_and_validate_entries`; validation here only checks
315
+ that every `@string` name is a valid BibDesk macro name."""
316
+ try:
317
+ parsed = bibtexparser.parse_string(text, parse_stack=[])
318
+ except Exception as exc: # pylint: disable=broad-except
319
+ return None, [f"parse error: {exc}"]
320
+ problems = _failed_block_problems(parsed)
321
+ problems.extend(
322
+ f"invalid macro name: {string.key!r}"
323
+ for string in parsed.strings
324
+ if not is_valid_macro_name(string.key, normalized=False)
325
+ )
326
+ return parsed, problems
327
+
328
+
329
+ # -- merging: entries --------------------------------------------------- #
330
+
331
+
332
+ def _merge_fields(original_entry, parsed_entry):
333
+ """Merge `parsed_entry`'s normal fields into `original_entry`
334
+ in-place: set changed/new fields, delete fields absent from the
335
+ edited block. The `keywords` field is not part of `Entry`'s dict
336
+ interface, so it is merged through the entry's dedicated keywords
337
+ accessors instead."""
338
+ parsed_by_key = {}
339
+ for field in parsed_entry.fields:
340
+ if _is_bdsk_field(field.key) or field.key.lower() in _DATE_KEYS:
341
+ continue
342
+ parsed_by_key[field.key.lower()] = (field.key, field.value)
343
+ keywords_item = parsed_by_key.pop("keywords", None)
344
+ new_keywords = (
345
+ _split_keywords(_strip_enclosing(keywords_item[1]))
346
+ if keywords_item is not None
347
+ else ()
348
+ )
349
+ if new_keywords != original_entry.keywords:
350
+ # pylint: disable=protected-access
351
+ original_entry._set_keywords(new_keywords)
352
+ for key in list(original_entry.keys()):
353
+ if key.lower() not in parsed_by_key:
354
+ del original_entry[key]
355
+ for orig_key, raw_value in parsed_by_key.values():
356
+ value = _strip_enclosing(raw_value)
357
+ if original_entry.get(orig_key) != value:
358
+ original_entry[orig_key] = value
359
+
360
+
361
+ def _merge_files_urls(original_entry, parsed_entry, base_dir):
362
+ """Merge `parsed_entry`'s `bdsk-file-N`/`bdsk-url-N` fields into
363
+ `original_entry`'s attachments/`.urls`, interpreting file paths
364
+ relative to `base_dir` (the library's `.bib` directory, or the
365
+ CWD when editing without a library).
366
+
367
+ A path matching one of `original_entry`'s current attachments
368
+ keeps that attachment's existing data (bookmark); a changed path
369
+ gets a fresh `BibDeskFile`. Validation (`_file_problems`) has
370
+ already ensured that every changed path exists relative to
371
+ `base_dir` (and that `base_dir` is not `None` when anything
372
+ changed), so `must_exist=False` here only covers the file
373
+ disappearing between validation and merge."""
374
+ file_items = []
375
+ url_items = []
376
+ for field in parsed_entry.fields:
377
+ match = _BDSK_FILE_RE.match(field.key)
378
+ if match:
379
+ file_items.append(
380
+ (int(match.group(1)), _strip_enclosing(field.value))
381
+ )
382
+ continue
383
+ match = _BDSK_URL_RE.match(field.key)
384
+ if match:
385
+ url_items.append(
386
+ (int(match.group(1)), _strip_enclosing(field.value))
387
+ )
388
+ new_files = [path for _, path in sorted(file_items)]
389
+ new_urls = [url for _, url in sorted(url_items)]
390
+ if new_files != original_entry.files:
391
+ # pylint: disable=protected-access
392
+ by_rel_path = {
393
+ f.relative_path: f for f in original_entry._file_objects()
394
+ }
395
+ original_entry._set_files(
396
+ [
397
+ by_rel_path.get(path)
398
+ or BibDeskFile(
399
+ base_dir / path,
400
+ relative_to=base_dir,
401
+ must_exist=False,
402
+ )
403
+ for path in new_files
404
+ ]
405
+ )
406
+ if new_urls != original_entry.urls:
407
+ original_entry.urls = new_urls
408
+
409
+
410
+ def _merge_entries(entries, parsed, base_dir):
411
+ """Merge every parsed entry block in `parsed` back into the
412
+ matching (by citation key) `Entry` of `entries`; warn about keys
413
+ that could not be matched in either direction (see the module
414
+ docstring's "known limitations"). `base_dir` is the directory
415
+ that `bdsk-file-N` paths are relative to (see
416
+ `_merge_files_urls`)."""
417
+ parsed_by_key = {entry.key: entry for entry in parsed.entries}
418
+ original_keys = {entry.key for entry in entries}
419
+ for entry in entries:
420
+ parsed_entry = parsed_by_key.get(entry.key)
421
+ if parsed_entry is None:
422
+ warnings.warn(
423
+ f"entry {entry.key!r} not found in the edited text; "
424
+ "left unchanged (edit_entries cannot delete entries)",
425
+ UserWarning,
426
+ stacklevel=3,
427
+ )
428
+ continue
429
+ if parsed_entry.entry_type != entry.entry_type:
430
+ entry.entry_type = parsed_entry.entry_type
431
+ _merge_fields(entry, parsed_entry)
432
+ _merge_files_urls(entry, parsed_entry, base_dir)
433
+ for key in parsed_by_key:
434
+ if key not in original_keys:
435
+ warnings.warn(
436
+ f"entry {key!r} is new and was ignored (edit_entries "
437
+ "cannot add entries)",
438
+ UserWarning,
439
+ stacklevel=3,
440
+ )
441
+
442
+
443
+ # -- merging: @string macros ---------------------------------------------- #
444
+
445
+
446
+ def _merge_strings(library, before, parsed_strings, allow_delete):
447
+ """Merge `parsed_strings` (`{name: value}`, from the edited text)
448
+ into `library.strings`, comparing against `before` (`{name:
449
+ value}`, a snapshot of `library.strings` taken before the edit).
450
+
451
+ * Unchanged name/value pairs are ignored.
452
+ * A name already in `before` whose value changed is redefined
453
+ (`library.strings[name] = value`).
454
+ * A new name whose value exactly matches an existing (and not yet
455
+ consumed by another rename this round) macro's original value is
456
+ treated as a rename (`library.rename_string`); otherwise it is a
457
+ new definition.
458
+ * If `allow_delete`, every name in `before` that is absent from
459
+ `parsed_strings` (and was not itself consumed by a rename this
460
+ round) is deleted (`del library.strings[name]`); a `ValueError`
461
+ from a still-in-use macro is caught and returned as a problem
462
+ string rather than propagated, so the caller can re-prompt for
463
+ another editing round instead of crashing -- other changes
464
+ already applied this round (defines/redefines/renames, and any
465
+ *other* successful deletions) are deliberately not rolled back.
466
+
467
+ Returns a list of problem strings (only ever non-empty when
468
+ `allow_delete` is true and a deletion failed).
469
+ """
470
+ consumed = set()
471
+ for name, value in parsed_strings.items():
472
+ if name in before:
473
+ if before[name] != value:
474
+ library.strings[name] = value
475
+ continue
476
+ rename_from = next(
477
+ (
478
+ old_name
479
+ for old_name, old_value in before.items()
480
+ if old_name not in parsed_strings
481
+ and old_name not in consumed
482
+ and old_value == value
483
+ ),
484
+ None,
485
+ )
486
+ if rename_from is not None:
487
+ library.rename_string(rename_from, name)
488
+ consumed.add(rename_from)
489
+ else:
490
+ library.strings[name] = value
491
+ problems = []
492
+ if allow_delete:
493
+ for name in before:
494
+ if name in parsed_strings or name in consumed:
495
+ continue
496
+ try:
497
+ del library.strings[name]
498
+ except ValueError as exc:
499
+ problems.append(str(exc))
500
+ return problems
501
+
502
+
503
+ # -- public API ----------------------------------------------------------- #
504
+
505
+
506
+ def edit_entries(entries, library=None, format="default", editor=None):
507
+ # pylint: disable=redefined-builtin
508
+ """Edit `entries` together in `$EDITOR`, merging changes back.
509
+
510
+ ```python
511
+ edit_entries(entries, library=None, format="default", editor=None)
512
+ ```
513
+
514
+ Exports `entries` (an iterable of {any}`bibdeskparser.entry.Entry`)
515
+ to a temporary file via {any}`bibdeskparser.exporting.export_entries`,
516
+ opens it in `editor`, waits for the editor to exit, re-parses the
517
+ result, validates it, and merges the changes back into the
518
+ *original* `Entry` objects (not copies) -- see the module docstring
519
+ for the exact merge algorithm and its known limitations.
520
+
521
+ * `entries`: an iterable of `Entry` to edit together in one file
522
+ (also covers "edit a single entry": pass a one-element list).
523
+ * `library`: optional {any}`bibdeskparser.library.Library`-like
524
+ object (only `.strings` mapping semantics and
525
+ `.rename_string(old, new)` are required -- no import of the
526
+ `Library` class happens here). If given, its current `@string`
527
+ definitions are included in the exported text (so it is
528
+ self-contained) and any `@string` changes detected in the edited
529
+ text are merged back into `library.strings` (including
530
+ macro-rename detection via `library.rename_string`).
531
+ * `format`: only `"default"` is accepted; raises `ValueError`
532
+ otherwise. Re-parsing the edited text relies on the exact shape
533
+ `export_entries(..., format="default")` produces (Unicode
534
+ values; `bdsk-file-N`/`bdsk-url-N` as plain paths/URLs); the
535
+ `"raw"` format is TeX-encoded rather than Unicode, and
536
+ `"minimal"` drops most fields -- neither can be merged back
537
+ correctly.
538
+ * `editor`: a shell command string (may include arguments, e.g.
539
+ `"code --wait"`); resolution order: this argument, else
540
+ `$EDITOR`, else `"vi"`.
541
+
542
+ If the edited text fails to parse or fails validation (see the
543
+ module docstring), a `UserWarning` describing the problem(s) is
544
+ emitted and the user is interactively prompted (via `input()`) to
545
+ either reopen the editor on the same file (preserving their
546
+ partial edit) or abandon the edit entirely. Under non-interactive
547
+ stdin (`input()` raising `EOFError`/`OSError`, e.g. under
548
+ pytest/CI), the edit is silently abandoned. On abandonment,
549
+ `entries`/`library` are left completely unchanged.
550
+
551
+ `bdsk-file-N` paths in the edited text are interpreted relative
552
+ to the directory of `library`'s `.bib` file (falling back to the
553
+ current working directory when no `library` is given). A changed
554
+ path must point to an existing file there; otherwise it is
555
+ reported as a validation problem (see above). If `library` has no
556
+ file path yet (never saved), any change to a `bdsk-file-N` line
557
+ is a validation problem: linked files are stored relative to the
558
+ library's `.bib` file, so the library must be saved first.
559
+ """
560
+ if format != "default":
561
+ raise ValueError(
562
+ "edit_entries only supports format='default': merging "
563
+ "changes back relies on the exact text shape produced by "
564
+ "export_entries(..., format='default') (unicode values, "
565
+ "bdsk-file-N/bdsk-url-N as plain paths/URLs); the 'raw' "
566
+ "format is TeX-encoded and 'minimal' drops most fields, "
567
+ "so neither can be parsed back and merged correctly"
568
+ )
569
+ entries = list(entries)
570
+ strings = dict(library.strings) if library is not None else None
571
+ if library is None:
572
+ base_dir = Path.cwd()
573
+ else:
574
+ # Reaching into `Library._path` crosses a module boundary, but
575
+ # not a public API boundary (same package); a `library` that
576
+ # has no such attribute is treated like a never-saved one.
577
+ library_path = getattr(library, "_path", None)
578
+ base_dir = (
579
+ Path(library_path).resolve().parent
580
+ if library_path is not None
581
+ else None
582
+ )
583
+ text = export_entries(entries, strings=strings, format="default")
584
+ path = _write_temp_file(text)
585
+ try:
586
+ while True:
587
+ _run_editor(editor, path)
588
+ edited_text = path.read_text(encoding="utf-8")
589
+ parsed, problems = _parse_and_validate_entries(
590
+ edited_text, library, entries, base_dir
591
+ )
592
+ if problems:
593
+ if _prompt_reopen_or_abandon(problems):
594
+ continue
595
+ return
596
+ _merge_entries(entries, parsed, base_dir)
597
+ if library is not None:
598
+ parsed_strings = {
599
+ string.key: _strip_enclosing(string.value)
600
+ for string in parsed.strings
601
+ }
602
+ _merge_strings(
603
+ library, strings, parsed_strings, allow_delete=False
604
+ )
605
+ return
606
+ finally:
607
+ path.unlink(missing_ok=True)
608
+
609
+
610
+ def edit_strings(library, editor=None):
611
+ """Edit just `library`'s `@string` macro definitions in `$EDITOR`.
612
+
613
+ ```python
614
+ edit_strings(library, editor=None)
615
+ ```
616
+
617
+ Exports `library.strings` (only, no entries) as a series of
618
+ `@string{name = {value}}` lines, sorted by name, opens it in
619
+ `editor`, waits for the editor to exit, re-parses the result,
620
+ validates it, and merges the changes back into `library.strings`:
621
+
622
+ * A new name/value pair is defined.
623
+ * A changed value is redefined in place.
624
+ * A new name whose value matches a pre-existing (and not yet
625
+ renamed-away) macro's original value is treated as a rename via
626
+ `library.rename_string`.
627
+ * A name that disappeared from the edited text is deleted
628
+ (`del library.strings[name]`); if that macro is still referenced
629
+ by an entry, the resulting `ValueError` is treated as a
630
+ validation problem for that editing round (see the module
631
+ docstring's "known limitations" for why other changes in the
632
+ same round are not rolled back).
633
+
634
+ * `library`: a {any}`bibdeskparser.library.Library`-like object
635
+ (only `.strings` mapping semantics and `.rename_string` are
636
+ used).
637
+ * `editor`: as in {any}`edit_entries`.
638
+
639
+ Validation failure (parse error, or an `@string` name that is not
640
+ a valid BibDesk macro name per
641
+ {any}`bibdeskparser.macros.is_valid_macro_name`, or a failed
642
+ deletion) triggers the same warn-and-prompt-to-reopen-or-abandon
643
+ flow as {any}`edit_entries`.
644
+ """
645
+ before = dict(library.strings)
646
+ text = "\n".join(
647
+ f"@string{{{name} = {{{value}}}}}"
648
+ for name, value in sorted(before.items())
649
+ )
650
+ if text:
651
+ text += "\n"
652
+ path = _write_temp_file(text)
653
+ try:
654
+ while True:
655
+ _run_editor(editor, path)
656
+ edited_text = path.read_text(encoding="utf-8")
657
+ parsed, problems = _parse_and_validate_strings(edited_text)
658
+ if not problems:
659
+ parsed_strings = {
660
+ string.key: _strip_enclosing(string.value)
661
+ for string in parsed.strings
662
+ }
663
+ problems = _merge_strings(
664
+ library, before, parsed_strings, allow_delete=True
665
+ )
666
+ before = dict(library.strings)
667
+ if problems:
668
+ if _prompt_reopen_or_abandon(problems):
669
+ continue
670
+ return
671
+ return
672
+ finally:
673
+ path.unlink(missing_ok=True)