papers-cli 2.14b0__tar.gz → 2.14.2__tar.gz
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.
- papers_cli-2.14.2/.github/FUNDING.yml +2 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/.github/workflows/tests.yml +0 -1
- {papers_cli-2.14b0 → papers_cli-2.14.2}/PKG-INFO +13 -11
- {papers_cli-2.14b0 → papers_cli-2.14.2}/README.md +11 -10
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/__main__.py +70 -15
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/_version.py +9 -4
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/bib.py +6 -8
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/config.py +2 -2
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/duplicate.py +0 -6
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/extract.py +93 -7
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/filename.py +34 -15
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers_cli.egg-info/PKG-INFO +13 -11
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers_cli.egg-info/SOURCES.txt +1 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers_cli.egg-info/requires.txt +1 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/pyproject.toml +2 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/requirements.txt +1 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_add.py +2 -2
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_duplicates.py +1 -1
- {papers_cli-2.14b0 → papers_cli-2.14.2}/.gitattributes +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/.github/workflows/pypi.yml +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/.gitignore +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/LICENSE +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/__init__.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/encoding.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/latexenc.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/utils.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers_cli.egg-info/dependency_links.txt +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers_cli.egg-info/entry_points.txt +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/papers_cli.egg-info/top_level.txt +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/setup.cfg +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/setup.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/__init__.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/common.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/config.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/download.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/downloadedpapers/.gitignore +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/downloadedpapers/bg-8-515-2011.txt +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_biblio.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_encoding.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_extract.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_filecheck.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_install.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_list.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_misc.py +0 -0
- {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_undo.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: papers-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.14.2
|
|
4
4
|
Summary: utilities to keep your PDF library organized
|
|
5
5
|
Author-email: Mahé Perrette <mahe.perrette@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -10,6 +10,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
10
10
|
Requires-Python: >=3.9
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
|
+
Requires-Dist: PyMuPDF
|
|
13
14
|
Requires-Dist: unidecode
|
|
14
15
|
Requires-Dist: crossrefapi
|
|
15
16
|
Requires-Dist: bibtexparser
|
|
@@ -17,7 +18,7 @@ Requires-Dist: scholarly
|
|
|
17
18
|
Requires-Dist: rapidfuzz
|
|
18
19
|
Requires-Dist: normality
|
|
19
20
|
|
|
20
|
-
[](https://github.com/perrette/papers/actions)
|
|
21
22
|
[]()
|
|
22
23
|
[]()
|
|
23
24
|
[]()
|
|
@@ -44,7 +45,7 @@ command-line bibliography management tool. Aims:
|
|
|
44
45
|
## Dependencies
|
|
45
46
|
|
|
46
47
|
- python 3.9+
|
|
47
|
-
- [poppler-utils](https://en.wikipedia.org/wiki/Poppler_(software)) (only:`pdftotext
|
|
48
|
+
- [PyMuPDF](https://github.com/pymupdf/PyMuPDF) (preferred) or [poppler-utils](https://en.wikipedia.org/wiki/Poppler_(software)) (only:`pdftotext`; deprecated): convert PDF to text for parsing
|
|
48
49
|
- [bibtexparser](https://bibtexparser.readthedocs.io) : parse bibtex files
|
|
49
50
|
- [crossrefapi](https://github.com/fabiobatalha/crossrefapi) : make polite requests to crossref API
|
|
50
51
|
- [scholarly](https://github.com/OrganicIrradiation/scholarly) : interface for google scholar
|
|
@@ -54,7 +55,6 @@ command-line bibliography management tool. Aims:
|
|
|
54
55
|
## Install
|
|
55
56
|
|
|
56
57
|
- `pip install papers-cli`
|
|
57
|
-
- install third-party dependencies (Ubuntu: `sudo apt install poppler-utils`)
|
|
58
58
|
|
|
59
59
|
Note there is another project registered on pypi as papers, hence `papers-cli` for command-line-interface.
|
|
60
60
|
|
|
@@ -306,6 +306,14 @@ That command is also convenient to check on what's actually tracked and what is
|
|
|
306
306
|
# ...
|
|
307
307
|
papers filecheck --rename --filesdir files
|
|
308
308
|
|
|
309
|
+
There is also a command specifically designed to clean up the zombie files and folders:
|
|
310
|
+
|
|
311
|
+
papers filecheck --clean-filesdir
|
|
312
|
+
|
|
313
|
+
That command will ask before removig anything, unless `--force` is passed. Currently
|
|
314
|
+
it ignores hidden files and folders, and will only consider folder that have a `.{folder}.bib` file inside,
|
|
315
|
+
which is the convention `papers` follows to store multiple attachments. That command works best
|
|
316
|
+
when the files are in their own folder, and not mixed up with other things, obviously.
|
|
309
317
|
|
|
310
318
|
### Setup git-tracked library (optional)
|
|
311
319
|
|
|
@@ -321,14 +329,8 @@ If `--git-lfs` is passed, the files will be backed-up along with the bibtex.
|
|
|
321
329
|
Under the hood, bibtex and files (if applicable) are copied (hard-linked) to a back-up directory.
|
|
322
330
|
Details are described in [issue 51](https://github.com/perrette/papers/issues/51).
|
|
323
331
|
|
|
324
|
-
|
|
325
|
-
Type `papers status -v` to find out.
|
|
326
|
-
|
|
332
|
+
Backup occurs in a subfolder of `~/.local/.share/papers` regardless of the type of installation. Type `papers status -v` to find out.
|
|
327
333
|
For local install that are already git-tracked, the feature remains useful as it is the basis for `papers undo` and `papers redo`.
|
|
328
|
-
You might want to add `.papers` to your .gitignore to avoid messing up with your larger project.
|
|
329
|
-
|
|
330
|
-
This feature is experimental.
|
|
331
|
-
|
|
332
334
|
|
|
333
335
|
### undo / redo
|
|
334
336
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
[](https://github.com/perrette/papers/actions)
|
|
2
2
|
[]()
|
|
3
3
|
[]()
|
|
4
4
|
[]()
|
|
@@ -25,7 +25,7 @@ command-line bibliography management tool. Aims:
|
|
|
25
25
|
## Dependencies
|
|
26
26
|
|
|
27
27
|
- python 3.9+
|
|
28
|
-
- [poppler-utils](https://en.wikipedia.org/wiki/Poppler_(software)) (only:`pdftotext
|
|
28
|
+
- [PyMuPDF](https://github.com/pymupdf/PyMuPDF) (preferred) or [poppler-utils](https://en.wikipedia.org/wiki/Poppler_(software)) (only:`pdftotext`; deprecated): convert PDF to text for parsing
|
|
29
29
|
- [bibtexparser](https://bibtexparser.readthedocs.io) : parse bibtex files
|
|
30
30
|
- [crossrefapi](https://github.com/fabiobatalha/crossrefapi) : make polite requests to crossref API
|
|
31
31
|
- [scholarly](https://github.com/OrganicIrradiation/scholarly) : interface for google scholar
|
|
@@ -35,7 +35,6 @@ command-line bibliography management tool. Aims:
|
|
|
35
35
|
## Install
|
|
36
36
|
|
|
37
37
|
- `pip install papers-cli`
|
|
38
|
-
- install third-party dependencies (Ubuntu: `sudo apt install poppler-utils`)
|
|
39
38
|
|
|
40
39
|
Note there is another project registered on pypi as papers, hence `papers-cli` for command-line-interface.
|
|
41
40
|
|
|
@@ -287,6 +286,14 @@ That command is also convenient to check on what's actually tracked and what is
|
|
|
287
286
|
# ...
|
|
288
287
|
papers filecheck --rename --filesdir files
|
|
289
288
|
|
|
289
|
+
There is also a command specifically designed to clean up the zombie files and folders:
|
|
290
|
+
|
|
291
|
+
papers filecheck --clean-filesdir
|
|
292
|
+
|
|
293
|
+
That command will ask before removig anything, unless `--force` is passed. Currently
|
|
294
|
+
it ignores hidden files and folders, and will only consider folder that have a `.{folder}.bib` file inside,
|
|
295
|
+
which is the convention `papers` follows to store multiple attachments. That command works best
|
|
296
|
+
when the files are in their own folder, and not mixed up with other things, obviously.
|
|
290
297
|
|
|
291
298
|
### Setup git-tracked library (optional)
|
|
292
299
|
|
|
@@ -302,14 +309,8 @@ If `--git-lfs` is passed, the files will be backed-up along with the bibtex.
|
|
|
302
309
|
Under the hood, bibtex and files (if applicable) are copied (hard-linked) to a back-up directory.
|
|
303
310
|
Details are described in [issue 51](https://github.com/perrette/papers/issues/51).
|
|
304
311
|
|
|
305
|
-
|
|
306
|
-
Type `papers status -v` to find out.
|
|
307
|
-
|
|
312
|
+
Backup occurs in a subfolder of `~/.local/.share/papers` regardless of the type of installation. Type `papers status -v` to find out.
|
|
308
313
|
For local install that are already git-tracked, the feature remains useful as it is the basis for `papers undo` and `papers redo`.
|
|
309
|
-
You might want to add `.papers` to your .gitignore to avoid messing up with your larger project.
|
|
310
|
-
|
|
311
|
-
This feature is experimental.
|
|
312
|
-
|
|
313
314
|
|
|
314
315
|
### undo / redo
|
|
315
316
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""That is the script called by the papers cli command
|
|
2
2
|
"""
|
|
3
3
|
import os
|
|
4
|
+
import copy
|
|
4
5
|
import sys
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
import logging
|
|
@@ -80,6 +81,8 @@ def _backup_bib(biblio, config, message=None):
|
|
|
80
81
|
shutil.copy(config.bibtex, config.backupfile)
|
|
81
82
|
config.gitcmd(f"add {config.backupfile.name}")
|
|
82
83
|
|
|
84
|
+
biblio = copy.deepcopy(biblio)
|
|
85
|
+
|
|
83
86
|
if config.backup_files:
|
|
84
87
|
logger.info('backup bibliography with files')
|
|
85
88
|
backupfilesdir = backupdir/"files"
|
|
@@ -290,19 +293,41 @@ def installcmd(parser, o, config):
|
|
|
290
293
|
"""
|
|
291
294
|
prompt = o.prompt and not o.edit
|
|
292
295
|
# installed = config.file is not None
|
|
296
|
+
# WARNING if pre-existing config file
|
|
293
297
|
if config.file is not None and prompt:
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
298
|
+
if ((o.local and config.local) or ((not o.local and not config.local))):
|
|
299
|
+
while True:
|
|
300
|
+
ans = input(f'An existing {"local" if config.local else "global"} install was found: {config.file}. Overwrite (O) or Edit (E) ? [o / e]')
|
|
301
|
+
if ans.lower() in ('o', 'e'):
|
|
302
|
+
break
|
|
303
|
+
else:
|
|
304
|
+
print('Use the --edit option to selectively edit existing configuration, or --force to ignore pre-existing configuration.')
|
|
305
|
+
o.edit = ans.lower() == 'e'
|
|
306
|
+
|
|
307
|
+
elif not o.local and config.local:
|
|
308
|
+
print(f"A local configuration file was found that will have precedence over the global install: {config.file}. Delete (D) or Ignore (any other key) ?")
|
|
309
|
+
ans = input()
|
|
310
|
+
if ans.lower() == "d":
|
|
311
|
+
logger.info(f"Removing pre-existing local configuration file {config.file}")
|
|
312
|
+
os.remove(config.file)
|
|
313
|
+
config = Config()
|
|
314
|
+
|
|
315
|
+
elif o.local and not config.local:
|
|
316
|
+
logger.warning(f"A global configuration file was found, but the local install will have precedence: {config.file}. Delete the global configuration file (D) or Ignore (any other key) ?")
|
|
317
|
+
ans = input()
|
|
318
|
+
if ans.lower() == "d":
|
|
319
|
+
logger.info(f"Removing pre-existing global configuration file {config.file}")
|
|
320
|
+
os.remove(config.file)
|
|
321
|
+
config = Config()
|
|
322
|
+
|
|
323
|
+
else:
|
|
324
|
+
raise PapersExit("Unexpected configuration state")
|
|
325
|
+
|
|
301
326
|
|
|
302
327
|
if not o.edit:
|
|
303
|
-
if config.
|
|
328
|
+
if config.file and os.path.exists(config.file) and ((o.local and config.local) or ((not o.local and not config.local))):
|
|
304
329
|
# if we don't do that the local install will take precedence over global install
|
|
305
|
-
logger.warning(f"remove pre-existing
|
|
330
|
+
logger.warning(f"remove pre-existing configuration file: {config.file}")
|
|
306
331
|
os.remove(config.file)
|
|
307
332
|
config = Config()
|
|
308
333
|
|
|
@@ -539,6 +564,7 @@ def addcmd(parser, o, config):
|
|
|
539
564
|
set_keyformat_config_from_cmd(o, config)
|
|
540
565
|
|
|
541
566
|
biblio = get_biblio(config)
|
|
567
|
+
biblio_init = copy.deepcopy(biblio)
|
|
542
568
|
|
|
543
569
|
entries = []
|
|
544
570
|
|
|
@@ -628,10 +654,33 @@ def addcmd(parser, o, config):
|
|
|
628
654
|
return
|
|
629
655
|
|
|
630
656
|
entries = [{k:v for k,v in e.items() if v != ""} for e in entries]
|
|
631
|
-
biblio.db.entries = otherentries + entries
|
|
657
|
+
biblio.db.entries = sorted(otherentries + entries, key=lambda e: biblio.key(e))
|
|
632
658
|
|
|
633
659
|
savebib(biblio, config)
|
|
634
660
|
|
|
661
|
+
# compare entries to inform user
|
|
662
|
+
# this is not very efficient but I have yet to hear a complaint about speed
|
|
663
|
+
|
|
664
|
+
old_set = set(e["ID"] for e in biblio_init.entries)
|
|
665
|
+
old_entries_by_key = {e["ID"]:e for e in biblio_init.db.entries}
|
|
666
|
+
new_set = set(e["ID"] for e in biblio.entries)
|
|
667
|
+
new_entries_by_key = {e["ID"]:e for e in biblio.db.entries}
|
|
668
|
+
modified_set = set(e["ID"] for e in entries ).intersection(set.intersection(old_set, new_set))
|
|
669
|
+
|
|
670
|
+
for ID in sorted(old_set - new_set):
|
|
671
|
+
print(format_entry(biblio_init, old_entries_by_key[ID], prefix="Removed"))
|
|
672
|
+
|
|
673
|
+
for ID in sorted(new_set - old_set):
|
|
674
|
+
print(format_entry(biblio, new_entries_by_key[ID], prefix="Added"))
|
|
675
|
+
|
|
676
|
+
for ID in sorted(modified_set):
|
|
677
|
+
before = old_entries_by_key[ID]
|
|
678
|
+
after = new_entries_by_key[ID]
|
|
679
|
+
if before != after:
|
|
680
|
+
print(format_entry(biblio, after, prefix="Modified"))
|
|
681
|
+
else:
|
|
682
|
+
print(format_entry(biblio, after, prefix="Existing"))
|
|
683
|
+
|
|
635
684
|
if o.open:
|
|
636
685
|
for e in entries:
|
|
637
686
|
view_entry_files(biblio, e)
|
|
@@ -1196,13 +1245,13 @@ def get_parser(config=None):
|
|
|
1196
1245
|
|
|
1197
1246
|
# doi
|
|
1198
1247
|
# ===
|
|
1199
|
-
doip = subparsers.add_parser('doi', description='parse DOI from PDF')
|
|
1248
|
+
doip = subparsers.add_parser('doi', description='parse DOI from PDF', parents=[loggingp])
|
|
1200
1249
|
doip.add_argument('pdf')
|
|
1201
1250
|
doip.add_argument('--image', action='store_true', help='convert to image and use tesseract instead of pdftotext')
|
|
1202
1251
|
|
|
1203
1252
|
# fetch
|
|
1204
1253
|
# =====
|
|
1205
|
-
fetchp = subparsers.add_parser('fetch', description='fetch bibtex from DOI or full-text')
|
|
1254
|
+
fetchp = subparsers.add_parser('fetch', description='fetch bibtex from DOI or full-text', parents=[loggingp])
|
|
1206
1255
|
fetchp.add_argument('doi_or_text', nargs='+', help='DOI or full text.')
|
|
1207
1256
|
fetchp.add_argument('--scholar', action='store_true', help='use google scholar instead of default crossref for fulltext search')
|
|
1208
1257
|
|
|
@@ -1241,6 +1290,13 @@ def get_parser(config=None):
|
|
|
1241
1290
|
# Main script
|
|
1242
1291
|
#############
|
|
1243
1292
|
|
|
1293
|
+
def handle_logging(o):
|
|
1294
|
+
# verbosity
|
|
1295
|
+
if getattr(o,'logging_level',None):
|
|
1296
|
+
logger.setLevel(o.logging_level)
|
|
1297
|
+
logger.debug("LOGGER LEVEL: "+logging.getLevelName(logger.getEffectiveLevel()))
|
|
1298
|
+
|
|
1299
|
+
|
|
1244
1300
|
def main(args=None):
|
|
1245
1301
|
papers.config.DRYRUN = False # reset in case main() if called directly
|
|
1246
1302
|
if args is not None:
|
|
@@ -1264,9 +1320,8 @@ def main(args=None):
|
|
|
1264
1320
|
print(__version__)
|
|
1265
1321
|
return
|
|
1266
1322
|
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
logger.setLevel(o.logging_level)
|
|
1323
|
+
handle_logging(o)
|
|
1324
|
+
|
|
1270
1325
|
# modify disk state?
|
|
1271
1326
|
if hasattr(o,'dry_run'):
|
|
1272
1327
|
papers.config.DRYRUN = o.dry_run
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
# file generated by
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
2
|
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
|
|
5
|
+
|
|
3
6
|
TYPE_CHECKING = False
|
|
4
7
|
if TYPE_CHECKING:
|
|
5
|
-
from typing import Tuple
|
|
8
|
+
from typing import Tuple
|
|
9
|
+
from typing import Union
|
|
10
|
+
|
|
6
11
|
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
7
12
|
else:
|
|
8
13
|
VERSION_TUPLE = object
|
|
@@ -12,5 +17,5 @@ __version__: str
|
|
|
12
17
|
__version_tuple__: VERSION_TUPLE
|
|
13
18
|
version_tuple: VERSION_TUPLE
|
|
14
19
|
|
|
15
|
-
__version__ = version = '2.
|
|
16
|
-
__version_tuple__ = version_tuple = (2, 14)
|
|
20
|
+
__version__ = version = '2.14.2'
|
|
21
|
+
__version_tuple__ = version_tuple = (2, 14, 2)
|
|
@@ -273,7 +273,6 @@ class Biblio:
|
|
|
273
273
|
keys = [self.key(ei) for ei in self.db.entries]
|
|
274
274
|
return bisect.bisect_left(keys, self.key(entry))
|
|
275
275
|
|
|
276
|
-
|
|
277
276
|
def insert_entry(self, entry, update_key=False, check_duplicate=False, rename=False, copy=False, metadata={}, **checkopt):
|
|
278
277
|
"""
|
|
279
278
|
"""
|
|
@@ -296,20 +295,21 @@ class Biblio:
|
|
|
296
295
|
if i < len(self.entries) and self.key(self.entries[i]) == self.key(entry):
|
|
297
296
|
logger.info('key duplicate: '+self.key(self.entries[i]))
|
|
298
297
|
|
|
298
|
+
if self.entries[i]["title"] == entry["title"]:
|
|
299
|
+
logger.info('exact duplicate')
|
|
300
|
+
return [ self.entries[i] ]
|
|
301
|
+
|
|
299
302
|
if update_key:
|
|
300
303
|
newkey = self.append_abc_to_key(entry) # add abc
|
|
301
304
|
logger.info('update key: {} => {}'.format(entry['ID'], newkey))
|
|
302
305
|
entry['ID'] = newkey
|
|
303
306
|
|
|
304
307
|
else:
|
|
305
|
-
print(format_entry(self, entry, prefix="New entry"))
|
|
306
|
-
print(format_entry(self, self.entries[i], prefix="Conflicts with"))
|
|
307
308
|
raise DuplicateKeyError('Key conflict. Try update_key is True or specify --key KEY explicitly')
|
|
308
309
|
|
|
309
310
|
else:
|
|
310
311
|
logger.info('new entry: '+self.key(entry))
|
|
311
312
|
|
|
312
|
-
print(format_entry(self, entry, prefix="Added"))
|
|
313
313
|
self.entries.insert(i, entry)
|
|
314
314
|
|
|
315
315
|
if rename: self.rename_entry_files(entry, copy=copy)
|
|
@@ -338,7 +338,6 @@ class Biblio:
|
|
|
338
338
|
if entry == candidate:
|
|
339
339
|
logger.debug('exact duplicate')
|
|
340
340
|
if rename: self.rename_entry_files(candidate, copy=copy)
|
|
341
|
-
print(format_entry(self, entry, prefix="Existing"))
|
|
342
341
|
return [ entry ] # do nothing
|
|
343
342
|
|
|
344
343
|
if update_key and entry['ID'] != candidate['ID']:
|
|
@@ -357,7 +356,6 @@ class Biblio:
|
|
|
357
356
|
logger.debug('fixed: exact duplicate')
|
|
358
357
|
entry = candidate
|
|
359
358
|
if rename: self.rename_entry_files(candidate, copy=copy)
|
|
360
|
-
print(format_entry(self, entry, prefix="Existing"))
|
|
361
359
|
return [ entry ] # do nothing
|
|
362
360
|
|
|
363
361
|
logger.debug('conflict resolution: '+on_conflict)
|
|
@@ -438,10 +436,10 @@ class Biblio:
|
|
|
438
436
|
files += attachments
|
|
439
437
|
|
|
440
438
|
self.set_files(entry, [os.path.abspath(f) for f in files])
|
|
441
|
-
entry['ID'] = self.
|
|
439
|
+
entry['ID'] = self.keyformat(entry)
|
|
442
440
|
logger.debug('generated PDF key: '+entry['ID'])
|
|
443
441
|
|
|
444
|
-
kw["update_key"] =
|
|
442
|
+
kw["update_key"] = True
|
|
445
443
|
return self.insert_entry(entry, **kw)
|
|
446
444
|
|
|
447
445
|
|
|
@@ -143,9 +143,9 @@ class Config:
|
|
|
143
143
|
def load(cls, the_file):
|
|
144
144
|
js = json.load(open(the_file))
|
|
145
145
|
if 'nameformat' in js:
|
|
146
|
-
js['nameformat'] = Format(**js.get('nameformat'))
|
|
146
|
+
js['nameformat'] = Format(**{**vars(NAMEFORMAT), **js.get('nameformat')})
|
|
147
147
|
if 'keyformat' in js:
|
|
148
|
-
js['keyformat'] = Format(**js.get('keyformat'))
|
|
148
|
+
js['keyformat'] = Format(**{**vars(KEYFORMAT), **js.get('keyformat')})
|
|
149
149
|
cfg = cls(file=the_file, **js)
|
|
150
150
|
cfg._update_paths_to_absolute()
|
|
151
151
|
return cfg
|
|
@@ -367,12 +367,6 @@ def edit_entries(entries, diff=False, ndiff=False, editor=None):
|
|
|
367
367
|
if res == 0:
|
|
368
368
|
logger.info('sucessfully edited file, insert edited entries')
|
|
369
369
|
db_updated = bibtexparser.loads(open(filename).read())
|
|
370
|
-
keys_before = [e.get('ID') for e in entries]
|
|
371
|
-
keys_after = [e.get('ID') for e in db_updated.entries]
|
|
372
|
-
if set(keys_before) != set(keys_after):
|
|
373
|
-
print(f"{bcolors.WARNING}Keys edited ->", bcolors.OKBLUE ," ".join(sorted(set(keys_before).difference(keys_after))), bcolors.ENDC,
|
|
374
|
-
"=>", bcolors.OKBLUE,
|
|
375
|
-
" ".join(sorted(set(keys_after).difference(keys_before))), bcolors.ENDC) if keys_after else '(deleted)'
|
|
376
370
|
return db_updated.entries
|
|
377
371
|
|
|
378
372
|
else:
|
|
@@ -14,6 +14,10 @@ from papers import logger
|
|
|
14
14
|
from papers.encoding import family_names
|
|
15
15
|
from bibtexparser.customization import convert_to_unicode
|
|
16
16
|
|
|
17
|
+
from bs4 import BeautifulSoup
|
|
18
|
+
from urllib.parse import urljoin
|
|
19
|
+
|
|
20
|
+
|
|
17
21
|
my_etiquette = Etiquette('papers', papers.__version__, 'https://github.com/perrette/papers', 'mahe.perrette@gmail.com')
|
|
18
22
|
work = Works(etiquette=my_etiquette)
|
|
19
23
|
|
|
@@ -26,12 +30,28 @@ class DOIRequestError(ValueError):
|
|
|
26
30
|
|
|
27
31
|
# PDF parsing / crossref requests
|
|
28
32
|
# ===============================
|
|
33
|
+
def readpdf_fitz(pdf_path, pages=None, first=None, last=None):
|
|
34
|
+
import fitz
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
raise ValueError(repr(pdf) + ": not a file")
|
|
36
|
+
# Open the PDF file
|
|
37
|
+
document = fitz.open(pdf_path)
|
|
38
|
+
text = ""
|
|
34
39
|
|
|
40
|
+
# Iterate through the pages
|
|
41
|
+
for page_num in range(len(document)):
|
|
42
|
+
if pages is not None and page_num+1 not in pages:
|
|
43
|
+
continue
|
|
44
|
+
elif first is not None and page_num+1 < first:
|
|
45
|
+
continue
|
|
46
|
+
elif last is not None and page_num+1 > last:
|
|
47
|
+
continue
|
|
48
|
+
page = document.load_page(page_num)
|
|
49
|
+
text += page.get_text()
|
|
50
|
+
|
|
51
|
+
return text
|
|
52
|
+
|
|
53
|
+
def readpdf_poputils(pdf, first=None, last=None, pages=None):
|
|
54
|
+
# DEPRECATED
|
|
35
55
|
tmptxt = tempfile.mktemp(suffix='.txt')
|
|
36
56
|
|
|
37
57
|
cmd = ['pdftotext']
|
|
@@ -46,6 +66,18 @@ def readpdf(pdf, first=None, last=None):
|
|
|
46
66
|
|
|
47
67
|
return txt
|
|
48
68
|
|
|
69
|
+
def readpdf(pdf, first=None, last=None):
|
|
70
|
+
# TODO the python package pdftotext can do this directly, with no temp file and no I/O.
|
|
71
|
+
if not os.path.isfile(pdf):
|
|
72
|
+
raise ValueError(repr(pdf) + ": not a file")
|
|
73
|
+
try:
|
|
74
|
+
return readpdf_fitz(pdf, first=first, last=last)
|
|
75
|
+
except ImportError:
|
|
76
|
+
logger.warning("PyMuPDF not installed, using pdftotext")
|
|
77
|
+
return readpdf_fitz(pdf, first=first, last=last)
|
|
78
|
+
|
|
79
|
+
return txt
|
|
80
|
+
|
|
49
81
|
|
|
50
82
|
def readpdf_image(pdf, first=None, last=None):
|
|
51
83
|
|
|
@@ -237,12 +269,22 @@ def fetch_bibtex_by_arxiv(arxiv_id):
|
|
|
237
269
|
else:
|
|
238
270
|
return f"Error: Unable to fetch BibTeX (HTTP {response.status_code})"
|
|
239
271
|
|
|
240
|
-
# @cached('crossref-bibtex.json')
|
|
241
272
|
def fetch_bibtex_by_doi(doi):
|
|
242
273
|
if "arxiv" in doi.lower():
|
|
243
274
|
return fetch_bibtex_by_arxiv(doi.split("arXiv.")[1])
|
|
244
|
-
|
|
245
|
-
|
|
275
|
+
try:
|
|
276
|
+
json_data = fetch_crossref_by_doi(doi)
|
|
277
|
+
return crossref_to_bibtex(json_data['message'])
|
|
278
|
+
except DOIRequestError as error:
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
return fetch_bibtex_on_journal_website(doi)
|
|
283
|
+
except:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
raise DOIRequestError(f"Unable to fetch BibTeX for DOI {doi}")
|
|
287
|
+
|
|
246
288
|
|
|
247
289
|
@cached('crossref-json.json')
|
|
248
290
|
def fetch_json_by_doi(doi):
|
|
@@ -449,3 +491,47 @@ def fetch_entry(e):
|
|
|
449
491
|
ValueError('no author nor title field')
|
|
450
492
|
db = bibtexparser.loads(bibtex)
|
|
451
493
|
return db.entries[0]
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
############### HERE A HACK DESIGNED FOR THE ESD JOURNAL ################
|
|
498
|
+
# That journal often provides DOI that are not yet registered in crossref
|
|
499
|
+
# so we need to fetch the bibtex from the journal website
|
|
500
|
+
# Thanks Le Chat for near-instantaneous and elegantly designed code
|
|
501
|
+
|
|
502
|
+
def fetch_html(url):
|
|
503
|
+
response = requests.get(url)
|
|
504
|
+
response.raise_for_status() # Raise an error for bad status codes
|
|
505
|
+
return response.text
|
|
506
|
+
|
|
507
|
+
def find_bibtex_links(html_content, base_url):
|
|
508
|
+
soup = BeautifulSoup(html_content, 'html.parser')
|
|
509
|
+
|
|
510
|
+
for link in soup.find_all('a', href=True):
|
|
511
|
+
href = link['href']
|
|
512
|
+
if href.endswith('.bib'):
|
|
513
|
+
full_url = urljoin(base_url, href)
|
|
514
|
+
yield full_url
|
|
515
|
+
|
|
516
|
+
def download_bibtex(url):
|
|
517
|
+
response = requests.get(url)
|
|
518
|
+
response.raise_for_status()
|
|
519
|
+
return response.text
|
|
520
|
+
|
|
521
|
+
def parse_bibtex(bibtex_content, target_doi):
|
|
522
|
+
bib_database = bibtexparser.loads(bibtex_content)
|
|
523
|
+
for entry in bib_database.entries:
|
|
524
|
+
if (entry.get('doi', '') or entry.get('DOI', '')).lower() == target_doi.lower():
|
|
525
|
+
return entry
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
def fetch_bibtex_on_journal_website(doi):
|
|
529
|
+
base_url = f"https://doi.org/{doi}"
|
|
530
|
+
html_content = fetch_html(base_url)
|
|
531
|
+
for bibtex_url in find_bibtex_links(html_content, base_url):
|
|
532
|
+
bibtex_content = download_bibtex(bibtex_url)
|
|
533
|
+
bibtex_entry = parse_bibtex(bibtex_content, doi)
|
|
534
|
+
if bibtex_entry:
|
|
535
|
+
return bibtex_entry
|
|
536
|
+
|
|
537
|
+
raise DOIRequestError("No matching BibTeX entry found for the given DOI.")
|
|
@@ -22,6 +22,10 @@ def _cite_author(names):
|
|
|
22
22
|
else:
|
|
23
23
|
return names[0]
|
|
24
24
|
|
|
25
|
+
UNKNOWN_AUTHOR = 'unknown'
|
|
26
|
+
UNKNOWN_YEAR = '0000'
|
|
27
|
+
UNKNOWN_JOURNAL = None
|
|
28
|
+
UNKNOWN_TITLE = ""
|
|
25
29
|
|
|
26
30
|
def make_template_fields(
|
|
27
31
|
entry,
|
|
@@ -31,6 +35,7 @@ def make_template_fields(
|
|
|
31
35
|
title_length=100,
|
|
32
36
|
author_sep="_",
|
|
33
37
|
title_sep="-",
|
|
38
|
+
**ignore,
|
|
34
39
|
):
|
|
35
40
|
"""
|
|
36
41
|
Available fields in output are explicitly listed here, and this is the single source of truth for these.
|
|
@@ -43,10 +48,12 @@ def make_template_fields(
|
|
|
43
48
|
- Title: same as title by with capitalized words
|
|
44
49
|
- year
|
|
45
50
|
- ID : bibtex key
|
|
51
|
+
- doi : bibtex DOI
|
|
52
|
+
- doi- : slugified doi (for use in filenames)
|
|
46
53
|
Each one of these needs a specific, explicit assignment below.
|
|
47
54
|
"""
|
|
48
55
|
# names = bibtexparser.customization.getnames(entry.get('author','unknown').lower().split(' and '))
|
|
49
|
-
_names = family_names(entry.get("author",
|
|
56
|
+
_names = family_names(entry.get("author", UNKNOWN_AUTHOR).lower())
|
|
50
57
|
_names = [slugify(nm) for nm in _names]
|
|
51
58
|
author = author_sep.join([nm for nm in _names[:author_num]])
|
|
52
59
|
Author = author_sep.join([nm.capitalize() for nm in _names[:author_num]])
|
|
@@ -54,13 +61,13 @@ def make_template_fields(
|
|
|
54
61
|
authorX = AuthorX.lower()
|
|
55
62
|
|
|
56
63
|
# a thing that's not a bibtex article won't have a journal
|
|
57
|
-
journal = entry.get("journal",
|
|
64
|
+
journal = entry.get("journal", UNKNOWN_JOURNAL)
|
|
58
65
|
|
|
59
|
-
year = str(entry.get("year",
|
|
66
|
+
year = str(entry.get("year", UNKNOWN_YEAR))
|
|
60
67
|
|
|
61
68
|
if not title_word_num or not entry.get("title", ""):
|
|
62
|
-
title =
|
|
63
|
-
Title =
|
|
69
|
+
title = UNKNOWN_TITLE
|
|
70
|
+
Title = UNKNOWN_TITLE
|
|
64
71
|
else:
|
|
65
72
|
titlewords = normalize(entry["title"]).lower().split()
|
|
66
73
|
_titles = listtag(
|
|
@@ -84,15 +91,12 @@ def make_template_fields(
|
|
|
84
91
|
"title": title,
|
|
85
92
|
"Title": Title,
|
|
86
93
|
"ID": entry.get("ID"),
|
|
94
|
+
"doi": entry.get("doi"),
|
|
95
|
+
"doi_": slugify(entry.get("doi", "")),
|
|
96
|
+
"doi_or_id": slugify(entry.get("doi", entry.get("ID", ""))),
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
|
|
90
|
-
def stringify_entry(entry, template, **opt):
|
|
91
|
-
fields = make_template_fields(entry, **opt)
|
|
92
|
-
res = template.format(**fields)
|
|
93
|
-
return res
|
|
94
|
-
|
|
95
|
-
|
|
96
100
|
class Format:
|
|
97
101
|
"""
|
|
98
102
|
Store formatting info as python template formatted with str.format() method. See make_template_fields for available fields.
|
|
@@ -113,7 +117,8 @@ class Format:
|
|
|
113
117
|
|
|
114
118
|
"""
|
|
115
119
|
# def __init__(self, template="{author}{year}{title}", author_num=2, title_word_num=5, author_sep="_", title_sep="-")
|
|
116
|
-
def __init__(self, template, author_num=2, title_word_num=100, title_word_size=1, title_length=100,
|
|
120
|
+
def __init__(self, template, author_num=2, title_word_num=100, title_word_size=1, title_length=100,
|
|
121
|
+
author_sep="_", title_sep="-", unknown_strict=False, unknown_template=None):
|
|
117
122
|
self.template = template
|
|
118
123
|
self.author_num = author_num
|
|
119
124
|
self.author_sep = author_sep
|
|
@@ -121,16 +126,30 @@ class Format:
|
|
|
121
126
|
self.title_sep = title_sep
|
|
122
127
|
self.title_word_num = title_word_num
|
|
123
128
|
self.title_word_size = title_word_size
|
|
129
|
+
self.unknown_strict = unknown_strict
|
|
130
|
+
self.unknown_template = unknown_template
|
|
124
131
|
|
|
125
132
|
def todict(self):
|
|
126
133
|
return vars(self)
|
|
127
134
|
|
|
135
|
+
def is_unknown(self, entry):
|
|
136
|
+
conditions = ( entry.get("author", UNKNOWN_AUTHOR) == UNKNOWN_AUTHOR, entry.get("year", UNKNOWN_YEAR) == UNKNOWN_YEAR, entry.get("title", UNKNOWN_TITLE) == UNKNOWN_TITLE)
|
|
137
|
+
if self.unknown_strict:
|
|
138
|
+
return all(conditions)
|
|
139
|
+
else:
|
|
140
|
+
return any(conditions)
|
|
141
|
+
|
|
128
142
|
def render(self, **entry):
|
|
129
|
-
|
|
143
|
+
fields = make_template_fields(entry, **vars(self))
|
|
144
|
+
|
|
145
|
+
if self.unknown_template and self.is_unknown(entry):
|
|
146
|
+
return self.unknown_template.format(**fields)
|
|
147
|
+
|
|
148
|
+
return self.template.format(**fields)
|
|
130
149
|
|
|
131
150
|
def __call__(self, entry):
|
|
132
151
|
return self.render(**entry)
|
|
133
152
|
|
|
134
153
|
|
|
135
|
-
KEYFORMAT = Format(template='{author}{year}', author_num=2, author_sep="_")
|
|
136
|
-
NAMEFORMAT = Format(template='{authorX}_{year}_{title}', author_sep="_", title_sep="-")
|
|
154
|
+
KEYFORMAT = Format(template='{author}{year}', author_num=2, author_sep="_", unknown_template="{doi}")
|
|
155
|
+
NAMEFORMAT = Format(template='{authorX}_{year}_{title}', author_sep="_", title_sep="-", unknown_template="unknown_{doi_or_id}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: papers-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.14.2
|
|
4
4
|
Summary: utilities to keep your PDF library organized
|
|
5
5
|
Author-email: Mahé Perrette <mahe.perrette@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -10,6 +10,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
10
10
|
Requires-Python: >=3.9
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
|
+
Requires-Dist: PyMuPDF
|
|
13
14
|
Requires-Dist: unidecode
|
|
14
15
|
Requires-Dist: crossrefapi
|
|
15
16
|
Requires-Dist: bibtexparser
|
|
@@ -17,7 +18,7 @@ Requires-Dist: scholarly
|
|
|
17
18
|
Requires-Dist: rapidfuzz
|
|
18
19
|
Requires-Dist: normality
|
|
19
20
|
|
|
20
|
-
[](https://github.com/perrette/papers/actions)
|
|
21
22
|
[]()
|
|
22
23
|
[]()
|
|
23
24
|
[]()
|
|
@@ -44,7 +45,7 @@ command-line bibliography management tool. Aims:
|
|
|
44
45
|
## Dependencies
|
|
45
46
|
|
|
46
47
|
- python 3.9+
|
|
47
|
-
- [poppler-utils](https://en.wikipedia.org/wiki/Poppler_(software)) (only:`pdftotext
|
|
48
|
+
- [PyMuPDF](https://github.com/pymupdf/PyMuPDF) (preferred) or [poppler-utils](https://en.wikipedia.org/wiki/Poppler_(software)) (only:`pdftotext`; deprecated): convert PDF to text for parsing
|
|
48
49
|
- [bibtexparser](https://bibtexparser.readthedocs.io) : parse bibtex files
|
|
49
50
|
- [crossrefapi](https://github.com/fabiobatalha/crossrefapi) : make polite requests to crossref API
|
|
50
51
|
- [scholarly](https://github.com/OrganicIrradiation/scholarly) : interface for google scholar
|
|
@@ -54,7 +55,6 @@ command-line bibliography management tool. Aims:
|
|
|
54
55
|
## Install
|
|
55
56
|
|
|
56
57
|
- `pip install papers-cli`
|
|
57
|
-
- install third-party dependencies (Ubuntu: `sudo apt install poppler-utils`)
|
|
58
58
|
|
|
59
59
|
Note there is another project registered on pypi as papers, hence `papers-cli` for command-line-interface.
|
|
60
60
|
|
|
@@ -306,6 +306,14 @@ That command is also convenient to check on what's actually tracked and what is
|
|
|
306
306
|
# ...
|
|
307
307
|
papers filecheck --rename --filesdir files
|
|
308
308
|
|
|
309
|
+
There is also a command specifically designed to clean up the zombie files and folders:
|
|
310
|
+
|
|
311
|
+
papers filecheck --clean-filesdir
|
|
312
|
+
|
|
313
|
+
That command will ask before removig anything, unless `--force` is passed. Currently
|
|
314
|
+
it ignores hidden files and folders, and will only consider folder that have a `.{folder}.bib` file inside,
|
|
315
|
+
which is the convention `papers` follows to store multiple attachments. That command works best
|
|
316
|
+
when the files are in their own folder, and not mixed up with other things, obviously.
|
|
309
317
|
|
|
310
318
|
### Setup git-tracked library (optional)
|
|
311
319
|
|
|
@@ -321,14 +329,8 @@ If `--git-lfs` is passed, the files will be backed-up along with the bibtex.
|
|
|
321
329
|
Under the hood, bibtex and files (if applicable) are copied (hard-linked) to a back-up directory.
|
|
322
330
|
Details are described in [issue 51](https://github.com/perrette/papers/issues/51).
|
|
323
331
|
|
|
324
|
-
|
|
325
|
-
Type `papers status -v` to find out.
|
|
326
|
-
|
|
332
|
+
Backup occurs in a subfolder of `~/.local/.share/papers` regardless of the type of installation. Type `papers status -v` to find out.
|
|
327
333
|
For local install that are already git-tracked, the feature remains useful as it is the basis for `papers undo` and `papers redo`.
|
|
328
|
-
You might want to add `.papers` to your .gitignore to avoid messing up with your larger project.
|
|
329
|
-
|
|
330
|
-
This feature is experimental.
|
|
331
|
-
|
|
332
334
|
|
|
333
335
|
### undo / redo
|
|
334
336
|
|
|
@@ -16,6 +16,7 @@ classifiers = [
|
|
|
16
16
|
"Programming Language :: Python :: 3",
|
|
17
17
|
]
|
|
18
18
|
dependencies = [
|
|
19
|
+
"PyMuPDF",
|
|
19
20
|
"unidecode",
|
|
20
21
|
"crossrefapi",
|
|
21
22
|
"bibtexparser",
|
|
@@ -58,6 +59,7 @@ python =
|
|
|
58
59
|
commands = pytest --cov=papers --cov-append --cov-report=term-missing {posargs} -xv
|
|
59
60
|
deps =
|
|
60
61
|
bibtexparser < 2.0.0
|
|
62
|
+
PyMuPDF
|
|
61
63
|
scholarly
|
|
62
64
|
crossrefapi
|
|
63
65
|
rapidfuzz
|
|
@@ -305,7 +305,7 @@ class TestAddConflict(BibTest):
|
|
|
305
305
|
title = {Something else entirely}
|
|
306
306
|
}"""
|
|
307
307
|
|
|
308
|
-
bibtex_conflict_key_fixed = """@article{
|
|
308
|
+
bibtex_conflict_key_fixed = """@article{10.5194/bg-8-515-2011XXX,
|
|
309
309
|
author = {M. Perrette and Another author},
|
|
310
310
|
doi = {10.5194/bg-8-515-2011XXX},
|
|
311
311
|
title = {Something else entirely}
|
|
@@ -416,7 +416,7 @@ class TestAddConflict(BibTest):
|
|
|
416
416
|
# key conflict and update entry
|
|
417
417
|
open(self.otherbib, 'w').write(self.bibtex_conflict_key)
|
|
418
418
|
paperscmd(f'add {self.otherbib} --bibtex {self.mybib} -u')
|
|
419
|
-
expected = self.
|
|
419
|
+
expected = self.bibtex_conflict_key_fixed+'\n\n'+self.bibtex
|
|
420
420
|
self.assertMultiLineEqual(open(self.mybib).read().strip(), expected) # entries did not change
|
|
421
421
|
|
|
422
422
|
def test_add_same_doi_unchecked(self):
|
|
@@ -266,7 +266,7 @@ class TestAddResolveDuplicate(BibTest):
|
|
|
266
266
|
|
|
267
267
|
def test_conflict_updated_from_original_but_originalkey(self):
|
|
268
268
|
|
|
269
|
-
expected = """@article{
|
|
269
|
+
expected = """@article{10.5194/bg-8-515-2011,
|
|
270
270
|
author = {New Author Field},
|
|
271
271
|
doi = {10.5194/bg-8-515-2011},
|
|
272
272
|
journal = {ConflictJournal},
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|