lixplore-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lixplore/commands.py ADDED
@@ -0,0 +1,1543 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ from . import dispatcher
7
+
8
+
9
+ def add_commands(parser: argparse.ArgumentParser):
10
+ """Register CLI arguments for lixplore."""
11
+
12
+ # Custom description and epilog for detailed help
13
+ parser.description = """
14
+ Lixplore - Academic Literature Search & Export Tool
15
+
16
+ Search across multiple academic databases (PubMed, Crossref, DOAJ, EuropePMC, arXiv)
17
+ and export results in various formats (CSV, Excel, JSON, BibTeX, RIS, EndNote, XML).
18
+ """
19
+
20
+ parser.epilog = """
21
+ EXAMPLES:
22
+ Basic search:
23
+ lixplore -P -q "cancer treatment" -m 10
24
+
25
+ Multi-source search with export:
26
+ lixplore -s PX -q "machine learning" -m 20 -X xlsx -o ml_papers.xlsx
27
+
28
+ Search all sources with deduplication:
29
+ lixplore -A -q "COVID-19" -m 50 -D -X csv
30
+
31
+ Search with date filter and abstracts:
32
+ lixplore -P -q "diabetes" -d 2020-01-01 2024-12-31 -m 15 -a
33
+
34
+ Search by author:
35
+ lixplore -P -au "Smith J" -m 10 -a
36
+
37
+ Export to EndNote:
38
+ lixplore -P -q "neuroscience" -m 30 -X enw -o neuro_papers.enw
39
+
40
+ Review articles in separate terminal (two-step):
41
+ lixplore -P -q "paracetamol" -m 10 # Step 1: Search
42
+ lixplore -R 1 5 9 # Step 2: Review articles #1, #5, #9
43
+ # In review window: Press 'q' or Ctrl+C to close
44
+
45
+ SOURCE CODES (for -s flag):
46
+ P = PubMed C = Crossref J = DOAJ
47
+ E = EuropePMC X = arXiv A = All sources
48
+
49
+ Examples: -s PX (PubMed+arXiv), -s PCE (PubMed+Crossref+EuropePMC)
50
+
51
+ EXPORT FORMATS:
52
+ csv - CSV format (Excel, Google Sheets)
53
+ xlsx - Microsoft Excel format with formatting
54
+ json - JSON structured data
55
+ bibtex - BibTeX format for LaTeX citations
56
+ ris - RIS format (Zotero, Mendeley, RefWorks)
57
+ enw - EndNote Tagged format (recommended for EndNote)
58
+ endnote - EndNote XML format
59
+ xml - Generic XML format
60
+
61
+ EXPORT LOCATIONS:
62
+ All exports are automatically saved to organized folders:
63
+ exports/csv/ - CSV files
64
+ exports/excel/ - Excel files
65
+ exports/json/ - JSON files
66
+ exports/bibtex/ - BibTeX files
67
+ exports/ris/ - RIS files
68
+ exports/endnote_tagged/ - EndNote .enw files
69
+ exports/endnote_xml/ - EndNote XML files
70
+ exports/xml/ - Generic XML files
71
+
72
+ For more information, visit: https://github.com/yourusername/lixplore
73
+ """
74
+
75
+ # ===== SOURCE SELECTION =====
76
+ source_group = parser.add_argument_group(
77
+ '[SOURCE SELECTION]',
78
+ 'Choose which academic databases to search'
79
+ )
80
+
81
+ source_group.add_argument(
82
+ "-P", "--pubmed", action="store_true",
83
+ help="Search PubMed (biomedical and life sciences literature)"
84
+ )
85
+ source_group.add_argument(
86
+ "-C", "--crossref", action="store_true",
87
+ help="Search Crossref (scholarly works with DOIs)"
88
+ )
89
+ source_group.add_argument(
90
+ "-J", "--doaj", action="store_true",
91
+ help="Search DOAJ - Directory of Open Access Journals"
92
+ )
93
+ source_group.add_argument(
94
+ "-E", "--europepmc", action="store_true",
95
+ help="Search EuropePMC (Europe PubMed Central)"
96
+ )
97
+ source_group.add_argument(
98
+ "-x", "--arxiv", action="store_true",
99
+ help="Search arXiv (preprint repository for physics, math, CS, etc.)"
100
+ )
101
+ source_group.add_argument(
102
+ "-A", "--all", action="store_true",
103
+ help="Search ALL sources at once (PubMed, Crossref, DOAJ, EuropePMC, arXiv)"
104
+ )
105
+ source_group.add_argument(
106
+ "-s", "--sources", type=str, metavar="CODES",
107
+ help="Combined source selection using codes: P=PubMed, C=Crossref, J=DOAJ, E=EuropePMC, X=arXiv, A=All. Example: -s PX for PubMed+arXiv, -s PCJE for multiple"
108
+ )
109
+ source_group.add_argument(
110
+ "--custom-api", type=str, metavar="NAME",
111
+ help="Use a custom API source (Springer, BASE, etc.). Must be configured in ~/.lixplore/apis/ or ~/.lixplore/custom_apis.json. Example: --custom-api springer"
112
+ )
113
+
114
+ # ===== SEARCH PARAMETERS =====
115
+ search_group = parser.add_argument_group(
116
+ '[SEARCH PARAMETERS]',
117
+ 'Define what and how to search'
118
+ )
119
+
120
+ search_group.add_argument(
121
+ "-q", "--query", type=str, metavar="TEXT",
122
+ help="Search query string. Supports Boolean operators: AND, OR, NOT, parentheses for grouping. Examples: -q \"cancer treatment\" | -q \"cancer AND treatment\" | -q \"(cancer OR tumor) AND treatment\" | -q \"diabetes NOT type1\""
123
+ )
124
+ search_group.add_argument(
125
+ "-au", "--author", type=str, metavar="NAME",
126
+ help="Search by author name. Example: -au \"Smith J\" or -au \"Einstein A\""
127
+ )
128
+ search_group.add_argument(
129
+ "-DOI", "--doi", type=str, metavar="DOI",
130
+ help="Search for a specific article by DOI. Example: -DOI \"10.1038/nature12345\""
131
+ )
132
+ search_group.add_argument(
133
+ "-m", "--max_results", type=int, default=10, metavar="N",
134
+ help="Maximum number of results to fetch per source (default: 10). Example: -m 50"
135
+ )
136
+
137
+ # ===== FILTERING & PROCESSING =====
138
+ filter_group = parser.add_argument_group(
139
+ '[FILTERING & PROCESSING]',
140
+ 'Filter and process search results'
141
+ )
142
+
143
+ filter_group.add_argument(
144
+ "-d", "--date", nargs=2, metavar=("FROM", "TO"),
145
+ help="Filter by publication date range in YYYY-MM-DD format. Example: -d 2020-01-01 2024-12-31"
146
+ )
147
+ filter_group.add_argument(
148
+ "-D", "--deduplicate", nargs="?", const="auto", metavar="STRATEGY",
149
+ choices=["auto", "doi_only", "title_only", "strict", "loose"],
150
+ help="Remove duplicate results. Strategies: auto (default, multi-level), doi_only (DOI matching only), title_only (title similarity only), strict (high threshold 0.95), loose (low threshold 0.75). Example: -D or -D strict"
151
+ )
152
+ filter_group.add_argument(
153
+ "--dedup-threshold", type=float, default=0.85, metavar="FLOAT",
154
+ help="Title similarity threshold for deduplication (0.0-1.0). Default: 0.85. Example: --dedup-threshold 0.9"
155
+ )
156
+ filter_group.add_argument(
157
+ "--dedup-keep", type=str,
158
+ choices=["first", "most_complete", "prefer_doi"],
159
+ default="most_complete", metavar="STRATEGY",
160
+ help="Which duplicate to keep: first (chronological), most_complete (most metadata), prefer_doi (prefer entries with DOI). Default: most_complete"
161
+ )
162
+ filter_group.add_argument(
163
+ "--dedup-merge", action="store_true",
164
+ help="Merge metadata from duplicates instead of discarding. Combines best data from all duplicates."
165
+ )
166
+ filter_group.add_argument(
167
+ "--sort", type=str, choices=["relevant", "newest", "oldest", "journal", "author"],
168
+ default="relevant", metavar="ORDER",
169
+ help="Sort results by: relevant (default/original order), newest (latest first), oldest (earliest first), journal (alphabetical), author (by first author). Example: --sort newest"
170
+ )
171
+ filter_group.add_argument(
172
+ "--enrich", nargs="*", metavar="API",
173
+ choices=["crossref", "pubmed", "arxiv", "all"],
174
+ help="Enrich metadata using external APIs. Use without arguments for all, or specify: crossref, pubmed, arxiv. Automatically validates and finds missing DOIs. Example: --enrich or --enrich crossref pubmed"
175
+ )
176
+
177
+ # ===== DISPLAY OPTIONS =====
178
+ display_group = parser.add_argument_group(
179
+ '[DISPLAY OPTIONS]',
180
+ 'Control how results are displayed'
181
+ )
182
+
183
+ display_group.add_argument(
184
+ "-a", "--abstract", action="store_true",
185
+ help="Display abstracts along with titles in the results"
186
+ )
187
+ display_group.add_argument(
188
+ "-i", "--interactive", action="store_true",
189
+ help="Launch simple interactive TUI mode for searching and browsing (can be used standalone: lixplore -i)"
190
+ )
191
+ display_group.add_argument(
192
+ "-N", "--number", type=int, nargs="+", default=[], metavar="N",
193
+ help="View detailed information for specific article(s) by number. Example: -N 1 or -N 1 2 3"
194
+ )
195
+ display_group.add_argument(
196
+ "-R", "--review", type=int, nargs="+", default=[], metavar="N",
197
+ help="Open article(s) in separate terminal window for detailed review. Two modes: 1) With search: -P -q 'query' -R 1 2, or 2) Standalone: lixplore -R 1 2 (uses cached results). Close window with 'q' or Ctrl+C. Example: -R 1 or -R 1 2 3"
198
+ )
199
+ display_group.add_argument(
200
+ "--stat", action="store_true",
201
+ help="Show comprehensive statistics dashboard with visualizations (publication trends, top journals, top authors, source distribution)"
202
+ )
203
+ display_group.add_argument(
204
+ "--stat-top", type=int, default=10, metavar="N",
205
+ help="Number of top items to show in statistics rankings (default: 10)"
206
+ )
207
+ display_group.add_argument(
208
+ "-p", "--page", type=int, default=1, metavar="N",
209
+ help="Page number to display when results exceed page size (default: 1). Example: -p 2"
210
+ )
211
+ display_group.add_argument(
212
+ "--page-size", type=int, default=20, metavar="N",
213
+ help="Number of results to display per page (default: 20). Example: --page-size 50"
214
+ )
215
+ display_group.add_argument(
216
+ "--show-pdf-links", action="store_true",
217
+ help="Display PDF links for open access articles (from PMC, arXiv, Unpaywall). Links are clickable in modern terminals. Example: -P -q 'cancer' --show-pdf-links"
218
+ )
219
+
220
+ # ===== EXPORT & OUTPUT =====
221
+ export_group = parser.add_argument_group(
222
+ '[EXPORT & OUTPUT]',
223
+ 'Export results in various formats'
224
+ )
225
+
226
+ export_group.add_argument(
227
+ "-X", "--export", type=str,
228
+ metavar="FORMAT",
229
+ help="Export results to format(s). Single format: csv, json, bibtex, ris, endnote (XML), enw (EndNote Tagged), xlsx (Excel), xml. Multiple formats (comma-separated): csv,ris,bibtex. Files saved to exports/ folder. Example: -X csv or -X csv,ris,bibtex"
230
+ )
231
+ export_group.add_argument(
232
+ "-o", "--output", type=str, metavar="FILE",
233
+ help="Custom output filename for export (default: auto-generated with timestamp). Example: -o my_results.csv"
234
+ )
235
+ export_group.add_argument(
236
+ "-S", "--select", nargs="+", default=[], metavar="SELECTION",
237
+ help="Select article(s) to export. Supports: numbers (1 3 5), ranges (1-10), keywords (odd, even, first:N, last:N, top:N). Examples: -S 1 3 5 | -S odd | -S even | -S 1-10 | -S first:5 | -S last:3 | -S top:10. Without this flag, all results are exported."
238
+ )
239
+ export_group.add_argument(
240
+ "--export-fields", type=str, nargs="+", metavar="FIELD",
241
+ help="Select specific fields to export. Available fields: title, authors, abstract, journal, year, doi, url, source. Example: --export-fields title authors year doi"
242
+ )
243
+ export_group.add_argument(
244
+ "--zip", action="store_true",
245
+ help="Compress exported file(s) to ZIP format. Example: -X csv --zip"
246
+ )
247
+ export_group.add_argument(
248
+ "-c", "--citations", type=str,
249
+ choices=["apa", "mla", "chicago", "ieee"],
250
+ metavar="STYLE",
251
+ help="Export as formatted citations. Styles: apa, mla, chicago, ieee. Example: -c apa or --citations apa"
252
+ )
253
+ export_group.add_argument(
254
+ "--save-profile", type=str, metavar="NAME",
255
+ help="Save current export settings as a reusable profile. Example: --save-profile my_nature_export"
256
+ )
257
+ export_group.add_argument(
258
+ "--load-profile", type=str, metavar="NAME",
259
+ help="Load previously saved export profile. Example: --load-profile my_nature_export"
260
+ )
261
+ export_group.add_argument(
262
+ "--template", type=str, metavar="NAME",
263
+ help="Use predefined export template. Available: nature, science, ieee. Example: --template nature. Use --list-templates to see all"
264
+ )
265
+ export_group.add_argument(
266
+ "--download-pdf", action="store_true",
267
+ help="Download full-text PDFs for search results. Tries PMC, arXiv, DOI resolution, and optionally SciHub. PDFs saved to ~/Lixplore_PDFs/"
268
+ )
269
+ export_group.add_argument(
270
+ "--pdf-numbers", type=int, nargs="+", metavar="N",
271
+ help="Download PDFs only for specific article numbers. Example: --pdf-numbers 1 3 5"
272
+ )
273
+ export_group.add_argument(
274
+ "--use-scihub", action="store_true",
275
+ help="Use SciHub as fallback for PDF download (requires --set-scihub-mirror configuration). Use at your own discretion."
276
+ )
277
+ export_group.add_argument(
278
+ "--add-to-zotero", action="store_true",
279
+ help="Add search results to Zotero library (requires Zotero API configuration)"
280
+ )
281
+ export_group.add_argument(
282
+ "--zotero-collection", type=str, metavar="KEY",
283
+ help="Zotero collection key to add items to (use with --add-to-zotero)"
284
+ )
285
+ export_group.add_argument(
286
+ "--export-for-mendeley", action="store_true",
287
+ help="Export results as RIS file for Mendeley Desktop import"
288
+ )
289
+
290
+ # ===== UTILITY =====
291
+ utility_group = parser.add_argument_group(
292
+ '[UTILITY]',
293
+ 'Additional utility options'
294
+ )
295
+
296
+ utility_group.add_argument(
297
+ "-H", "--history", action="store_true",
298
+ help="Show search history"
299
+ )
300
+ utility_group.add_argument(
301
+ "--refresh", action="store_true",
302
+ help="Bypass cache and fetch fresh results (ignore cached data)"
303
+ )
304
+ utility_group.add_argument(
305
+ "--examples", action="store_true",
306
+ help="Show quick examples (tldr-style) and exit"
307
+ )
308
+ utility_group.add_argument(
309
+ "--list-profiles", action="store_true",
310
+ help="List all saved export profiles"
311
+ )
312
+ utility_group.add_argument(
313
+ "--delete-profile", type=str, metavar="NAME",
314
+ help="Delete saved export profile"
315
+ )
316
+ utility_group.add_argument(
317
+ "--list-templates", action="store_true",
318
+ help="List all available export templates"
319
+ )
320
+ utility_group.add_argument(
321
+ "--list-custom-apis", action="store_true",
322
+ help="List all configured custom API sources"
323
+ )
324
+ utility_group.add_argument(
325
+ "--create-api-examples", action="store_true",
326
+ help="Create example custom API configurations (Springer, BASE, etc.)"
327
+ )
328
+ utility_group.add_argument(
329
+ "--set-scihub-mirror", type=str, metavar="URL",
330
+ help="Configure SciHub mirror URL for PDF downloads. Example: --set-scihub-mirror https://sci-hub.se"
331
+ )
332
+ utility_group.add_argument(
333
+ "--show-pdf-dir", action="store_true",
334
+ help="Show PDF download directory location"
335
+ )
336
+ utility_group.add_argument(
337
+ "--configure-zotero", nargs=2, metavar=("API_KEY", "USER_ID"),
338
+ help="Configure Zotero API access. Get API key from https://www.zotero.org/settings/keys"
339
+ )
340
+ utility_group.add_argument(
341
+ "--show-zotero-collections", action="store_true",
342
+ help="List Zotero collections with their keys"
343
+ )
344
+
345
+ # ===== INTERACTIVE MODES =====
346
+ mode_group = parser.add_argument_group(
347
+ '[INTERACTIVE MODES]',
348
+ 'Enhanced interactive experiences for easier usage'
349
+ )
350
+
351
+ mode_group.add_argument(
352
+ "--tui", action="store_true",
353
+ help="Launch enhanced TUI (Text User Interface) mode - the primary interactive interface. Includes search, annotation, statistics, and export in a beautiful visual interface. This is the DEFAULT when no query is provided."
354
+ )
355
+ mode_group.add_argument(
356
+ "--shell", action="store_true",
357
+ help="[Deprecated - use --tui instead] Launch interactive shell mode."
358
+ )
359
+ mode_group.add_argument(
360
+ "--wizard", action="store_true",
361
+ help="[Deprecated - use --tui instead] Launch wizard mode."
362
+ )
363
+
364
+ # ===== ANNOTATIONS =====
365
+ annotation_group = parser.add_argument_group(
366
+ '[ANNOTATIONS]',
367
+ 'Annotate, rate, and organize articles'
368
+ )
369
+
370
+ annotation_group.add_argument(
371
+ "--annotate", type=int, metavar="N",
372
+ help="Annotate article #N from last search. Use with --comment, --rating, --tags, etc. Example: --annotate 5 --rating 4 --tags 'important,methodology'"
373
+ )
374
+ annotation_group.add_argument(
375
+ "--comment", type=str, metavar="TEXT",
376
+ help="Add comment/note to article (use with --annotate). Example: --annotate 3 --comment 'Excellent methodology section'"
377
+ )
378
+ annotation_group.add_argument(
379
+ "--rating", type=int, choices=[1, 2, 3, 4, 5], metavar="1-5",
380
+ help="Rate article 1-5 stars (use with --annotate). Example: --annotate 3 --rating 5"
381
+ )
382
+ annotation_group.add_argument(
383
+ "--tags", type=str, metavar="TAGS",
384
+ help="Add comma-separated tags (use with --annotate). Example: --annotate 3 --tags 'important,review-later,cite'"
385
+ )
386
+ annotation_group.add_argument(
387
+ "--read-status", type=str, choices=['unread', 'reading', 'read'], metavar="STATUS",
388
+ help="Set read status: unread, reading, read (use with --annotate). Example: --annotate 3 --read-status read"
389
+ )
390
+ annotation_group.add_argument(
391
+ "--priority", type=str, choices=['low', 'medium', 'high'], metavar="LEVEL",
392
+ help="Set priority level: low, medium, high (use with --annotate). Example: --annotate 3 --priority high"
393
+ )
394
+ annotation_group.add_argument(
395
+ "--show-annotation", type=int, metavar="N",
396
+ help="Show annotation for article #N from last search"
397
+ )
398
+ annotation_group.add_argument(
399
+ "--list-annotations", action="store_true",
400
+ help="List all annotated articles"
401
+ )
402
+ annotation_group.add_argument(
403
+ "--filter-annotations", type=str, metavar="FILTERS",
404
+ help="Filter annotations. Format: key=value,key=value. Keys: min_rating, read_status, priority, tag. Example: --filter-annotations 'min_rating=4,priority=high'"
405
+ )
406
+ annotation_group.add_argument(
407
+ "--search-annotations", type=str, metavar="QUERY",
408
+ help="Search annotations by keyword in comments, tags, or title. Example: --search-annotations 'methodology'"
409
+ )
410
+ annotation_group.add_argument(
411
+ "--export-annotations", type=str, choices=['markdown', 'json', 'csv'], metavar="FORMAT",
412
+ help="Export all annotations to file. Formats: markdown, json, csv. Example: --export-annotations markdown"
413
+ )
414
+ annotation_group.add_argument(
415
+ "--annotation-stats", action="store_true",
416
+ help="Show annotation statistics (total, ratings distribution, tags, etc.)"
417
+ )
418
+ annotation_group.add_argument(
419
+ "--delete-annotation", type=int, metavar="N",
420
+ help="Delete annotation for article #N from last search"
421
+ )
422
+
423
+ parser.set_defaults(func=run_main)
424
+
425
+
426
+ def sort_results(results, sort_order):
427
+ """
428
+ Sort results based on specified order.
429
+
430
+ Args:
431
+ results: List of article dictionaries
432
+ sort_order: Sort order (relevant, newest, oldest, journal, author)
433
+
434
+ Returns:
435
+ Sorted list of articles
436
+ """
437
+ if not results or sort_order == "relevant":
438
+ # Keep original order (most relevant from API)
439
+ return results
440
+
441
+ sorted_results = results.copy()
442
+
443
+ if sort_order == "newest":
444
+ # Sort by year descending (newest first)
445
+ sorted_results.sort(key=lambda x: x.get('year', 0), reverse=True)
446
+
447
+ elif sort_order == "oldest":
448
+ # Sort by year ascending (oldest first)
449
+ sorted_results.sort(key=lambda x: x.get('year', 9999))
450
+
451
+ elif sort_order == "journal":
452
+ # Sort by journal name alphabetically
453
+ sorted_results.sort(key=lambda x: (x.get('journal', '') or '').lower())
454
+
455
+ elif sort_order == "author":
456
+ # Sort by first author's last name
457
+ def get_first_author_last_name(article):
458
+ authors = article.get('authors', [])
459
+ if authors and len(authors) > 0:
460
+ # Get first author, try to extract last name
461
+ first_author = authors[0]
462
+ # Assume last name is the last word
463
+ parts = first_author.split()
464
+ return parts[-1].lower() if parts else ''
465
+ return ''
466
+
467
+ sorted_results.sort(key=get_first_author_last_name)
468
+
469
+ return sorted_results
470
+
471
+
472
+ def parse_selection(selection_args, total_results):
473
+ """
474
+ Parse selection arguments and return list of article indices.
475
+
476
+ Supports:
477
+ - Numbers: 1 3 5
478
+ - Ranges: 1-10, 5-15
479
+ - Keywords: odd, even
480
+ - First N: first:5, top:5
481
+ - Last N: last:3
482
+
483
+ Args:
484
+ selection_args: List of selection arguments
485
+ total_results: Total number of results available
486
+
487
+ Returns:
488
+ List of article numbers (1-based)
489
+ """
490
+ selected = set()
491
+
492
+ for arg in selection_args:
493
+ arg = str(arg).lower()
494
+
495
+ # Keyword: odd
496
+ if arg == 'odd':
497
+ selected.update(range(1, total_results + 1, 2))
498
+
499
+ # Keyword: even
500
+ elif arg == 'even':
501
+ selected.update(range(2, total_results + 1, 2))
502
+
503
+ # Keyword: first:N or top:N
504
+ elif arg.startswith('first:') or arg.startswith('top:'):
505
+ try:
506
+ n = int(arg.split(':')[1])
507
+ selected.update(range(1, min(n + 1, total_results + 1)))
508
+ except (ValueError, IndexError):
509
+ print(f"Warning: Invalid format '{arg}'. Use 'first:N' or 'top:N'")
510
+
511
+ # Keyword: last:N
512
+ elif arg.startswith('last:'):
513
+ try:
514
+ n = int(arg.split(':')[1])
515
+ start = max(1, total_results - n + 1)
516
+ selected.update(range(start, total_results + 1))
517
+ except (ValueError, IndexError):
518
+ print(f"Warning: Invalid format '{arg}'. Use 'last:N'")
519
+
520
+ # Range: 1-10
521
+ elif '-' in arg and not arg.startswith('-'):
522
+ try:
523
+ start, end = arg.split('-')
524
+ start, end = int(start), int(end)
525
+ if start > end:
526
+ start, end = end, start
527
+ selected.update(range(start, min(end + 1, total_results + 1)))
528
+ except ValueError:
529
+ print(f"Warning: Invalid range '{arg}'. Use format: 1-10")
530
+
531
+ # Single number
532
+ else:
533
+ try:
534
+ num = int(arg)
535
+ if 1 <= num <= total_results:
536
+ selected.add(num)
537
+ else:
538
+ print(f"Warning: Article #{num} is out of range (1-{total_results})")
539
+ except ValueError:
540
+ print(f"Warning: Unrecognized selection '{arg}'")
541
+
542
+ return sorted(list(selected))
543
+
544
+
545
+ def _supports_unicode_output() -> bool:
546
+ """Return True if stdout can encode common Unicode characters (emojis/box)."""
547
+ import sys
548
+ test_string = "═│"
549
+ try:
550
+ test_string.encode(sys.stdout.encoding or "utf-8")
551
+ return True
552
+ except Exception:
553
+ return False
554
+
555
+
556
+ def _examples_text(unicode_ok: bool) -> str:
557
+ if unicode_ok:
558
+ return """
559
+ ╔═══════════════════════════════════════════════════════════════════════╗
560
+ ║ LIXPLORE - Quick Examples ║
561
+ ╚═══════════════════════════════════════════════════════════════════════╝
562
+
563
+ BASIC SEARCH
564
+ Search PubMed for a query:
565
+ $ lixplore -P -q "cancer treatment" -m 10
566
+
567
+ Search with abstracts:
568
+ $ lixplore -P -q "diabetes" -m 5 -a
569
+
570
+ MULTI-SOURCE SEARCH
571
+ Search PubMed + arXiv:
572
+ $ lixplore -s PX -q "machine learning" -m 20
573
+
574
+ Search all sources with deduplication:
575
+ $ lixplore -A -q "COVID-19" -m 50 -D
576
+
577
+ EXPORT RESULTS
578
+ Export to Excel:
579
+ $ lixplore -P -q "neuroscience" -m 15 -X xlsx -o brain_research.xlsx
580
+
581
+ Export to EndNote Tagged:
582
+ $ lixplore -P -q "quantum physics" -m 20 -X enw -o physics.enw
583
+
584
+ Export to CSV:
585
+ $ lixplore -P -q "climate change" -m 30 -X csv
586
+
587
+ Export to BibTeX:
588
+ $ lixplore -P -q "artificial intelligence" -m 25 -X bibtex
589
+
590
+ ADVANCED SEARCH
591
+ Search with date filter:
592
+ $ lixplore -P -q "vaccine development" -d 2020-01-01 2024-12-31 -m 15
593
+
594
+ Search by author:
595
+ $ lixplore -P -au "Smith J" -m 10 -a
596
+
597
+ Search by DOI:
598
+ $ lixplore -DOI "10.1038/nature12345"
599
+
600
+ 🔤 BOOLEAN OPERATORS (Advanced Query Syntax)
601
+ AND operator (both terms required):
602
+ $ lixplore -P -q "cancer AND treatment" -m 10
603
+
604
+ OR operator (either term):
605
+ $ lixplore -P -q "cancer OR tumor" -m 10
606
+
607
+ NOT operator (exclude term):
608
+ $ lixplore -P -q "diabetes NOT type1" -m 10
609
+
610
+ Complex queries with parentheses:
611
+ $ lixplore -P -q "(cancer OR tumor) AND (treatment OR therapy)" -m 20
612
+
613
+ Combine with other features:
614
+ $ lixplore -A -q "COVID-19 AND vaccine" -m 50 -D --sort newest -X xlsx
615
+
616
+ 🔄 COMBINED FEATURES
617
+ Multi-source search with export and deduplication:
618
+ $ lixplore -s PCE -q "gene therapy" -m 30 -D -X xlsx -o genes.xlsx
619
+
620
+ Search with date filter, abstracts, and export:
621
+ $ lixplore -P -q "cancer immunotherapy" -d 2023-01-01 2024-12-31 -a -m 20 -X json
622
+
623
+ 📖 REVIEW ARTICLES (Two-Step Workflow)
624
+ Step 1 - Search and cache results:
625
+ $ lixplore -P -q "diabetes" -m 10
626
+
627
+ Step 2 - Review specific articles:
628
+ $ lixplore -R 2 # Review article #2
629
+ $ lixplore -R 1 5 9 # Review multiple articles
630
+
631
+ One-step (search + review):
632
+ $ lixplore -P -q "aspirin" -m 10 -R 1 3 5
633
+
634
+ In review window:
635
+ Press 'q' or Ctrl+C to close (won't close with other keys)
636
+
637
+ 🔢 SMART SELECTION (Export Specific Articles)
638
+ Export odd-numbered articles:
639
+ $ lixplore -P -q "research" -m 50 -S odd -X csv
640
+
641
+ Export even-numbered articles:
642
+ $ lixplore -P -q "study" -m 50 -S even -X xlsx
643
+
644
+ Export first 10 articles:
645
+ $ lixplore -P -q "cancer" -m 50 -S first:10 -X enw
646
+
647
+ Export last 5 articles:
648
+ $ lixplore -P -q "science" -m 30 -S last:5 -X csv
649
+
650
+ Export specific range:
651
+ $ lixplore -P -q "biology" -m 50 -S 10-20 -X xlsx
652
+
653
+ Mixed selection (combine patterns):
654
+ $ lixplore -P -q "chemistry" -m 50 -S 1 3 5-10 odd -X csv
655
+
656
+ SORT RESULTS
657
+ Sort by newest (latest first):
658
+ $ lixplore -P -q "COVID-19" -m 50 --sort newest
659
+
660
+ Sort by oldest (historical research):
661
+ $ lixplore -P -q "diabetes" -m 50 --sort oldest
662
+
663
+ Sort by journal (alphabetical):
664
+ $ lixplore -A -q "AI" -m 50 -D --sort journal
665
+
666
+ Sort by author (alphabetical):
667
+ $ lixplore -P -q "physics" -m 50 --sort author
668
+
669
+ Combine sort + selection + export:
670
+ $ lixplore -P -q "cancer" -m 50 --sort newest -S first:10 -X xlsx
671
+
672
+ PDF LINKS (Display without downloading)
673
+ Show clickable PDF links for open access articles:
674
+ $ lixplore -x -q "neural networks" -m 10 --show-pdf-links
675
+
676
+ Combine with abstracts:
677
+ $ lixplore -P -q "cancer" -m 20 -a --show-pdf-links
678
+
679
+ Multi-source with PDF links:
680
+ $ lixplore -A -q "COVID-19" -m 50 -D --show-pdf-links
681
+
682
+ Links work in modern terminals (iTerm2, GNOME Terminal, Windows Terminal)
683
+ Click to open PDF in browser - no download required!
684
+
685
+ SOURCE CODES (for -s flag)
686
+ P = PubMed C = Crossref J = DOAJ
687
+ E = EuropePMC X = arXiv A = All sources
688
+
689
+ EXPORT FORMATS
690
+ csv - CSV (Excel, Google Sheets)
691
+ xlsx - Excel with formatting
692
+ json - JSON structured data
693
+ bibtex - BibTeX for LaTeX
694
+ ris - RIS (Zotero, Mendeley)
695
+ enw - EndNote Tagged (recommended)
696
+ endnote - EndNote XML
697
+ xml - Generic XML
698
+
699
+ Export locations: All files saved to exports/ folder
700
+ (organized by format: exports/csv/, exports/excel/, etc.)
701
+
702
+ TIP: Use -D flag when searching multiple sources to remove duplicates
703
+ TIP: Use -a flag to see abstracts in results
704
+ TIP: Use -R for detailed review in separate windows (press 'q' to close)
705
+ TIP: Use -S with keywords (odd, even, first:N, last:N) for smart selection
706
+ TIP: Use --sort newest to get latest research first
707
+ TIP: Use --show-pdf-links to see clickable PDF links (works in modern terminals!)
708
+ TIP: Results are cached - review later with: lixplore -R 1 2 3
709
+ TIP: Combine features: --sort newest -S first:10 -X xlsx
710
+ TIP: Use --help for complete documentation
711
+ TIP: Use 'man lixplore' for detailed manual page
712
+
713
+ For more information: lixplore --help
714
+ """
715
+ else:
716
+ return """
717
+ =======================================================================
718
+ LIXPLORE - Quick Examples
719
+ =======================================================================
720
+
721
+ BASIC SEARCH
722
+ Search PubMed for a query:
723
+ $ lixplore -P -q "cancer treatment" -m 10
724
+
725
+ Search with abstracts:
726
+ $ lixplore -P -q "diabetes" -m 5 -a
727
+
728
+ MULTI-SOURCE SEARCH
729
+ Search PubMed + arXiv:
730
+ $ lixplore -s PX -q "machine learning" -m 20
731
+
732
+ Search all sources with deduplication:
733
+ $ lixplore -A -q "COVID-19" -m 50 -D
734
+
735
+ EXPORT RESULTS
736
+ Export to Excel:
737
+ $ lixplore -P -q "neuroscience" -m 15 -X xlsx -o brain_research.xlsx
738
+
739
+ Export to EndNote Tagged:
740
+ $ lixplore -P -q "quantum physics" -m 20 -X enw -o physics.enw
741
+
742
+ Export to CSV:
743
+ $ lixplore -P -q "climate change" -m 30 -X csv
744
+
745
+ Export to BibTeX:
746
+ $ lixplore -P -q "artificial intelligence" -m 25 -X bibtex
747
+
748
+ ADVANCED SEARCH
749
+ Search with date filter:
750
+ $ lixplore -P -q "vaccine development" -d 2020-01-01 2024-12-31 -m 15
751
+
752
+ Search by author:
753
+ $ lixplore -P -au "Smith J" -m 10 -a
754
+
755
+ Search by DOI:
756
+ $ lixplore -DOI "10.1038/nature12345"
757
+
758
+ BOOLEAN OPERATORS (Advanced Query Syntax)
759
+ AND operator (both terms required):
760
+ $ lixplore -P -q "cancer AND treatment" -m 10
761
+
762
+ OR operator (either term):
763
+ $ lixplore -P -q "cancer OR tumor" -m 10
764
+
765
+ NOT operator (exclude term):
766
+ $ lixplore -P -q "diabetes NOT type1" -m 10
767
+
768
+ Complex queries with parentheses:
769
+ $ lixplore -P -q "(cancer OR tumor) AND (treatment OR therapy)" -m 20
770
+
771
+ Combine with other features:
772
+ $ lixplore -A -q "COVID-19 AND vaccine" -m 50 -D --sort newest -X xlsx
773
+
774
+ COMBINED FEATURES
775
+ Multi-source search with export and deduplication:
776
+ $ lixplore -s PCE -q "gene therapy" -m 30 -D -X xlsx -o genes.xlsx
777
+
778
+ Search with date filter, abstracts, and export:
779
+ $ lixplore -P -q "cancer immunotherapy" -d 2023-01-01 2024-12-31 -a -m 20 -X json
780
+
781
+ REVIEW ARTICLES (Two-Step Workflow)
782
+ Step 1 - Search and cache results:
783
+ $ lixplore -P -q "diabetes" -m 10
784
+
785
+ Step 2 - Review specific articles:
786
+ $ lixplore -R 2 # Review article #2
787
+ $ lixplore -R 1 5 9 # Review multiple articles
788
+
789
+ One-step (search + review):
790
+ $ lixplore -P -q "aspirin" -m 10 -R 1 3 5
791
+
792
+ In review window:
793
+ Press 'q' or Ctrl+C to close (won't close with other keys)
794
+
795
+ SMART SELECTION (Export Specific Articles)
796
+ Export odd-numbered articles:
797
+ $ lixplore -P -q "research" -m 50 -S odd -X csv
798
+
799
+ Export even-numbered articles:
800
+ $ lixplore -P -q "study" -m 50 -S even -X xlsx
801
+
802
+ Export first 10 articles:
803
+ $ lixplore -P -q "cancer" -m 50 -S first:10 -X enw
804
+
805
+ Export last 5 articles:
806
+ $ lixplore -P -q "science" -m 30 -S last:5 -X csv
807
+
808
+ Export specific range:
809
+ $ lixplore -P -q "biology" -m 50 -S 10-20 -X xlsx
810
+
811
+ Mixed selection (combine patterns):
812
+ $ lixplore -P -q "chemistry" -m 50 -S 1 3 5-10 odd -X csv
813
+
814
+ SORT RESULTS
815
+ Sort by newest (latest first):
816
+ $ lixplore -P -q "COVID-19" -m 50 --sort newest
817
+
818
+ Sort by oldest (historical research):
819
+ $ lixplore -P -q "diabetes" -m 50 --sort oldest
820
+
821
+ Sort by journal (alphabetical):
822
+ $ lixplore -A -q "AI" -m 50 -D --sort journal
823
+
824
+ Sort by author (alphabetical):
825
+ $ lixplore -P -q "physics" -m 50 --sort author
826
+
827
+ Combine sort + selection + export:
828
+ $ lixplore -P -q "cancer" -m 50 --sort newest -S first:10 -X xlsx
829
+
830
+ SOURCE CODES (for -s flag)
831
+ P = PubMed C = Crossref J = DOAJ
832
+ E = EuropePMC X = arXiv A = All sources
833
+
834
+ EXPORT FORMATS
835
+ csv - CSV (Excel, Google Sheets)
836
+ xlsx - Excel with formatting
837
+ json - JSON structured data
838
+ bibtex - BibTeX for LaTeX
839
+ ris - RIS (Zotero, Mendeley)
840
+ enw - EndNote Tagged (recommended)
841
+ endnote - EndNote XML
842
+ xml - Generic XML
843
+
844
+ Export locations: All files saved to exports/ folder
845
+ (organized by format: exports/csv/, exports/excel/, etc.)
846
+
847
+ TIP: Use -D flag when searching multiple sources to remove duplicates
848
+ TIP: Use -a flag to see abstracts in results
849
+ TIP: Use -R for detailed review in separate windows (press 'q' to close)
850
+ TIP: Use -S with keywords (odd, even, first:N, last:N) for smart selection
851
+ TIP: Use --sort newest to get latest research first
852
+ TIP: Results are cached - review later with: lixplore -R 1 2 3
853
+ TIP: Combine features: --sort newest -S first:10 -X xlsx
854
+ TIP: Use --help for complete documentation
855
+ TIP: Use 'man lixplore' for detailed manual page
856
+
857
+ For more information: lixplore --help
858
+ """
859
+
860
+
861
+ def show_examples():
862
+ """Display tldr-style quick examples."""
863
+ unicode_ok = _supports_unicode_output()
864
+ print(_examples_text(unicode_ok))
865
+
866
+
867
+ def run_main(args):
868
+ """Main handler for CLI options."""
869
+
870
+ # Handle interactive modes first (only if used standalone, without search)
871
+ # If -i is used WITH search flags, it will be handled later after the search
872
+ has_search_params = (args.query or args.author or args.doi or
873
+ any([args.pubmed, args.crossref, args.doaj,
874
+ args.europepmc, args.arxiv, getattr(args, 'all', False),
875
+ args.sources]))
876
+
877
+ if args.interactive and not has_search_params:
878
+ # Launch simple interactive TUI standalone
879
+ from lixplore.utils.interactive_tui import launch_interactive_mode
880
+ launch_interactive_mode([])
881
+ return
882
+
883
+ if args.tui:
884
+ from lixplore.utils.enhanced_tui import launch_enhanced_tui
885
+ launch_enhanced_tui()
886
+ return
887
+
888
+ # Deprecated modes (kept for backwards compatibility)
889
+ if args.shell:
890
+ print("Note: --shell is deprecated.")
891
+ from lixplore.utils.shell_mode import launch_shell
892
+ launch_shell()
893
+ return
894
+
895
+ if args.wizard:
896
+ print("Note: --wizard is deprecated.")
897
+ from lixplore.utils.wizard_mode import launch_wizard
898
+ launch_wizard()
899
+ return
900
+
901
+ # If user only wants examples
902
+ if args.examples:
903
+ show_examples()
904
+ return
905
+
906
+ # If user only wants history
907
+ # Handle annotation commands
908
+ from lixplore.utils.annotations import AnnotationManager, display_annotation
909
+
910
+ # Annotation-only commands (don't require search)
911
+ if args.list_annotations:
912
+ manager = AnnotationManager()
913
+ filter_params = None
914
+
915
+ if hasattr(args, 'filter_annotations') and args.filter_annotations:
916
+ # Parse filter string: "min_rating=4,priority=high"
917
+ filter_params = {}
918
+ for item in args.filter_annotations.split(','):
919
+ if '=' in item:
920
+ key, value = item.split('=', 1)
921
+ key = key.strip()
922
+ value = value.strip()
923
+
924
+ if key == 'min_rating':
925
+ filter_params['min_rating'] = int(value)
926
+ elif key == 'max_rating':
927
+ filter_params['max_rating'] = int(value)
928
+ elif key == 'read_status':
929
+ filter_params['read_status'] = value
930
+ elif key == 'priority':
931
+ filter_params['priority'] = value
932
+ elif key == 'tag':
933
+ filter_params['tags'] = [value]
934
+
935
+ annotations = manager.list_all(filter_params)
936
+
937
+ if not annotations:
938
+ print("No annotated articles found.")
939
+ if filter_params:
940
+ print("Try removing filters or annotate some articles first.")
941
+ else:
942
+ print(f"\n{'='*80}")
943
+ print(f"ANNOTATED ARTICLES ({len(annotations)})")
944
+ print(f"{'='*80}\n")
945
+
946
+ for i, item in enumerate(annotations, 1):
947
+ annotation = item['annotation']
948
+ info = annotation.get('article_info', {})
949
+
950
+ print(f"[{i}] {info.get('title', 'No title')}")
951
+
952
+ # Show rating
953
+ if annotation.get('rating'):
954
+ stars = '' * annotation['rating']
955
+ print(f" Rating: {stars} ({annotation['rating']}/5)")
956
+
957
+ # Show tags
958
+ if annotation.get('tags'):
959
+ print(f" Tags: {', '.join(annotation['tags'])}")
960
+
961
+ # Show status and priority
962
+ print(f" Status: {annotation.get('read_status', 'unread').title()} | Priority: {annotation.get('priority', 'medium').title()}")
963
+
964
+ # Show comment count
965
+ comments_count = len(annotation.get('comments', []))
966
+ if comments_count > 0:
967
+ print(f" Comments: {comments_count}")
968
+
969
+ print()
970
+
971
+ print(f"{'='*80}\n")
972
+ return
973
+
974
+ if args.search_annotations:
975
+ manager = AnnotationManager()
976
+ results = manager.search_annotations(args.search_annotations)
977
+
978
+ if not results:
979
+ print(f"No annotations found matching: '{args.search_annotations}'")
980
+ else:
981
+ print(f"\n{'='*80}")
982
+ print(f"SEARCH RESULTS ({len(results)} matches)")
983
+ print(f"{'='*80}\n")
984
+
985
+ for i, item in enumerate(results, 1):
986
+ annotation = item['annotation']
987
+ info = annotation.get('article_info', {})
988
+
989
+ print(f"[{i}] {info.get('title', 'No title')}")
990
+ print(f" Match type: {item['match_type']}")
991
+ print(f" Match: {item['match_text'][:100]}...")
992
+ print()
993
+
994
+ print(f"{'='*80}\n")
995
+ return
996
+
997
+ if args.export_annotations:
998
+ manager = AnnotationManager()
999
+ output_file = manager.export_annotations(format=args.export_annotations)
1000
+ print(f"Annotations exported to: {output_file}")
1001
+ return
1002
+
1003
+ if args.annotation_stats:
1004
+ manager = AnnotationManager()
1005
+ stats = manager.get_statistics()
1006
+
1007
+ print(f"\n{'='*80}")
1008
+ print("ANNOTATION STATISTICS")
1009
+ print(f"{'='*80}\n")
1010
+
1011
+ print(f"Total Annotated Articles: {stats['total']}")
1012
+
1013
+ if stats['total'] == 0:
1014
+ print("\nNo annotations yet. Start annotating articles with:")
1015
+ print(" lixplore -P -q 'query' -m 10")
1016
+ print(" lixplore --annotate 1 --rating 5 --tags 'important'")
1017
+ print(f"{'='*80}\n")
1018
+ return
1019
+
1020
+ # Rating distribution
1021
+ if stats['by_rating']:
1022
+ print(f"\nRating Distribution:")
1023
+ for rating in sorted(stats['by_rating'].keys(), reverse=True):
1024
+ count = stats['by_rating'][rating]
1025
+ stars = '' * rating
1026
+ bar = '█' * count
1027
+ print(f" {stars} ({rating}): {bar} {count}")
1028
+
1029
+ # Read status
1030
+ if stats['by_status']:
1031
+ print(f"\nRead Status:")
1032
+ for status, count in stats['by_status'].items():
1033
+ print(f" {status.title()}: {count}")
1034
+
1035
+ # Priority
1036
+ if stats['by_priority']:
1037
+ print(f"\nPriority:")
1038
+ for priority, count in stats['by_priority'].items():
1039
+ print(f" {priority.title()}: {count}")
1040
+
1041
+ # Comments
1042
+ print(f"\nComments:")
1043
+ print(f" Articles with comments: {stats['with_comments']}")
1044
+ print(f" Total comments: {stats['total_comments']}")
1045
+
1046
+ # Tags
1047
+ print(f"\nTags:")
1048
+ print(f" Unique tags: {stats['total_tags']}")
1049
+ if stats['unique_tags']:
1050
+ print(f" Tags: {', '.join(stats['unique_tags'][:20])}")
1051
+ if len(stats['unique_tags']) > 20:
1052
+ print(f" ... and {len(stats['unique_tags']) - 20} more")
1053
+
1054
+ print(f"\n{'='*80}\n")
1055
+ return
1056
+
1057
+ # Handle template and profile management commands
1058
+ from lixplore.utils import profiles
1059
+ from lixplore.utils import template_engine
1060
+ from lixplore.utils import custom_apis
1061
+
1062
+ # Handle PDF downloader configuration commands
1063
+ from lixplore.utils import pdf_downloader
1064
+
1065
+ if args.set_scihub_mirror:
1066
+ pdf_downloader.set_scihub_mirror(args.set_scihub_mirror)
1067
+ return
1068
+
1069
+ if args.show_pdf_dir:
1070
+ print(f"PDF download directory: {pdf_downloader.PDF_DIR}")
1071
+ if os.path.exists(pdf_downloader.PDF_DIR):
1072
+ # Count PDFs
1073
+ pdf_count = sum(1 for root, dirs, files in os.walk(pdf_downloader.PDF_DIR)
1074
+ for f in files if f.endswith('.pdf'))
1075
+ print(f"Total PDFs downloaded: {pdf_count}")
1076
+ else:
1077
+ print("(Directory does not exist yet - will be created on first download)")
1078
+ return
1079
+
1080
+ # Handle reference manager configuration commands
1081
+ from lixplore.utils import reference_managers
1082
+
1083
+ if args.configure_zotero:
1084
+ api_key, user_id = args.configure_zotero
1085
+ reference_managers.configure_zotero(api_key, user_id)
1086
+ return
1087
+
1088
+ if args.show_zotero_collections:
1089
+ reference_managers.show_zotero_collections()
1090
+ return
1091
+
1092
+ # Handle custom API management commands
1093
+ if args.list_custom_apis:
1094
+ api_list = custom_apis.list_custom_apis()
1095
+ if api_list:
1096
+ print("Configured custom API sources:")
1097
+ for api in api_list:
1098
+ config = custom_apis.load_custom_api_config(api)
1099
+ if config:
1100
+ print(f" • {api}")
1101
+ if 'description' in config:
1102
+ print(f" {config['description']}")
1103
+ if 'requires_auth' in config and config['requires_auth']:
1104
+ print(f" Requires authentication")
1105
+ else:
1106
+ print("No custom APIs configured.")
1107
+ print("Run: lixplore --create-api-examples to create example configurations")
1108
+ return
1109
+
1110
+ if args.create_api_examples:
1111
+ custom_apis.create_example_configs()
1112
+ return
1113
+
1114
+ if args.list_templates:
1115
+ template_names = template_engine.list_templates()
1116
+ if template_names:
1117
+ print("Available export templates:")
1118
+ for name in template_names:
1119
+ template = template_engine.load_template(name.replace(' (user)', ''))
1120
+ if template:
1121
+ print(f" • {name}")
1122
+ if 'description' in template:
1123
+ print(f" {template['description']}")
1124
+ if 'format' in template:
1125
+ print(f" Format: {template['format']}")
1126
+ else:
1127
+ print("No templates found.")
1128
+ return
1129
+
1130
+ if args.list_profiles:
1131
+ profile_names = profiles.list_profiles()
1132
+ if profile_names:
1133
+ print("Saved export profiles:")
1134
+ for name in profile_names:
1135
+ profile = profiles.load_profile(name)
1136
+ print(f" • {name}")
1137
+ if 'export_format' in profile:
1138
+ print(f" Format: {profile['export_format']}")
1139
+ if 'citation_style' in profile:
1140
+ print(f" Citation: {profile['citation_style']}")
1141
+ else:
1142
+ print("No saved profiles found.")
1143
+ print("Save a profile with: --save-profile <name>")
1144
+ return
1145
+
1146
+ if args.delete_profile:
1147
+ if profiles.delete_profile(args.delete_profile):
1148
+ print(f"Profile '{args.delete_profile}' deleted successfully")
1149
+ return
1150
+
1151
+ # Load template if specified
1152
+ if args.template:
1153
+ template = template_engine.load_template(args.template)
1154
+ if template:
1155
+ print(f"Loading template: {args.template}")
1156
+ args = template_engine.apply_template_to_args(args, template)
1157
+ else:
1158
+ print(f"Error: Template '{args.template}' not found")
1159
+ print("Use --list-templates to see available templates")
1160
+ return
1161
+
1162
+ # Load profile if specified (profiles override templates)
1163
+ if args.load_profile:
1164
+ profile = profiles.load_profile(args.load_profile)
1165
+ if profile:
1166
+ print(f"Loading profile: {args.load_profile}")
1167
+ args = profiles.apply_profile_to_args(args, profile)
1168
+ else:
1169
+ print(f"Error: Profile '{args.load_profile}' not found")
1170
+ print("Use --list-profiles to see available profiles")
1171
+ return
1172
+
1173
+ if args.history:
1174
+ dispatcher.show_history()
1175
+ return
1176
+
1177
+ # If user only wants to review cached results (no new search)
1178
+ if args.review and not any([args.pubmed, args.crossref, args.doaj, args.europepmc, args.arxiv, args.all, args.sources, args.query]):
1179
+ # Load cached results and review (ignore --refresh flag for standalone review)
1180
+ cached_results = dispatcher.load_cached_results(check_expiry=True, force_refresh=False)
1181
+ if cached_results:
1182
+ print(f"Loading cached results ({len(cached_results)} articles)...")
1183
+ print("\nCached results:")
1184
+ for i, result in enumerate(cached_results, 1):
1185
+ print(f"[{i}] {result.get('title', 'No title')}")
1186
+ print("")
1187
+ dispatcher.review_articles(cached_results, args.review)
1188
+ else:
1189
+ print("No cached results found. Please run a search first.")
1190
+ print("Example: lixplore -P -q \"paracetamol\" -m 5")
1191
+ return
1192
+
1193
+ # Determine which sources to search
1194
+ sources_to_search = []
1195
+ use_custom_api = False
1196
+ custom_api_name = None
1197
+
1198
+ # Check for custom API first (takes precedence)
1199
+ if hasattr(args, 'custom_api') and args.custom_api:
1200
+ use_custom_api = True
1201
+ custom_api_name = args.custom_api
1202
+ # Custom API is handled separately, not added to sources_to_search
1203
+
1204
+ # Source letter mapping
1205
+ source_map = {
1206
+ 'P': 'pubmed',
1207
+ 'C': 'crossref',
1208
+ 'J': 'doaj',
1209
+ 'E': 'europepmc',
1210
+ 'X': 'arxiv',
1211
+ 'A': 'all'
1212
+ }
1213
+
1214
+ # Check for combined sources flag (-s/--sources)
1215
+ if hasattr(args, 'sources') and args.sources:
1216
+ sources_str = args.sources.upper()
1217
+
1218
+ # If 'A' is in the string, search all sources
1219
+ if 'A' in sources_str:
1220
+ sources_to_search = ["pubmed", "crossref", "doaj", "europepmc", "arxiv"]
1221
+ else:
1222
+ # Parse each character
1223
+ for char in sources_str:
1224
+ if char in source_map and source_map[char] != 'all':
1225
+ source = source_map[char]
1226
+ if source not in sources_to_search:
1227
+ sources_to_search.append(source)
1228
+ elif char not in [' ', ',']: # Ignore spaces and commas
1229
+ print(f"Warning: Unknown source code '{char}' - ignoring")
1230
+
1231
+ # Check for -A/--all flag
1232
+ elif args.all:
1233
+ sources_to_search = ["pubmed", "crossref", "doaj", "europepmc", "arxiv"]
1234
+
1235
+ # Fall back to individual source flags
1236
+ else:
1237
+ if args.pubmed:
1238
+ sources_to_search.append("pubmed")
1239
+ if args.crossref:
1240
+ sources_to_search.append("crossref")
1241
+ if args.doaj:
1242
+ sources_to_search.append("doaj")
1243
+ if args.europepmc:
1244
+ sources_to_search.append("europepmc")
1245
+ if args.arxiv:
1246
+ sources_to_search.append("arxiv")
1247
+
1248
+ # Check if at least one source is selected (either standard or custom)
1249
+ if not sources_to_search and not use_custom_api:
1250
+ print("Error: Please specify at least one source to search:")
1251
+ print(" -s PX Combined sources (P=PubMed, C=Crossref, J=DOAJ, E=EuropePMC, X=arXiv, A=All)")
1252
+ print(" -P or --pubmed Search PubMed")
1253
+ print(" -C or --crossref Search Crossref")
1254
+ print(" -J or --doaj Search DOAJ")
1255
+ print(" -E or --europepmc Search EuropePMC")
1256
+ print(" -x or --arxiv Search arXiv")
1257
+ print(" -A or --all Search all sources")
1258
+ print(" --custom-api NAME Search custom API (Springer, BASE, etc.)")
1259
+ print("\nFor interactive mode, use: lixplore -i")
1260
+ print("\nExamples:")
1261
+ print(" lixplore -P -q 'search term' -m 20 # PubMed search")
1262
+ print(" lixplore -s PX -q 'search term' # PubMed + arXiv")
1263
+ print(" lixplore -s PCE -q 'search term' # PubMed + Crossref + EuropePMC")
1264
+ print(" lixplore -i # Interactive TUI mode")
1265
+ print(" lixplore --custom-api springer -q 'term' # Custom API (requires configuration)")
1266
+ return
1267
+
1268
+ results = []
1269
+ query = None
1270
+
1271
+ # Build query based on search type
1272
+ if args.query:
1273
+ query = args.query
1274
+ print(f"Searching for query: {query}")
1275
+ elif args.author:
1276
+ # Note: Author search syntax is PubMed-specific
1277
+ query = f"{args.author}[Author]" if "pubmed" in sources_to_search else args.author
1278
+ print(f"Searching articles by author: {args.author}")
1279
+ elif args.doi:
1280
+ query = args.doi
1281
+ print(f"Fetching article with DOI: {args.doi}")
1282
+ else:
1283
+ print("Error: Please provide a query, author, or DOI to search:")
1284
+ print(" -q, --query QUERY Search query")
1285
+ print(" -au, --author AUTHOR Search by author")
1286
+ print(" -DOI DOI Fetch article by DOI")
1287
+ print("\nFor interactive mode, use: lixplore -i")
1288
+ print("\nExample: lixplore -P -q \"cancer treatment\" -m 20")
1289
+ return
1290
+
1291
+ # Display selected sources
1292
+ source_names = {
1293
+ "pubmed": "PubMed",
1294
+ "crossref": "Crossref",
1295
+ "doaj": "DOAJ",
1296
+ "europepmc": "EuropePMC",
1297
+ "arxiv": "arXiv"
1298
+ }
1299
+
1300
+ # Show standard sources if any
1301
+ if sources_to_search:
1302
+ selected_names = [source_names[src] for src in sources_to_search]
1303
+ print(f"Sources: {', '.join(selected_names)}")
1304
+
1305
+ # Show custom API if selected
1306
+ if use_custom_api:
1307
+ print(f"Custom API: {custom_api_name}")
1308
+
1309
+ # Execute search on selected sources
1310
+ for src in sources_to_search:
1311
+ print(f" Searching {source_names[src]}...")
1312
+ src_results = dispatcher.search(
1313
+ source=src,
1314
+ query=query,
1315
+ limit=args.max_results,
1316
+ )
1317
+ results.extend(src_results)
1318
+
1319
+ # Execute search on custom API if selected
1320
+ if use_custom_api:
1321
+ print(f" Searching {custom_api_name} (custom API)...")
1322
+ custom_results = custom_apis.call_custom_api(custom_api_name, query, args.max_results)
1323
+ results.extend(custom_results)
1324
+
1325
+ if (len(sources_to_search) > 1) or (len(sources_to_search) > 0 and use_custom_api):
1326
+ print(f"Total results before deduplication: {len(results)}")
1327
+
1328
+ # Post-processing
1329
+ if args.deduplicate and results:
1330
+ print("Removing duplicates")
1331
+ results = dispatcher.deduplicate_advanced(
1332
+ results,
1333
+ strategy=args.deduplicate,
1334
+ title_threshold=args.dedup_threshold,
1335
+ keep_preference=args.dedup_keep,
1336
+ merge_metadata=args.dedup_merge
1337
+ )
1338
+
1339
+ # Enrich metadata if requested
1340
+ if args.enrich is not None and results:
1341
+ from lixplore.utils.enrichment import enrich_results
1342
+ # If --enrich used without arguments, use all APIs
1343
+ apis = args.enrich if args.enrich else ['all']
1344
+ results = enrich_results(results, apis, show_progress=True)
1345
+
1346
+ # Sort results if requested
1347
+ if results and args.sort and args.sort != "relevant":
1348
+ results = sort_results(results, args.sort)
1349
+ print(f"Results sorted by: {args.sort}")
1350
+
1351
+ # Export to file format if requested
1352
+ if args.export and results:
1353
+ # Check if multiple formats specified (comma-separated)
1354
+ formats = [f.strip() for f in args.export.split(',')]
1355
+
1356
+ # Validate formats
1357
+ valid_formats = ["csv", "json", "bibtex", "ris", "endnote", "enw", "xlsx", "xml"]
1358
+ invalid_formats = [f for f in formats if f not in valid_formats]
1359
+ if invalid_formats:
1360
+ print(f"Error: Invalid export format(s): {', '.join(invalid_formats)}")
1361
+ print(f"Valid formats: {', '.join(valid_formats)}")
1362
+ return
1363
+
1364
+ # Filter results if specific articles selected
1365
+ if args.select:
1366
+ # Parse selection arguments (supports: numbers, ranges, keywords)
1367
+ selected_numbers = parse_selection(args.select, len(results))
1368
+
1369
+ if selected_numbers:
1370
+ selected_results = [results[num - 1] for num in selected_numbers]
1371
+ print(f"Selected articles: {', '.join(f'#{n}' for n in selected_numbers)}")
1372
+ print(f"Exporting {len(selected_results)} selected article(s)...")
1373
+
1374
+ # Use batch export if multiple formats, otherwise single export
1375
+ if len(formats) > 1:
1376
+ # Extract base filename from output (remove extension)
1377
+ output_base = args.output.rsplit('.', 1)[0] if args.output else None
1378
+ dispatcher.batch_export(selected_results, formats, output_base, args.export_fields, args.zip)
1379
+ else:
1380
+ dispatcher.export_to_format(selected_results, formats[0], args.output, args.export_fields, args.zip)
1381
+ else:
1382
+ print("No valid articles selected for export.")
1383
+ else:
1384
+ # Export all results
1385
+ if len(formats) > 1:
1386
+ # Batch export: Extract base filename from output (remove extension)
1387
+ output_base = args.output.rsplit('.', 1)[0] if args.output else None
1388
+ dispatcher.batch_export(results, formats, output_base, args.export_fields, args.zip)
1389
+ else:
1390
+ # Single format export
1391
+ dispatcher.export_to_format(results, formats[0], args.output, args.export_fields, args.zip)
1392
+
1393
+ # Export as formatted citations if requested
1394
+ if args.citations and results:
1395
+ from lixplore.utils.export import export_to_citations, compress_export
1396
+ print(f"Generating {args.citations.upper()} style citations...")
1397
+ exported_path = export_to_citations(results, args.citations, args.output, args.export_fields)
1398
+ if args.zip and exported_path:
1399
+ compress_export(exported_path, remove_original=False)
1400
+
1401
+ # Save profile if requested (after export completes)
1402
+ if args.save_profile:
1403
+ config = profiles.create_profile_from_args(args)
1404
+ if config: # Only save if there are settings to save
1405
+ if profiles.save_profile(args.save_profile, config):
1406
+ print(f"Profile '{args.save_profile}' saved successfully")
1407
+ print(f" Settings saved: {', '.join(config.keys())}")
1408
+ else:
1409
+ print(f"Error: Could not save profile '{args.save_profile}'")
1410
+ else:
1411
+ print("Warning: No export settings to save in profile")
1412
+
1413
+ # Show results (titles + optional inline abstracts)
1414
+ # Display results BEFORE review so users don't see them again after closing review windows
1415
+ if results:
1416
+ print(f"\nFound {len(results)} results:")
1417
+ dispatcher.show_results(results, args)
1418
+
1419
+ # Save results to cache for later review
1420
+ all_sources = sources_to_search.copy()
1421
+ if use_custom_api:
1422
+ all_sources.append(f"custom:{custom_api_name}")
1423
+ dispatcher.save_results(results, query=query, sources=all_sources)
1424
+
1425
+ # Save to search history
1426
+ dispatcher.save_to_history(query=query, sources=all_sources, result_count=len(results))
1427
+
1428
+ # If user requested detailed view(s) via -N, print them inline
1429
+ if args.number:
1430
+ for n in args.number:
1431
+ if not isinstance(n, int):
1432
+ print(f"Invalid selection (not an integer): {n}")
1433
+ continue
1434
+ if 1 <= n <= len(results):
1435
+ idx = n - 1
1436
+ print("\n=== Detailed View ===")
1437
+ # print dict as readable JSON
1438
+ print(json.dumps(results[idx], indent=2, ensure_ascii=False))
1439
+ else:
1440
+ print(f"Selection out of range: {n} (valid 1..{len(results)})")
1441
+
1442
+ # Download PDFs if requested
1443
+ if args.download_pdf:
1444
+ pdf_numbers = args.pdf_numbers if args.pdf_numbers else None
1445
+ pdf_downloader.download_multiple_pdfs(
1446
+ results,
1447
+ article_numbers=pdf_numbers,
1448
+ use_scihub=args.use_scihub
1449
+ )
1450
+
1451
+ # Add to Zotero if requested
1452
+ if args.add_to_zotero:
1453
+ collection_key = args.zotero_collection if hasattr(args, 'zotero_collection') and args.zotero_collection else None
1454
+ reference_managers.add_to_zotero(results, collection_key=collection_key)
1455
+
1456
+ # Export for Mendeley if requested
1457
+ if args.export_for_mendeley:
1458
+ reference_managers.export_for_mendeley(results)
1459
+
1460
+ # Show statistics dashboard if requested
1461
+ if args.stat:
1462
+ from lixplore.utils.statistics import generate_statistics_report
1463
+ stats_report = generate_statistics_report(results, top_n=args.stat_top)
1464
+ print(stats_report)
1465
+
1466
+ # Launch interactive mode if requested
1467
+ if args.interactive:
1468
+ from lixplore.utils.interactive_tui import launch_interactive_mode
1469
+ launch_interactive_mode(results)
1470
+ return # Interactive mode handles everything
1471
+
1472
+ # Review articles in separate terminal if requested
1473
+ # This comes AFTER showing results so user sees the list, then reviews, then returns without duplication
1474
+ if args.review:
1475
+ dispatcher.review_articles(results, args.review)
1476
+
1477
+ # Handle annotations for articles from search results
1478
+ if args.annotate or args.show_annotation or args.delete_annotation:
1479
+ manager = AnnotationManager()
1480
+
1481
+ # Annotate article
1482
+ if args.annotate:
1483
+ article_num = args.annotate
1484
+ if 1 <= article_num <= len(results):
1485
+ article = results[article_num - 1]
1486
+
1487
+ # Collect annotation data
1488
+ tags_list = None
1489
+ if args.tags:
1490
+ tags_list = [t.strip() for t in args.tags.split(',')]
1491
+
1492
+ # Add annotation
1493
+ article_id = manager.annotate(
1494
+ article=article,
1495
+ comment=args.comment if hasattr(args, 'comment') and args.comment else None,
1496
+ rating=args.rating if hasattr(args, 'rating') and args.rating else None,
1497
+ tags=tags_list,
1498
+ read_status=args.read_status if hasattr(args, 'read_status') and args.read_status else None,
1499
+ priority=args.priority if hasattr(args, 'priority') and args.priority else None
1500
+ )
1501
+
1502
+ print(f"\nAnnotation saved for article #{article_num}: {article.get('title', 'No title')[:60]}...")
1503
+
1504
+ # Show the annotation
1505
+ annotation = manager.get_annotation(article_id)
1506
+ if annotation:
1507
+ display_annotation(annotation, article_id)
1508
+ else:
1509
+ print(f"Error: Article #{article_num} is out of range (1-{len(results)})")
1510
+
1511
+ # Show annotation
1512
+ if args.show_annotation:
1513
+ article_num = args.show_annotation
1514
+ if 1 <= article_num <= len(results):
1515
+ article = results[article_num - 1]
1516
+ annotation = manager.get_annotation_for_article(article)
1517
+
1518
+ if annotation:
1519
+ article_id = manager._get_article_id(article)
1520
+ display_annotation(annotation, article_id)
1521
+ else:
1522
+ print(f"\nNo annotation found for article #{article_num}")
1523
+ print(f"Add annotation with: lixplore --annotate {article_num} --rating 5 --tags 'important'")
1524
+ else:
1525
+ print(f"Error: Article #{article_num} is out of range (1-{len(results)})")
1526
+
1527
+ # Delete annotation
1528
+ if args.delete_annotation:
1529
+ article_num = args.delete_annotation
1530
+ if 1 <= article_num <= len(results):
1531
+ article = results[article_num - 1]
1532
+ article_id = manager._get_article_id(article)
1533
+
1534
+ if manager.remove_annotation(article_id):
1535
+ print(f"Annotation deleted for article #{article_num}")
1536
+ else:
1537
+ print(f"No annotation found for article #{article_num}")
1538
+ else:
1539
+ print(f"Error: Article #{article_num} is out of range (1-{len(results)})")
1540
+
1541
+ else:
1542
+ print("No results found.")
1543
+