papers-cli 2.4__py3-none-any.whl → 2.6__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.
papers/__main__.py CHANGED
@@ -1,4 +1,4 @@
1
- """That is the script called by papers
1
+ """That is the script called by the papers cli command
2
2
  """
3
3
  import os
4
4
  import sys
@@ -15,22 +15,41 @@ from papers import logger
15
15
  from papers.extract import extract_pdf_doi, isvaliddoi, extract_pdf_metadata
16
16
  from papers.extract import fetch_bibtex_by_doi
17
17
  from papers.encoding import parse_file, format_file, family_names, format_entries
18
- from papers.config import bcolors, Config, search_config, CONFIG_FILE, DATA_DIR, CONFIG_FILE_LEGACY
18
+ from papers.config import bcolors, Config, search_config, CONFIG_FILE, CONFIG_FILE_LOCAL, DATA_DIR, CONFIG_FILE_LEGACY, BACKUP_DIR
19
19
  from papers.duplicate import list_duplicates, list_uniques, edit_entries
20
- from papers.bib import Biblio, FUZZY_RATIO, DEFAULT_SIMILARITY, entry_filecheck, backupfile, isvalidkey
20
+ from papers.bib import Biblio, FUZZY_RATIO, DEFAULT_SIMILARITY, entry_filecheck, backupfile as backupfile_func, isvalidkey
21
+ from papers.utils import move, checksum, ansi_link as link, view_pdf
21
22
  from papers import __version__
22
23
 
23
24
 
24
- def check_legacy_config(configfile):
25
- " move config file from ~/.config/papersconfig.json to ~/.local/.share/papers/ "
26
- if not os.path.exists(configfile) and os.path.exists(CONFIG_FILE_LEGACY):
27
- os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True)
25
+ if os.path.exists(CONFIG_FILE_LEGACY):
26
+ # move config file from ~/.local/.share/papers/ to ~/.config/papersconfig.json .papers/config.json to .papersconfig.json"
27
+ if not os.path.exists(CONFIG_FILE):
28
+ logger.warning(f"Move legacy config file {CONFIG_FILE_LEGACY} to {CONFIG_FILE}'")
28
29
  shutil.move(CONFIG_FILE_LEGACY, CONFIG_FILE)
29
- configfile = CONFIG_FILE
30
+ else:
31
+ logger.warning(f"Legacy config file found: {CONFIG_FILE_LEGACY}. Delete to remove this warning: rm -f '{CONFIG_FILE_LEGACY}'")
32
+
33
+ def check_legacy_config(configfile):
34
+ " move config file from ~/.local/.share/papers/ to ~/.config/papersconfig.json and .papers/config.json to .papersconfig.json"
35
+ if os.path.exists(configfile):
36
+ p = Path(configfile)
37
+ if configfile == CONFIG_FILE_LEGACY:
38
+ shutil.move(configfile, CONFIG_FILE)
39
+ configfile = CONFIG_FILE
40
+ elif p.name == "config.json" and p.parent.name == ".papers":
41
+ newname = str(p.parent.parent/CONFIG_FILE_LOCAL)
42
+ shutil.move(configfile, newname)
43
+ configfile = newname
44
+
30
45
  return configfile
31
46
 
32
47
 
33
48
  def get_biblio(config):
49
+ """
50
+ This function initializes a Biblio object based on the bibtex file specified as command line argument or in config file.
51
+ If no bibtex file is specified, it raises a ValueError().
52
+ """
34
53
  if config.bibtex is None:
35
54
  raise ValueError('bibtex is not initialized')
36
55
  relative_to = os.path.sep if config.absolute_paths else (os.path.dirname(config.bibtex) if config.bibtex else None)
@@ -44,91 +63,146 @@ def get_biblio(config):
44
63
 
45
64
 
46
65
  def _backup_bib(biblio, config, message=None):
66
+ if not config.git:
67
+ raise PapersExit('cannot backup without --git enabled')
47
68
  # backupdir = Path(config.file).parent
48
69
  backupdir = Path(config.gitdir)
49
70
  backupdir.mkdir(exist_ok=True)
50
- backupfile = backupdir/'papers.bib'
51
71
 
52
72
  # remove if exists
53
- backupfile.unlink(missing_ok=True)
73
+ config.backupfile.unlink(missing_ok=True)
74
+ config.backupfile_clean.unlink(missing_ok=True)
54
75
 
55
76
  ## Here we could create a copy of biblio since it is modified in place
56
77
  ## For now, we exit the program after saving, so don't bother
78
+ logger.debug(f'BACKUP: cp {config.bibtex} {config.backupfile}')
79
+ shutil.copy(config.bibtex, config.backupfile)
80
+ config.gitcmd(f"add {config.backupfile.name}")
81
+
57
82
  if config.backup_files:
58
83
  logger.info('backup bibliography with files')
59
84
  backupfilesdir = backupdir/"files"
60
85
  backupfilesdir.mkdir(exist_ok=True)
61
86
  biblio.filesdir = str(backupfilesdir)
62
- biblio.rename_entries_files(copy=True, relative_to=backupdir)
63
- biblio.save(backupfile)
64
- config.gitcmd(f"add papers.bib")
87
+ biblio.rename_entries_files(copy=True, relative_to=backupdir, hardlink=True)
88
+ biblio.save(config.backupfile_clean)
89
+ config.gitcmd(f"add {config.backupfile_clean.name}")
65
90
  config.gitcmd(f"add files")
66
91
 
67
92
  else:
68
93
  logger.info('backup bibliography only (without files)')
69
94
  biblio.update_file_path(relative_to=backupdir)
70
- biblio.save(backupfile)
71
- config.gitcmd(f"add papers.bib", check=False)
95
+ biblio.save(config.backupfile_clean)
96
+ config.gitcmd(f"add {config.backupfile_clean.name}")
72
97
 
73
- message = message or f'back-up {config.bibtex} after command:\n\n papers ' +' '.join(sys.argv[1:])
98
+ message = message or f'papers ' +' '.join(sys.argv[1:])
74
99
  res = config.gitcmd(f"commit -m '{message}'", check=False)
75
- config.gitcmd(f"clean -f") # will also clean future files
100
+ # work on "main" branch for comitting (out of history branch)
101
+ config.gitcmd(f"checkout -B main")
102
+ config.gitcmd(f"clean -f")
103
+
104
+
105
+ def _restore_from_backupdir(config, restore_files=False):
106
+ restore_cmd = f"papers add {config.backupfile_clean} --rename --copy" if config.backup_files else f"cp {config.backupfile} {config.bibtex}"
107
+ repair_message = f"papers backup broken :: cannot repair file links :: try to recover manually with `{restore_cmd}`"
108
+ try:
109
+ return _restore_from_backupdir_wrapped(config, restore_files=restore_files)
110
+ except Exception as error:
111
+ raise
112
+ logger.error(str(error))
113
+ raise PapersExit(repair_message)
114
+
115
+
116
+ def _restore_from_backupdir_wrapped(config, restore_files=False):
117
+ # current = sp.check_output(f"git rev-parse --short HEAD", shell=True, cwd=config.gitdir).strip().decode()
118
+ current = sp.check_output(f"git rev-parse HEAD", shell=True, cwd=config.gitdir).strip().decode()
119
+ message = sp.check_output(f"git log {current} --pretty='format:%C(auto)%h %s (%ad)' -1", shell=True, cwd=config.gitdir).strip().decode()
120
+ logger.info(f'restore bibliography to {message}')
76
121
 
77
- def _restore_from_backupdir(config):
78
- logger.info('restore bibliography')
79
- # copy the files to the working directory
80
- # backupdir = Path(config.file).parent
81
- backupdir = Path(config.gitdir)
82
- backupfile = backupdir/'papers.bib'
83
122
  if os.path.exists(config.bibtex):
84
123
  os.remove(config.bibtex)
85
- open(config.bibtex, 'w').write('')
86
- if config.backup_files:
87
- cmd = f"add {backupfile} --bibtex {config.bibtex} --filesdir {config.filesdir} --rename --copy --no-git"
88
- else:
89
- cmd = f"add {backupfile} --bibtex {config.bibtex} --filesdir {config.filesdir} --no-git"
90
- # return sp.check_call(f"PYTHONPATH={Path(papers.__file__).parent.parent} python3 -m papers {cmd}", shell=True)
91
- # Here we avoid starting a new process with re-importing python libs etc
92
- logger.info("papers "+cmd)
93
- main(cmd.split())
94
-
95
- def _git_undo(config):
96
- """papers undo using git backup"""
97
- # backupdir = Path(config.file).parent
98
- backupdir = Path(config.gitdir)
99
- backupfile = backupdir/'papers.bib'
100
- # keep track of where we come from, for future redos
101
- current = sp.check_output(f"git rev-parse HEAD", shell=True, cwd=backupdir).strip().decode()
102
- # now go back
103
- config.gitcmd(f"reset --hard HEAD^")
104
- futuresfile = backupdir/"futures.txt"
105
- open(futuresfile, 'a+').write(current+"\n")
106
- _restore_from_backupdir(config)
107
-
108
- def _git_redo(config):
109
- """papers redo using git backup"""
110
- # backupdir = Path(config.file).parent
111
- backupdir = Path(config.gitdir)
112
- backupfile = backupdir/'papers.bib'
113
- # keep track of where we come from, for future redos
114
- futuresfile = backupdir/"futures.txt"
115
- try:
116
- futures = open(futuresfile, 'r').readlines()
117
- last = futures[-1].strip()
118
- except:
119
- last = ""
120
- if not last:
121
- logger.info("Nothing to redo")
124
+ shutil.copy(config.backupfile, config.bibtex)
125
+
126
+ # Re-name the file according to back-up bibtex
127
+ if not config.backup_files:
122
128
  return
123
129
 
124
- config.gitcmd(f"reset --hard {last}")
125
- futures = futures[:-1]
126
- if futures:
127
- open(futuresfile, 'w').write("\n".join(futures))
128
- else:
129
- futuresfile.unlink()
130
+ biblio = Biblio.load(config.bibtex, config.filesdir)
131
+ biblio_clean = Biblio.load(config.backupfile_clean, config.filesdir)
132
+
133
+ assert len(biblio.entries) == len(biblio_clean.entries)
134
+
135
+ for e, e_clean in zip(biblio.entries, biblio_clean.entries):
136
+ assert e['ID'] == e_clean['ID'], f"{e['ID']} != {e_clean['ID']})"
137
+ files = biblio.get_files(e)
138
+ files_clean = biblio_clean.get_files(e_clean)
139
+ assert len(files) == len(files_clean), f"{e['ID']} :: files {len(files)} != {len(files_clean)})"
140
+
141
+ new_files = []
142
+
143
+ for f, f_clean in zip(files, files_clean):
144
+ # broken link in the backup
145
+ if not os.path.exists(f_clean):
146
+ logger.debug(f"BACKUP FILE DOES NOT EXISTS: {f_clean} ")
147
+ logger.debug(f"BACKUP ENTRY: {e_clean['file']} ")
148
+ logger.debug(f"BROKEN ENTRY: {e['file']} ")
149
+ logger.warning(f"{e['ID']} :: file link broken => {f} ")
150
+ new_files.append(f)
151
+ continue
152
+
153
+ # backup file is fine
154
+
155
+ # original bibtex link matches something on disk
156
+ if os.path.exists(f):
157
+
158
+ # same file: nothing to do
159
+ if os.path.samefile(f, f_clean) or checksum(f_clean) == checksum(f):
160
+ new_files.append(f)
161
+
162
+ else:
163
+ logger.warning(f"{e['ID']} :: file found but does not match backup (keep pointer to backup): {f} != {f_clean}")
164
+ new_files.append(f_clean)
165
+
166
+ # original bibtex link is broken, --restore-file is active
167
+ elif restore_files:
168
+ try:
169
+ move(f_clean, f, copy=True, interactive=False)
170
+ new_files.append(f)
171
+
172
+ except Exception as error:
173
+ logger.error(f"{error}")
174
+ logger.error(f"{e['ID']} :: failed to restore file (keep pointer to backup)")
175
+ new_files.append(f_clean)
176
+ pass
177
+
178
+ # original bibtex link is broken, default without --restore-file : do not do anything
179
+ else:
180
+ new_files.append(f_clean)
181
+
182
+ biblio.set_files(e, new_files)
183
+
184
+ biblio.save(config.bibtex)
185
+
186
+
187
+ def _git_reset_to_commit(config, commit, restore_files=False):
188
+ config.gitcmd(f"reset --hard {commit}")
189
+ _restore_from_backupdir(config, restore_files=restore_files)
130
190
 
131
- _restore_from_backupdir(config)
191
+
192
+ def _git_undo(config, restore_files=False, steps=1):
193
+ sp.check_call(f"git checkout -B history", shell=True, cwd=config.gitdir)
194
+ _git_reset_to_commit(config, 'HEAD'+'^'*steps, restore_files=restore_files)
195
+ # _git_reset_to_commit(config, 'HEAD^', restore_files=o.restore_files)
196
+
197
+
198
+ def _git_redo(config, restore_files=False, steps=1):
199
+ current = sp.check_output(f"git rev-parse HEAD", shell=True, cwd=config.gitdir).strip().decode()
200
+ futures = sp.check_output(f"git rev-list {current}..main", shell=True, cwd=config.gitdir).strip().decode().splitlines()[::-1]
201
+ try:
202
+ future = futures[steps-1]
203
+ except Exception as error:
204
+ raise PapersExit("nothing to redo")
205
+ _git_reset_to_commit(config, future, restore_files=restore_files)
132
206
 
133
207
 
134
208
  def savebib(biblio, config):
@@ -143,6 +217,8 @@ def savebib(biblio, config):
143
217
  biblio.save(config.bibtex)
144
218
  if config.file and config.git:
145
219
  _backup_bib(biblio, config)
220
+ else:
221
+ logger.debug(f'do not backup bib: {config.file}, {config.git}')
146
222
  # if config.git:
147
223
  # config.gitcommit()
148
224
 
@@ -150,7 +226,7 @@ def savebib(biblio, config):
150
226
  def set_keyformat_config_from_cmd(o, config):
151
227
  """
