crossref-local 0.5.0__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. {crossref_local-0.5.0 → crossref_local-0.5.1}/PKG-INFO +1 -1
  2. {crossref_local-0.5.0 → crossref_local-0.5.1}/pyproject.toml +1 -1
  3. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/__init__.py +7 -1
  4. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_cli/cli.py +15 -138
  5. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_cli/mcp_server.py +59 -15
  6. crossref_local-0.5.1/src/crossref_local/_cli/search.py +199 -0
  7. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_core/__init__.py +4 -0
  8. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_core/api.py +3 -1
  9. crossref_local-0.5.1/src/crossref_local/_core/export.py +344 -0
  10. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_core/fts.py +20 -1
  11. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_core/models.py +109 -0
  12. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_remote/base.py +25 -3
  13. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_server/models.py +14 -0
  14. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_server/routes_works.py +63 -13
  15. {crossref_local-0.5.0 → crossref_local-0.5.1}/.env.example +0 -0
  16. {crossref_local-0.5.0 → crossref_local-0.5.1}/.github/workflows/test.yml +0 -0
  17. {crossref_local-0.5.0 → crossref_local-0.5.1}/.gitignore +0 -0
  18. {crossref_local-0.5.0 → crossref_local-0.5.1}/.readthedocs.yaml +0 -0
  19. {crossref_local-0.5.0 → crossref_local-0.5.1}/CHANGELOG.md +0 -0
  20. {crossref_local-0.5.0 → crossref_local-0.5.1}/Makefile +0 -0
  21. {crossref_local-0.5.0 → crossref_local-0.5.1}/README.md +0 -0
  22. {crossref_local-0.5.0 → crossref_local-0.5.1}/docs/remote-deployment.md +0 -0
  23. {crossref_local-0.5.0 → crossref_local-0.5.1}/docs/sphinx/api/crossref_local.rst +0 -0
  24. {crossref_local-0.5.0 → crossref_local-0.5.1}/docs/sphinx/cli_reference.rst +0 -0
  25. {crossref_local-0.5.0 → crossref_local-0.5.1}/docs/sphinx/conf.py +0 -0
  26. {crossref_local-0.5.0 → crossref_local-0.5.1}/docs/sphinx/http_api.rst +0 -0
  27. {crossref_local-0.5.0 → crossref_local-0.5.1}/docs/sphinx/index.rst +0 -0
  28. {crossref_local-0.5.0 → crossref_local-0.5.1}/docs/sphinx/installation.rst +0 -0
  29. {crossref_local-0.5.0 → crossref_local-0.5.1}/docs/sphinx/quickstart.rst +0 -0
  30. {crossref_local-0.5.0 → crossref_local-0.5.1}/docs/sphinx/requirements.txt +0 -0
  31. {crossref_local-0.5.0 → crossref_local-0.5.1}/docs/to_claude/examples/example-python-project-scitex/data/.gitkeep +0 -0
  32. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/00_run_all.sh +0 -0
  33. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/01_quickstart.py +0 -0
  34. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/02_citation_network/01_generate_visualization.py +0 -0
  35. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/02_citation_network/citation_network.html +0 -0
  36. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/02_citation_network/citation_network.yaml +0 -0
  37. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/02_demo_mcp.org +0 -0
  38. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/03_impact_factor/00_calculate_impact_factor.py +0 -0
  39. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/03_impact_factor/01_compare_jcr.py +0 -0
  40. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/03_impact_factor/01_compare_jcr_out/all_combined.json +0 -0
  41. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/03_impact_factor/01_compare_jcr_out/biomedical_engineering.json +0 -0
  42. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/03_impact_factor/01_compare_jcr_out/high_impact.json +0 -0
  43. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/03_impact_factor/01_compare_jcr_out/neuroscience.json +0 -0
  44. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/03_impact_factor/02_compare_jcr_plot.py +0 -0
  45. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/03_impact_factor/02_compare_jcr_plot_out/scatter_calc_vs_jcr.yaml +0 -0
  46. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/03_impact_factor/run_all_demos.sh +0 -0
  47. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/04_mcp_demo.org +0 -0
  48. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/04_mcp_demo_out/demo_mcp.tex +0 -0
  49. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/04_mcp_demo_out/evolution.mmd +0 -0
  50. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/04_mcp_demo_out/pipeline.mmd +0 -0
  51. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/04_mcp_demo_out/states.mmd +0 -0
  52. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/04_mcp_demo_out/tradeoff.mmd +0 -0
  53. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/compose_readme.py +0 -0
  54. {crossref_local-0.5.0 → crossref_local-0.5.1}/examples/readme_figure.yaml +0 -0
  55. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/README.md +0 -0
  56. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/create_test_db.py +0 -0
  57. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/00_rebuild_all.sh +0 -0
  58. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/02_create_missing_indexes.sh +0 -0
  59. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/03_rebuild_citations_table.py +0 -0
  60. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/03_rebuild_citations_table_optimized.py +0 -0
  61. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/04a_download_openalex_journals.py +0 -0
  62. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/04b_build_issn_table.py +0 -0
  63. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/04c_build_journals_table.py +0 -0
  64. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/04d_build_from_issn_list.py +0 -0
  65. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/05_build_fts5_index.py +0 -0
  66. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/06_calculate_abstract_ratio.py +0 -0
  67. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/99_check_db_connections.py +0 -0
  68. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/99_db_info.sh +0 -0
  69. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/99_maintain_indexes.sh +0 -0
  70. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/99_switch_to_optimized.sh +0 -0
  71. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/database/README.md +0 -0
  72. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/deployment/build_apptainer.sh +0 -0
  73. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/deployment/install_apptainer.sh +0 -0
  74. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/deployment/mcp/Dockerfile.mcp +0 -0
  75. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/deployment/mcp/crossref-mcp.service +0 -0
  76. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/deployment/mcp/docker-compose.mcp.yml +0 -0
  77. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/deployment/mcp/docker.sh +0 -0
  78. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/deployment/mcp/install.sh +0 -0
  79. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/deployment/mcp/status.sh +0 -0
  80. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/deployment/run_apptainer.sh +0 -0
  81. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/deployment/run_docker.sh +0 -0
  82. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/nfs/check.sh +0 -0
  83. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/nfs/setup_nfs.sh +0 -0
  84. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/nfs/status.sh +0 -0
  85. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/nfs/stop.sh +0 -0
  86. {crossref_local-0.5.0 → crossref_local-0.5.1}/scripts/status.sh +0 -0
  87. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/__main__.py +0 -0
  88. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_aio/__init__.py +0 -0
  89. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_aio/_impl.py +0 -0
  90. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_cache/__init__.py +0 -0
  91. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_cache/export.py +0 -0
  92. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_cache/utils.py +0 -0
  93. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_cache/viz.py +0 -0
  94. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_cli/__init__.py +0 -0
  95. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_cli/cache.py +0 -0
  96. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_cli/completion.py +0 -0
  97. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_cli/main.py +0 -0
  98. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_cli/mcp.py +0 -0
  99. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_core/citations.py +0 -0
  100. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_core/config.py +0 -0
  101. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_core/db.py +0 -0
  102. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_impact_factor/__init__.py +0 -0
  103. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_impact_factor/calculator.py +0 -0
  104. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_impact_factor/journal_lookup.py +0 -0
  105. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_remote/__init__.py +0 -0
  106. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_remote/collections.py +0 -0
  107. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_server/__init__.py +0 -0
  108. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_server/middleware.py +0 -0
  109. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_server/routes_citations.py +0 -0
  110. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_server/routes_collections.py +0 -0
  111. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_server/routes_compat.py +0 -0
  112. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/_server/server.py +0 -0
  113. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/aio.py +0 -0
  114. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/cache.py +0 -0
  115. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/cli.py +0 -0
  116. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/jobs.py +0 -0
  117. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/mcp_server.py +0 -0
  118. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/remote.py +0 -0
  119. {crossref_local-0.5.0 → crossref_local-0.5.1}/src/crossref_local/server.py +0 -0
  120. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/conftest.py +0 -0
  121. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/impact_factor/test_calculator.py +0 -0
  122. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/impact_factor/test_journal_lookup.py +0 -0
  123. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/test_aio.py +0 -0
  124. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/test_api.py +0 -0
  125. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/test_cache.py +0 -0
  126. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/test_citations.py +0 -0
  127. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/test_cli.py +0 -0
  128. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/test_cli_completion.py +0 -0
  129. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/test_config.py +0 -0
  130. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/test_db.py +0 -0
  131. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/test_fts.py +0 -0
  132. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/test_jobs.py +0 -0
  133. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/test_mcp_server.py +0 -0
  134. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/test_models.py +0 -0
  135. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/test_remote.py +0 -0
  136. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/crossref_local/test_server.py +0 -0
  137. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/fixtures/.gitkeep +0 -0
  138. {crossref_local-0.5.0 → crossref_local-0.5.1}/tests/sync_tests_with_source.sh +0 -0
  139. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/.gitignore +0 -0
  140. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/LICENSE +0 -0
  141. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/NOTES.md +0 -0
  142. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/README.md +0 -0
  143. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/dois2sqlite/__init__.py +0 -0
  144. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/dois2sqlite/cli.py +0 -0
  145. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/dois2sqlite/database.py +0 -0
  146. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/dois2sqlite/file_handlers.py +0 -0
  147. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/dois2sqlite/models.py +0 -0
  148. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/dois2sqlite/representations.py +0 -0
  149. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/dois2sqlite/utils.py +0 -0
  150. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/examples/cnserver/NOTES.md +0 -0
  151. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/examples/cnserver/README.md +0 -0
  152. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/examples/cnserver/app.py +0 -0
  153. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/examples/cnserver/cn-quick-test.py +0 -0
  154. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/examples/cnserver/cnserver/__init__.py +0 -0
  155. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/examples/cnserver/cnserver/accept_header_utils.py +0 -0
  156. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/examples/cnserver/cnserver/cslutils.py +0 -0
  157. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/examples/cnserver/cnserver/settings.py +0 -0
  158. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/pyproject.toml +0 -0
  159. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/quick_convert.sh +0 -0
  160. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/quick_no_convert.sh +0 -0
  161. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/utils/create-rnd-tar-subset.py +0 -0
  162. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/dois2sqlite/utils/select-rnd-dois-from-db.py +0 -0
  163. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/.gitignore +0 -0
  164. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/LICENSE.md +0 -0
  165. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/README.md +0 -0
  166. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/crossrefDataFile/__init__.py +0 -0
  167. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/crossrefDataFile/api.py +0 -0
  168. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/crossrefDataFile/app_urls.py +0 -0
  169. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/crossrefDataFile/asgi.py +0 -0
  170. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/crossrefDataFile/migrations/0001_initial.py +0 -0
  171. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/crossrefDataFile/migrations/0002_dataindexwithlocation.py +0 -0
  172. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/crossrefDataFile/migrations/__init__.py +0 -0
  173. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/crossrefDataFile/models.py +0 -0
  174. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/crossrefDataFile/urls.py +0 -0
  175. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/crossrefDataFile/views.py +0 -0
  176. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/crossrefDataFile/wsgi.py +0 -0
  177. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/main.py +0 -0
  178. {crossref_local-0.5.0 → crossref_local-0.5.1}/vendor/labs-data-file-api/manage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crossref-local
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Local CrossRef database with 167M+ works and full-text search
5
5
  Project-URL: Homepage, https://github.com/ywatanabe1989/crossref_local
