crossref-local 0.3.1__py3-none-any.whl → 0.4.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.
crossref_local/cli.py CHANGED
@@ -2,17 +2,15 @@
2
2
 
3
3
  import click
4
4
  import json
5
- import logging
6
5
  import re
7
6
  import sys
8
7
  from typing import Optional
9
8
 
10
- from . import search, get, count, info, __version__
9
+ from rich.console import Console
11
10
 
12
- from .impact_factor import ImpactFactorCalculator
11
+ from . import search, get, info, __version__
13
12
 
14
- # Suppress noisy warnings from impact_factor module in CLI
15
- logging.getLogger("crossref_local.impact_factor").setLevel(logging.ERROR)
13
+ console = Console()
16
14
 
17
15
 
18
16
  def _strip_xml_tags(text: str) -> str:
@@ -76,30 +74,61 @@ class AliasedGroup(click.Group):
76
74
  CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
77
75
 
78
76
 
77
+ def _print_recursive_help(ctx, param, value):
78
+ """Callback for --help-recursive flag."""
79
+ if not value or ctx.resilient_parsing:
80
+ return
81
+
82
+ def _print_command_help(cmd, prefix: str, parent_ctx):
83
+ """Recursively print help for a command and its subcommands."""
84
+ console.print(f"\n[bold cyan]━━━ {prefix} ━━━[/bold cyan]")
85
+ sub_ctx = click.Context(cmd, info_name=prefix.split()[-1], parent=parent_ctx)
86
+ console.print(cmd.get_help(sub_ctx))
87
+
88
+ if isinstance(cmd, click.Group):
89
+ for sub_name, sub_cmd in sorted(cmd.commands.items()):
90
+ _print_command_help(sub_cmd, f"{prefix} {sub_name}", sub_ctx)
91
+
92
+ # Print main help
93
+ console.print("[bold cyan]━━━ crossref-local ━━━[/bold cyan]")
94
+ console.print(ctx.get_help())
95
+
96
+ # Print all subcommands recursively
97
+ for name, cmd in sorted(cli.commands.items()):
98
+ _print_command_help(cmd, f"crossref-local {name}", ctx)
99
+
100
+ ctx.exit(0)
101
+
102
+
79
103
  @click.group(cls=AliasedGroup, context_settings=CONTEXT_SETTINGS)
80
104
  @click.version_option(version=__version__, prog_name="crossref-local")
105
+ @click.option("--http", is_flag=True, help="Use HTTP API instead of direct database")
81
106
  @click.option(
82
- "--remote", "-r", is_flag=True, help="Use remote API instead of local database"
107
+ "--api-url",
108
+ envvar="CROSSREF_LOCAL_API_URL",
109
+ help="API URL for http mode (default: auto-detect)",
83
110
  )
84
111
  @click.option(
85
- "--api-url",
86
- envvar="CROSSREF_LOCAL_API",
87
- help="API URL for remote mode (default: auto-detect)",
112
+ "--help-recursive",
113
+ is_flag=True,
114
+ is_eager=True,
115
+ expose_value=False,
116
+ callback=_print_recursive_help,
117
+ help="Show help for all commands recursively.",
88
118
  )
89
119
  @click.pass_context
90
- def cli(ctx, remote: bool, api_url: str):
120
+ def cli(ctx, http: bool, api_url: str):
91
121
  """Local CrossRef database with 167M+ works and full-text search.
92
122
 
93
- Supports both local database access and remote API mode.
123
+ Supports both direct database access (db mode) and HTTP API (http mode).
94
124
 
95
125
  \b
96
- Local mode (default if database found):
126
+ DB mode (default if database found):
97
127
  crossref-local search "machine learning"
98
128
 
99
129
  \b
100
- Remote mode (via SSH tunnel):
101
- ssh -L 3333:127.0.0.1:3333 nas # First, create tunnel
102
- crossref-local --remote search "machine learning"
130
+ HTTP mode (connect to API server):
131
+ crossref-local --http search "machine learning"
103
132
  """
104
133
  from .config import Config
