arionxiv 1.0.32__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.
Files changed (69) hide show
  1. arionxiv/__init__.py +40 -0
  2. arionxiv/__main__.py +10 -0
  3. arionxiv/arxiv_operations/__init__.py +0 -0
  4. arionxiv/arxiv_operations/client.py +225 -0
  5. arionxiv/arxiv_operations/fetcher.py +173 -0
  6. arionxiv/arxiv_operations/searcher.py +122 -0
  7. arionxiv/arxiv_operations/utils.py +293 -0
  8. arionxiv/cli/__init__.py +4 -0
  9. arionxiv/cli/commands/__init__.py +1 -0
  10. arionxiv/cli/commands/analyze.py +587 -0
  11. arionxiv/cli/commands/auth.py +365 -0
  12. arionxiv/cli/commands/chat.py +714 -0
  13. arionxiv/cli/commands/daily.py +482 -0
  14. arionxiv/cli/commands/fetch.py +217 -0
  15. arionxiv/cli/commands/library.py +295 -0
  16. arionxiv/cli/commands/preferences.py +426 -0
  17. arionxiv/cli/commands/search.py +254 -0
  18. arionxiv/cli/commands/settings_unified.py +1407 -0
  19. arionxiv/cli/commands/trending.py +41 -0
  20. arionxiv/cli/commands/welcome.py +168 -0
  21. arionxiv/cli/main.py +407 -0
  22. arionxiv/cli/ui/__init__.py +1 -0
  23. arionxiv/cli/ui/global_theme_manager.py +173 -0
  24. arionxiv/cli/ui/logo.py +127 -0
  25. arionxiv/cli/ui/splash.py +89 -0
  26. arionxiv/cli/ui/theme.py +32 -0
  27. arionxiv/cli/ui/theme_system.py +391 -0
  28. arionxiv/cli/utils/__init__.py +54 -0
  29. arionxiv/cli/utils/animations.py +522 -0
  30. arionxiv/cli/utils/api_client.py +583 -0
  31. arionxiv/cli/utils/api_config.py +505 -0
  32. arionxiv/cli/utils/command_suggestions.py +147 -0
  33. arionxiv/cli/utils/db_config_manager.py +254 -0
  34. arionxiv/github_actions_runner.py +206 -0
  35. arionxiv/main.py +23 -0
  36. arionxiv/prompts/__init__.py +9 -0
  37. arionxiv/prompts/prompts.py +247 -0
  38. arionxiv/rag_techniques/__init__.py +8 -0
  39. arionxiv/rag_techniques/basic_rag.py +1531 -0
  40. arionxiv/scheduler_daemon.py +139 -0
  41. arionxiv/server.py +1000 -0
  42. arionxiv/server_main.py +24 -0
  43. arionxiv/services/__init__.py +73 -0
  44. arionxiv/services/llm_client.py +30 -0
  45. arionxiv/services/llm_inference/__init__.py +58 -0
  46. arionxiv/services/llm_inference/groq_client.py +469 -0
  47. arionxiv/services/llm_inference/llm_utils.py +250 -0
  48. arionxiv/services/llm_inference/openrouter_client.py +564 -0
  49. arionxiv/services/unified_analysis_service.py +872 -0
  50. arionxiv/services/unified_auth_service.py +457 -0
  51. arionxiv/services/unified_config_service.py +456 -0
  52. arionxiv/services/unified_daily_dose_service.py +823 -0
  53. arionxiv/services/unified_database_service.py +1633 -0
  54. arionxiv/services/unified_llm_service.py +366 -0
  55. arionxiv/services/unified_paper_service.py +604 -0
  56. arionxiv/services/unified_pdf_service.py +522 -0
  57. arionxiv/services/unified_prompt_service.py +344 -0
  58. arionxiv/services/unified_scheduler_service.py +589 -0
  59. arionxiv/services/unified_user_service.py +954 -0
  60. arionxiv/utils/__init__.py +51 -0
  61. arionxiv/utils/api_helpers.py +200 -0
  62. arionxiv/utils/file_cleanup.py +150 -0
  63. arionxiv/utils/ip_helper.py +96 -0
  64. arionxiv-1.0.32.dist-info/METADATA +336 -0
  65. arionxiv-1.0.32.dist-info/RECORD +69 -0
  66. arionxiv-1.0.32.dist-info/WHEEL +5 -0
  67. arionxiv-1.0.32.dist-info/entry_points.txt +4 -0
  68. arionxiv-1.0.32.dist-info/licenses/LICENSE +21 -0
  69. arionxiv-1.0.32.dist-info/top_level.txt +1 -0