6
6
  Project-URL: Repository, https://github.com/ywatanabe1989/crossref_local
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "crossref-local"
7
- version = "0.5.0"
7
+ version = "0.5.1"
8
8
  description = "Local CrossRef database with 167M+ works and full-text search"
9
9
  readme = "README.md"
10
10
  license = "AGPL-3.0"
@@ -64,7 +64,7 @@ Modules:
64
64
  aio - Async versions of all API functions
65
65
  """
66
66
 
67
- __version__ = "0.5.0"
67
+ __version__ = "0.5.1"
68
68
 
69
69
  # Core API (from _core package)
70
70
  from ._core import (
@@ -89,6 +89,9 @@ from ._core import (
89
89
  get_cited,
90
90
  get_citation_count,
91
91
  CitationNetwork,
92
+ # Export
93
+ save,
94
+ SUPPORTED_FORMATS,
92
95
  )
93
96
 
94
97
  # Async API (public module)
@@ -134,6 +137,9 @@ __all__ = [
134
137
  "get_cited",
135
138
  "get_citation_count",
136
139
  "CitationNetwork",
140
+ # Export/Save
141
+ "save",
142
+ "SUPPORTED_FORMATS",
137
143
  ]
138
144
 
139
145
 
@@ -1,29 +1,15 @@
1
1
  """Command-line interface for crossref_local."""
2
2
 
3
- import click
4
- import json
5
- import re
6
3
  import sys
7
- from typing import Optional
8
4
 
5
+ import click
9
6
  from rich.console import Console
10
7
 
11
- from .. import search, get, info, __version__
8
+ from .. import __version__, info
12
9
 
13
10
  console = Console()
14
11
 
15
12
 
16
- def _strip_xml_tags(text: str) -> str:
17
- """Strip XML/JATS tags from abstract text."""
18
- if not text:
19
- return text
20
- # Remove XML tags
21
- text = re.sub(r"<[^>]+>", " ", text)
22
- # Collapse multiple spaces
23
- text = re.sub(r"\s+", " ", text)
24
- return text.strip()
25
-
26
-
27
13
  class AliasedGroup(click.Group):
28
14
  """Click group that supports command aliases."""
29
15
 
@@ -140,126 +126,20 @@ def cli(ctx, http: bool, api_url: str):
140
126
  Config.set_mode("http")
141
127
 
142
128
 
143
- def _get_if_fast(db, issn: str, cache: dict) -> Optional[float]:
144
- """Fast IF lookup from pre-computed OpenAlex data."""
145
- if issn in cache:
146
- return cache[issn]
147
- row = db.fetchone(
148
- "SELECT two_year_mean_citedness FROM journals_openalex WHERE issns LIKE ?",
149
- (f"%{issn}%",),
150
- )
151
- cache[issn] = row["two_year_mean_citedness"] if row else None
152
- return cache[issn]
153
-
154
-
155
- @cli.command("search", context_settings=CONTEXT_SETTINGS)
156
- @click.argument("query")
157
- @click.option(
158
- "-n", "--number", "limit", default=10, show_default=True, help="Number of results"
159
- )
160
- @click.option("-o", "--offset", default=0, help="Skip first N results")
161
- @click.option("-a", "--abstracts", is_flag=True, help="Show abstracts")
162
- @click.option("-A", "--authors", is_flag=True, help="Show authors")
163
- @click.option(
164
- "-if", "--impact-factor", "with_if", is_flag=True, help="Show journal impact factor"
165
- )
166
- @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
167
- def search_cmd(
168
- query: str,
169
- limit: int,
170
- offset: int,
171
- abstracts: bool,
172
- authors: bool,
173
- with_if: bool,
174
- as_json: bool,
175
- ):
176
- """Search for works by title, abstract, or authors."""
177
- from .._core.db import get_db
178
-
179
- try:
180
- results = search(query, limit=limit, offset=offset)
181
- except ConnectionError as e:
182
- click.secho(f"Error: {e}", fg="red", err=True)
183
- sys.exit(1)
184
-
185
- if_cache, db = {}, None
186
- try:
187
- db = get_db() if with_if else None
188
- except FileNotFoundError:
189
- pass # HTTP mode: IF lookup unavailable
190
-
191
- if as_json:
192
- output = {
193
- "query": results.query,
194
- "total": results.total,
195
- "elapsed_ms": results.elapsed_ms,
196
- "works": [w.to_dict() for w in results.works],
197
- }
198
- click.echo(json.dumps(output, indent=2))
199
- else:
200
- click.secho(
201
- f"Found {results.total:,} matches in {results.elapsed_ms:.1f}ms\n",
202
- fg="green",
203
- )
204
- for i, work in enumerate(results.works, start=offset + 1):
205
- title = _strip_xml_tags(work.title) if work.title else "Untitled"
206
- year = f"({work.year})" if work.year else ""
207
- click.secho(f"{i}. {title} {year}", fg="cyan", bold=True)
208
- click.echo(f" DOI: {work.doi or 'N/A'}")
209
- if authors and work.authors:
210
- authors_str = ", ".join(work.authors[:5])
211
- if len(work.authors) > 5:
212
- authors_str += f" et al. ({len(work.authors)} total)"
213
- click.echo(f" Authors: {authors_str}")
214
- journal_line = f" Journal: {work.journal or 'N/A'}"
215
- if db and work.issn and (if_val := _get_if_fast(db, work.issn, if_cache)):
216
- journal_line += f" (IF: {if_val:.2f}, OpenAlex)"
217
- click.echo(journal_line)
218
- if abstracts and work.abstract:
219
- abstract = _strip_xml_tags(work.abstract)[:500]
220
- click.echo(
221
- f" Abstract: {abstract}{'...' if len(work.abstract) > 500 else ''}"
222
- )
223
- click.echo()
224
-
225
-
226
- @cli.command("search-by-doi", context_settings=CONTEXT_SETTINGS)
227
- @click.argument("doi")
228
- @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
229
- @click.option("--citation", is_flag=True, help="Output as citation")
230
- def search_by_doi_cmd(doi: str, as_json: bool, citation: bool):
231
- """Search for a work by DOI."""
232
- try:
233
- work = get(doi)
234
- except ConnectionError as e:
235
- click.echo(f"Error: {e}", err=True)
236
- click.echo("\nRun 'crossref-local status' to check configuration.", err=True)
237
- sys.exit(1)
238
-
239
- if work is None:
240
- click.echo(f"DOI not found: {doi}", err=True)
241
- sys.exit(1)
129
+ # Register search commands from search module
130
+ from .search import search_by_doi_cmd, search_cmd
242
131
 
243
- if as_json:
244
- click.echo(json.dumps(work.to_dict(), indent=2))
245
- elif citation:
246
- click.echo(work.citation())
247
- else:
248
- click.echo(f"Title: {work.title}")
249
- click.echo(f"Authors: {', '.join(work.authors)}")
250
- click.echo(f"Year: {work.year}")
251
- click.echo(f"Journal: {work.journal}")
252
- click.echo(f"DOI: {work.doi}")
253
- if work.citation_count:
254
- click.echo(f"Citations: {work.citation_count}")
132
+ cli.add_command(search_cmd)
133
+ cli.add_command(search_by_doi_cmd)
255
134
 
256
135
 
257
136
  @cli.command(context_settings=CONTEXT_SETTINGS)
258
137
  def status():
259
138
  """Show status and configuration."""
260
- from .._core.config import DEFAULT_DB_PATHS, DEFAULT_API_URLS
261
139
  import os
262
140
 
141
+ from .._core.config import DEFAULT_API_URLS, DEFAULT_DB_PATHS
142
+
263
143
  click.echo("CrossRef Local - Status")
264
144
  click.echo("=" * 50)
265
145
  click.echo()
@@ -299,10 +179,10 @@ def status():
299
179
  for var_name, description, value in env_vars:
300
180
  if value:
301
181
  if var_name == "CROSSREF_LOCAL_DB":
302
- status = " (OK)" if os.path.exists(value) else " (NOT FOUND)"
182
+ stat = " (OK)" if os.path.exists(value) else " (NOT FOUND)"
303
183
  else:
304
- status = ""
305
- click.echo(f" {var_name}={value}{status}")
184
+ stat = ""
185
+ click.echo(f" {var_name}={value}{stat}")
306
186
  click.echo(f" | {description}")
307
187
  else:
308
188
  click.echo(f" {var_name} (not set)")
@@ -327,11 +207,10 @@ def status():
327
207
  # Check API servers
328
208
  click.echo("API Servers:")
329
209
  api_found = None
330
- api_compatible = False
331
210
  for url in DEFAULT_API_URLS:
332
211
  try:
333
- import urllib.request
334
212
  import json as json_module
213
+ import urllib.request
335
214
 
336
215
  # Check root endpoint for version
337
216
  req = urllib.request.Request(f"{url}/", method="GET")
@@ -344,13 +223,12 @@ def status():
344
223
  # Check version compatibility
345
224
  if server_version == __version__:
346
225
  click.echo(f" [OK] {url} (v{server_version})")
347
- api_compatible = True
348
226
  else:
349
227
  click.echo(
350
228
  f" [WARN] {url} (v{server_version} != v{__version__})"
351
229
  )
352
230
  click.echo(
353
- f" Server version mismatch - may be incompatible"
231
+ " Server version mismatch - may be incompatible"
354
232
  )
355
233
 
356
234
  if api_found is None:
@@ -442,7 +320,7 @@ def relay(host: str, port: int):
442
320
  curl "http://localhost:8333/works?q=CRISPR&limit=10"
443
321
  """
