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,1407 @@
1
+ """
2
+ Unified Settings System for ArionXiv CLI - User-friendly configuration interface
3
+ Provides seamless access to all user settings with short, intuitive commands
4
+ """
5
+
6
+ import sys
7
+ import logging
8
+ import asyncio
9
+ from pathlib import Path
10
+ from datetime import datetime, time
11
+ from typing import Dict, Any, List, Optional
12
+
13
+ # Add backend to Python path
14
+ backend_path = Path(__file__).parent.parent.parent
15
+ sys.path.insert(0, str(backend_path))
16
+
17
+ import click
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+ from rich.prompt import Prompt, Confirm, IntPrompt
21
+ from rich.table import Table
22
+ from rich.text import Text
23
+ from rich.columns import Columns
24
+
25
+ from ..utils.db_config_manager import db_config_manager
26
+ from ..ui.theme import create_themed_console, print_header, style_text, print_success, print_warning, print_error, get_theme_colors, set_theme_colors
27
+ from ..utils.command_suggestions import show_command_suggestions
28
+ from ..utils.api_config import api_config_manager, show_api_status
29
+ from ..utils.animations import left_to_right_reveal
30
+ # Note: theme_selector import will be conditional since it may not exist
31
+ try:
32
+ from ..ui.theme_system import run_theme_selection
33
+ except ImportError:
34
+ run_theme_selection = None
35
+
36
+ from ...services.unified_user_service import unified_user_service
37
+ from ...services.unified_config_service import unified_config_service
38
+ from ..utils.api_client import api_client, APIClientError
39
+
40
+ # Try to import schedule_user_daily_dose, fallback if not available
41
+ try:
42
+ from ...services.unified_scheduler_service import schedule_user_daily_dose
43
+ except ImportError:
44
+ schedule_user_daily_dose = None
45
+
46
+ console = create_themed_console()
47
+
48
+ # ================================
49
+ # CUSTOM ERROR HANDLING FOR SETTINGS
50
+ # ================================
51
+
52
+ class SettingsGroup(click.Group):
53
+ """Custom Click group for settings with proper error handling for invalid subcommands"""
54
+
55
+ def invoke(self, ctx):
56
+ """Override invoke to catch errors from subcommands"""
57
+ try:
58
+ return super().invoke(ctx)
59
+ except click.UsageError as e:
60
+ self._show_error(e, ctx)
61
+ raise SystemExit(1)
62
+
63
+ def _show_error(self, error, ctx):
64
+ """Display themed error message for invalid subcommands"""
65
+ colors = get_theme_colors()
66
+ error_console = Console()
67
+ error_msg = str(error)
68
+
69
+ error_console.print()
70
+ error_console.print(f"[bold {colors['error']}]⚠ Invalid Settings Command[/bold {colors['error']}]")
71
+ error_console.print(f"[{colors['error']}]{error_msg}[/{colors['error']}]")
72
+ error_console.print()
73
+
74
+ # Show available subcommands
75
+ error_console.print(f"[bold white]Available 'settings' subcommands:[/bold white]")
76
+ for cmd_name in sorted(self.list_commands(ctx)):
77
+ cmd = self.get_command(ctx, cmd_name)
78
+ if cmd and not cmd.hidden:
79
+ help_text = cmd.get_short_help_str(limit=50)
80
+ error_console.print(f" [{colors['primary']}]{cmd_name}[/{colors['primary']}] {help_text}")
81
+
82
+ error_console.print()
83
+ error_console.print(f"Run [{colors['primary']}]arionxiv settings --help[/{colors['primary']}] for more information.")
84
+ error_console.print()
85
+
86
+ # ================================
87
+ # MAIN SETTINGS COMMAND
88
+ # ================================
89
+
90
+ @click.group(cls=SettingsGroup, invoke_without_command=True)
91
+ @click.pass_context
92
+ def settings(ctx):
93
+ """
94
+ ArionXiv Settings - Configure your research experience
95
+
96
+ Quick access commands:
97
+ \b
98
+ arionxiv settings show # View all settings
99
+ arionxiv settings theme # Change theme color
100
+ arionxiv settings api # Configure API keys
101
+ arionxiv settings preferences # Configure paper preferences
102
+ arionxiv settings categories # Set research categories
103
+ arionxiv settings keywords # Manage keywords
104
+ arionxiv settings authors # Set preferred authors
105
+ arionxiv settings daily # Daily dose configuration
106
+ arionxiv settings time # Set daily analysis time
107
+ arionxiv settings papers # Manage saved papers
108
+ """
109
+ if ctx.invoked_subcommand is None:
110
+ # Show available subcommands when called without arguments
111
+ colors = get_theme_colors()
112
+ console.print(f"\n[bold {colors['primary']}]ArionXiv Settings[/bold {colors['primary']}]")
113
+ console.rule(style=f"bold {colors['primary']}")
114
+ console.print(f"\n[bold {colors['primary']}]Available settings commands:[/bold {colors['primary']}]\n")
115
+
116
+ table = Table(show_header=False, box=None, padding=(0, 2))
117
+ table.add_column("Command", style=f"bold {colors['primary']}")
118
+ table.add_column("Description", style="white")
119
+
120
+ for cmd_name in sorted(ctx.command.list_commands(ctx)):
121
+ cmd = ctx.command.get_command(ctx, cmd_name)
122
+ if cmd and not cmd.hidden:
123
+ help_text = cmd.get_short_help_str(limit=50)
124
+ table.add_row(f"arionxiv settings {cmd_name}", help_text)
125
+
126
+ console.print(table)
127
+ console.print(f"\n[bold {colors['primary']}]Example:[/bold {colors['primary']}] arionxiv settings theme\n")
128
+
129
+ # ================================
130
+ # SHOW ALL SETTINGS
131
+ # ================================
132
+
133
+ @settings.command('show')
134
+ @click.option('--detailed', '-d', is_flag=True, help='Show detailed configuration')
135
+ def show_settings(detailed: bool):
136
+ """Show current ArionXiv settings overview"""
137
+
138
+ async def _show():
139
+ await _ensure_authenticated()
140
+ print_header(console, "ArionXiv Settings Overview")
141
+
142
+ # Load current configuration
143
+ await db_config_manager.load_config()
144
+ user = unified_user_service.get_current_user()
145
+ user_id = user['id']
146
+
147
+ # Get user preferences
148
+ prefs_result = await unified_user_service.get_user_preferences(user_id)
149
+ prefs = prefs_result.get('preferences', {}) if prefs_result['success'] else {}
150
+
151
+ # Theme & Display
152
+ theme_color = db_config_manager.get_theme_color()
153
+ colors = get_theme_colors()
154
+
155
+ if detailed:
156
+ await _show_detailed_settings(prefs, theme_color)
157
+ else:
158
+ await _show_compact_settings(prefs, theme_color)
159
+
160
+ # Quick actions
161
+ console.print(f"\n{style_text('Quick Actions:', 'primary')}")
162
+ console.print(f"- {style_text('arionxiv settings theme', 'primary')} - Change color theme")
163
+ console.print(f"- {style_text('arionxiv settings prefs', 'primary')} - Configure preferences")
164
+ console.print(f"- {style_text('arionxiv settings daily', 'primary')} - Daily dose settings")
165
+
166
+ asyncio.run(_show())
167
+
168
+ # ================================
169
+ # THEME CONFIGURATION
170
+ # ================================
171
+
172
+ @settings.command('theme')
173
+ @click.option('--color', type=click.Choice(['red', 'blue', 'green', 'purple', 'cyan', 'amber']), help='Set theme directly')
174
+ def theme_settings(color: Optional[str]):
175
+ """Change ArionXiv color theme"""
176
+
177
+ async def _theme():
178
+ await _ensure_authenticated()
179
+ colors = get_theme_colors()
180
+
181
+ console.print()
182
+ left_to_right_reveal(console, "Theme Settings", style=f"bold {colors['primary']}", duration=1.0)
183
+ console.print()
184
+
185
+ current_theme = db_config_manager.get_theme_color()
186
+ left_to_right_reveal(console, f"Current: {current_theme.title()}", style=f"bold {colors['primary']}", duration=1.0)
187
+ console.print()
188
+
189
+ if color:
190
+ # Direct color change
191
+ if await db_config_manager.set_theme_color(color):
192
+ # Reload the theme immediately to update global colors
193
+ await db_config_manager.load_config()
194
+ # Force update the global THEME_COLORS
195
+ set_theme_colors(color)
196
+ # Get fresh colors after theme reload
197
+ new_colors = get_theme_colors()
198
+ left_to_right_reveal(console, f"Theme changed to {color.title()}", style=f"bold {new_colors['primary']}", duration=1.0)
199
+ console.print()
200
+ show_command_suggestions(console, context='settings')
201
+ else:
202
+ left_to_right_reveal(console, "Failed to save", style=f"bold {colors['error']}", duration=1.0)
203
+ else:
204
+ # Interactive theme selection
205
+ if run_theme_selection:
206
+ selected = run_theme_selection(console)
207
+ if await db_config_manager.set_theme_color(selected):
208
+ # Reload the theme immediately to update global colors
209
+ await db_config_manager.load_config()
210
+ # Force update the global THEME_COLORS
211
+ set_theme_colors(selected)
212
+ show_command_suggestions(console, context='settings')
213
+ else:
214
+ # Get fresh colors for error message
215
+ current_colors = get_theme_colors()
216
+ left_to_right_reveal(console, "Failed to save", style=f"bold {current_colors['error']}", duration=1.0)
217
+
218
+ asyncio.run(_theme())
219
+
220
+ # ================================
221
+ # PREFERENCES OVERVIEW
222
+ # ================================
223
+
224
+ @settings.command('preferences')
225
+ @click.option('--reset', is_flag=True, help='Reset all preferences to defaults')
226
+ def preferences_overview(reset: bool):
227
+ """Configure paper preferences overview"""
228
+
229
+ async def _prefs():
230
+ await _ensure_authenticated()
231
+ print_header(console, "Paper Preferences")
232
+
233
+ user = unified_user_service.get_current_user()
234
+ user_id = user['id']
235
+
236
+ if reset:
237
+ if Confirm.ask(f"{style_text('Reset all preferences to defaults?', 'warning')}", default=False):
238
+ # Reset preferences logic here
239
+ print_success(console, "Preferences reset to defaults!")
240
+ return
241
+
242
+ # Show current preferences
243
+ prefs_result = await unified_user_service.get_user_preferences(user_id)
244
+ if not prefs_result['success']:
245
+ print_error(console, "Failed to load preferences")
246
+ return
247
+
248
+ prefs = prefs_result['preferences']
249
+ await _display_preferences_overview(prefs)
250
+
251
+ # Quick actions menu
252
+ console.print(f"\n{style_text('Quick Actions:', 'primary')}")
253
+ actions = [
254
+ ("categories", "Configure research categories"),
255
+ ("keywords", "Manage keywords & exclusions"),
256
+ ("daily", "Daily dose settings")
257
+ ]
258
+
259
+ for cmd, desc in actions:
260
+ console.print(f"- {style_text(f'arionxiv settings {cmd}', 'primary')} - {desc}")
261
+
262
+ asyncio.run(_prefs())
263
+
264
+ # ================================
265
+ # CATEGORIES CONFIGURATION
266
+ # ================================
267
+
268
+ @settings.command('categories')
269
+ @click.option('--add', multiple=True, help='Add categories (e.g., --add cs.AI --add cs.LG)')
270
+ @click.option('--remove', multiple=True, help='Remove categories')
271
+ @click.option('--clear', is_flag=True, help='Clear all categories')
272
+ def categories_config(add: tuple, remove: tuple, clear: bool):
273
+ """Configure research categories (ArXiv categories)"""
274
+
275
+ async def _categories():
276
+ await _ensure_authenticated()
277
+ print_header(console, "Research Categories")
278
+
279
+ user = unified_user_service.get_current_user()
280
+ user_id = user['id']
281
+
282
+ # Get current preferences
283
+ prefs_result = await unified_user_service.get_user_preferences(user_id)
284
+ current_categories = prefs_result['preferences']['categories'] if prefs_result['success'] else []
285
+
286
+ # Handle command-line options
287
+ new_categories = set(current_categories)
288
+
289
+ if clear:
290
+ if Confirm.ask(f"{style_text('Clear all categories?', 'warning')}", default=False):
291
+ new_categories.clear()
292
+
293
+ if add:
294
+ new_categories.update(add)
295
+
296
+ if remove:
297
+ new_categories.difference_update(remove)
298
+
299
+ # If any changes made via CLI, save and exit
300
+ if add or remove or clear:
301
+ await _save_categories(user_id, list(new_categories))
302
+ return
303
+
304
+ # Interactive mode
305
+ await _interactive_categories_config(user_id, current_categories)
306
+
307
+ asyncio.run(_categories())
308
+
309
+ # ================================
310
+ # KEYWORDS CONFIGURATION
311
+ # ================================
312
+
313
+ @settings.command('keywords')
314
+ @click.option('--add', multiple=True, help='Add keywords')
315
+ @click.option('--remove', multiple=True, help='Remove keywords')
316
+ @click.option('--exclude', multiple=True, help='Add exclude keywords')
317
+ @click.option('--clear', is_flag=True, help='Clear all keywords')
318
+ def keywords_config(add: tuple, remove: tuple, exclude: tuple, clear: bool):
319
+ """Configure keywords and exclusions"""
320
+
321
+ async def _keywords():
322
+ await _ensure_authenticated()
323
+ print_header(console, "Keywords Configuration")
324
+
325
+ user = unified_user_service.get_current_user()
326
+ user_id = user['id']
327
+
328
+ # Get current preferences
329
+ prefs_result = await unified_user_service.get_user_preferences(user_id)
330
+ prefs = prefs_result['preferences'] if prefs_result['success'] else {}
331
+
332
+ current_keywords = set(prefs.get('keywords', []))
333
+ current_excludes = set(prefs.get('exclude_keywords', []))
334
+
335
+ # Handle CLI options
336
+ if clear:
337
+ if Confirm.ask(f"{style_text('Clear all keywords?', 'warning')}", default=False):
338
+ current_keywords.clear()
339
+ current_excludes.clear()
340
+
341
+ if add:
342
+ current_keywords.update(add)
343
+
344
+ if remove:
345
+ current_keywords.difference_update(remove)
346
+
347
+ if exclude:
348
+ current_excludes.update(exclude)
349
+
350
+ # If changes made via CLI, save and exit
351
+ if add or remove or exclude or clear:
352
+ await _save_keywords(user_id, list(current_keywords), list(current_excludes))
353
+ return
354
+
355
+ # Interactive mode
356
+ await _interactive_keywords_config(user_id, prefs)
357
+
358
+ asyncio.run(_keywords())
359
+
360
+ # ================================
361
+ # AUTHORS CONFIGURATION
362
+ # ================================
363
+
364
+ @settings.command('authors')
365
+ @click.option('--add', multiple=True, help='Add preferred authors')
366
+ @click.option('--remove', multiple=True, help='Remove authors')
367
+ @click.option('--clear', is_flag=True, help='Clear all authors')
368
+ def authors_config(add: tuple, remove: tuple, clear: bool):
369
+ """Configure preferred authors"""
370
+
371
+ async def _authors():
372
+ await _ensure_authenticated()
373
+ print_header(console, "Preferred Authors")
374
+
375
+ user = unified_user_service.get_current_user()
376
+ user_id = user['id']
377
+
378
+ # Get current preferences
379
+ prefs_result = await unified_user_service.get_user_preferences(user_id)
380
+ current_authors = set(prefs_result['preferences']['authors']) if prefs_result['success'] else set()
381
+
382
+ # Handle CLI options
383
+ if clear:
384
+ if Confirm.ask(f"{style_text('Clear all authors?', 'warning')}", default=False):
385
+ current_authors.clear()
386
+
387
+ if add:
388
+ current_authors.update(add)
389
+
390
+ if remove:
391
+ current_authors.difference_update(remove)
392
+
393
+ # If changes made via CLI, save and exit
394
+ if add or remove or clear:
395
+ await _save_authors(user_id, list(current_authors))
396
+ return
397
+
398
+ # Interactive mode
399
+ await _interactive_authors_config(user_id, list(current_authors))
400
+
401
+ asyncio.run(_authors())
402
+
403
+ # ================================
404
+ # DAILY DOSE CONFIGURATION
405
+ # ================================
406
+
407
+ @settings.command('daily')
408
+ @click.option('--enable/--disable', default=None, help='Enable or disable daily dose')
409
+ @click.option('--time', 'time_str', help='Set delivery time in UTC (HH:MM format)')
410
+ @click.option('--papers', type=int, help='Max papers per day (1-10)')
411
+ @click.option('--keywords', help='Set keywords (comma-separated)')
412
+ @click.option('--show', is_flag=True, help='Show current daily dose settings')
413
+ def daily_config(enable: Optional[bool], time_str: Optional[str], papers: Optional[int], keywords: Optional[str], show: bool):
414
+ """Configure daily dose settings via Vercel API"""
415
+
416
+ async def _daily():
417
+ await _ensure_authenticated()
418
+
419
+ # Use api_client for Vercel API consistency
420
+ from ..utils.api_client import api_client
421
+
422
+ print_header(console, "Daily Dose Configuration")
423
+
424
+ colors = get_theme_colors()
425
+
426
+ # Load current settings from Vercel API
427
+ settings_result = await api_client.get_settings()
428
+ all_settings = settings_result.get("settings", {}) if settings_result.get("success") else {}
429
+ current_settings = all_settings.get("daily_dose", {})
430
+
431
+ # Show current settings if requested or no changes
432
+ if show or (enable is None and time_str is None and papers is None and keywords is None):
433
+ console.print(f"\n[bold {colors['primary']}]Current Daily Dose Settings:[/bold {colors['primary']}]\n")
434
+
435
+ enabled = current_settings.get("enabled", False)
436
+ scheduled_time = current_settings.get("scheduled_time", "Not set")
437
+ max_papers = current_settings.get("max_papers", 5)
438
+ kw_list = current_settings.get("keywords", [])
439
+
440
+ table = Table(show_header=False, box=None, padding=(0, 2))
441
+ table.add_column("Setting", style="bold white")
442
+ table.add_column("Value", style="white")
443
+
444
+ status_color = colors['primary'] if enabled else colors['warning']
445
+ table.add_row("Status", f"[bold {status_color}]{'Enabled' if enabled else 'Disabled'}[/bold {status_color}]")
446
+ table.add_row("Scheduled Time (UTC)", scheduled_time if scheduled_time else "[white]Not configured[/white]")
447
+ table.add_row("Max Papers", str(max_papers))
448
+ table.add_row("Keywords", ", ".join(kw_list) if kw_list else "[white]None set[/white]")
449
+
450
+ console.print(table)
451
+
452
+ if not (enable is None and time_str is None and papers is None and keywords is None):
453
+ return
454
+
455
+ # Interactive mode
456
+ console.print(f"\n[bold {colors['primary']}]Configure Daily Dose:[/bold {colors['primary']}]")
457
+ await _interactive_daily_dose_config_api(current_settings, all_settings, api_client)
458
+ return
459
+
460
+ # Handle CLI options
461
+ changes_made = False
462
+ update_kwargs = {}
463
+
464
+ if enable is not None:
465
+ update_kwargs["enabled"] = enable
466
+ changes_made = True
467
+
468
+ if time_str:
469
+ # Validate time format
470
+ try:
471
+ datetime.strptime(time_str, "%H:%M")
472
+ update_kwargs["scheduled_time"] = time_str
473
+ changes_made = True
474
+ except ValueError:
475
+ print_error(console, "Invalid time format. Use HH:MM (e.g., 09:00)")
476
+ return
477
+
478
+ if papers is not None:
479
+ if 1 <= papers <= 10:
480
+ update_kwargs["max_papers"] = papers
481
+ changes_made = True
482
+ else:
483
+ print_error(console, "Papers must be between 1 and 10 (v1 limit)")
484
+ return
485
+
486
+ if keywords:
487
+ kw_list = [k.strip() for k in keywords.split(",") if k.strip()]
488
+ update_kwargs["keywords"] = kw_list
489
+ changes_made = True
490
+
491
+ if changes_made:
492
+ # Merge with existing settings and update via Vercel API
493
+ new_daily_dose = {**current_settings, **update_kwargs}
494
+ all_settings["daily_dose"] = new_daily_dose
495
+ result = await api_client.update_settings(all_settings)
496
+
497
+ if result.get("success"):
498
+ print_success(console, "Daily dose settings updated successfully")
499
+ else:
500
+ print_error(console, f"Failed to update settings: {result.get('message', 'Unknown error')}")
501
+
502
+ asyncio.run(_daily())
503
+
504
+ # ================================
505
+ # API KEYS CONFIGURATION
506
+ # ================================
507
+
508
+ @settings.command('api')
509
+ @click.option('--show', '-s', is_flag=True, help='Show current API configuration status')
510
+ @click.option('--gemini', 'set_gemini', help='Set Gemini API key directly')
511
+ @click.option('--huggingface', '--hf', 'set_hf', help='Set HuggingFace API key directly')
512
+ @click.option('--groq', 'set_groq', help='Set Groq API key directly')
513
+ @click.option('--remove', type=click.Choice(['gemini', 'huggingface', 'groq']), help='Remove an API key')
514
+ def api_config(show: bool, set_gemini: Optional[str], set_hf: Optional[str], set_groq: Optional[str], remove: Optional[str]):
515
+ """Configure API keys for AI services (Gemini, HuggingFace, Groq)
516
+
517
+ All API keys are FREE to obtain!
518
+ Keys are stored securely in ~/.arionxiv/api_keys.json
519
+ They persist across sessions - configure once, use forever!
520
+ """
521
+
522
+ colors = get_theme_colors()
523
+ print_header(console, "API Keys Configuration")
524
+
525
+ # Show info about persistence
526
+ console.print(f"\n[white]Your keys are stored locally and persist across sessions.[/white]")
527
+ console.print(f"[white]Configure once - they'll work even after logout/login![/white]\n")
528
+
529
+ # Handle direct key setting
530
+ if set_gemini:
531
+ if api_config_manager.set_api_key("gemini", set_gemini):
532
+ print_success(console, "Gemini API key saved successfully!")
533
+ else:
534
+ print_error(console, "Failed to save Gemini API key")
535
+ return
536
+
537
+ if set_hf:
538
+ if api_config_manager.set_api_key("huggingface", set_hf):
539
+ print_success(console, "HuggingFace API key saved successfully!")
540
+ else:
541
+ print_error(console, "Failed to save HuggingFace API key")
542
+ return
543
+
544
+ if set_groq:
545
+ if api_config_manager.set_api_key("groq", set_groq):
546
+ print_success(console, "Groq API key saved successfully!")
547
+ else:
548
+ print_error(console, "Failed to save Groq API key")
549
+ return
550
+
551
+ if remove:
552
+ if api_config_manager.remove_api_key(remove):
553
+ print_success(console, f"{remove.title()} API key removed")
554
+ else:
555
+ print_error(console, f"Failed to remove {remove} API key")
556
+ return
557
+
558
+ if show:
559
+ show_api_status(console)
560
+ return
561
+
562
+ # Interactive mode
563
+ show_api_status(console)
564
+ _interactive_api_config(colors)
565
+
566
+
567
+ def _interactive_api_config(colors: Dict[str, str]):
568
+ """Interactive API key configuration menu"""
569
+
570
+ while True:
571
+ # Get current status for display
572
+ status = api_config_manager.get_status()
573
+
574
+ gemini_status = "configured" if status["gemini"]["configured"] else "not set"
575
+ hf_status = "configured" if status["huggingface"]["configured"] else "not set"
576
+ groq_status = "configured" if status["groq"]["configured"] else "not set"
577
+ openrouter_status = "configured" if status["openrouter"]["configured"] else "not set"
578
+ openrouter_model_status = "configured" if status["openrouter_model"]["configured"] else "not set"
579
+
580
+ left_to_right_reveal(console, "\nOptions:", style=f"bold {colors['primary']}", duration=0.5)
581
+ left_to_right_reveal(console, f"1. Configure Gemini API key (current: {gemini_status})", style=colors['primary'], duration=0.3)
582
+ left_to_right_reveal(console, f"2. Configure HuggingFace API key (current: {hf_status})", style=colors['primary'], duration=0.3)
583
+ left_to_right_reveal(console, f"3. Configure Groq API key (current: {groq_status})", style=colors['primary'], duration=0.3)
584
+ left_to_right_reveal(console, f"4. Configure OpenRouter API key (current: {openrouter_status})", style=colors['primary'], duration=0.3)
585
+ left_to_right_reveal(console, f"5. Configure OpenRouter Model (current: {openrouter_model_status})", style=colors['primary'], duration=0.3)
586
+ left_to_right_reveal(console, f"6. Show API status", style=colors['primary'], duration=0.3)
587
+ left_to_right_reveal(console, f"7. Done - Return to main menu", style=colors['primary'], duration=0.3)
588
+
589
+ choice = Prompt.ask(
590
+ f"[bold {colors['primary']}]Select option[/bold {colors['primary']}]",
591
+ choices=["1", "2", "3", "4", "5", "6", "7"],
592
+ default="7"
593
+ )
594
+
595
+ if choice == "1":
596
+ _configure_api_key_interactive("gemini", colors)
597
+ elif choice == "2":
598
+ _configure_api_key_interactive("huggingface", colors)
599
+ elif choice == "3":
600
+ _configure_api_key_interactive("groq", colors)
601
+ elif choice == "4":
602
+ _configure_api_key_interactive("openrouter", colors)
603
+ elif choice == "5":
604
+ _configure_api_key_interactive("openrouter_model", colors)
605
+ elif choice == "6":
606
+ show_api_status(console)
607
+ elif choice == "7":
608
+ show_command_suggestions(console, context='settings')
609
+ break
610
+
611
+
612
+ # Step-by-step instructions for getting API keys
613
+ API_KEY_INSTRUCTIONS = {
614
+ "gemini": {
615
+ "title": "How to Get Your Google Gemini API Key (FREE)",
616
+ "steps": [
617
+ "1. Go to: https://aistudio.google.com/app/apikey",
618
+ "2. Sign in with your Google account",
619
+ "3. Click 'Create API Key'",
620
+ "4. Select a Google Cloud project (or create a new one)",
621
+ "5. Copy your API key",
622
+ "",
623
+ "Note: Gemini has a generous FREE tier - no credit card needed!"
624
+ ]
625
+ },
626
+ "huggingface": {
627
+ "title": "How to Get Your HuggingFace API Token (FREE)",
628
+ "steps": [
629
+ "1. Go to: https://huggingface.co/settings/tokens",
630
+ "2. Create a free account or sign in",
631
+ "3. Click 'New token'",
632
+ "4. Give it a name (e.g., 'ArionXiv')",
633
+ "5. Select 'Read' access (that's all we need)",
634
+ "6. Click 'Generate token' and copy it",
635
+ "",
636
+ "Note: HuggingFace is FREE for most models!"
637
+ ]
638
+ },
639
+ "groq": {
640
+ "title": "How to Get Your Groq API Key (FREE & FAST)",
641
+ "steps": [
642
+ "1. Go to: https://console.groq.com/keys",
643
+ "2. Create a free account or sign in",
644
+ "3. Click 'Create API Key'",
645
+ "4. Give it a name (e.g., 'ArionXiv')",
646
+ "5. Copy your API key",
647
+ "",
648
+ "Note: Groq is FREE and incredibly fast!",
649
+ " It's REQUIRED for AI analysis and chat features."
650
+ ]
651
+ },
652
+ "openrouter": {
653
+ "title": "How to Get Your OpenRouter API Key (FREE Models Available)",
654
+ "steps": [
655
+ "1. Go to: https://openrouter.ai/keys",
656
+ "2. Create a free account or sign in",
657
+ "3. Click 'Create Key'",
658
+ "4. Give it a name (e.g., 'ArionXiv')",
659
+ "5. Copy your API key",
660
+ "",
661
+ "Note: OpenRouter provides access to many FREE models!",
662
+ " Use it for paper chat with Llama, Gemma, Qwen, etc."
663
+ ]
664
+ },
665
+ "openrouter_model": {
666
+ "title": "Configure OpenRouter Model",
667
+ "steps": [
668
+ "Browse available models at: https://openrouter.ai/models",
669
+ "",
670
+ "Popular FREE models:",
671
+ " • meta-llama/llama-3.3-70b-instruct:free",
672
+ " • google/gemma-2-9b-it:free",
673
+ " • qwen/qwen-2.5-72b-instruct:free",
674
+ "",
675
+ "Paid models (require credits):",
676
+ " • openai/gpt-4o-mini",
677
+ " • anthropic/claude-3.5-sonnet",
678
+ "",
679
+ "Enter the full model ID as shown on OpenRouter."
680
+ ]
681
+ }
682
+ }
683
+
684
+
685
+ def _configure_api_key_interactive(provider: str, colors: Dict[str, str]):
686
+ """Configure a single API key interactively with step-by-step instructions"""
687
+
688
+ info = api_config_manager.PROVIDERS.get(provider)
689
+ if not info:
690
+ print_error(console, f"Unknown provider: {provider}")
691
+ return
692
+
693
+ current_key = api_config_manager.get_api_key(provider)
694
+ required_text = "REQUIRED" if info["required"] else "optional"
695
+
696
+ left_to_right_reveal(console, f"\n{'='*60}", style=colors['primary'], duration=0.5)
697
+ left_to_right_reveal(console, f"{info['name']} Configuration ({required_text})", style=f"bold {colors['primary']}", duration=0.8)
698
+ left_to_right_reveal(console, f"{info['description']}", style="white", duration=0.6)
699
+
700
+ # Show step-by-step instructions
701
+ if provider in API_KEY_INSTRUCTIONS:
702
+ instructions = API_KEY_INSTRUCTIONS[provider]
703
+ steps_text = "\n".join(instructions["steps"])
704
+ left_to_right_reveal(console, "", duration=0.3)
705
+ console.print(Panel(
706
+ steps_text,
707
+ title=f"[bold {colors['primary']}]{instructions['title']}[/bold {colors['primary']}]",
708
+ border_style=f"bold {colors['primary']}",
709
+ padding=(1, 2)
710
+ ))
711
+
712
+ if current_key:
713
+ left_to_right_reveal(console, f"\nCurrently configured: {api_config_manager._mask_key(current_key)}", style=colors['primary'], duration=0.8)
714
+
715
+ action = Prompt.ask(
716
+ f"\n[bold {colors['primary']}]What would you like to do?[/bold {colors['primary']}]",
717
+ choices=["update", "remove", "cancel"],
718
+ default="cancel"
719
+ )
720
+
721
+ if action == "update":
722
+ new_key = Prompt.ask(f"[bold {colors['primary']}]Enter new API key[/bold {colors['primary']}]", default="", show_default=False)
723
+ if new_key.strip():
724
+ if api_config_manager.set_api_key(provider, new_key.strip()):
725
+ print_success(console, f"{info['name']} key updated successfully!")
726
+ else:
727
+ print_error(console, "Failed to save key")
728
+ else:
729
+ print_warning(console, "No key entered, keeping existing")
730
+ elif action == "remove":
731
+ if Confirm.ask(f"[bold {colors['warning']}]Remove {info['name']} key?[/bold {colors['warning']}]", default=False):
732
+ if api_config_manager.remove_api_key(provider):
733
+ print_success(console, f"{info['name']} key removed")
734
+ else:
735
+ print_error(console, "Failed to remove key")
736
+ else:
737
+ new_key = Prompt.ask(
738
+ f"\n[bold {colors['primary']}]Enter {info['name']} API key (or press Enter to skip)[/bold {colors['primary']}]",
739
+ default="",
740
+ show_default=False
741
+ )
742
+ if new_key.strip():
743
+ if api_config_manager.set_api_key(provider, new_key.strip()):
744
+ print_success(console, f"{info['name']} key saved successfully!")
745
+ else:
746
+ print_error(console, "Failed to save key")
747
+ else:
748
+ if info["required"]:
749
+ print_warning(console, f"{info['name']} key is REQUIRED for AI features")
750
+ else:
751
+ left_to_right_reveal(console, f"Skipped {info['name']}", style="white", duration=0.8)
752
+
753
+
754
+ # ================================
755
+ # TIME CONFIGURATION (Quick access)
756
+ # ================================
757
+
758
+ @settings.command('time')
759
+ @click.argument('time_str', required=False)
760
+ def time_config(time_str: Optional[str]):
761
+ """Set daily analysis delivery time (HH:MM format)"""
762
+
763
+ async def _time():
764
+ await _ensure_authenticated()
765
+
766
+ if time_str:
767
+ # Validate time format
768
+ try:
769
+ datetime.strptime(time_str, "%H:%M")
770
+ # Save time setting
771
+ print_success(console, f"Daily analysis time set to {time_str}")
772
+ print_warning(console, "Use 'arionxiv settings daily' for more options")
773
+ except ValueError:
774
+ print_error(console, "Invalid time format. Use HH:MM (e.g., 09:00)")
775
+ else:
776
+ # Show current time and prompt for new one
777
+ print_header(console, "Daily Analysis Time")
778
+ console.print("Current time: [bold]09:00[/bold] (example)")
779
+
780
+ new_time = Prompt.ask(
781
+ f"{style_text('Enter new time (HH:MM)', 'primary')}",
782
+ default="09:00"
783
+ )
784
+
785
+ try:
786
+ datetime.strptime(new_time, "%H:%M")
787
+ print_success(console, f"Daily analysis time set to {new_time}")
788
+ except ValueError:
789
+ print_error(console, "Invalid time format")
790
+
791
+ asyncio.run(_time())
792
+
793
+ # ================================
794
+ # SAVED PAPERS MANAGEMENT
795
+ # ================================
796
+
797
+ @settings.command('papers')
798
+ def papers_config():
799
+ """Manage your saved papers (delete papers from library)"""
800
+
801
+ async def _papers():
802
+ await _ensure_authenticated()
803
+ print_header(console, "Saved Papers Management")
804
+
805
+ colors = get_theme_colors()
806
+
807
+ # Get user's saved papers from API
808
+ try:
809
+ result = await api_client.get_library(limit=100)
810
+ if not result.get("success"):
811
+ print_error(console, "Failed to fetch library")
812
+ return
813
+ user_papers = result.get("papers", [])
814
+ except APIClientError as e:
815
+ print_error(console, f"API Error: {e.message}")
816
+ return
817
+
818
+ if not user_papers:
819
+ print_warning(console, "No saved papers in your library.")
820
+ console.print(f"\n{style_text('Use arionxiv chat to chat with papers and save them.', 'primary')}")
821
+ show_command_suggestions(console, context='settings')
822
+ return
823
+
824
+ console.print(f"\n[bold {colors['primary']}]Your saved papers ({len(user_papers)}/10):[/bold {colors['primary']}]\n")
825
+
826
+ # Create table
827
+ table = Table(show_header=True, header_style=f"bold {colors['primary']}")
828
+ table.add_column("#", style="bold white", width=3)
829
+ table.add_column("Title", style="white", max_width=50)
830
+ table.add_column("ArXiv ID", style="white", width=15)
831
+ table.add_column("Added", style="white", width=12)
832
+
833
+ for i, paper in enumerate(user_papers):
834
+ title = paper.get("title", "Unknown")
835
+
836
+ arxiv_id = paper.get("arxiv_id", "Unknown")
837
+ added_at = paper.get("added_at")
838
+ if added_at:
839
+ added_str = added_at.strftime("%Y-%m-%d") if hasattr(added_at, 'strftime') else str(added_at)[:10]
840
+ else:
841
+ added_str = "Unknown"
842
+ table.add_row(str(i + 1), title, arxiv_id, added_str)
843
+
844
+ console.print(table)
845
+
846
+ # Options
847
+ console.print(f"\n{style_text('Actions:', 'primary')}")
848
+ console.print(f"[bold {colors['primary']}]1.[/bold {colors['primary']}] Delete papers")
849
+ console.print(f"[bold {colors['primary']}]2.[/bold {colors['primary']}] Exit")
850
+
851
+ action = Prompt.ask(f"[bold {colors['primary']}]Select action[/bold {colors['primary']}]", choices=["1", "2"], default="2")
852
+
853
+ if action == "1":
854
+ console.print(f"\n[bold {colors['primary']}]Enter paper numbers to delete (comma-separated, e.g., 1,3,5) or 0 to cancel:[/bold {colors['primary']}]")
855
+
856
+ choice = Prompt.ask(f"[bold {colors['primary']}]Papers to delete[/bold {colors['primary']}]")
857
+
858
+ if choice.strip() == "0" or not choice.strip():
859
+ print_warning(console, "Cancelled.")
860
+ return
861
+
862
+ try:
863
+ indices = [int(x.strip()) - 1 for x in choice.split(",")]
864
+ valid_indices = [i for i in indices if 0 <= i < len(user_papers)]
865
+
866
+ if not valid_indices:
867
+ print_error(console, "No valid selections.")
868
+ return
869
+
870
+ # Confirm deletion
871
+ papers_to_delete = [user_papers[i].get("title", "Unknown")[:30] for i in valid_indices]
872
+ console.print(f"\n[bold {colors['primary']}]Papers to delete:[/bold {colors['primary']}]")
873
+ for title in papers_to_delete:
874
+ console.print(f" - {title}")
875
+
876
+ if Confirm.ask(f"\n[bold {colors['red']}]Confirm deletion?[/bold {colors['red']}]", default=False):
877
+ deleted_count = 0
878
+ for idx in valid_indices:
879
+ paper = user_papers[idx]
880
+ arxiv_id = paper.get("arxiv_id")
881
+ if arxiv_id:
882
+ try:
883
+ result = await api_client.remove_from_library(arxiv_id)
884
+ if result.get("success"):
885
+ deleted_count += 1
886
+ except APIClientError as e:
887
+ logging.error(f"Failed to remove paper from library: {e}", exc_info=True)
888
+ console.print(f"[bold {colors['error']}]Failed to remove paper from library.[/bold {colors['error']}")
889
+
890
+ print_success(console, f"Deleted {deleted_count} paper(s) from your library.")
891
+ else:
892
+ print_warning(console, "Deletion cancelled.")
893
+
894
+ except ValueError:
895
+ print_error(console, "Invalid input. Use comma-separated numbers.")
896
+
897
+ # Show command suggestions
898
+ show_command_suggestions(console, context='settings')
899
+
900
+ asyncio.run(_papers())
901
+
902
+ # ================================
903
+ # HELPER FUNCTIONS
904
+ # ================================
905
+
906
+ async def _ensure_authenticated():
907
+ """Ensure user is authenticated"""
908
+ if not unified_user_service.is_authenticated():
909
+ print_error(console, "Please login first: arionxiv auth --login")
910
+ raise click.Abort()
911
+
912
+ async def _show_compact_settings(prefs: Dict[str, Any], theme_color: str):
913
+ """Show compact settings overview"""
914
+ colors = get_theme_colors()
915
+
916
+ # Create a summary table
917
+ table = Table(title="Settings Summary", show_header=True, header_style=f"bold {colors['primary']}")
918
+ table.add_column("Setting", style="bold white", width=20)
919
+ table.add_column("Value", style="white", width=40)
920
+ table.add_column("Command", style="white", width=25)
921
+
922
+ # Theme
923
+ table.add_row("Theme Color", f"[{theme_color}]{theme_color.title()}[/{theme_color}]", "settings theme")
924
+
925
+ # Categories
926
+ categories = prefs.get('categories', [])
927
+ table.add_row("Categories", ", ".join(categories[:3]) + ("..." if len(categories) > 3 else ""), "settings categories")
928
+
929
+ # Keywords
930
+ keywords = prefs.get('keywords', [])
931
+ table.add_row("Keywords", ", ".join(keywords[:3]) + ("..." if len(keywords) > 3 else ""), "settings keywords")
932
+
933
+ # Authors
934
+ authors = prefs.get('authors', [])
935
+ table.add_row("Authors", ", ".join(authors[:2]) + ("..." if len(authors) > 2 else ""), "settings authors")
936
+
937
+ # Daily settings
938
+ max_papers = prefs.get('max_papers_per_day', 10)
939
+ table.add_row("Max Papers/Day", str(max_papers), "settings daily")
940
+
941
+ # Relevance threshold
942
+ min_relevance = prefs.get('min_relevance_score', 0.2)
943
+ table.add_row("Min Relevance", f"{min_relevance:.1f}", "settings prefs")
944
+
945
+ console.print(table)
946
+
947
+ async def _show_detailed_settings(prefs: Dict[str, Any], theme_color: str):
948
+ """Show detailed settings view"""
949
+
950
+ # Theme section
951
+ theme_panel = Panel(
952
+ f"Color Theme: [bold {theme_color}]{theme_color.title()}[/bold {theme_color}]\n"
953
+ f"Use: [white]arionxiv settings theme[/white]",
954
+ title="Display",
955
+ border_style=theme_color
956
+ )
957
+
958
+ # Preferences section
959
+ categories = prefs.get('categories', [])
960
+ keywords = prefs.get('keywords', [])
961
+ authors = prefs.get('authors', [])
962
+ exclude_keywords = prefs.get('exclude_keywords', [])
963
+
964
+ prefs_content = f"""
965
+ Categories: {', '.join(categories) if categories else 'None set'}
966
+ Keywords: {', '.join(keywords) if keywords else 'None set'}
967
+ Authors: {', '.join(authors) if authors else 'None set'}
968
+ Exclude: {', '.join(exclude_keywords) if exclude_keywords else 'None set'}
969
+
970
+ Max Papers/Day: {prefs.get('max_papers_per_day', 10)}
971
+ Min Relevance: {prefs.get('min_relevance_score', 0.2):.1f}
972
+ """.strip()
973
+
974
+ prefs_panel = Panel(
975
+ prefs_content,
976
+ title="Paper Preferences",
977
+ border_style=theme_color
978
+ )
979
+
980
+ # Daily dose section
981
+ daily_content = """
982
+ Status: Enabled
983
+ Delivery Time: 09:00
984
+ Fetch Days: 1 day back
985
+ Analysis: LLM-powered
986
+ """.strip()
987
+
988
+ daily_panel = Panel(
989
+ daily_content,
990
+ title="Daily Dose",
991
+ border_style=theme_color
992
+ )
993
+
994
+ # Display panels
995
+ console.print(Columns([theme_panel, prefs_panel]))
996
+ console.print(daily_panel)
997
+
998
+ async def _display_preferences_overview(prefs: Dict[str, Any]):
999
+ """Display preferences in a clean overview format"""
1000
+
1001
+ colors = get_theme_colors()
1002
+
1003
+ # Categories
1004
+ categories = prefs.get('categories', [])
1005
+ if categories:
1006
+ console.print(f"\n{style_text('Research Categories:', 'primary')}")
1007
+ for cat in categories:
1008
+ console.print(f" • {cat}")
1009
+ else:
1010
+ console.print(f"\n{style_text('Research Categories:', 'primary')} None set")
1011
+
1012
+ # Keywords
1013
+ keywords = prefs.get('keywords', [])
1014
+ if keywords:
1015
+ console.print(f"\n{style_text('Keywords:', 'primary')}")
1016
+ for kw in keywords:
1017
+ console.print(f" - {kw}")
1018
+ else:
1019
+ console.print(f"\n{style_text('Keywords:', 'primary')} None set")
1020
+
1021
+ # Authors
1022
+ authors = prefs.get('authors', [])
1023
+ if authors:
1024
+ console.print(f"\n{style_text('Preferred Authors:', 'primary')}")
1025
+ for author in authors:
1026
+ console.print(f" • {author}")
1027
+ else:
1028
+ console.print(f"\n{style_text('Preferred Authors:', 'primary')} None set")
1029
+
1030
+ # Settings
1031
+ console.print(f"\n{style_text('Settings:', 'primary')}")
1032
+ console.print(f" • Max papers per day: {prefs.get('max_papers_per_day', 10)}")
1033
+ console.print(f" • Min relevance score: {prefs.get('min_relevance_score', 0.2):.1f}")
1034
+
1035
+ async def _interactive_categories_config(user_id: str, current_categories: List[str]):
1036
+ """Interactive categories configuration"""
1037
+
1038
+ # Show available categories
1039
+ available_cats = unified_user_service.get_available_categories()
1040
+
1041
+ console.print(f"\n{style_text('Available ArXiv Categories:', 'primary')}")
1042
+
1043
+ colors = get_theme_colors()
1044
+
1045
+ # Create table of categories
1046
+ table = Table(show_header=True, header_style=f"bold {colors['primary']}")
1047
+ table.add_column("Code", style="bold", width=8)
1048
+ table.add_column("Description", width=40)
1049
+ table.add_column("Selected", width=8)
1050
+
1051
+ for code, desc in list(available_cats.items())[:10]: # Show top 10
1052
+ selected = "[X]" if code in current_categories else "[ ]"
1053
+ table.add_row(code, desc, selected)
1054
+
1055
+ console.print(table)
1056
+ console.print(f"\n{style_text('Current selections:', 'primary')} {', '.join(current_categories)}")
1057
+
1058
+ # Interactive selection
1059
+ action = Prompt.ask(
1060
+ "\n[bold]Action[/bold]",
1061
+ choices=["add", "remove", "clear", "done"],
1062
+ default="done"
1063
+ )
1064
+
1065
+ if action == "add":
1066
+ new_cat = Prompt.ask("Enter category code (e.g., cs.AI)")
1067
+ if new_cat in available_cats:
1068
+ new_categories = list(set(current_categories + [new_cat]))
1069
+ await _save_categories(user_id, new_categories)
1070
+ else:
1071
+ print_error(console, "Invalid category code")
1072
+
1073
+ elif action == "remove":
1074
+ if current_categories:
1075
+ cat_to_remove = Prompt.ask("Enter category to remove", choices=current_categories)
1076
+ new_categories = [c for c in current_categories if c != cat_to_remove]
1077
+ await _save_categories(user_id, new_categories)
1078
+ else:
1079
+ print_warning(console, "No categories to remove")
1080
+
1081
+ elif action == "clear":
1082
+ if Confirm.ask("Clear all categories?", default=False):
1083
+ await _save_categories(user_id, [])
1084
+
1085
+ async def _interactive_keywords_config(user_id: str, prefs: Dict[str, Any]):
1086
+ """Interactive keywords configuration"""
1087
+
1088
+ keywords = prefs.get('keywords', [])
1089
+ exclude_keywords = prefs.get('exclude_keywords', [])
1090
+
1091
+ console.print(f"\n{style_text('Current Keywords:', 'primary')}")
1092
+ console.print(f"Include: {', '.join(keywords) if keywords else 'None'}")
1093
+ console.print(f"Exclude: {', '.join(exclude_keywords) if exclude_keywords else 'None'}")
1094
+
1095
+ action = Prompt.ask(
1096
+ "\n[bold]Action[/bold]",
1097
+ choices=["add", "exclude", "remove", "clear", "done"],
1098
+ default="done"
1099
+ )
1100
+
1101
+ if action == "add":
1102
+ new_keywords = Prompt.ask("Enter keywords (comma-separated)")
1103
+ new_kw_list = [kw.strip() for kw in new_keywords.split(',') if kw.strip()]
1104
+ combined = list(set(keywords + new_kw_list))
1105
+ await _save_keywords(user_id, combined, exclude_keywords)
1106
+
1107
+ elif action == "exclude":
1108
+ exclude_kw = Prompt.ask("Enter keywords to exclude (comma-separated)")
1109
+ new_exclude = [kw.strip() for kw in exclude_kw.split(',') if kw.strip()]
1110
+ combined_exclude = list(set(exclude_keywords + new_exclude))
1111
+ await _save_keywords(user_id, keywords, combined_exclude)
1112
+
1113
+ elif action == "remove":
1114
+ if keywords:
1115
+ kw_to_remove = Prompt.ask("Enter keyword to remove", choices=keywords)
1116
+ new_keywords = [k for k in keywords if k != kw_to_remove]
1117
+ await _save_keywords(user_id, new_keywords, exclude_keywords)
1118
+
1119
+ elif action == "clear":
1120
+ if Confirm.ask("Clear all keywords?", default=False):
1121
+ await _save_keywords(user_id, [], [])
1122
+
1123
+ async def _interactive_authors_config(user_id: str, current_authors: List[str]):
1124
+ """Interactive authors configuration"""
1125
+
1126
+ console.print(f"\n{style_text('Current Authors:', 'primary')}")
1127
+ if current_authors:
1128
+ for author in current_authors:
1129
+ console.print(f" • {author}")
1130
+ else:
1131
+ console.print(" None set")
1132
+
1133
+ action = Prompt.ask(
1134
+ "\n[bold]Action[/bold]",
1135
+ choices=["add", "remove", "clear", "done"],
1136
+ default="done"
1137
+ )
1138
+
1139
+ if action == "add":
1140
+ new_author = Prompt.ask("Enter author name")
1141
+ new_authors = list(set(current_authors + [new_author.strip()]))
1142
+ await _save_authors(user_id, new_authors)
1143
+
1144
+ elif action == "remove":
1145
+ if current_authors:
1146
+ author_to_remove = Prompt.ask("Enter author to remove", choices=current_authors)
1147
+ new_authors = [a for a in current_authors if a != author_to_remove]
1148
+ await _save_authors(user_id, new_authors)
1149
+
1150
+ elif action == "clear":
1151
+ if Confirm.ask("Clear all authors?", default=False):
1152
+ await _save_authors(user_id, [])
1153
+
1154
+ async def _interactive_daily_config(user_id: str, prefs: Dict[str, Any]):
1155
+ """Interactive daily dose configuration"""
1156
+
1157
+ console.print(f"\n{style_text('Daily Dose Settings:', 'primary')}")
1158
+
1159
+ # Current settings
1160
+ max_papers = prefs.get('max_papers_per_day', 10)
1161
+ min_relevance = prefs.get('min_relevance_score', 0.2)
1162
+
1163
+ console.print(f"Max papers per day: {max_papers}")
1164
+ console.print(f"Min relevance score: {min_relevance:.1f}")
1165
+
1166
+ # Configuration options
1167
+ if Confirm.ask("\nChange max papers per day?", default=False):
1168
+ new_max = IntPrompt.ask("Enter max papers (1-50)", default=max_papers)
1169
+ if 1 <= new_max <= 50:
1170
+ prefs['max_papers_per_day'] = new_max
1171
+ await unified_user_service.update_user_preferences(user_id, prefs)
1172
+ print_success(console, f"Max papers updated to {new_max}")
1173
+
1174
+ if Confirm.ask("Change relevance threshold?", default=False):
1175
+ new_relevance = float(Prompt.ask("Enter min relevance (0.0-1.0)", default=str(min_relevance)))
1176
+ if 0.0 <= new_relevance <= 1.0:
1177
+ prefs['min_relevance_score'] = new_relevance
1178
+ await unified_user_service.update_user_preferences(user_id, prefs)
1179
+ print_success(console, f"Min relevance updated to {new_relevance:.1f}")
1180
+
1181
+ async def _interactive_daily_dose_config(user_id: str, current_settings: Dict[str, Any], daily_dose_service):
1182
+ """Interactive daily dose configuration with full options"""
1183
+
1184
+ colors = get_theme_colors()
1185
+
1186
+ while True:
1187
+ # Get current values for display
1188
+ enabled = current_settings.get("enabled", False)
1189
+ scheduled_time = current_settings.get("scheduled_time", "08:00") or "08:00"
1190
+ max_papers = current_settings.get("max_papers", 5)
1191
+ keywords = current_settings.get("keywords", [])
1192
+ keywords_str = ", ".join(keywords[:3]) + ("..." if len(keywords) > 3 else "") if keywords else "None"
1193
+
1194
+ console.print(f"\n[bold {colors['primary']}]Options:[/bold {colors['primary']}]")
1195
+ console.print(f"[bold {colors['primary']}]1.[/bold {colors['primary']}] Toggle enabled/disabled [white](current: {'Enabled' if enabled else 'Disabled'})[/white]")
1196
+ console.print(f"[bold {colors['primary']}]2.[/bold {colors['primary']}] Set scheduled time (UTC) [white](current: {scheduled_time})[/white]")
1197
+ console.print(f"[bold {colors['primary']}]3.[/bold {colors['primary']}] Set max papers (1-10) [white](current: {max_papers})[/white]")
1198
+ console.print(f"[bold {colors['primary']}]4.[/bold {colors['primary']}] Set keywords [white](current: {keywords_str})[/white]")
1199
+ console.print(f"[bold {colors['primary']}]5.[/bold {colors['primary']}] Done - Return to main menu")
1200
+
1201
+ choice = Prompt.ask(f"[bold {colors['primary']}]Select option[/bold {colors['primary']}]", choices=["1", "2", "3", "4", "5"], default="5")
1202
+
1203
+ if choice == "1":
1204
+ current_enabled = current_settings.get("enabled", False)
1205
+ new_enabled = not current_enabled
1206
+ result = await daily_dose_service.update_user_daily_dose_settings(user_id, enabled=new_enabled)
1207
+ if result["success"]:
1208
+ current_settings["enabled"] = new_enabled
1209
+ status = "enabled" if new_enabled else "disabled"
1210
+ print_success(console, f"Daily dose {status}")
1211
+ else:
1212
+ print_error(console, f"Failed to update: {result.get('message')}")
1213
+
1214
+ elif choice == "2":
1215
+ current_time = current_settings.get("scheduled_time", "08:00")
1216
+ console.print(f"[white]Note: Time is in UTC timezone. Your daily dose will run at this UTC time.[/white]")
1217
+ new_time = Prompt.ask(f"[bold {colors['primary']}]Enter time in UTC (HH:MM)[/bold {colors['primary']}]", default=current_time or "08:00")
1218
+ try:
1219
+ datetime.strptime(new_time, "%H:%M")
1220
+ result = await daily_dose_service.update_user_daily_dose_settings(user_id, scheduled_time=new_time)
1221
+ if result["success"]:
1222
+ current_settings["scheduled_time"] = new_time
1223
+ print_success(console, f"Scheduled time set to {new_time} UTC")
1224
+
1225
+ # Schedule the job if enabled
1226
+ if current_settings.get("enabled") and schedule_user_daily_dose:
1227
+ schedule_result = await schedule_user_daily_dose(user_id, new_time)
1228
+ if schedule_result.get("success"):
1229
+ print_success(console, "Cron job scheduled")
1230
+ else:
1231
+ print_error(console, f"Failed to update: {result.get('message')}")
1232
+ except ValueError:
1233
+ print_error(console, "Invalid time format. Use HH:MM")
1234
+
1235
+ elif choice == "3":
1236
+ current_max = current_settings.get("max_papers", 5)
1237
+ new_max = IntPrompt.ask(f"[bold {colors['primary']}]Enter max papers (1-10)[/bold {colors['primary']}]", default=current_max)
1238
+ if 1 <= new_max <= 10:
1239
+ result = await daily_dose_service.update_user_daily_dose_settings(user_id, max_papers=new_max)
1240
+ if result["success"]:
1241
+ current_settings["max_papers"] = new_max
1242
+ print_success(console, f"Max papers set to {new_max}")
1243
+ else:
1244
+ print_error(console, f"Failed to update: {result.get('message')}")
1245
+ else:
1246
+ print_error(console, "Max papers must be between 1 and 10")
1247
+
1248
+ elif choice == "4":
1249
+ current_keywords = current_settings.get("keywords", [])
1250
+ console.print(f"\n[bold {colors['primary']}]Current keywords:[/bold {colors['primary']}] {', '.join(current_keywords) if current_keywords else 'None'}")
1251
+ new_keywords_str = Prompt.ask(f"[bold {colors['primary']}]Enter keywords (comma-separated)[/bold {colors['primary']}]", default=", ".join(current_keywords))
1252
+ new_keywords = [k.strip() for k in new_keywords_str.split(",") if k.strip()]
1253
+ result = await daily_dose_service.update_user_daily_dose_settings(user_id, keywords=new_keywords)
1254
+ if result["success"]:
1255
+ current_settings["keywords"] = new_keywords
1256
+ print_success(console, f"Keywords updated: {', '.join(new_keywords)}")
1257
+ else:
1258
+ print_error(console, f"Failed to update: {result.get('message')}")
1259
+
1260
+ elif choice == "5":
1261
+ # Show main menu / command suggestions before exiting
1262
+ show_command_suggestions(console, context='settings')
1263
+ break
1264
+
1265
+
1266
+ async def _interactive_daily_dose_config_api(current_settings: Dict[str, Any], all_settings: Dict[str, Any], api_client):
1267
+ """Interactive daily dose configuration using Vercel API"""
1268
+
1269
+ colors = get_theme_colors()
1270
+
1271
+ while True:
1272
+ # Get current values for display
1273
+ enabled = current_settings.get("enabled", False)
1274
+ scheduled_time = current_settings.get("scheduled_time", "08:00") or "08:00"
1275
+ max_papers = current_settings.get("max_papers", 5)
1276
+ keywords = current_settings.get("keywords", [])
1277
+ keywords_str = ", ".join(keywords[:3]) + ("..." if len(keywords) > 3 else "") if keywords else "None"
1278
+
1279
+ console.print(f"\n[bold {colors['primary']}]Options:[/bold {colors['primary']}]")
1280
+ console.print(f"[bold {colors['primary']}]1.[/bold {colors['primary']}] Toggle enabled/disabled [white](current: {'Enabled' if enabled else 'Disabled'})[/white]")
1281
+ console.print(f"[bold {colors['primary']}]2.[/bold {colors['primary']}] Set scheduled time (UTC) [white](current: {scheduled_time})[/white]")
1282
+ console.print(f"[bold {colors['primary']}]3.[/bold {colors['primary']}] Set max papers (1-10) [white](current: {max_papers})[/white]")
1283
+ console.print(f"[bold {colors['primary']}]4.[/bold {colors['primary']}] Set keywords [white](current: {keywords_str})[/white]")
1284
+ console.print(f"[bold {colors['primary']}]5.[/bold {colors['primary']}] Done - Return to main menu")
1285
+
1286
+ choice = Prompt.ask(f"[bold {colors['primary']}]Select option[/bold {colors['primary']}]", choices=["1", "2", "3", "4", "5"], default="5")
1287
+
1288
+ if choice == "1":
1289
+ new_enabled = not enabled
1290
+ current_settings["enabled"] = new_enabled
1291
+ all_settings["daily_dose"] = current_settings
1292
+ result = await api_client.update_settings(all_settings)
1293
+ if result.get("success"):
1294
+ status = "enabled" if new_enabled else "disabled"
1295
+ print_success(console, f"Daily dose {status}")
1296
+ else:
1297
+ current_settings["enabled"] = enabled # Revert on failure
1298
+ print_error(console, f"Failed to update: {result.get('message', 'Unknown error')}")
1299
+
1300
+ elif choice == "2":
1301
+ console.print(f"[white]Note: Time is in UTC timezone. Your daily dose will run at this UTC time.[/white]")
1302
+ new_time = Prompt.ask(f"[bold {colors['primary']}]Enter time in UTC (HH:MM)[/bold {colors['primary']}]", default=scheduled_time)
1303
+ try:
1304
+ datetime.strptime(new_time, "%H:%M")
1305
+ current_settings["scheduled_time"] = new_time
1306
+ all_settings["daily_dose"] = current_settings
1307
+ result = await api_client.update_settings(all_settings)
1308
+ if result.get("success"):
1309
+ print_success(console, f"Scheduled time set to {new_time} UTC")
1310
+ else:
1311
+ current_settings["scheduled_time"] = scheduled_time # Revert on failure
1312
+ print_error(console, f"Failed to update: {result.get('message', 'Unknown error')}")
1313
+ except ValueError:
1314
+ print_error(console, "Invalid time format. Use HH:MM")
1315
+
1316
+ elif choice == "3":
1317
+ new_max = IntPrompt.ask(f"[bold {colors['primary']}]Enter max papers (1-10)[/bold {colors['primary']}]", default=max_papers)
1318
+ if 1 <= new_max <= 10:
1319
+ current_settings["max_papers"] = new_max
1320
+ all_settings["daily_dose"] = current_settings
1321
+ result = await api_client.update_settings(all_settings)
1322
+ if result.get("success"):
1323
+ print_success(console, f"Max papers set to {new_max}")
1324
+ else:
1325
+ current_settings["max_papers"] = max_papers # Revert on failure
1326
+ print_error(console, f"Failed to update: {result.get('message', 'Unknown error')}")
1327
+ else:
1328
+ print_error(console, "Max papers must be between 1 and 10")
1329
+
1330
+ elif choice == "4":
1331
+ console.print(f"\n[bold {colors['primary']}]Current keywords:[/bold {colors['primary']}] {', '.join(keywords) if keywords else 'None'}")
1332
+ new_keywords_str = Prompt.ask(f"[bold {colors['primary']}]Enter keywords (space-separated)[/bold {colors['primary']}]", default=" ".join(keywords))
1333
+ new_keywords = [k.strip() for k in new_keywords_str.split() if k.strip()]
1334
+ current_settings["keywords"] = new_keywords
1335
+ all_settings["daily_dose"] = current_settings
1336
+ result = await api_client.update_settings(all_settings)
1337
+ if result.get("success"):
1338
+ print_success(console, f"Keywords updated: {' '.join(new_keywords)}")
1339
+ else:
1340
+ current_settings["keywords"] = keywords # Revert on failure
1341
+ print_error(console, f"Failed to update: {result.get('message', 'Unknown error')}")
1342
+
1343
+ elif choice == "5":
1344
+ show_command_suggestions(console, context='settings')
1345
+ break
1346
+
1347
+ # ================================
1348
+ # SAVE HELPER FUNCTIONS
1349
+ # ================================
1350
+
1351
+ async def _save_categories(user_id: str, categories: List[str]):
1352
+ """Save categories and update MongoDB"""
1353
+ try:
1354
+ # Get current preferences
1355
+ prefs_result = await unified_user_service.get_user_preferences(user_id)
1356
+ prefs = prefs_result['preferences'] if prefs_result['success'] else {}
1357
+
1358
+ # Update categories
1359
+ prefs['categories'] = categories
1360
+
1361
+ # Save to preferences service (which handles MongoDB updates)
1362
+ result = await unified_user_service.update_user_preferences(user_id, prefs)
1363
+
1364
+ if result['success']:
1365
+ print_success(console, f"Categories updated: {', '.join(categories) if categories else 'None'}")
1366
+ else:
1367
+ print_error(console, "Failed to save categories")
1368
+ except Exception as e:
1369
+ print_error(console, f"Error saving categories: {e}")
1370
+
1371
+ async def _save_keywords(user_id: str, keywords: List[str], exclude_keywords: List[str]):
1372
+ """Save keywords and update MongoDB"""
1373
+ try:
1374
+ prefs_result = await unified_user_service.get_user_preferences(user_id)
1375
+ prefs = prefs_result['preferences'] if prefs_result['success'] else {}
1376
+
1377
+ prefs['keywords'] = keywords
1378
+ prefs['exclude_keywords'] = exclude_keywords
1379
+
1380
+ result = await unified_user_service.update_user_preferences(user_id, prefs)
1381
+
1382
+ if result['success']:
1383
+ print_success(console, "Keywords updated successfully")
1384
+ else:
1385
+ print_error(console, "Failed to save keywords")
1386
+ except Exception as e:
1387
+ print_error(console, f"Error saving keywords: {e}")
1388
+
1389
+ async def _save_authors(user_id: str, authors: List[str]):
1390
+ """Save authors and update MongoDB"""
1391
+ try:
1392
+ prefs_result = await unified_user_service.get_user_preferences(user_id)
1393
+ prefs = prefs_result['preferences'] if prefs_result['success'] else {}
1394
+
1395
+ prefs['authors'] = authors
1396
+
1397
+ result = await unified_user_service.update_user_preferences(user_id, prefs)
1398
+
1399
+ if result['success']:
1400
+ print_success(console, f"Authors updated: {', '.join(authors) if authors else 'None'}")
1401
+ else:
1402
+ print_error(console, "Failed to save authors")
1403
+ except Exception as e:
1404
+ print_error(console, f"Error saving authors: {e}")
1405
+
1406
+ if __name__ == "__main__":
1407
+ settings()