pyzotero 1.7.6__py3-none-any.whl → 1.9.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.
pyzotero/cli.py CHANGED
@@ -3,10 +3,20 @@
3
3
  import json
4
4
  import sys
5
5
 
6
- import click
6
+ import click # ty:ignore[unresolved-import]
7
7
  import httpx
8
8
 
9
9
  from pyzotero import __version__, zotero
10
+ from pyzotero.semantic_scholar import (
11
+ PaperNotFoundError,
12
+ RateLimitError,
13
+ SemanticScholarError,
14
+ filter_by_citations,
15
+ get_citations,
16
+ get_recommendations,
17
+ get_references,
18
+ search_papers,
19
+ )
10
20
  from pyzotero.zotero import chunks
11
21
 
12
22
 
@@ -73,12 +83,23 @@ def main(ctx, locale):
73
83
  "--collection",
74
84
  help="Filter by collection key (returns only items in this collection)",
75
85
  )
86
+ @click.option(
87
+ "--tag",
88
+ multiple=True,
89
+ help="Filter by tag (can be specified multiple times for AND search)",
90
+ )
76
91
  @click.option(
77
92
  "--limit",
78
93
  type=int,
79
94
  default=1000000,
80
95
  help="Maximum number of results to return (default: 1000000)",
81
96
  )
97
+ @click.option(
98
+ "--offset",
99
+ type=int,
100
+ default=0,
101
+ help="Number of results to skip for pagination (default: 0)",
102
+ )
82
103
  @click.option(
83
104
  "--json",
84
105
  "output_json",
@@ -86,7 +107,7 @@ def main(ctx, locale):
86
107
  help="Output results as JSON",
87
108
  )
88
109
  @click.pass_context
89
- def search(ctx, query, fulltext, itemtype, collection, limit, output_json): # noqa: PLR0912, PLR0915
110
+ def search(ctx, query, fulltext, itemtype, collection, tag, limit, offset, output_json): # noqa: PLR0912, PLR0915
90
111
  """Search local Zotero library.
91
112
 
92
113
  By default, searches top-level items in titles and metadata.
@@ -106,6 +127,10 @@ def search(ctx, query, fulltext, itemtype, collection, limit, output_json): # n
106
127
 
107
128
  pyzotero search -q "climate" --json
108
129
 
130
+ pyzotero search -q "topic" --limit 20 --offset 20 --json
131
+
132
+ pyzotero search -q "topic" --tag "climate" --tag "adaptation" --json
133
+
109
134
  """
110
135
  try:
111
136
  locale = ctx.obj.get("locale", "en-US")
@@ -114,6 +139,9 @@ def search(ctx, query, fulltext, itemtype, collection, limit, output_json): # n
114
139
  # Build query parameters
115
140
  params = {"limit": limit}
116
141
 
142
+ if offset > 0:
143
+ params["start"] = offset
144
+
117
145
  if query:
118
146
  params["q"] = query
119
147
 
@@ -124,6 +152,10 @@ def search(ctx, query, fulltext, itemtype, collection, limit, output_json): # n
124
152
  # Join multiple item types with || for OR search
125
153
  params["itemType"] = " || ".join(itemtype)
126
154
 
155
+ if tag:
156
+ # Multiple tags are passed as a list for AND search
157
+ params["tag"] = list(tag)
158
+
127
159
  # Execute search
128
160
  # When fulltext is enabled, use items() or collection_items() to get both
129
161
  # top-level items and attachments. Otherwise use top() or collection_items_top()
@@ -310,7 +342,7 @@ def listcollections(ctx, limit):
310
342
  key = data.get("key", "")
311
343
  name = data.get("name", "")
312
344
  if key:
313
- collection_map[key] = name if name else None
345
+ collection_map[key] = name or None
314
346
 
315
347
  # Build JSON output
316
348
  output = []
@@ -325,7 +357,7 @@ def listcollections(ctx, limit):
325
357
 