152
228
  Given options and a config state, applies the desired key options to the config.
153
- """
229
+ """
154
230
  config.keyformat.template = o.key_template
155
231
  config.keyformat.author_num = o.key_author_num
156
232
  config.keyformat.author_sep = o.key_author_sep
@@ -186,12 +262,18 @@ def installcmd(parser, o, config):
186
262
  o.edit = ans.lower() == 'e'
187
263
 
188
264
  if not o.edit:
265
+ if config.local and os.path.exists(config.file):
266
+ # if we don't do that the local install will take precedence over global install
267
+ logger.warning(f"remove pre-existing local configuration file: {config.file}")
268
+ os.remove(config.file)
189
269
  config = Config()
190
270
 
191
271
  if o.local is None:
192
272
  if config.local is not None:
273
+ logger.debug(f'keep config to pre-existing: {config.local}')
193
274
  o.local = config.local
194
275
  else:
276
+ logger.debug(f'default to global config')
195
277
  # default ?
196
278
  o.local = False
197
279
 
@@ -203,11 +285,10 @@ def installcmd(parser, o, config):
203
285
  default_filesdir = config.filesdir or "files"
204
286
 
205
287
  if o.local:
206
- papersconfig = config.file or ".papers/config.json"
288
+ papersconfig = config.file or CONFIG_FILE_LOCAL
207
289
  workdir = Path('.')