@@ -0,0 +1,217 @@
1
+ """Fetch command for ArionXiv CLI"""
2
+
3
+ import sys
4
+ import asyncio
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ backend_path = Path(__file__).parent.parent.parent
9
+ sys.path.insert(0, str(backend_path))
10
+
11
+ import click
12
+ from rich.console import Console
13
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
14
+ from rich.panel import Panel
15
+ from rich.text import Text
16
+ from typing import Optional
17
+
18
+ from ...arxiv_operations.fetcher import arxiv_fetcher
19
+ from ...arxiv_operations.client import arxiv_client
20
+ from ...arxiv_operations.utils import ArxivUtils
21
+ from ...services.unified_pdf_service import pdf_processor
22
+ from ..utils.db_config_manager import db_config_manager as config_manager
23
+ from ..ui.theme import create_themed_console, print_header, style_text, print_success, print_error, print_warning, get_theme_colors
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ console = create_themed_console()
28
+
29
+ @click.command()
30
+ @click.argument('paper_id')
31
+ @click.option('--save-path', help='Custom save location for the PDF')
32
+ @click.option('--extract-text', is_flag=True, help='Extract text content from PDF')
33
+ @click.option('--no-download', is_flag=True, help='Skip PDF download, only fetch metadata')
34
+ def fetch_command(paper_id: str, save_path: str, extract_text: bool, no_download: bool):
35
+ """
36
+ Fetch and download a research paper
37
+
38
+ Examples:
39
+ \b
40
+ arionxiv fetch 2301.07041
41
+ arionxiv fetch 2301.07041 --save-path ./papers/
42
+ arionxiv fetch 2301.07041 --extract-text
43
+ arionxiv fetch 2301.07041 --no-download
44
+ """
45
+ asyncio.run(_fetch_paper(paper_id, save_path, extract_text, no_download))
46
+
47
+ async def _fetch_paper(paper_id: str, save_path: Optional[str], extract_text: bool, no_download: bool):
48
+ """Execute the paper fetch operation"""
49
+
50
+ logger.info(f"Fetching paper: {paper_id}, extract_text={extract_text}, no_download={no_download}")
51
+
52
+ # Get theme colors for consistent styling
53
+ colors = get_theme_colors()
54
+
55
+ # Clean up paper ID (remove version suffix if present)
56
+ clean_paper_id = ArxivUtils.normalize_arxiv_id(paper_id)
57
+ logger.debug(f"Normalized paper ID: {clean_paper_id}")
58
+
59
+ # Set up progress columns based on download flag
60
+ progress_columns = [
61
+ SpinnerColumn(),
62
+ TextColumn("[progress.description]{task.description}")
63
+ ]
64
+
65
+ if not no_download:
66
+ progress_columns.append(BarColumn())
67
+
68
+ with Progress(*progress_columns, console=console) as progress:
69
+
70
+ # Step 1: Fetch metadata
71
+ metadata_task = progress.add_task("Fetching paper metadata...", total=None)
72
+
73
+ try:
74
+ logger.debug("Fetching paper metadata from arXiv")
75
+ paper_metadata = arxiv_client.get_paper_by_id(clean_paper_id)
76
+
77
+ if not paper_metadata:
78
+ logger.warning(f"Paper not found: {paper_id}")
79
+ progress.remove_task(metadata_task)
80
+ console.print(f"Paper not found: {paper_id}", style=colors['error'])
81
+ return
82
+
83
+ logger.info(f"Metadata fetched for: {paper_metadata.get('title', 'Unknown')[:50]}")
84
+ progress.update(metadata_task, description="Metadata fetched")
85
+ progress.remove_task(metadata_task)
86
+
87
+ # Display paper info
88
+ _display_paper_info(paper_metadata)
89
+
90
+ if no_download:
91
+ logger.debug("Download skipped as requested")
92
+ console.print("\nMetadata fetch complete (download skipped)", style=colors['primary'])
93
+ return
94
+
95
+ # Step 2: Download PDF
96
+ download_task = progress.add_task("Downloading PDF...", total=100)
97
+
98
+ # Determine save path
99
+ if not save_path:
100
+ downloads_dir = Path(backend_path.parent) / "downloads"
101
+ downloads_dir.mkdir(exist_ok=True)
102
+ save_path = downloads_dir
103
+ else:
104
+ save_path = Path(save_path)
105
+ save_path.mkdir(parents=True, exist_ok=True)
106
+
107
+ logger.debug(f"Save path: {save_path}")
108
+
109
+ # Generate filename
110
+ title_clean = "".join(c for c in paper_metadata.get("title", "paper") if c.isalnum() or c in (' ', '-', '_')).rstrip()
111
+ title_clean = title_clean.replace(' ', '_')[:50] # Limit length
112
+ filename = f"{clean_paper_id}_{title_clean}.pdf"
113
+ file_path = save_path / filename
114
+
115
+ # Download the PDF
116
+ pdf_url = paper_metadata.get("pdf_url", "")
117
+ if not pdf_url:
118
+ logger.error("No PDF URL found for paper")
119
+ progress.remove_task(download_task)
120
+ console.print("No PDF URL found for this paper", style=colors['error'])
121
+ return
122
+
123
+ # Use fetcher to download
124
+ logger.info(f"Downloading PDF from: {pdf_url}")
125
+ download_result = await arxiv_fetcher.download_paper(clean_paper_id, str(save_path))
126
+
127
+ progress.update(download_task, completed=100)
128
+ progress.remove_task(download_task)
129
+
130
+ if not download_result["success"]:
131
+ logger.error(f"Download failed: {download_result.get('error', 'Unknown error')}")
132
+ console.print(f"Download failed: {download_result.get('error', 'Unknown error')}", style=colors['error'])
133
+ return
134
+
135
+ downloaded_path = download_result["file_path"]
136
+ console.print(f"\nPDF downloaded to: {downloaded_path}", style=colors['primary'])
137
+
138
+ # Step 3: Extract text if requested
139
+ if extract_text:
140
+ extract_task = progress.add_task("Extracting text content...", total=None)
141
+
142
+ try:
143
+ text_content = pdf_processor.extract_text(downloaded_path)
144
+
145
+ if text_content:
146
+ # Save text to file
147
+ text_file = Path(downloaded_path).with_suffix('.txt')
148
+ with open(text_file, 'w', encoding='utf-8') as f:
149
+ f.write(text_content)
150
+
151
+ progress.update(extract_task, description="Text extracted")
152
+ progress.remove_task(extract_task)
153
+
154
+ console.print(f"Text extracted to: {text_file}", style=colors['primary'])
155
+
156
+ # Show preview
157
+ preview = text_content[:500] + "..." if len(text_content) > 500 else text_content
158
+ console.print(f"\nText Preview:", style="bold")
159
+ console.print(Panel(preview, border_style="dim"))
160
+ else:
161
+ progress.remove_task(extract_task)
162
+ console.print("Could not extract text from PDF", style=colors['warning'])
163
+
164
+ except Exception as e:
165
+ progress.remove_task(extract_task)
166
+ console.print(f"Text extraction failed: {str(e)}", style=colors['error'])
167
+
168
+ # Show next steps
169
+ _show_next_steps(clean_paper_id, downloaded_path)
170
+
171
+ except Exception as e:
172
+ console.print(f"Fetch error: {str(e)}", style=colors['error'])
173
+ return
174
+
175
+ def _display_paper_info(paper):
176
+ """Display paper information"""
177
+ # Get theme colors for consistent styling
178
+ colors = get_theme_colors()
179
+
180
+ console.print(f"\nPaper Information", style=f"bold {colors['primary']}")
181
+
182
+ title = paper.get("title", "Unknown Title")
183
+ console.print(f"Title: {title}", style="bold")
184
+
185
+ authors = paper.get("authors", [])
186
+ if isinstance(authors, list):
187
+ author_str = ", ".join(authors)
188
+ else:
189
+ author_str = str(authors)
190
+ console.print(f"Authors: {author_str}", style=colors['primary'])
191
+
192
+ categories = paper.get("categories", [])
193
+ category_str = ", ".join(categories) if categories else "Unknown"
194
+ console.print(f"Categories: {category_str}", style=colors['info'])
195
+
196
+ pub_date = paper.get("published", "Unknown")
197
+ console.print(f"Published: {pub_date}", style=colors['warning'])
198
+
199
+ arxiv_id = paper.get("arxiv_id", "")
200
+ if arxiv_id:
201
+ console.print(f"ArXiv ID: {arxiv_id}", style=colors['primary'])
202
+
203
+ abstract = paper.get("abstract", "No abstract available")
204
+ if len(abstract) > 300:
205
+ abstract = abstract[:297] + "..."
206
+
207
+ console.print(f"\nAbstract:", style="bold")
208
+ console.print(Panel(abstract, border_style="dim"))
209
+
210
+ def _show_next_steps(paper_id: str, file_path: str):
211
+ """Show suggested next steps"""
212
+ console.print(f"\nNext Steps:", style="bold")
213
+ console.print(f" arionxiv analyze {paper_id} - Get AI analysis of this paper")
214
+ console.print(f" arionxiv chat - Start an interactive chat")
215
+ console.print(f" arionxiv library - View your research library")
216
+
217
+ console.print(f"\nFile location: {file_path}", style="dim")
@@ -0,0 +1,295 @@
1
+ """Library command for ArionXiv CLI - Uses hosted API"""
2
+
3
+ import asyncio
4
+ import logging
5
+ from datetime import datetime
6
+ from typing import List, Dict, Any
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+ from rich.panel import Panel
12
+ from rich.progress import Progress, SpinnerColumn, TextColumn
13
+
14
+ from ...arxiv_operations.client import arxiv_client
15
+ from ...arxiv_operations.utils import ArxivUtils
16
+ from ..utils.api_client import api_client, APIClientError
17
+ from ..ui.theme import create_themed_console, get_theme_colors
18
+ from ...services.unified_user_service import unified_user_service
19
+
20
+ logger = logging.getLogger(__name__)
21
+ console = create_themed_console()
22
+
23
+
24
+ class LibraryGroup(click.Group):
25
+ """Custom Click group for library with proper error handling"""
26
+
27
+ def invoke(self, ctx):
28
+ try:
29
+ return super().invoke(ctx)
30
+ except click.UsageError as e:
31
+ self._show_error(e, ctx)
32
+ raise SystemExit(1)
33
+
34
+ def _show_error(self, error, ctx):
35
+ colors = get_theme_colors()
36
+ error_console = Console()
37
+
38
+ error_console.print()
39
+ error_console.print(f"[bold {colors['error']}]Invalid Library Command[/bold {colors['error']}]")
40
+ error_console.print(f"[bold {colors['error']}]{error}[/bold {colors['error']}]")
41
+ error_console.print()
42
+
43
+ error_console.print(f"[bold white]Available 'library' subcommands:[/bold white]")
44
+ for cmd_name in sorted(self.list_commands(ctx)):
45
+ cmd = self.get_command(ctx, cmd_name)
46
+ if cmd and not cmd.hidden:
47
+ help_text = cmd.get_short_help_str(limit=50)
48
+ error_console.print(f" [bold {colors['primary']}]{cmd_name}[/bold {colors['primary']}] {help_text}")
49
+
50
+ error_console.print()
51
+ error_console.print(f"Run [bold {colors['primary']}]arionxiv library --help[/bold {colors['primary']}] for more information.")
52
+
53
+
54
+ @click.group(cls=LibraryGroup, invoke_without_command=True)
55
+ @click.pass_context
56
+ def library_command(ctx):
57
+ """
58
+ Manage your research library
59
+
60
+ Examples:
61
+ \b
62
+ arionxiv library # View library dashboard
63
+ arionxiv library list # List all papers
64
+ arionxiv library stats # View statistics
65
+ """
66
+ if ctx.invoked_subcommand is None:
67
+ asyncio.run(_show_library_dashboard())
68
+
69
+
70
+ def _check_auth() -> bool:
71
+ """Check if user is authenticated, show error if not"""
72
+ colors = get_theme_colors()
73
+ if not unified_user_service.is_authenticated() and not api_client.is_authenticated():
74
+ console.print("You must be logged in to use the library. Run: arionxiv login", style=f"bold {colors['error']}")
75
+ return False
76
+ return True
77
+
78
+
79
+ async def _show_library_dashboard():
80
+ """Show library analytics dashboard"""
81
+ colors = get_theme_colors()
82
+
83
+ if not _check_auth():
84
+ return
85
+
86
+ console.print(f"\n[bold {colors['primary']}]Library Dashboard[/bold {colors['primary']}]")
87
+ console.rule(style=f"bold {colors['primary']}")
88
+
89
+ try:
90
+ # Fetch library and chat sessions data with progress spinner
91
+ with Progress(
92
+ SpinnerColumn(style=f"bold {colors['primary']}"),
93
+ TextColumn(f"[bold {colors['primary']}]{{task.description}}[/bold {colors['primary']}]"),
94
+ console=console,
95
+ transient=True
96
+ ) as progress:
97
+ task = progress.add_task("Fetching your library data...", total=None)
98
+ library_result = await api_client.get_library(limit=100)
99
+ progress.update(task, description="Fetching chat sessions...")
100
+ sessions_result = await api_client.get_chat_sessions()
101
+ progress.update(task, description="Done!")
102
+
103
+ papers = library_result.get("papers", []) if library_result.get("success") else []
104
+ sessions = sessions_result.get("sessions", []) if sessions_result.get("success") else []
105
+
106
+ total_papers = len(papers)
107
+ active_sessions = len(sessions)
108
+
109
+ # Count papers by read status
110
+ unread = sum(1 for p in papers if p.get("read_status") == "unread")
111
+ reading = sum(1 for p in papers if p.get("read_status") == "reading")
112
+ completed = sum(1 for p in papers if p.get("read_status") == "completed")
113
+
114
+ # Get recent papers (added in last 7 days)
115
+ from datetime import timedelta
116
+ week_ago = datetime.utcnow() - timedelta(days=7)
117
+ recent_papers = 0
118
+ for p in papers:
119
+ added_at = p.get("added_at")
120
+ if added_at:
121
+ if isinstance(added_at, str):
122
+ try:
123
+ added_at = datetime.fromisoformat(added_at.replace('Z', '+00:00').replace('+00:00', ''))
124
+ except:
125
+ continue
126
+ if isinstance(added_at, datetime) and added_at > week_ago:
127
+ recent_papers += 1
128
+
129
+ # Stats panel
130
+ stats_content = (
131
+ f"[bold]Total Papers Saved:[/bold] [bold {colors['primary']}]{total_papers}[/bold {colors['primary']}]\n"
132
+ f"[bold]Added This Week:[/bold] [bold {colors['primary']}]{recent_papers}[/bold {colors['primary']}]\n"
133
+ f"[bold]Active Chat Sessions:[/bold] [bold {colors['primary']}]{active_sessions}[/bold {colors['primary']}]\n\n"
134
+ f"[bold]Reading Progress:[/bold]\n"
135
+ f" [bold {colors['primary']}]Unread:[/bold {colors['primary']}] {unread} "
136
+ f"[bold {colors['primary']}]Reading:[/bold {colors['primary']}] {reading} "
137
+ f"[bold {colors['primary']}]Completed:[/bold {colors['primary']}] {completed}"
138
+ )
139
+
140
+ console.print(Panel(
141
+ stats_content,
142
+ title=f"[bold {colors['primary']}]Library Statistics[/bold {colors['primary']}]",
143
+ border_style=f"bold {colors['primary']}"
144
+ ))
145
+
146
+ # Quick actions
147
+ console.print(f"\n[bold {colors['primary']}]Quick Actions:[/bold {colors['primary']}]")
148
+
149
+ actions_table = Table(show_header=False, box=None, padding=(0, 2))
150
+ actions_table.add_column("Command", style=f"bold {colors['primary']}")
151
+ actions_table.add_column("Description", style="white")
152
+
153
+ actions_table.add_row("arionxiv library list", "View all saved papers")
154
+ actions_table.add_row("arionxiv library stats", "View detailed statistics")
155
+ actions_table.add_row("arionxiv chat", "Start chatting with a paper")
156
+
157
+ console.print(actions_table)
158
+
159
+ except APIClientError as e:
160
+ console.print(f"Error loading library: {e.message}", style=f"bold {colors['error']}")
161
+ except Exception as e:
162
+ logger.error(f"Library dashboard error: {e}", exc_info=True)
163
+ console.print(f"Error: {str(e)}", style=f"bold {colors['error']}")
164
+
165
+ @library_command.command()
166
+ @click.option('--tags', help='Filter by tags')
167
+ @click.option('--category', help='Filter by category')
168
+ @click.option('--status', type=click.Choice(['read', 'unread', 'reading']), help='Filter by read status')
169
+ def list(tags: str, category: str, status: str):
170
+ """List papers in your library"""
171
+
172
+ async def _list_papers():
173
+ colors = get_theme_colors()
174
+
175
+ if not _check_auth():
176
+ return
177
+
178
+ try:
179
+ result = await api_client.get_library(limit=100)
180
+
181
+ if not result.get("success"):
182
+ console.print(result.get("message", "Failed to fetch library"), style=f"bold {colors['error']}")
183
+ return
184
+
185
+ library = result.get("papers", [])
186
+
187
+ if not library:
188
+ console.print("Your library is empty. Use 'arionxiv library add <paper_id>' to add papers.", style=f"bold {colors['warning']}")
189
+ return
190
+
191
+ # Apply local filters if specified
192
+ if category:
193
+ library = [p for p in library if category in p.get("categories", [])]
194
+ if status:
195
+ library = [p for p in library if p.get("read_status") == status]
196
+ if tags:
197
+ tag_list = [t.strip() for t in tags.split(',')]
198
+ library = [p for p in library if any(t in p.get("tags", []) for t in tag_list)]
199
+
200
+ if not library:
201
+ console.print("No papers match your filters.", style=f"bold {colors['warning']}")
202
+ return
203
+
204
+ user = unified_user_service.get_current_user()
205
+ user_name = user.get("user_name", "User") if user else "User"
206
+
207
+ table = Table(title=f"{user_name}'s Library", header_style=f"bold {colors['primary']}")
208
+ table.add_column("#", style=f"bold {colors['primary']}", width=4)
209
+ table.add_column("Paper ID", style=f"bold {colors['primary']}", width=12)
210
+ table.add_column("Title", style=f"bold {colors['primary']}", width=50)
211
+ table.add_column("Status", style=f"bold {colors['primary']}", width=10)
212
+ table.add_column("Added", style=f"bold {colors['primary']}", width=12)
213
+
214
+ for i, item in enumerate(library[:20], 1):
215
+ title = item.get('title', 'Unknown')
216
+
217
+ added = item.get('added_at', '')
218
+ if isinstance(added, datetime):
219
+ added_str = added.strftime('%Y-%m-%d')
220
+ else:
221
+ added_str = str(added)[:10] if added else 'Unknown'
222
+
223
+ table.add_row(
224
+ str(i),
225
+ item.get('arxiv_id', 'Unknown')[:12],
226
+ title,
227
+ item.get('read_status', 'unread'),
228
+ added_str
229
+ )
230
+
231
+ console.print(table)
232
+ console.print(f"\nTotal papers: {len(library)}", style=f"bold {colors['primary']}")
233
+
234
+ except APIClientError as e:
235
+ console.print(f"Error: {e.message}", style=f"bold {colors['error']}")
236
+
237
+ asyncio.run(_list_papers())
238
+
239
+
240
+ @library_command.command()
241
+ def stats():
242
+ """Show library statistics"""
243
+
244
+ async def _show_stats():
245
+ colors = get_theme_colors()
246
+
247
+ if not _check_auth():
248
+ return
249
+
250
+ try:
251
+ result = await api_client.get_library(limit=100)
252
+
253
+ if not result.get("success"):
254
+ console.print(result.get("message", "Failed to fetch library"), style=f"bold {colors['error']}")
255
+ return
256
+
257
+ library = result.get("papers", [])
258
+
259
+ if not library:
260
+ console.print("Your library is empty.", style=f"bold {colors['warning']}")
261
+ return
262
+
263
+ total = len(library)
264
+
265
+ category_counts: Dict[str, int] = {}
266
+ for paper in library:
267
+ for cat in paper.get("categories", []):
268
+ category_counts[cat] = category_counts.get(cat, 0) + 1
269
+
270
+ top_categories = sorted(category_counts.items(), key=lambda x: x[1], reverse=True)[:5]
271
+
272
+ status_counts: Dict[str, int] = {}
273
+ for paper in library:
274
+ s = paper.get("read_status", "unread")
275
+ status_counts[s] = status_counts.get(s, 0) + 1
276
+
277
+ user = unified_user_service.get_current_user()
278
+ user_name = user.get("user_name", "User") if user else "User"
279
+
280
+ stats_text = f"[bold]Total Papers:[/bold] {total}\n\n"
281
+ stats_text += "[bold]Top Categories:[/bold]\n"
282
+ stats_text += "\n".join([f" - {cat}: {count}" for cat, count in top_categories])
283
+ stats_text += "\n\n[bold]Reading Status:[/bold]\n"
284
+ stats_text += "\n".join([f" - {s}: {count}" for s, count in status_counts.items()])
285
+
286
+ console.print(Panel(
287
+ stats_text,
288
+ title=f"{user_name}'s Library Statistics",
289
+ border_style=f"bold {colors['primary']}"
290
+ ))
291
+
292
+ except APIClientError as e:
293
+ console.print(f"Error: {e.message}", style=f"bold {colors['error']}")
294
+
295
+ asyncio.run(_show_stats())