326
358
  collection_obj = {
327
359
  "id": key,
328
- "name": name if name else None,
360
+ "name": name or None,
329
361
  "items": num_items,
330
362
  }
331
363
 
@@ -419,6 +451,254 @@ def test(ctx):
419
451
  sys.exit(1)
420
452
 
421
453
 
454
+ @main.command()
455
+ @click.argument("key")
456
+ @click.option(
457
+ "--json",
458
+ "output_json",
459
+ is_flag=True,
460
+ help="Output results as JSON",
461
+ )
462
+ @click.pass_context
463
+ def item(ctx, key, output_json):
464
+ """Get a single item by its key.
465
+
466
+ Returns full item data for the specified Zotero item key.
467
+
468
+ Examples:
469
+ pyzotero item ABC123
470
+
471
+ pyzotero item ABC123 --json
472
+
473
+ """
474
+ try:
475
+ locale = ctx.obj.get("locale", "en-US")
476
+ zot = _get_zotero_client(locale)
477
+
478
+ # Fetch the item
479
+ result = zot.item(key)
480
+
481
+ if not result:
482
+ if output_json:
483
+ click.echo(json.dumps(None))
484
+ else:
485
+ click.echo(f"Item not found: {key}")
486
+ return
487
+
488
+ data = result.get("data", {})
489
+
490
+ if output_json:
491
+ click.echo(json.dumps(result, indent=2))
492
+ else:
493
+ title = data.get("title", "No title")
494
+ item_type = data.get("itemType", "Unknown")
495
+ date = data.get("date", "No date")
496
+ item_key = data.get("key", "")
497
+ doi = data.get("DOI", "")
498
+ url = data.get("url", "")
499
+
500
+ # Format creators
501
+ creators = data.get("creators", [])
502
+ creator_names = []
503
+ for creator in creators:
504
+ if "lastName" in creator:
505
+ if "firstName" in creator:
506
+ creator_names.append(
507
+ f"{creator['firstName']} {creator['lastName']}"
508
+ )
509
+ else:
510
+ creator_names.append(creator["lastName"])
511
+ elif "name" in creator:
512
+ creator_names.append(creator["name"])
513
+
514
+ authors_str = ", ".join(creator_names) if creator_names else "No authors"
515
+
516
+ click.echo(f"[{item_type}] {title}")
517
+ click.echo(f"Authors: {authors_str}")
518
+ click.echo(f"Date: {date}")
519
+ click.echo(f"DOI: {doi}")
520
+ click.echo(f"URL: {url}")
521
+ click.echo(f"Key: {item_key}")
522
+
523
+ except Exception as e:
524
+ click.echo(f"Error: {e!s}", err=True)
525
+ sys.exit(1)
526
+
527
+
528
+ @main.command()
529
+ @click.argument("key")
530
+ @click.option(
531
+ "--json",
532
+ "output_json",
533
+ is_flag=True,
534
+ help="Output results as JSON",
535
+ )
536
+ @click.pass_context
537
+ def children(ctx, key, output_json):
538
+ """Get child items (attachments, notes) of a specific item.
539
+
540
+ Returns all child items for the specified Zotero item key.
541
+ Useful for finding PDF attachments without the N+1 overhead during search.
542
+
543
+ Examples:
544
+ pyzotero children ABC123
545
+
546
+ pyzotero children ABC123 --json
547
+
548
+ """
549
+ try:
550
+ locale = ctx.obj.get("locale", "en-US")
551
+ zot = _get_zotero_client(locale)
552
+
553
+ # Fetch children
554
+ results = zot.children(key)
555
+
556
+ if not results:
557
+ if output_json:
558
+ click.echo(json.dumps([]))
559
+ else:
560
+ click.echo(f"No children found for item: {key}")
561
+ return
562
+
563
+ if output_json:
564
+ click.echo(json.dumps(results, indent=2))
565
+ else:
566
+ click.echo(f"\nFound {len(results)} child items:\n")
567
+ for idx, child in enumerate(results, 1):
568
+ data = child.get("data", {})
569
+ item_type = data.get("itemType", "Unknown")
570
+ child_key = data.get("key", "")
571
+ title = data.get("title", data.get("note", "No title")[:50] + "...")
572
+ content_type = data.get("contentType", "")
573
+
574
+ click.echo(f"{idx}. [{item_type}] {title}")
575
+ click.echo(f" Key: {child_key}")
576
+ if content_type:
577
+ click.echo(f" Content-Type: {content_type}")
578
+
579
+ # Show file URL for attachments
580
+ file_url = child.get("links", {}).get("enclosure", {}).get("href", "")
581
+ if file_url:
582
+ click.echo(f" File: {file_url}")
583
+ click.echo()
584
+
585
+ except Exception as e:
586
+ click.echo(f"Error: {e!s}", err=True)
587
+ sys.exit(1)
588
+
589
+
590
+ @main.command()
591
+ @click.option(
592
+ "--collection",
593
+ help="Filter tags to a specific collection key",
594
+ )
595
+ @click.option(
596
+ "--json",
597
+ "output_json",
598
+ is_flag=True,
599
+ help="Output results as JSON",
600
+ )
601
+ @click.pass_context
602
+ def tags(ctx, collection, output_json):
603
+ """List all tags in the library.
604
+
605
+ Returns all tags used in the library, or only tags from a specific collection.
606
+
607
+ Examples:
608
+ pyzotero tags
609
+
610
+ pyzotero tags --collection ABC123
611
+
612
+ pyzotero tags --json
613
+
614
+ """
615
+ try:
616
+ locale = ctx.obj.get("locale", "en-US")
617
+ zot = _get_zotero_client(locale)
618
+
619
+ # Fetch tags
620
+ if collection:
621
+ results = zot.collection_tags(collection)
622
+ else:
623
+ results = zot.tags()
624
+
625
+ if not results:
626
+ if output_json:
627
+ click.echo(json.dumps([]))
628
+ else:
629
+ click.echo("No tags found.")
630
+ return
631
+
632
+ if output_json:
633
+ click.echo(json.dumps(results, indent=2))
634
+ else:
635
+ click.echo(f"\nFound {len(results)} tags:\n")
636
+ for tag in sorted(results):
637
+ click.echo(f" {tag}")
638
+
639
+ except Exception as e:
640
+ click.echo(f"Error: {e!s}", err=True)
641
+ sys.exit(1)
642
+
643
+
644
+ @main.command()
645
+ @click.argument("keys", nargs=-1, required=True)
646
+ @click.option(
647
+ "--json",
648
+ "output_json",
649
+ is_flag=True,
650
+ help="Output results as JSON",
651
+ )
652
+ @click.pass_context
653
+ def subset(ctx, keys, output_json):
654
+ """Get multiple items by their keys in a single call.
655
+
656
+ Efficiently retrieve up to 50 items by key in a single API call.
657
+ Far more efficient than multiple individual item lookups.
658
+
659
+ Examples:
660
+ pyzotero subset ABC123 DEF456 GHI789
661
+
662
+ pyzotero subset ABC123 DEF456 --json
663
+
664
+ """
665
+ try:
666
+ locale = ctx.obj.get("locale", "en-US")
667
+ zot = _get_zotero_client(locale)
668
+
669
+ if len(keys) > 50: # noqa: PLR2004 - Zotero API limit
670
+ click.echo("Error: Maximum 50 items per call.", err=True)
671
+ sys.exit(1)
672
+
673
+ # Fetch items
674
+ results = zot.get_subset(list(keys))
675
+
676
+ if not results:
677
+ if output_json:
678
+ click.echo(json.dumps([]))
679
+ else:
680
+ click.echo("No items found.")
681
+ return
682
+
683
+ if output_json:
684
+ click.echo(json.dumps(results, indent=2))
685
+ else:
686
+ click.echo(f"\nFound {len(results)} items:\n")
687
+ for idx, item in enumerate(results, 1):
688
+ data = item.get("data", {})
689
+ title = data.get("title", "No title")
690
+ item_type = data.get("itemType", "Unknown")
691
+ item_key = data.get("key", "")
692
+
693
+ click.echo(f"{idx}. [{item_type}] {title}")
694
+ click.echo(f" Key: {item_key}")
695
+ click.echo()
696
+
697
+ except Exception as e:
698
+ click.echo(f"Error: {e!s}", err=True)
699
+ sys.exit(1)
700
+
701
+
422
702
  @main.command()