208
290
  bibtex_files = [str(f) for f in sorted(workdir.glob("*.bib"))]
209
- config.gitdir = config.data = os.path.dirname(papersconfig)
210
-
291
+
211
292
  if o.absolute_paths is None:
212
293
  o.absolute_paths = False
213
294
 
@@ -216,7 +297,7 @@ def installcmd(parser, o, config):
216
297
  workdir = Path(DATA_DIR)
217
298
  bibtex_files = [str(f) for f in sorted(Path('.').glob("*.bib"))] + [str(f) for f in sorted(workdir.glob("*.bib"))]
218
299
  checkdirs = [os.path.join(DATA_DIR, "files")] + checkdirs
219
-
300
+
220
301
  if o.absolute_paths is None:
221
302
  o.absolute_paths = True
222
303
 
@@ -271,16 +352,25 @@ def installcmd(parser, o, config):
271
352
  config.bibtex = o.bibtex
272
353
  config.filesdir = o.filesdir
273
354
  config.file = papersconfig
274
- config.gitdir = config.data = os.path.dirname(config.file)
275
355
  config.local = o.local
276
356
  config.absolute_paths = o.absolute_paths
277
357
 
358
+ # Unless otherwise specified (option not advertized -- help suppressed)
359
+ # the git directory is centralized in the back up dir.
360
+ if o.gitdir:
361
+ config.gitdir = o.gitdir
362
+
363
+ elif config.bibtex is not None:
364
+ from normality import slugify
365
+ bibi = config.bibtex[:-len(".bib")] if config.bibtex.endswith(".bib") else config.bibtex
366
+ config.gitdir = os.path.join(BACKUP_DIR, slugify(bibi))
367
+
278
368
  if o.editor:
279
369
  config.editor = o.editor
280
370
 
281
371
  # create bibtex file if not existing
282
372
  bibtex = Path(o.bibtex) if o.bibtex else None
283
-
373
+
284
374
  if bibtex and not bibtex.exists():
285
375
  logger.info(f'create empty bibliography database: {bibtex}')
286
376
  bibtex.parent.mkdir(parents=True, exist_ok=True)
@@ -325,32 +415,27 @@ def installcmd(parser, o, config):
325
415
  config.backup_files = config.gitlfs
326
416
 
327
417
  logger.info('save config file: '+config.file)
328
- os.makedirs(os.path.dirname(config.file), exist_ok=True)
418
+ if os.path.dirname(config.file):
419
+ # typically is current dir = ""
420
+ os.makedirs(os.path.dirname(config.file), exist_ok=True)
329
421
 
330
422
  config.git = o.git
331
423
 
332
424
  config.save()
333
425
 
334
426
  if config.git:
427
+ # add a gitignore file to skip gitdir
335
428
  if (Path(config.gitdir)/'.git').exists():
336
429
  logger.warning(f'{config.gitdir} is already initialized')
337
430
  else:
338
431
  os.makedirs(config.gitdir, exist_ok=True)
339
432
  config.gitcmd('init')
340
433
 
