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.
Files changed (45) hide show
  1. papers_cli-2.14.2/.github/FUNDING.yml +2 -0
  2. {papers_cli-2.14b0 → papers_cli-2.14.2}/.github/workflows/tests.yml +0 -1
  3. {papers_cli-2.14b0 → papers_cli-2.14.2}/PKG-INFO +13 -11
  4. {papers_cli-2.14b0 → papers_cli-2.14.2}/README.md +11 -10
  5. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/__main__.py +70 -15
  6. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/_version.py +9 -4
  7. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/bib.py +6 -8
  8. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/config.py +2 -2
  9. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/duplicate.py +0 -6
  10. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/extract.py +93 -7
  11. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/filename.py +34 -15
  12. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers_cli.egg-info/PKG-INFO +13 -11
  13. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers_cli.egg-info/SOURCES.txt +1 -0
  14. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers_cli.egg-info/requires.txt +1 -0
  15. {papers_cli-2.14b0 → papers_cli-2.14.2}/pyproject.toml +2 -0
  16. {papers_cli-2.14b0 → papers_cli-2.14.2}/requirements.txt +1 -0
  17. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_add.py +2 -2
  18. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_duplicates.py +1 -1
  19. {papers_cli-2.14b0 → papers_cli-2.14.2}/.gitattributes +0 -0
  20. {papers_cli-2.14b0 → papers_cli-2.14.2}/.github/workflows/pypi.yml +0 -0
  21. {papers_cli-2.14b0 → papers_cli-2.14.2}/.gitignore +0 -0
  22. {papers_cli-2.14b0 → papers_cli-2.14.2}/LICENSE +0 -0
  23. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/__init__.py +0 -0
  24. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/encoding.py +0 -0
  25. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/latexenc.py +0 -0
  26. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers/utils.py +0 -0
  27. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers_cli.egg-info/dependency_links.txt +0 -0
  28. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers_cli.egg-info/entry_points.txt +0 -0
  29. {papers_cli-2.14b0 → papers_cli-2.14.2}/papers_cli.egg-info/top_level.txt +0 -0
  30. {papers_cli-2.14b0 → papers_cli-2.14.2}/setup.cfg +0 -0
  31. {papers_cli-2.14b0 → papers_cli-2.14.2}/setup.py +0 -0
  32. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/__init__.py +0 -0
  33. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/common.py +0 -0
  34. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/config.py +0 -0
  35. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/download.py +0 -0
  36. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/downloadedpapers/.gitignore +0 -0
  37. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/downloadedpapers/bg-8-515-2011.txt +0 -0
  38. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_biblio.py +0 -0
  39. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_encoding.py +0 -0
  40. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_extract.py +0 -0
  41. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_filecheck.py +0 -0
  42. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_install.py +0 -0
  43. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_list.py +0 -0
  44. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_misc.py +0 -0
  45. {papers_cli-2.14b0 → papers_cli-2.14.2}/tests/test_undo.py +0 -0
@@ -0,0 +1,2 @@
1
+ # These are supported funding model platforms
2
+ github: [perrette]
@@ -22,6 +22,5 @@ jobs:
22
22
  run: |
23
23
  python -m pip install --upgrade pip
24
24
  pip install tox tox-gh-actions
25
- sudo apt install poppler-utils
26
25
  - name: Test with tox