444
322
  try:
445
- from .server import run_server
323
+ from .._server import run_server
446
324
  except ImportError:
447
325
  click.echo(
448
326
  "API server requires fastapi and uvicorn. Install with:\n"
@@ -451,7 +329,7 @@ def relay(host: str, port: int):
451
329
  )
452
330
  sys.exit(1)
453
331
 
454
- from .server import DEFAULT_HOST, DEFAULT_PORT
332
+ from .._server import DEFAULT_HOST, DEFAULT_PORT
455
333
 
456
334
  host = host or DEFAULT_HOST
457
335
  port = port or DEFAULT_PORT
@@ -485,7 +363,6 @@ def list_apis(verbose, max_depth, as_json):
485
363
  """List Python APIs (alias for: scitex introspect api crossref_local)."""
486
364
  try:
487
365
  from scitex.cli.introspect import api
488
- import click
489
366
 
490
367
  ctx = click.Context(api)
491
368
  ctx.invoke(
@@ -35,6 +35,8 @@ def search(
35
35
  limit: int = 10,
36
36
  offset: int = 0,
37
37
  with_abstracts: bool = False,
38
+ save_path: str | None = None,
39
+ save_format: str = "json",
38
40
  ) -> str:
39
41
  """Search for academic works by title, abstract, or authors.
40
42
 
@@ -43,9 +45,11 @@ def search(
43
45
 
44
46
  Args:
45
47
  query: Search query (e.g., "machine learning", "CRISPR", "neural network AND hippocampus")
46
- limit: Maximum number of results to return (default: 10, max: 100)
48
+ limit: Maximum number of results to return (default: 10)
47
49
  offset: Skip first N results for pagination (default: 0)
48
50
  with_abstracts: Include abstracts in results (default: False)
51
+ save_path: Optional file path to save results (e.g., "results.json", "papers.bib")
52
+ save_format: Output format for save_path: "text", "json", or "bibtex" (default: "json")
49
53
 
50
54
  Returns:
51
55
  JSON string with search results including total count and matching works.
@@ -54,8 +58,21 @@ def search(
54
58
  search("machine learning")
55
59
  search("CRISPR", limit=20)
56
60
  search("neural network AND memory", with_abstracts=True)
61
+ search("epilepsy", save_path="epilepsy.bib", save_format="bibtex")
57
62
  """
58
- results = _search(query, limit=min(limit, 100), offset=offset)
63
+ results = _search(query, limit=limit, offset=offset)
64
+
65
+ # Save to file if requested
66
+ saved_path = None
67
+ if save_path:
68
+ from .._core.export import save as _save
69
+
70
+ try:
71
+ saved_path = _save(
72
+ results, save_path, format=save_format, include_abstract=with_abstracts
73
+ )
74
+ except Exception as e:
75
+ return json.dumps({"error": f"Failed to save: {e}"})
59
76
 
60
77
  works_data = []
61
78
  for work in results.works:
@@ -70,25 +87,34 @@ def search(
70
87
  work_dict["abstract"] = work.abstract
71
88
  works_data.append(work_dict)
72
89
 
73
- return json.dumps(
74
- {
75
- "query": results.query,
76
- "total": results.total,
77
- "returned": len(works_data),
78
- "elapsed_ms": round(results.elapsed_ms, 2),
79
- "works": works_data,
80
- },
81
- indent=2,
82
- )
90
+ result = {
91
+ "query": results.query,
92
+ "total": results.total,
93
+ "returned": len(works_data),
94
+ "elapsed_ms": round(results.elapsed_ms, 2),
95
+ "works": works_data,
96
+ }
97
+
98
+ if saved_path:
99
+ result["saved_to"] = saved_path
100
+
101
+ return json.dumps(result, indent=2)
83
102
 
84
103
 
85
104
  @mcp.tool()
86
- def search_by_doi(doi: str, as_citation: bool = False) -> str:
105
+ def search_by_doi(
106
+ doi: str,
107
+ as_citation: bool = False,
108
+ save_path: str | None = None,
109
+ save_format: str = "json",
110
+ ) -> str:
87
111
  """Get detailed information about a work by DOI.
88
112
 
89
113
  Args:
90
114
  doi: Digital Object Identifier (e.g., "10.1038/nature12373")
91
115
  as_citation: Return formatted citation instead of full metadata
116
+ save_path: Optional file path to save result (e.g., "paper.json", "paper.bib")
117
+ save_format: Output format for save_path: "text", "json", or "bibtex" (default: "json")
92
118
 
93
119
  Returns:
94
120
  JSON string with work metadata, or formatted citation string.
@@ -96,16 +122,34 @@ def search_by_doi(doi: str, as_citation: bool = False) -> str:
96
122
  Examples:
97
123
  search_by_doi("10.1038/nature12373")
98
124
  search_by_doi("10.1126/science.aax0758", as_citation=True)
125
+ search_by_doi("10.1038/nature12373", save_path="paper.bib", save_format="bibtex")
99
126
  """
100
127
  work = _get(doi)
101
128
 
102
129
  if work is None:
103
130
  return json.dumps({"error": f"DOI not found: {doi}"})
104
131
 
132
+ # Save to file if requested
133
+ saved_path = None
134
+ if save_path:
135
+ from .._core.export import save as _save
136
+
137
+ try:
138
+ saved_path = _save(work, save_path, format=save_format)
139
+ except Exception as e:
140
+ return json.dumps({"error": f"Failed to save: {e}"})
141
+
105
142
  if as_citation:
106
- return work.citation()
143
+ result = work.citation()
144
+ if saved_path:
145
+ result += f"\n\n(Saved to: {saved_path})"
146
+ return result
107
147
 
108
- return json.dumps(work.to_dict(), indent=2)
148
+ result = work.to_dict()
149
+ if saved_path:
150
+ result["saved_to"] = saved_path
151
+
152
+ return json.dumps(result, indent=2)
109
153
 
110
154
 
111
155
  @mcp.tool()
@@ -0,0 +1,199 @@
1
+ """Search commands for crossref-local CLI."""
2
+
3
+ import json
4
+ import re
5
+ import sys
6
+ from typing import Optional
7
+
8
+ import click
9
+ from rich.console import Console
10
+
11
+ from .. import get, search
12
+ from .._core.export import save as _save
13
+
14
+ console = Console()
15
+
16
+ CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
17
+
18
+
19
+ def _strip_xml_tags(text: str) -> str:
20
+ """Strip XML/JATS tags from abstract text."""
21
+ if not text:
22
+ return text
23
+ text = re.sub(r"<[^>]+>", " ", text)
24
+ text = re.sub(r"\s+", " ", text)
25
+ return text.strip()
26
+
27
+
28
+ def _get_if_fast(db, issn: str, cache: dict) -> Optional[float]:
29
+ """Fast IF lookup from OpenAlex data."""
30
+ if issn in cache:
31
+ return cache[issn]
32
+ q = "SELECT two_year_mean_citedness FROM journals_openalex WHERE issns LIKE ?"
33
+ row = db.fetchone(q, (f"%{issn}%",))
34
+ cache[issn] = row["two_year_mean_citedness"] if row else None
35
+ return cache[issn]
36
+
37
+
38
+ @click.command("search", context_settings=CONTEXT_SETTINGS)
39
+ @click.argument("query")
40
+ @click.option(
41
+ "-n", "--number", "limit", default=10, show_default=True, help="Number of results"
42
+ )
43
+ @click.option("-o", "--offset", default=0, help="Skip first N results")
44
+ @click.option("-a", "--abstracts", is_flag=True, help="Show abstracts")
45
+ @click.option("-A", "--authors", is_flag=True, help="Show authors")
46
+ @click.option(
47
+ "-if", "--impact-factor", "with_if", is_flag=True, help="Show journal impact factor"
48
+ )
49
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
50
+ @click.option(
51
+ "--save",
52
+ "save_path",
53
+ type=click.Path(),
54
+ help="Save results to file",
55
+ )
56
+ @click.option(
57
+ "--format",
58
+ "save_format",
59
+ type=click.Choice(["text", "json", "bibtex"]),
60
+ default="json",
61
+ help="Output format for --save (default: json)",
62
+ )
63
+ def search_cmd(
64
+ query: str,
65
+ limit: int,
66
+ offset: int,
67
+ abstracts: bool,
68
+ authors: bool,
69
+ with_if: bool,
70
+ as_json: bool,
71
+ save_path: Optional[str],
72
+ save_format: str,
73
+ ):
74
+ """Search for works by title, abstract, or authors."""
75
+ from .._core.config import Config
76
+ from .._core.db import get_db
77
+
78
+ try:
79
+ results = search(query, limit=limit, offset=offset, with_if=with_if)
80
+ except ConnectionError as e:
81
+ click.secho(f"Error: {e}", fg="red", err=True)
82
+ sys.exit(1)
83
+
84
+ # Local IF lookup only in DB mode (HTTP gets IF from API)
85
+ if_cache, db = {}, None
86
+ if with_if and Config.get_mode() != "http":
87
+ try:
88
+ db = get_db()
89
+ except FileNotFoundError:
90
+ pass
91
+
92
+ # Save to file if requested
93
+ if save_path:
94
+ try:
95
+ saved = _save(
96
+ results, save_path, format=save_format, include_abstract=abstracts
97
+ )
98
+ click.secho(
99
+ f"Saved {len(results)} results to {saved}", fg="green", err=True
100
+ )
101
+ except Exception as e:
102
+ click.secho(f"Error saving: {e}", fg="red", err=True)
103
+ sys.exit(1)
104
+
105
+ if as_json:
106
+ output = {
107
+ "query": results.query,
108
+ "total": results.total,
109
+ "elapsed_ms": results.elapsed_ms,
110
+ "works": [w.to_dict() for w in results.works],
111
+ }
112
+ click.echo(json.dumps(output, indent=2))
113
+ else:
114
+ click.secho(
115
+ f"Found {results.total:,} matches in {results.elapsed_ms:.1f}ms\n",
116
+ fg="green",
117
+ )
118
+ for i, work in enumerate(results.works, start=offset + 1):
119
+ title = _strip_xml_tags(work.title) if work.title else "Untitled"
120
+ year = f"({work.year})" if work.year else ""
121
+ click.secho(f"{i}. {title} {year}", fg="cyan", bold=True)
122
+ click.echo(f" DOI: {work.doi or 'N/A'}")
123
+ if authors and work.authors:
124
+ authors_str = ", ".join(work.authors[:5])
125
+ if len(work.authors) > 5:
126
+ authors_str += f" et al. ({len(work.authors)} total)"
127
+ click.echo(f" Authors: {authors_str}")
128
+ journal_line = f" Journal: {work.journal or 'N/A'}"
129
+ if_val = work.impact_factor or (
130
+ db and work.issn and _get_if_fast(db, work.issn, if_cache)
131
+ )
132
+ if if_val:
133
+ journal_line += f" (IF: {if_val:.2f}, OpenAlex)"
134
+ click.echo(journal_line)
135
+ if abstracts and work.abstract:
136
+ abstract = _strip_xml_tags(work.abstract)[:500]
137
+ click.echo(
138
+ f" Abstract: {abstract}{'...' if len(work.abstract) > 500 else ''}"
139
+ )
140
+ click.echo()
141
+
142
+
143
+ @click.command("search-by-doi", context_settings=CONTEXT_SETTINGS)
144
+ @click.argument("doi")
145
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
146
+ @click.option("--citation", is_flag=True, help="Output as citation")
147
+ @click.option(
148
+ "--save",
149
+ "save_path",
150
+ type=click.Path(),
151
+ help="Save result to file",
152
+ )
153
+ @click.option(
154
+ "--format",
155
+ "save_format",
156
+ type=click.Choice(["text", "json", "bibtex"]),
157
+ default="json",
158
+ help="Output format for --save (default: json)",
159
+ )
160
+ def search_by_doi_cmd(
161
+ doi: str,
162
+ as_json: bool,
163
+ citation: bool,
164
+ save_path: Optional[str],
165
+ save_format: str,
166
+ ):
167
+ """Search for a work by DOI."""
168
+ try:
169
+ work = get(doi)
170
+ except ConnectionError as e:
171
+ click.echo(f"Error: {e}", err=True)
172
+ click.echo("\nRun 'crossref-local status' to check configuration.", err=True)
173
+ sys.exit(1)
174
+
175
+ if work is None:
176
+ click.echo(f"DOI not found: {doi}", err=True)
177
+ sys.exit(1)
178
+
179
+ # Save to file if requested
180
+ if save_path:
181
+ try:
182
+ saved = _save(work, save_path, format=save_format)
183
+ click.secho(f"Saved to {saved}", fg="green", err=True)
184
+ except Exception as e:
185
+ click.secho(f"Error saving: {e}", fg="red", err=True)
186
+ sys.exit(1)
187
+
188
+ if as_json:
189
+ click.echo(json.dumps(work.to_dict(), indent=2))
190
+ elif citation:
191
+ click.echo(work.citation())
192
+ else:
193
+ click.echo(f"Title: {work.title}")
194
+ click.echo(f"Authors: {', '.join(work.authors)}")
195
+ click.echo(f"Year: {work.year}")
196
+ click.echo(f"Journal: {work.journal}")
197
+ click.echo(f"DOI: {work.doi}")
198
+ if work.citation_count:
199
+ click.echo(f"Citations: {work.citation_count}")
@@ -23,6 +23,7 @@ from .citations import (
23
23
  )
24
24
  from .config import Config
25
25
  from .db import Database, close_db, get_db
26
+ from .export import SUPPORTED_FORMATS, save
26
27
  from .models import SearchResult, Work
27
28
 
28
29
  __all__ = [
@@ -53,6 +54,9 @@ __all__ = [
53
54
  "close_db",
54
55
  # Config
55
56
  "Config",
57
+ # Export
58
+ "save",
59
+ "SUPPORTED_FORMATS",
56
60
  ]
57
61
 
58
62
  # EOF
@@ -48,6 +48,7 @@ def search(
48
48
  query: str,
49
49
  limit: int = 10,
50
50
  offset: int = 0,
51
+ with_if: bool = False,
51
52
  ) -> SearchResult:
52
53
  """
53
54
  Full-text search across works.
@@ -58,6 +59,7 @@ def search(
58
59
  query: Search query (supports FTS5 syntax)
59
60
  limit: Maximum results to return
60
61
  offset: Skip first N results (for pagination)
62
+ with_if: Include impact factor data (OpenAlex)
61
63
 
62
64
  Returns:
63
65
  SearchResult with matching works
@@ -69,7 +71,7 @@ def search(
69
71
  """
70
72
  if Config.get_mode() == "http":
71
73
  client = _get_http_client()
72
- return client.search(query=query, limit=limit, offset=offset)
74
+ return client.search(query=query, limit=limit, offset=offset, with_if=with_if)
73
75
  return fts.search(query, limit, offset)
74
76
 
75
77