341
-
342
434
  if config.gitlfs:
343
435
  config.gitcmd('lfs track "files/"')
344
436
  config.gitcmd('add .gitattributes')
437
+ config.gitcmd(f'commit -m "papers install: .gitattribute"', check=False)
345
438
 
346
- with open(Path(config.gitdir)/'.gitignore', 'a+') as f:
347
- lines = f.readlines()
348
- if 'futures.txt' not in (l.strip() for l in lines):
349
- f.write('futures.txt\n')
350
- config.gitcmd('add .gitignore')
351
- config.gitcmd(f'add {os.path.abspath(config.file)}')
352
- message = f'papers ' +' '.join(sys.argv[1:])
353
- config.gitcmd(f'commit -m "new install: config file"', check=False)
354
439
  biblio = get_biblio(config)
355
440
  _backup_bib(biblio, config)
356
441
 
@@ -362,7 +447,7 @@ def _dir_is_empty(dir):
362
447
  return not any(it)
363
448
 
364
449
  def uninstallcmd(parser, o, config):
365
- # TODO this is actually never tested.
450
+ # TODO this is actually never tested.
366
451
  if Path(config.file).exists():
367
452
  logger.info(f"The uninstaller will now remove {config.file}")
368
453
  os.remove(config.file)
@@ -376,11 +461,11 @@ def uninstallcmd(parser, o, config):
376
461
  return
377
462
 
378
463
  if o.recursive:
379
- config.file = search_config([os.path.join(".papers", "config.json")], start_dir=".", default=CONFIG_FILE)
464
+ config.file = search_config([CONFIG_FILE_LOCAL, os.path.join(".papers", "config.json")], start_dir=".", default=CONFIG_FILE)
380
465
  config.file = check_legacy_config(config.file)
381
466
  uninstallcmd(parser, o, config)
382
467
 
383
- def check_install(parser, o, config):
468
+ def check_install(parser, o, config, bibtex_must_exist=True):
384
469
  """
385
470
  Given an option and config, checks to see if the install is done correctly on this filesystem.
386
471
  """
@@ -398,7 +483,8 @@ def check_install(parser, o, config):
398
483
  parser.print_help()
399
484
  print(f"--bibtex must be specified, or {install_doc}")
400
485
  raise PapersExit()
401
- elif not os.path.exists(config.bibtex):
486
+
487
+ if bibtex_must_exist and not os.path.exists(config.bibtex):
402
488
  print(f'papers: error: no bibtex file found, do `touch {config.bibtex}` or {install_doc}')
403
489
  raise PapersExit()
404
490
  logger.info(f'bibtex: {config.bibtex!r}')
@@ -408,7 +494,7 @@ def check_install(parser, o, config):
408
494
 
409
495
  def addcmd(parser, o, config):
410
496
  """
411
- Given an options set and a config, sets up the function call to add the file or dir to the bibtex, and executes it.
497
+ Given an options set and a config, sets up the function call to add the file or all files in the directory to the bibtex, and executes it.
412
498
  """
413
499
 
414
500
  set_nameformat_config_from_cmd(o, config)
@@ -447,7 +533,7 @@ def addcmd(parser, o, config):
447
533
  **kw)
448
534
  else:
449
535
  raise ValueError(file+' is a directory, requires --recursive to explore')
450
-
536
+
451
537
  elif file.endswith('.pdf'):