27
26
  run: tox
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: papers-cli
3
- Version: 2.14b0
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
- [![test](https://github.com/perrette/papers/workflows/CI/badge.svg?branch=main)](https://github.com/perrette/papers/actions)
21
+ [![test](https://github.com/perrette/papers/workflows/CI/badge.svg?query=branch%3Amaster)](https://github.com/perrette/papers/actions)
21
22
  [![python](https://img.shields.io/badge/python-3.9-blue.svg)]()
22
23
  [![python](https://img.shields.io/badge/python-3.10-blue.svg)]()
23
24
  [![python](https://img.shields.io/badge/python-3.11-blue.svg)]()
@@ -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`): convert PDF to text for parsing
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
- In local installs, backup occurs in `.papers/`. In global installs, defaults to `~/.local/.share/papers`.
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
- [![test](https://github.com/perrette/papers/workflows/CI/badge.svg?branch=main)](https://github.com/perrette/papers/actions)
1
+ [![test](https://github.com/perrette/papers/workflows/CI/badge.svg?query=branch%3Amaster)](https://github.com/perrette/papers/actions)
2
2
  [![python](https://img.shields.io/badge/python-3.9-blue.svg)]()
3
3
  [![python](https://img.shields.io/badge/python-3.10-blue.svg)]()
4
4
  [![python](https://img.shields.io/badge/python-3.11-blue.svg)]()
@@ -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`): convert PDF to text for parsing
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
- In local installs, backup occurs in `.papers/`. In global installs, defaults to `~/.local/.share/papers`.
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
- while True:
295
- ans = input(f'An existing {"local" if config.local else "global"} install was found: {config.file}. Overwrite (O) or Edit (E) ? [o / e]')
296
- if ans.lower() in ('o', 'e'):
297
- break
298
- else:
299
- print('Use the --edit option to selectively edit existing configuration, or --force to ignore pre-existing configuration.')
300
- o.edit = ans.lower() == 'e'
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.local and os.path.exists(config.file):
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 local configuration file: {config.file}")
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
- # verbosity
1268
- if getattr(o,'logging_level',None):
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 setuptools_scm
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, Union
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.14b0'
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.generate_key(entry)
439
+ entry['ID'] = self.keyformat(entry)
442
440
  logger.debug('generated PDF key: '+entry['ID'])
443
441
 
444
- kw["update_key"] = False # already updated
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
- def readpdf(pdf, first=None, last=None):
31
- # TODO the python package pdftotext can do this directly, with no temp file and no I/O.
32
- if not os.path.isfile(pdf):
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
- json_data = fetch_crossref_by_doi(doi)
245
- return crossref_to_bibtex(json_data['message'])
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", "unknown").lower())
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", None)
64
+ journal = entry.get("journal", UNKNOWN_JOURNAL)
58
65
 
59
- year = str(entry.get("year", "0000"))
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, author_sep="_", title_sep="-"):
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
- return stringify_entry(entry, **vars(self))
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.14b0
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
- [![test](https://github.com/perrette/papers/workflows/CI/badge.svg?branch=main)](https://github.com/perrette/papers/actions)
21
+ [![test](https://github.com/perrette/papers/workflows/CI/badge.svg?query=branch%3Amaster)](https://github.com/perrette/papers/actions)
21
22
  [![python](https://img.shields.io/badge/python-3.9-blue.svg)]()
22
23
  [![python](https://img.shields.io/badge/python-3.10-blue.svg)]()
23
24
  [![python](https://img.shields.io/badge/python-3.11-blue.svg)]()
@@ -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`): convert PDF to text for parsing
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
- In local installs, backup occurs in `.papers/`. In global installs, defaults to `~/.local/.share/papers`.
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
 
@@ -5,6 +5,7 @@ README.md
5
5
  pyproject.toml
6
6
  requirements.txt
7
7
  setup.py
8
+ .github/FUNDING.yml
8
9
  .github/workflows/pypi.yml
9
10
  .github/workflows/tests.yml
10
11
  papers/__init__.py
@@ -1,3 +1,4 @@
1
+ PyMuPDF
1
2
  unidecode
2
3
  crossrefapi
3
4
  bibtexparser
@@ -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
@@ -1,3 +1,4 @@
1
+ PyMuPDF
1
2
  unidecode
2
3
  crossrefapi
3
4
  bibtexparser < 2.0.0
@@ -305,7 +305,7 @@ class TestAddConflict(BibTest):
305
305
  title = {Something else entirely}
306
306
  }"""
307
307
 
308
- bibtex_conflict_key_fixed = """@article{perrette_author0000,
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.bibtex+'\n\n'+self.bibtex_conflict_key_fixed
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{fieldRareYear,
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