105
134
 
@@ -107,8 +136,8 @@ def cli(ctx, remote: bool, api_url: str):
107
136
 
108
137
  if api_url:
109
138
  Config.set_api_url(api_url)
110
- elif remote:
111
- Config.set_mode("remote")
139
+ elif http:
140
+ Config.set_mode("http")
112
141
 
113
142
 
114
143
  def _get_if_fast(db, issn: str, cache: dict) -> Optional[float]:
@@ -117,24 +146,42 @@ def _get_if_fast(db, issn: str, cache: dict) -> Optional[float]:
117
146
  return cache[issn]
118
147
  row = db.fetchone(
119
148
  "SELECT two_year_mean_citedness FROM journals_openalex WHERE issns LIKE ?",
120
- (f"%{issn}%",)
149
+ (f"%{issn}%",),
121
150
  )
122
151
  cache[issn] = row["two_year_mean_citedness"] if row else None
123
152
  return cache[issn]
124
153
 
125
154
 
126
- @cli.command("search", aliases=["s"], context_settings=CONTEXT_SETTINGS)
155
+ @cli.command("search", context_settings=CONTEXT_SETTINGS)
127
156
  @click.argument("query")
128
- @click.option("-n", "--number", "limit", default=10, show_default=True, help="Number of results")
157
+ @click.option(
158
+ "-n", "--number", "limit", default=10, show_default=True, help="Number of results"
159
+ )
129
160
  @click.option("-o", "--offset", default=0, help="Skip first N results")
130
161
  @click.option("-a", "--abstracts", is_flag=True, help="Show abstracts")
131
162
  @click.option("-A", "--authors", is_flag=True, help="Show authors")
132
- @click.option("-if", "--impact-factor", "with_if", is_flag=True, help="Show journal impact factor")
163
+ @click.option(
164
+ "-if", "--impact-factor", "with_if", is_flag=True, help="Show journal impact factor"
165
+ )
133
166
  @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
134
- def search_cmd(query: str, limit: int, offset: int, abstracts: bool, authors: bool, with_if: bool, as_json: bool):
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
+ ):
135
176
  """Search for works by title, abstract, or authors."""
136
177
  from .db import get_db
137
- results = search(query, limit=limit, offset=offset)
178
+
179
+ try:
180
+ results = search(query, limit=limit, offset=offset)
181
+ except ConnectionError as e:
182
+ click.echo(f"Error: {e}", err=True)
183
+ click.echo("\nRun 'crossref-local status' to check configuration.", err=True)
184
+ sys.exit(1)
138
185
 
139
186
  # Cache for fast IF lookups
140
187
  if_cache = {}