452
538
  biblio.add_pdf(file, attachments=o.attachment, rename=o.rename, copy=o.copy,
453
539
  search_doi=not o.no_query_doi,
@@ -456,7 +542,7 @@ def addcmd(parser, o, config):
456
542
  **kw)
457
543
 
458
544
  else: # file.endswith('.bib'):
459
- biblio.add_bibtex_file(file, **kw)
545
+ biblio.add_bibtex_file(file, attachments=o.attachment, rename=o.rename, copy=o.copy, **kw)
460
546
 
461
547
  except Exception as error:
462
548
  # print(error)
@@ -471,10 +557,13 @@ def addcmd(parser, o, config):
471
557
  savebib(biblio, config)
472
558
 
473
559
  def checkcmd(parser, o, config):
560
+ """
561
+ Loops over the entire bib file that the Papers install sees, and checks each entry for formatting and for the existance of duplicates. Then writes the Biblio object back to your Bibtex file.
562
+ """
474
563
  set_keyformat_config_from_cmd(o, config)
475
564
 
476
565
  biblio = get_biblio(config)
477
-
566
+
478
567
  # if o.fix_all:
479
568
  # o.fix_doi = True
480
569
  # o.fetch_all = True
@@ -505,21 +594,21 @@ def filecheckcmd(parser, o, config):
505
594
 
506
595
  if o.rename:
507
596
  biblio.rename_entries_files(o.copy)
508
-
597
+
509
598
  savebib(biblio, config)
510
599
 
511
600
  def redocmd(parser, o, config):
512
601
  if config.git:
513
- return _git_redo(config)
602
+ return _git_redo(config, restore_files=o.restore_files, steps=o.steps)
514
603
  else:
515
604
  undocmd(parser, o, config)
516
605
 
517
606
  def undocmd(parser, o, config):
518
607
  if config.git:
519
- return _git_undo(config)
608
+ return _git_undo(config, restore_files=o.restore_files, steps=o.steps)
520
609
 
521
610
  logger.warning("git-tracking is not installed: undo / redo is limited to 1 step back and forth")
522
- back = backupfile(config.bibtex)
611
+ back = backupfile_func(config.bibtex)
523
612
  tmp = config.bibtex + '.tmp'
524
613
  # my = :config.bibtex, config.filesdir)
525
614
  logger.info(config.bibtex+' <==> '+back)
@@ -528,6 +617,16 @@ def undocmd(parser, o, config):
528
617
  shutil.move(tmp, back)
529
618
  # o.savebib()
530
619
 
620
+ def restorecmd(parser, o, config):
621
+ if not config.git:
622
+ parser.print_help()
623
+ raise PapersExit('only valid with --git enabled')
624
+ if o.ref:
625
+ _git_reset_to_commit(config, o.ref, restore_files=o.restore_files)
626
+ else:
627
+ _restore_from_backupdir(config, restore_files=o.restore_files)
628
+
629
+
531
630
  def gitcmd(parser, o, config):
532
631
  try:
533
632
  out = sp.check_output(['git']+o.gitargs, cwd=config.gitdir)
@@ -537,7 +636,7 @@ def gitcmd(parser, o, config):
537
636
  parser.error('papers failed to execute git command -- you should check your system git install.')
538
637
 
539
638
  def doicmd(parser, o):
540
- print(extract_pdf_doi(o.pdf, image=o.image))
639
+ print(extract_pdf_doi(o.pdf, image=o.image))
541
640
 
542
641
  def fetchcmd(parser, o):
543
642
  print(fetch_bibtex_by_doi(o.doi))
@@ -665,6 +764,20 @@ def listcmd(parser, o, config):
665
764
  e['keywords'] = ", ".join(keywords)
666
765
  savebib(biblio, config)
667
766
 
767
+ elif o.add_files:
768
+ if len(entries) != 1:
769
+ raise PapersExit("list only one entry to use --add-files")
770
+ e = entries[0]
771
+ files = biblio.get_files(e)
772
+ for f in o.add_files:
773
+ if not os.path.exists(f):
774
+ raise PapersExit(f"file {f} does not exist")
775
+ files.extend(o.add_files)
776
+ biblio.set_files(e, files)
777
+ if o.rename:
778
+ biblio.rename_entry_files(e, copy=o.copy)
779
+ savebib(biblio, config)
780
+
668
781
  elif o.edit:
669
782
  otherentries = [e for e in biblio.db.entries if e not in entries]