423
703
  @click.argument("dois", nargs=-1)
424
704
  @click.option(
@@ -513,5 +793,510 @@ def alldoi(ctx, dois, output_json): # noqa: PLR0912
513
793
  sys.exit(1)
514
794
 
515
795
 
796
+ @main.command()
797
+ @click.pass_context
798
+ def doiindex(ctx):
799
+ """Output the complete DOI-to-key mapping for the library.
800
+
801
+ Returns a JSON mapping of normalised DOIs to item keys and original DOIs.
802
+ This allows the skill to cache the index and avoid repeated full-library scans.
803
+
804
+ Output format:
805
+ {
806
+ "10.1234/abc": {"key": "ABC123", "original": "https://doi.org/10.1234/ABC"},
807
+ ...
808
+ }
809
+
810
+ Examples:
811
+ pyzotero doiindex
812
+
813
+ pyzotero doiindex > doi_cache.json
814
+
815
+ """
816
+ try:
817
+ locale = ctx.obj.get("locale", "en-US")
818
+ zot = _get_zotero_client(locale)
819
+
820
+ click.echo("Building DOI index from library...", err=True)
821
+ doi_map = _build_doi_index_full(zot)
822
+ click.echo(f"Indexed {len(doi_map)} items with DOIs", err=True)
823
+
824
+ click.echo(json.dumps(doi_map, indent=2))
825
+
826
+ except Exception as e:
827
+ click.echo(f"Error: {e!s}", err=True)
828
+ sys.exit(1)
829
+
830
+
831
+ @main.command()
832
+ @click.argument("key")
833
+ @click.pass_context
834
+ def fulltext(ctx, key):
835
+ """Get full-text content of an attachment.
836
+
837
+ Returns the full-text content extracted from a PDF or other attachment.
838
+ The key should be the key of an attachment item (not a top-level item).
839
+
840
+ Output format:
841
+ {
842
+ "content": "Full-text extracted from PDF...",
843
+ "indexedPages": 50,
844
+ "totalPages": 50
845
+ }
846
+
847
+ Examples:
848
+ pyzotero fulltext ABC123
849
+
850
+ """
851
+ try:
852
+ locale = ctx.obj.get("locale", "en-US")
853
+ zot = _get_zotero_client(locale)
854
+
855
+ result = zot.fulltext_item(key)
856
+
857
+ if not result:
858
+ click.echo(json.dumps({"error": "No full-text content available"}))
859
+ return
860
+
861
+ click.echo(json.dumps(result, indent=2))
862
+
863
+ except Exception as e:
864
+ click.echo(f"Error: {e!s}", err=True)
865
+ sys.exit(1)
866
+
867
+
868
+ def _build_doi_index_full(zot):
869
+ """Build a mapping of normalised DOIs to Zotero item keys and original DOIs.
870
+
871
+ Returns:
872
+ Dict mapping normalised DOIs to dicts with 'key' and 'original' fields
873
+
874
+ """
875
+ doi_map = {}
876
+ all_items = zot.everything(zot.items())
877
+
878
+ for item in all_items:
879
+ data = item.get("data", {})
880
+ item_doi = data.get("DOI", "")
881
+
882
+ if item_doi:
883
+ normalised_doi = _normalize_doi(item_doi)
884
+ item_key = data.get("key", "")
885
+
886
+ if normalised_doi and item_key:
887
+ doi_map[normalised_doi] = {"key": item_key, "original": item_doi}
888
+
889
+ return doi_map
890
+
891
+
892
+ def _build_doi_index(zot):
893
+ """Build a mapping of normalised DOIs to Zotero item keys.
894
+
895
+ Returns:
896
+ Dict mapping normalised DOIs to item keys
897
+
898
+ """
899
+ doi_map = {}
900
+ all_items = zot.everything(zot.items())
901
+
902
+ for item in all_items:
903
+ data = item.get("data", {})
904
+ item_doi = data.get("DOI", "")
905
+
906
+ if item_doi:
907
+ normalised_doi = _normalize_doi(item_doi)
908
+ item_key = data.get("key", "")
909
+
910
+ if normalised_doi and item_key:
911
+ doi_map[normalised_doi] = item_key
912
+
913
+ return doi_map
914
+
915
+
916
+ def _format_s2_paper(paper, in_library=None):
917
+ """Format a Semantic Scholar paper for output.
918
+
919
+ Args:
920
+ paper: Normalised paper dict from semantic_scholar module
921
+ in_library: Boolean indicating if paper is in local Zotero
922
+
923
+ Returns:
924
+ Formatted dict for output
925
+
926
+ """
927
+ result = {
928
+ "paperId": paper.get("paperId"),
929
+ "doi": paper.get("doi"),
930
+ "title": paper.get("title"),
931
+ "authors": [a.get("name") for a in (paper.get("authors") or [])],
932
+ "year": paper.get("year"),
933
+ "venue": paper.get("venue"),
934
+ "citationCount": paper.get("citationCount"),
935
+ "referenceCount": paper.get("referenceCount"),
936
+ "isOpenAccess": paper.get("isOpenAccess"),
937
+ "openAccessPdfUrl": paper.get("openAccessPdfUrl"),
938
+ }
939
+
940
+ if in_library is not None:
941
+ result["inLibrary"] = in_library
942
+
943
+ return result
944
+
945
+
946
+ def _annotate_with_library(papers, doi_map):
947
+ """Annotate papers with in_library status based on DOI matching.
948
+
949
+ Args:
950
+ papers: List of normalised paper dicts
951
+ doi_map: Dict mapping normalised DOIs to Zotero item keys
952
+
953
+ Returns:
954
+ List of formatted paper dicts with inLibrary field
955
+
956
+ """
957
+ results = []
958
+ for paper in papers:
959
+ doi = paper.get("doi")
960
+ in_library = False
961
+ if doi:
962
+ normalised = _normalize_doi(doi)
963
+ in_library = normalised in doi_map
964
+ results.append(_format_s2_paper(paper, in_library))
965
+ return results
966
+
967
+
968
+ @main.command()
969
+ @click.option(
970
+ "--doi",
971
+ required=True,
972
+ help="DOI of the paper to find related papers for",
973
+ )
974
+ @click.option(
975
+ "--limit",
976
+ type=int,
977
+ default=20,
978
+ help="Maximum number of results to return (default: 20, max: 500)",
979
+ )
980
+ @click.option(
981
+ "--min-citations",
982
+ type=int,
983
+ default=0,
984
+ help="Minimum citation count filter (default: 0)",
985
+ )
986
+ @click.option(
987
+ "--check-library/--no-check-library",
988
+ default=True,
989
+ help="Check if papers exist in local Zotero (default: True)",
990
+ )
991
+ @click.pass_context
992
+ def related(ctx, doi, limit, min_citations, check_library):
993
+ """Find papers related to a given paper using Semantic Scholar.
994
+
995
+ Uses SPECTER2 embeddings to find semantically similar papers.
996
+
997
+ Examples:
998
+ pyzotero related --doi "10.1038/nature12373"
999
+
1000
+ pyzotero related --doi "10.1038/nature12373" --limit 50
1001
+
1002
+ pyzotero related --doi "10.1038/nature12373" --min-citations 100
1003
+
1004
+ """
1005
+ try:
1006
+ # Get recommendations from Semantic Scholar
1007
+ click.echo(f"Fetching related papers for DOI: {doi}...", err=True)
1008
+ result = get_recommendations(doi, id_type="doi", limit=limit)
1009
+ papers = result.get("papers", [])
1010
+
1011
+ # Apply citation filter
1012
+ if min_citations > 0:
1013
+ papers = filter_by_citations(papers, min_citations)
1014
+
1015
+ if not papers:
1016
+ click.echo(json.dumps({"count": 0, "papers": []}))
1017
+ return
1018
+
1019
+ # Optionally annotate with library status
1020
+ if check_library:
1021
+ click.echo("Checking local Zotero library...", err=True)
1022
+ locale = ctx.obj.get("locale", "en-US")
1023
+ zot = _get_zotero_client(locale)
1024
+ doi_map = _build_doi_index(zot)
1025
+ output_papers = _annotate_with_library(papers, doi_map)
1026
+ else:
1027
+ output_papers = [_format_s2_paper(p) for p in papers]
1028
+
1029
+ click.echo(
1030
+ json.dumps({"count": len(output_papers), "papers": output_papers}, indent=2)
1031
+ )
1032
+
1033
+ except PaperNotFoundError:
1034
+ click.echo("Error: Paper not found in Semantic Scholar.", err=True)
1035
+ sys.exit(1)
1036
+ except RateLimitError:
1037
+ click.echo("Error: Rate limit exceeded. Please wait and try again.", err=True)
1038
+ sys.exit(1)
1039
+ except SemanticScholarError as e:
1040
+ click.echo(f"Error: {e!s}", err=True)
1041
+ sys.exit(1)
1042
+ except Exception as e:
1043
+ click.echo(f"Error: {e!s}", err=True)
1044
+ sys.exit(1)
1045
+
1046
+
1047
+ @main.command()
1048
+ @click.option(
1049
+ "--doi",
1050
+ required=True,
1051
+ help="DOI of the paper to find citations for",
1052
+ )
1053
+ @click.option(
1054
+ "--limit",
1055
+ type=int,
1056
+ default=100,
1057
+ help="Maximum number of results to return (default: 100, max: 1000)",
1058
+ )
1059
+ @click.option(
1060
+ "--min-citations",
1061
+ type=int,
1062
+ default=0,
1063
+ help="Minimum citation count filter (default: 0)",
1064
+ )
1065
+ @click.option(
1066
+ "--check-library/--no-check-library",
1067
+ default=True,
1068
+ help="Check if papers exist in local Zotero (default: True)",
1069
+ )
1070
+ @click.pass_context
1071
+ def citations(ctx, doi, limit, min_citations, check_library):
1072
+ """Find papers that cite a given paper using Semantic Scholar.
1073
+
1074
+ Examples:
1075
+ pyzotero citations --doi "10.1038/nature12373"
1076
+
1077
+ pyzotero citations --doi "10.1038/nature12373" --limit 50
1078
+
1079
+ pyzotero citations --doi "10.1038/nature12373" --min-citations 50
1080
+
1081
+ """
1082
+ try:
1083
+ # Get citations from Semantic Scholar
1084
+ click.echo(f"Fetching citations for DOI: {doi}...", err=True)
1085
+ result = get_citations(doi, id_type="doi", limit=limit)
1086
+ papers = result.get("papers", [])
1087
+
1088
+ # Apply citation filter
1089
+ if min_citations > 0:
1090
+ papers = filter_by_citations(papers, min_citations)
1091
+
1092
+ if not papers:
1093
+ click.echo(json.dumps({"count": 0, "papers": []}))
1094
+ return
1095
+
1096
+ # Optionally annotate with library status
1097
+ if check_library:
1098
+ click.echo("Checking local Zotero library...", err=True)
1099
+ locale = ctx.obj.get("locale", "en-US")
1100
+ zot = _get_zotero_client(locale)
1101
+ doi_map = _build_doi_index(zot)
1102
+ output_papers = _annotate_with_library(papers, doi_map)
1103
+ else:
1104
+ output_papers = [_format_s2_paper(p) for p in papers]
1105
+
1106
+ click.echo(
1107
+ json.dumps({"count": len(output_papers), "papers": output_papers}, indent=2)
1108
+ )
1109
+
1110
+ except PaperNotFoundError:
1111
+ click.echo("Error: Paper not found in Semantic Scholar.", err=True)
1112
+ sys.exit(1)
1113
+ except RateLimitError:
1114
+ click.echo("Error: Rate limit exceeded. Please wait and try again.", err=True)
1115
+ sys.exit(1)
1116
+ except SemanticScholarError as e:
1117
+ click.echo(f"Error: {e!s}", err=True)
1118
+ sys.exit(1)
1119
+ except Exception as e:
1120
+ click.echo(f"Error: {e!s}", err=True)
1121
+ sys.exit(1)
1122
+
1123
+
1124
+ @main.command()
1125
+ @click.option(
1126
+ "--doi",
1127
+ required=True,
1128
+ help="DOI of the paper to find references for",
1129
+ )
1130
+ @click.option(
1131
+ "--limit",
1132
+ type=int,
1133
+ default=100,
1134
+ help="Maximum number of results to return (default: 100, max: 1000)",
1135
+ )
1136
+ @click.option(
1137
+ "--min-citations",
1138
+ type=int,
1139
+ default=0,
1140
+ help="Minimum citation count filter (default: 0)",
1141
+ )
1142
+ @click.option(
1143
+ "--check-library/--no-check-library",
1144
+ default=True,
1145
+ help="Check if papers exist in local Zotero (default: True)",
1146
+ )
1147
+ @click.pass_context
1148
+ def references(ctx, doi, limit, min_citations, check_library):
1149
+ """Find papers referenced by a given paper using Semantic Scholar.
1150
+
1151
+ Examples:
1152
+ pyzotero references --doi "10.1038/nature12373"
1153
+
1154
+ pyzotero references --doi "10.1038/nature12373" --limit 50
1155
+
1156
+ pyzotero references --doi "10.1038/nature12373" --min-citations 100
1157
+
1158
+ """
1159
+ try:
1160
+ # Get references from Semantic Scholar
1161
+ click.echo(f"Fetching references for DOI: {doi}...", err=True)
1162
+ result = get_references(doi, id_type="doi", limit=limit)
1163
+ papers = result.get("papers", [])
1164
+
1165
+ # Apply citation filter
1166
+ if min_citations > 0:
1167
+ papers = filter_by_citations(papers, min_citations)
1168
+
1169
+ if not papers:
1170
+ click.echo(json.dumps({"count": 0, "papers": []}))
1171
+ return
1172
+
1173
+ # Optionally annotate with library status
1174
+ if check_library:
1175
+ click.echo("Checking local Zotero library...", err=True)
1176
+ locale = ctx.obj.get("locale", "en-US")
1177
+ zot = _get_zotero_client(locale)
1178
+ doi_map = _build_doi_index(zot)
1179
+ output_papers = _annotate_with_library(papers, doi_map)
1180
+ else:
1181
+ output_papers = [_format_s2_paper(p) for p in papers]
1182
+
1183
+ click.echo(
1184
+ json.dumps({"count": len(output_papers), "papers": output_papers}, indent=2)
1185
+ )
1186
+
1187
+ except PaperNotFoundError:
1188
+ click.echo("Error: Paper not found in Semantic Scholar.", err=True)
1189
+ sys.exit(1)
1190
+ except RateLimitError:
1191
+ click.echo("Error: Rate limit exceeded. Please wait and try again.", err=True)
1192
+ sys.exit(1)
1193
+ except SemanticScholarError as e:
1194
+ click.echo(f"Error: {e!s}", err=True)
1195
+ sys.exit(1)
1196
+ except Exception as e:
1197
+ click.echo(f"Error: {e!s}", err=True)
1198
+ sys.exit(1)
1199
+
1200
+
1201
+ @main.command()
1202
+ @click.option(
1203
+ "-q",
1204
+ "--query",
1205
+ required=True,
1206
+ help="Search query string",
1207
+ )
1208
+ @click.option(
1209
+ "--limit",
1210
+ type=int,
1211
+ default=20,
1212
+ help="Maximum number of results to return (default: 20, max: 100)",
1213
+ )
1214
+ @click.option(
1215
+ "--year",
1216
+ help="Year filter (e.g., '2020', '2018-2022', '2020-')",
1217
+ )
1218
+ @click.option(
1219
+ "--open-access/--no-open-access",
1220
+ default=False,
1221
+ help="Only return open access papers (default: False)",
1222
+ )
1223
+ @click.option(
1224
+ "--sort",
1225
+ type=click.Choice(["citations", "year"], case_sensitive=False),
1226
+ help="Sort results by citation count or year (descending)",
1227
+ )
1228
+ @click.option(
1229
+ "--min-citations",
1230
+ type=int,
1231
+ default=0,
1232
+ help="Minimum citation count filter (default: 0)",
1233
+ )
1234
+ @click.option(
1235
+ "--check-library/--no-check-library",
1236
+ default=True,
1237
+ help="Check if papers exist in local Zotero (default: True)",
1238
+ )
1239
+ @click.pass_context
1240
+ def s2search(ctx, query, limit, year, open_access, sort, min_citations, check_library):
1241
+ """Search for papers on Semantic Scholar.
1242
+
1243
+ Search across Semantic Scholar's index of over 200M papers.
1244
+
1245
+ Examples:
1246
+ pyzotero s2search -q "climate adaptation"
1247
+
1248
+ pyzotero s2search -q "machine learning" --year 2020-2024
1249
+
1250
+ pyzotero s2search -q "neural networks" --open-access --limit 50
1251
+
1252
+ pyzotero s2search -q "deep learning" --sort citations --min-citations 100
1253
+
1254
+ """
1255
+ try:
1256
+ # Search Semantic Scholar
1257
+ click.echo(f'Searching Semantic Scholar for: "{query}"...', err=True)
1258
+ result = search_papers(
1259
+ query,
1260
+ limit=limit,
1261
+ year=year,
1262
+ open_access_only=open_access,
1263
+ sort=sort,
1264
+ min_citations=min_citations,
1265
+ )
1266
+ papers = result.get("papers", [])
1267
+ total = result.get("total", len(papers))
1268
+
1269
+ if not papers:
1270
+ click.echo(json.dumps({"count": 0, "total": total, "papers": []}))
1271
+ return
1272
+
1273
+ # Optionally annotate with library status
1274
+ if check_library:
1275
+ click.echo("Checking local Zotero library...", err=True)
1276
+ locale = ctx.obj.get("locale", "en-US")
1277
+ zot = _get_zotero_client(locale)
1278
+ doi_map = _build_doi_index(zot)
1279
+ output_papers = _annotate_with_library(papers, doi_map)
1280
+ else:
1281
+ output_papers = [_format_s2_paper(p) for p in papers]
1282
+
1283
+ click.echo(
1284
+ json.dumps(
1285
+ {"count": len(output_papers), "total": total, "papers": output_papers},
1286
+ indent=2,
1287
+ )
1288
+ )
1289
+
1290
+ except RateLimitError:
1291
+ click.echo("Error: Rate limit exceeded. Please wait and try again.", err=True)
1292
+ sys.exit(1)
1293
+ except SemanticScholarError as e:
1294
+ click.echo(f"Error: {e!s}", err=True)
1295
+ sys.exit(1)
1296
+ except Exception as e:
1297
+ click.echo(f"Error: {e!s}", err=True)
1298
+ sys.exit(1)
1299
+
1300
+
516
1301
  if __name__ == "__main__":
517
1302
  main()