@@ -177,13 +224,18 @@ def search_cmd(query: str, limit: int, offset: int, abstracts: bool, authors: bo
177
224
  click.echo()
178
225
 
179
226
 
180
- @cli.command("get", aliases=["g"], context_settings=CONTEXT_SETTINGS)
227
+ @cli.command("search-by-doi", context_settings=CONTEXT_SETTINGS)
181
228
  @click.argument("doi")
182
229
  @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
183
230
  @click.option("--citation", is_flag=True, help="Output as citation")
184
- def get_cmd(doi: str, as_json: bool, citation: bool):
185
- """Get a work by DOI."""
186
- work = get(doi)
231
+ def search_by_doi_cmd(doi: str, as_json: bool, citation: bool):
232
+ """Search for a work by DOI."""
233
+ try:
234
+ work = get(doi)
235
+ except ConnectionError as e:
236
+ click.echo(f"Error: {e}", err=True)
237
+ click.echo("\nRun 'crossref-local status' to check configuration.", err=True)
238
+ sys.exit(1)
187
239
 
188
240
  if work is None:
189
241
  click.echo(f"DOI not found: {doi}", err=True)
@@ -203,92 +255,60 @@ def get_cmd(doi: str, as_json: bool, citation: bool):
203
255
  click.echo(f"Citations: {work.citation_count}")
204
256
 
205
257
 
206
- @cli.command("count", aliases=["c"], context_settings=CONTEXT_SETTINGS)
207
- @click.argument("query")
208
- def count_cmd(query: str):
209
- """Count matching works."""
210
- n = count(query)
211
- click.echo(f"{n:,}")
212
-
213
-
214
- @cli.command("info", aliases=["i"], context_settings=CONTEXT_SETTINGS)
215
- @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
216
- def info_cmd(as_json: bool):
217
- """Show database/API information."""
218
- db_info = info()
219
-
220
- if as_json:
221
- click.echo(json.dumps(db_info, indent=2))
222
- else:
223
- mode = db_info.get("mode", "local")
224
- if mode == "remote":
225
- click.echo("CrossRef Local API (Remote)")
226
- click.echo("-" * 40)
227
- click.echo(f"API URL: {db_info.get('api_url', 'unknown')}")
228
- click.echo(f"Status: {db_info.get('status', 'unknown')}")
229
- else:
230
- click.echo("CrossRef Local Database")
231
- click.echo("-" * 40)
232
- click.echo(f"Database: {db_info.get('db_path', 'unknown')}")
233
- click.echo(f"Works: {db_info.get('works', 0):,}")
234
- click.echo(f"FTS indexed: {db_info.get('fts_indexed', 0):,}")
235
- click.echo(f"Citations: {db_info.get('citations', 0):,}")
236
-
237
-
238
- @cli.command("impact-factor", aliases=["if"], context_settings=CONTEXT_SETTINGS)
239
- @click.argument("journal")
240
- @click.option("-y", "--year", default=2023, help="Target year")
241
- @click.option("-w", "--window", default=2, help="Citation window years")
242
- @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
243
- def impact_factor_cmd(journal: str, year: int, window: int, as_json: bool):
244
- """Calculate impact factor for a journal."""
245
- with ImpactFactorCalculator() as calc:
246
- result = calc.calculate_impact_factor(
247
- journal_identifier=journal,
248
- target_year=year,
249
- window_years=window,
250
- )
251
-
252
- if as_json:
253
- click.echo(json.dumps(result, indent=2))
254
- else:
255
- click.echo(f"Journal: {result['journal']}")
256
- click.echo(f"Year: {result['target_year']}")
257
- click.echo(f"Window: {result['window_range']}")
258
- click.echo(f"Articles: {result['total_articles']:,}")
259
- click.echo(f"Citations: {result['total_citations']:,}")
260
- click.echo(f"Impact Factor: {result['impact_factor']:.3f}")
261
-
262
-
263
258
  @cli.command(context_settings=CONTEXT_SETTINGS)
264
- def setup():
265
- """Check setup status and configuration."""
259
+ def status():
260
+ """Show status and configuration."""
266
261
  from .config import DEFAULT_DB_PATHS, DEFAULT_API_URLS
267
262
  import os
268
263
 
269
- click.echo("CrossRef Local - Setup Status")
264
+ click.echo("CrossRef Local - Status")
270
265
  click.echo("=" * 50)
271
266
  click.echo()
272
267
 
273
268
  # Check environment variables
274
269
  click.echo("Environment Variables:")
275
- env_db = os.environ.get("CROSSREF_LOCAL_DB")
276
- env_api = os.environ.get("CROSSREF_LOCAL_API")
277
- env_mode = os.environ.get("CROSSREF_LOCAL_MODE")
278
-
279
- if env_db:
280
- status = "OK" if os.path.exists(env_db) else "NOT FOUND"
281
- click.echo(f" CROSSREF_LOCAL_DB: {env_db} ({status})")
282
- else:
283
- click.echo(" CROSSREF_LOCAL_DB: (not set)")
284
-
285
- if env_api:
286
- click.echo(f" CROSSREF_LOCAL_API: {env_api}")
287
- else:
288
- click.echo(" CROSSREF_LOCAL_API: (not set)")
270
+ click.echo()
289
271
 
290
- if env_mode:
291
- click.echo(f" CROSSREF_LOCAL_MODE: {env_mode}")
272
+ env_vars = [
273
+ (
274
+ "CROSSREF_LOCAL_DB",
275
+ "Path to SQLite database file",
276
+ os.environ.get("CROSSREF_LOCAL_DB"),
277
+ ),
278
+ (
279
+ "CROSSREF_LOCAL_API_URL",
280
+ "HTTP API URL (e.g., http://localhost:8333)",
281
+ os.environ.get("CROSSREF_LOCAL_API_URL"),
282
+ ),
283
+ (
284
+ "CROSSREF_LOCAL_MODE",
285
+ "Force mode: 'db', 'http', or 'auto'",
286
+ os.environ.get("CROSSREF_LOCAL_MODE"),
287
+ ),
288
+ (
289
+ "CROSSREF_LOCAL_HOST",
290
+ "Host for run-server-http (default: 0.0.0.0)",
291
+ os.environ.get("CROSSREF_LOCAL_HOST"),
292
+ ),
293
+ (
294
+ "CROSSREF_LOCAL_PORT",
295
+ "Port for run-server-http (default: 8333)",
296
+ os.environ.get("CROSSREF_LOCAL_PORT"),
297
+ ),
298
+ ]
299
+
300
+ for var_name, description, value in env_vars:
301
+ if value:
302
+ if var_name == "CROSSREF_LOCAL_DB":
303
+ status = " (OK)" if os.path.exists(value) else " (NOT FOUND)"
304
+ else:
305
+ status = ""
306
+ click.echo(f" {var_name}={value}{status}")
307
+ click.echo(f" | {description}")
308
+ else:
309
+ click.echo(f" {var_name} (not set)")
310
+ click.echo(f" | {description}")
311
+ click.echo()
292
312
 
293
313
  click.echo()
294
314
 
@@ -305,17 +325,35 @@ def setup():
305
325
 
306
326
  click.echo()
307
327
 
308
- # Check remote API endpoints
309
- click.echo("Remote API Endpoints:")
328
+ # Check API servers
329
+ click.echo("API Servers:")
310
330
  api_found = None
331
+ api_compatible = False
311
332
  for url in DEFAULT_API_URLS:
312
333
  try:
313
334
  import urllib.request
335
+ import json as json_module
314
336
 
315
- req = urllib.request.Request(f"{url}/health", method="GET")
337
+ # Check root endpoint for version
338
+ req = urllib.request.Request(f"{url}/", method="GET")
339
+ req.add_header("Accept", "application/json")
316
340
  with urllib.request.urlopen(req, timeout=3) as resp:
317
341
  if resp.status == 200:
318
- click.echo(f" [OK] {url}")
342
+ data = json_module.loads(resp.read().decode())
343
+ server_version = data.get("version", "unknown")
344
+
345
+ # Check version compatibility
346
+ if server_version == __version__:
347
+ click.echo(f" [OK] {url} (v{server_version})")
348
+ api_compatible = True
349
+ else:
350
+ click.echo(
351
+ f" [WARN] {url} (v{server_version} != v{__version__})"
352
+ )
353
+ click.echo(
354
+ f" Server version mismatch - may be incompatible"
355
+ )
356
+
319
357
  if api_found is None:
320
358
  api_found = url
321
359
  else:
@@ -338,59 +376,81 @@ def setup():
338
376
  click.echo("Ready! Try:")
339
377
  click.echo(' crossref-local search "machine learning"')
340
378
  elif api_found:
341
- click.echo(f"Remote API available: {api_found}")
379
+ click.echo(f"HTTP API available: {api_found}")
342
380
  click.echo()
343
381
  click.echo("Ready! Try:")
344
- click.echo(' crossref-local --remote search "machine learning"')
382
+ click.echo(' crossref-local --http search "machine learning"')
345
383
  click.echo()
346
384
  click.echo("Or set environment:")
347
- click.echo(" export CROSSREF_LOCAL_MODE=remote")
385
+ click.echo(" export CROSSREF_LOCAL_MODE=http")
348
386
  else:
349
- click.echo("No database or API found!")
387
+ click.echo("No database or API server found!")
350
388
  click.echo()
351
389
  click.echo("Options:")
352
- click.echo(" 1. Local database:")
390
+ click.echo(" 1. Direct database access (db mode):")
353
391
  click.echo(" export CROSSREF_LOCAL_DB=/path/to/crossref.db")
354
392
  click.echo()
355
- click.echo(" 2. Remote API (via SSH tunnel):")
356
- click.echo(" ssh -L 3333:127.0.0.1:3333 your-nas")
357
- click.echo(" crossref-local --remote search 'query'")
393
+ click.echo(" 2. HTTP API (connect to server):")
394
+ click.echo(" crossref-local --http search 'query'")
358
395
 
359
396
 
360
- @cli.command(context_settings=CONTEXT_SETTINGS)
397
+ @cli.command("run-server-mcp", context_settings=CONTEXT_SETTINGS)
361
398
  @click.option(
362
399
  "-t",
363
400
  "--transport",
364
401
  type=click.Choice(["stdio", "sse", "http"]),
365
402
  default="stdio",
366
- help="Transport protocol (stdio for Claude Desktop)",
403
+ help="Transport protocol (http recommended for remote)",
367
404
  )
368
- @click.option("--host", default="localhost", help="Host for HTTP/SSE transport")
369
- @click.option("--port", default=8082, type=int, help="Port for HTTP/SSE transport")
370
- def serve(transport: str, host: str, port: int):
371
- """Run MCP server for Claude integration.
405
+ @click.option(
406
+ "--host",
407
+ default="localhost",
408
+ envvar="CROSSREF_LOCAL_MCP_HOST",
409
+ help="Host for HTTP/SSE transport",
410
+ )
411
+ @click.option(
412
+ "--port",
413
+ default=8082,
414
+ type=int,
415
+ envvar="CROSSREF_LOCAL_MCP_PORT",
416
+ help="Port for HTTP/SSE transport",
417
+ )
418
+ def serve_mcp(transport: str, host: str, port: int):
419
+ """Run MCP (Model Context Protocol) server.
372
420
 
373
421
  \b
374
- Claude Desktop configuration (claude_desktop_config.json):
422
+ Transports:
423
+ stdio - Standard I/O (default, for Claude Desktop local)
424
+ http - Streamable HTTP (recommended for remote/persistent)
425
+ sse - Server-Sent Events (deprecated as of MCP spec 2025-03-26)
426
+
427
+ \b
428
+ Local configuration (stdio):
375
429
  {
376
430
  "mcpServers": {
377
431
  "crossref": {
378
432
  "command": "crossref-local",
379
- "args": ["serve"]
433
+ "args": ["run-server-mcp"]
380
434
  }
381
435
  }
382
436
  }
383
437
 
384
438
  \b
385
- Or with explicit path:
439
+ Remote configuration (http):
440
+ # Start server:
441
+ crossref-local run-server-mcp -t http --host 0.0.0.0 --port 8082
442
+
443
+ # Client config:
386
444
  {
387
445
  "mcpServers": {
388
- "crossref": {
389
- "command": "python",
390
- "args": ["-m", "crossref_local.mcp_server"]
446
+ "crossref-remote": {
447
+ "url": "http://your-server:8082/mcp"
391
448
  }
392
449
  }
393
450
  }
451
+
452
+ \b
453
+ See docs/remote-deployment.md for systemd and Docker setup.
394
454
  """
395
455
  try:
396
456
  from .mcp_server import run_server
@@ -405,11 +465,19 @@ def serve(transport: str, host: str, port: int):
405
465
  run_server(transport=transport, host=host, port=port)
406
466
 
407
467
 
408
- @cli.command(context_settings=CONTEXT_SETTINGS)
409
- @click.option("--host", default="0.0.0.0", help="Host to bind")
410
- @click.option("--port", default=3333, type=int, help="Port to listen on")
411
- def api(host: str, port: int):
412
- """Run HTTP API server with FTS5 search.
468
+ @cli.command("run-server-http", context_settings=CONTEXT_SETTINGS)
469
+ @click.option(
470
+ "--host", default="0.0.0.0", envvar="CROSSREF_LOCAL_HOST", help="Host to bind"
471
+ )
472
+ @click.option(
473
+ "--port",
474
+ default=8333,
475
+ type=int,
476
+ envvar="CROSSREF_LOCAL_PORT",
477
+ help="Port to listen on",
478
+ )
479
+ def serve_http(host: str, port: int):
480
+ """Run HTTP API server.
413
481
 
414
482
  \b
415
483
  This runs a FastAPI server that provides proper full-text search
@@ -417,13 +485,13 @@ def api(host: str, port: int):
417
485
 
418
486
  \b
419
487
  Example:
420
- crossref-local api # Run on 0.0.0.0:3333
421
- crossref-local api --port 8080 # Custom port
488
+ crossref-local run-server-http # Run on 0.0.0.0:8333
489
+ crossref-local run-server-http --port 8080 # Custom port
422
490
 
423
491
  \b
424
- Then from a client:
425
- curl "http://localhost:3333/search?q=CRISPR&limit=10"
426
- curl "http://localhost:3333/get/10.1038/nature12373"
492
+ Then connect with http mode:
493
+ crossref-local --http search "CRISPR"
494
+ curl "http://localhost:8333/works?q=CRISPR&limit=10"
427
495
  """
428
496
  try:
429
497
  from .server import run_server
@@ -0,0 +1,179 @@
1
+ """CLI commands for cache management.
2
+
3
+ This module provides cache-related CLI commands that are registered
4
+ with the main CLI application.
5
+ """
6
+
7
+ import json
8
+ import click
9
+
10
+
11
+ def register_cache_commands(cli_group):
12
+ """Register cache commands with the CLI group."""
13
+
14
+ @cli_group.group()
15
+ def cache():
16
+ """Manage paper caches for efficient querying."""
17
+ pass
18
+
19
+ @cache.command("create")
20
+ @click.argument("name")
21
+ @click.option("-q", "--query", required=True, help="FTS search query")
22
+ @click.option("-l", "--limit", default=1000, help="Max papers to cache")
23
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
24
+ def cache_create(name, query, limit, as_json):
25
+ """Create a cache from search query.
26
+
27
+ Example:
28
+ crossref-local cache create epilepsy -q "epilepsy seizure" -l 500
29
+ """
30
+ from . import cache as cache_module
31
+
32
+ info = cache_module.create(name, query=query, limit=limit)
33
+ if as_json:
34
+ click.echo(json.dumps(info.to_dict(), indent=2))
35
+ else:
36
+ click.echo(f"Created cache: {info.name}")
37
+ click.echo(f" Papers: {info.paper_count}")
38
+ click.echo(f" Size: {info.size_bytes / 1024 / 1024:.2f} MB")
39
+ click.echo(f" Path: {info.path}")
40
+
41
+ @cache.command("list")
42
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
43
+ def cache_list(as_json):
44
+ """List all available caches."""
45
+ from . import cache as cache_module
46
+
47
+ caches = cache_module.list_caches()
48
+ if as_json:
49
+ click.echo(json.dumps([c.to_dict() for c in caches], indent=2))
50
+ else:
51
+ if not caches:
52
+ click.echo("No caches found.")
53
+ return
54
+ for c in caches:
55
+ click.echo(
56
+ f"{c.name}: {c.paper_count} papers, {c.size_bytes / 1024 / 1024:.2f} MB"
57
+ )
58
+
59
+ @cache.command("query")
60
+ @click.argument("name")
61
+ @click.option("-f", "--fields", help="Comma-separated field list")
62
+ @click.option("--abstract", is_flag=True, help="Include abstracts")
63
+ @click.option("--refs", is_flag=True, help="Include references")
64
+ @click.option("--citations", is_flag=True, help="Include citation counts")
65
+ @click.option("--year-min", type=int, help="Minimum year filter")
66
+ @click.option("--year-max", type=int, help="Maximum year filter")
67
+ @click.option("--journal", help="Journal name filter")
68
+ @click.option("-l", "--limit", type=int, help="Max results")
69
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
70
+ def cache_query(
71
+ name,
72
+ fields,
73
+ abstract,
74
+ refs,
75
+ citations,
76
+ year_min,
77
+ year_max,
78
+ journal,
79
+ limit,
80
+ as_json,
81
+ ):
82
+ """Query cache with field filtering.
83
+
84
+ Examples:
85
+ crossref-local cache query epilepsy -f doi,title,year
86
+ crossref-local cache query epilepsy --year-min 2020 --citations
87
+ """
88
+ from . import cache as cache_module
89
+
90
+ field_list = fields.split(",") if fields else None
91
+ papers = cache_module.query(
92
+ name,
93
+ fields=field_list,
94
+ include_abstract=abstract,
95
+ include_references=refs,
96
+ include_citations=citations,
97
+ year_min=year_min,
98
+ year_max=year_max,
99
+ journal=journal,
100
+ limit=limit,
101
+ )
102
+
103
+ if as_json:
104
+ click.echo(json.dumps(papers, indent=2))
105
+ else:
106
+ click.echo(f"Found {len(papers)} papers")
107
+ for p in papers[:10]:
108
+ title = p.get("title", "No title")[:60]
109
+ year = p.get("year", "?")
110
+ click.echo(f" [{year}] {title}...")
111
+ if len(papers) > 10:
112
+ click.echo(f" ... and {len(papers) - 10} more")
113
+
114
+ @cache.command("stats")
115
+ @click.argument("name")
116
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
117
+ def cache_stats(name, as_json):
118
+ """Show cache statistics."""
119
+ from . import cache as cache_module
120
+
121
+ stats = cache_module.stats(name)
122
+ if as_json:
123
+ click.echo(json.dumps(stats, indent=2))
124
+ else:
125
+ click.echo(f"Papers: {stats['paper_count']}")
126
+ yr = stats.get("year_range", {})
127
+ click.echo(f"Years: {yr.get('min', '?')} - {yr.get('max', '?')}")
128
+ click.echo(f"Abstracts: {stats['abstract_coverage']}%")
129
+ click.echo("\nTop journals:")
130
+ for j in stats.get("top_journals", [])[:5]:
131
+ click.echo(f" {j['journal']}: {j['count']}")
132
+
133
+ @cache.command("export")
134
+ @click.argument("name")
135
+ @click.argument("output")
136
+ @click.option(
137
+ "--format", "fmt", default="json", help="Format: json, csv, bibtex, dois"
138
+ )
139
+ @click.option("-f", "--fields", help="Comma-separated field list")
140
+ def cache_export(name, output, fmt, fields):
141
+ """Export cache to file.
142
+
143
+ Examples:
144
+ crossref-local cache export epilepsy papers.csv --format csv
145
+ crossref-local cache export epilepsy refs.bib --format bibtex
146
+ """
147
+ from . import cache as cache_module
148
+
149
+ field_list = fields.split(",") if fields else None
150
+ path = cache_module.export(name, output, format=fmt, fields=field_list)
151
+ click.echo(f"Exported to: {path}")
152
+
153
+ @cache.command("delete")
154
+ @click.argument("name")
155
+ @click.option("--yes", is_flag=True, help="Skip confirmation")
156
+ def cache_delete(name, yes):
157
+ """Delete a cache."""
158
+ from . import cache as cache_module
159
+
160
+ if not yes:
161
+ if not click.confirm(f"Delete cache '{name}'?"):
162
+ return
163
+
164
+ if cache_module.delete(name):
165
+ click.echo(f"Deleted: {name}")
166
+ else:
167
+ click.echo(f"Cache not found: {name}")
168
+
169
+ @cache.command("dois")
170
+ @click.argument("name")
171
+ def cache_dois(name):
172
+ """Output DOIs from cache (one per line)."""
173
+ from . import cache as cache_module
174
+
175
+ dois = cache_module.query_dois(name)
176
+ for doi in dois:
177
+ click.echo(doi)
178
+
179
+ return cache