670
783
  try:
@@ -675,7 +788,7 @@ def listcmd(parser, o, config):
675
788
  return
676
789
 
677
790
  savebib(biblio, config)
678
-
791
+
679
792
  elif o.fetch:
680
793
  for e in entries:
681
794
  biblio.fix_entry(e, fix_doi=True, fix_key=True, fetch_all=True, interactive=True)
@@ -686,6 +799,12 @@ def listcmd(parser, o, config):
686
799
  biblio.db.entries.remove(e)
687
800
  savebib(biblio, config)
688
801
 
802
+ elif o.open:
803
+ for e in entries:
804
+ for f in biblio.get_files(e):
805
+ logger.info(f"opening {f} ...")
806
+ view_pdf(f)
807
+
689
808
  elif o.field:
690
809
  # entries = [{k:e[k] for k in e if k in o.field+['ID','ENTRYTYPE']} for e in entries]
691
810
  for e in entries:
@@ -698,21 +817,26 @@ def listcmd(parser, o, config):
698
817
  tit = e.get('title', '')[:60]+ ('...' if len(e.get('title', ''))>60 else '')
699
818
  info = []
700
819
  if e.get('doi',''):
701
- info.append('doi:'+e['doi'])
820
+ info.append(link(f"https://doi.org/{e['doi']}", 'doi:'+e['doi']))
702
821
  n = _nfiles(e)
703
822
  if n:
704
- info.append(bcolors.OKGREEN+('files:' if n > 1 else 'file:')+str(n)+bcolors.ENDC)
823
+ files = parse_file(e.get('file',''), relative_to=biblio.relative_to)
824
+ file_link = f"file:///{Path(files[0]).resolve()}"
825
+ ansi_link = link(file_link, f'{"file" if n == 1 else "files"}:{str(n)}')
826
+ info.append(bcolors.OKGREEN+ansi_link+bcolors.ENDC)
705
827
  if e.get('keywords',''):
706
828
  keywords = parse_keywords(e)
707
829
  info.append(bcolors.WARNING+" | ".join(keywords)+bcolors.ENDC)
708
830
  infotag = '('+', '.join(info)+')' if info else ''
709
831
  print(key(e), tit, infotag)
832
+
710
833
  else:
711
834
  print(format_entries(entries))
712
835
 
836
+
713
837
  def statuscmd(parser, o, config):
714
838
  print(config.status(check_files=not o.no_check_files, verbose=o.verbose))
715
-
839
+
716
840
 
717
841
 
718
842
  def get_parser(config=None):
@@ -930,7 +1054,7 @@ def get_parser(config=None):
930
1054
 
931
1055
  # list
932
1056
  # ======
933
- listp = subparsers.add_parser('list', description='list (a subset of) entries',
1057
+ listp = subparsers.add_parser('list', description='list (a subset of) entries in the existing bib file',
934
1058
  parents=[cfg])
935
1059
 
936
1060
  listp.add_argument('fullsearch', nargs='*', help='''Search field. Usually no quotes required. See keywords to search specific fields. All words must find a match, unless --any is passed.''')
@@ -976,6 +1100,10 @@ def get_parser(config=None):
976
1100
  grp.add_argument('--edit', action='store_true', help='interactive edit text file with entries, and re-insert them')
977
1101
  grp.add_argument('--fetch', action='store_true', help='fetch and fix metadata')
978
1102
  grp.add_argument('--add-keywords', '--add-tag', nargs='+', help='add keywords to the selected entries')
1103
+ grp.add_argument('--add-files', nargs='+', help='add files to the selected entries (only one entry must be listed)')
1104
+ grp.add_argument('--rename', action='store_true', help='rename added file(s) into files folder. Used together with --add-files')
1105
+ grp.add_argument('--copy', action='store_true', help='copy added file(s) into files folder. Used together with --add-files --rename')
1106
+ grp.add_argument('-o', '--open', action='store_true', help='open attachments (if any)')
979
1107
 
980
1108
  # grp.add_argument('--merge-duplicates', action='store_true')
981
1109
 
@@ -1005,8 +1133,16 @@ def get_parser(config=None):
1005
1133
 
1006
1134
  # undo
1007
1135
  # ====
1008
- undop = subparsers.add_parser('undo', parents=[cfg], help='this command is modified and more powerful if git-tracking is enabled (infinite memory vs back-and-forth switch)')
1009
- redop = subparsers.add_parser('redo', parents=[cfg], help='this command is modified and more powerful if git-tracking is enabled (infinite memory vs back-and-forth switch)')
1136
+ _restorep = argparse.ArgumentParser(add_help=False)
1137
+ _restorep.add_argument('--restore-files', action='store_true', help='Use this option to restore files that have been renamed. By default the file link points to the back-up repository. This command has no effect without --git-lfs, and will result in broken file links.')
1138
+
1139
+ _stepsp = argparse.ArgumentParser(add_help=False)
1140
+ _stepsp.add_argument('-n', '--steps', type=int, default=1, help='number of times undo/redo should be performed')
1141
+
1142
+ undop = subparsers.add_parser('undo', parents=[cfg, _restorep, _stepsp], help='Undo changes on bibtex (if --git is not enabled, only back and forth with last modification). If --git-lfs is enabled, the file entry may differ if it does not exist on disk any more, unless --restore-files was passed.')
1143
+ redop = subparsers.add_parser('redo', parents=[cfg, _restorep, _stepsp], help='Redo changes on bibtex (if --git is not enabled, this has the same effect as papers undo)')
1144
+ restorep = subparsers.add_parser('restore-backup', parents=[cfg, _restorep], help='Restore bibtex from backup. Also restore files if --restore-files if passed (--git-lfs only).')
1145
+ restorep.add_argument('--ref', help='Optional: restore specific commit (execute `papers git whatchanged` to obtain appropriate reference)')
1010
1146
 
1011
1147
  # git
1012
1148
  # ===
@@ -1021,8 +1157,11 @@ def get_parser(config=None):
1021
1157
 
1022
1158
  def main(args=None):
1023
1159
  papers.config.DRYRUN = False # reset in case main() if called directly
1160
+ if args is not None:
1161
+ # used in the commit message
1162
+ sys.argv = sys.argv[:1] + args
1024
1163
 
1025
- configfile = search_config([os.path.join(".papers", "config.json")], start_dir=".", default=CONFIG_FILE)
1164
+ configfile = search_config([CONFIG_FILE_LOCAL, os.path.join(".papers", "config.json")], start_dir=".", default=CONFIG_FILE)
1026
1165
  configfile = check_legacy_config(configfile)
1027
1166
  if not os.path.exists(configfile):
1028
1167
  config = Config()
@@ -1079,6 +1218,8 @@ def main(args=None):
1079
1218
  check_install(subp, o, config) and undocmd(subp, o, config)
1080
1219
  elif o.cmd == 'redo':
1081
1220
  check_install(subp, o, config) and redocmd(subp, o, config)
1221
+ elif o.cmd == 'restore-backup':
1222
+ check_install(subp, o, config, bibtex_must_exist=False) and restorecmd(subp, o, config)
1082
1223
  elif o.cmd == 'git':
1083
1224
  if not installed:
1084
1225
  subp.error('papers must be installed to use git command')
@@ -1099,10 +1240,17 @@ class PapersExit(Exception):
1099
1240
  pass
1100
1241
 
1101
1242
 
1243
+ def main_clean_exit(args=None):
1244
+ try:
1245
+ main(args)
1246
+ except PapersExit as error:
1247
+ if logger.getEffectiveLevel() == logging.DEBUG:
1248
+ raise
1249
+ if error.args:
1250
+ logger.error(str(error))
1251
+ sys.exit(1)
1252
+
1102
1253
  if __name__ == "__main__":
1103
1254
  # we use try/except here to use a clean exit instead of trace
1104
1255
  # test and debugging may use main() directly for speed-up => better to avoid sys.exit there
1105
- try:
1106
- main()
1107
- except PapersExit:
1108
- sys.exit(1)
1256
+ main_clean_exit()
papers/_version.py CHANGED
@@ -1,4 +1,16 @@
1
1
  # file generated by setuptools_scm
2
2
  # don't change, don't track in version control
3
- __version__ = version = '2.4'
4
- __version_tuple__ = version_tuple = (2, 4)
3
+ TYPE_CHECKING = False
4
+ if TYPE_CHECKING:
5
+ from typing import Tuple, Union
6
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
7
+ else:
8
+ VERSION_TUPLE = object
9
+
10
+ version: str
11
+ __version__: str
12
+ __version_tuple__: VERSION_TUPLE
13
+ version_tuple: VERSION_TUPLE
14
+
15
+ __version__ = version = '2.6'
16
+ __version_tuple__ = version_tuple = (2, 6)