dsap-cli 0.1.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.
- dsap/__init__.py +21 -0
- dsap/__main__.py +9 -0
- dsap/cli.py +618 -0
- dsap/config.py +158 -0
- dsap/data/blind75.yaml +405 -0
- dsap/data/grind75.yaml +401 -0
- dsap/data/neetcode150.yaml +796 -0
- dsap/database.py +774 -0
- dsap/models.py +225 -0
- dsap/problem_sets.py +195 -0
- dsap/py.typed +0 -0
- dsap/sm2.py +521 -0
- dsap/ui.py +445 -0
- dsap_cli-0.1.0.dist-info/METADATA +287 -0
- dsap_cli-0.1.0.dist-info/RECORD +18 -0
- dsap_cli-0.1.0.dist-info/WHEEL +4 -0
- dsap_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dsap_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
dsap/ui.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Rich Terminal UI Components for DSAP.
|
|
2
|
+
|
|
3
|
+
Provides beautiful terminal output using Rich library:
|
|
4
|
+
- Problem display with clickable links (OSC 8 hyperlinks)
|
|
5
|
+
- Statistics tables and progress bars
|
|
6
|
+
- Interactive quality rating prompts
|
|
7
|
+
- Session status displays
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import webbrowser
|
|
11
|
+
|
|
12
|
+
from rich import box
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.prompt import Confirm
|
|
16
|
+
from rich.style import Style
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from dsap.models import Difficulty, Problem, ProblemProgress, Statistics
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Global console instance
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
# Difficulty color styles
|
|
26
|
+
DIFFICULTY_STYLES: dict[Difficulty, Style] = {
|
|
27
|
+
Difficulty.EASY: Style(color="green", bold=True),
|
|
28
|
+
Difficulty.MEDIUM: Style(color="yellow", bold=True),
|
|
29
|
+
Difficulty.HARD: Style(color="red", bold=True),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def make_link(url: str, text: str) -> str:
|
|
34
|
+
"""Create a Rich markup hyperlink.
|
|
35
|
+
|
|
36
|
+
Rich automatically handles OSC 8 hyperlinks in supported terminals
|
|
37
|
+
(iTerm2, Windows Terminal, VSCode terminal, etc.)
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
url: The URL to link to
|
|
41
|
+
text: The display text
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Rich markup string with link
|
|
45
|
+
"""
|
|
46
|
+
return f"[link={url}]{text}[/link]"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def display_welcome() -> None:
|
|
50
|
+
"""Display welcome message when dsap is run without arguments."""
|
|
51
|
+
welcome = """
|
|
52
|
+
[bold cyan]DSAP[/bold cyan] - DSA Practice with Spaced Repetition
|
|
53
|
+
|
|
54
|
+
[bold]Quick Start:[/bold]
|
|
55
|
+
dsap load blind75 Load the Blind 75 problem set
|
|
56
|
+
dsap review Start reviewing due problems
|
|
57
|
+
dsap stats Check your progress
|
|
58
|
+
|
|
59
|
+
[bold]Commands:[/bold]
|
|
60
|
+
dsap --help See all available commands
|
|
61
|
+
dsap <cmd> --help Get help for a specific command
|
|
62
|
+
|
|
63
|
+
[dim]Master algorithms with scientifically-proven spaced repetition (SM-2)[/dim]
|
|
64
|
+
"""
|
|
65
|
+
console.print(Panel(welcome.strip(), border_style="blue", box=box.ROUNDED))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def display_problem(
|
|
69
|
+
problem: Problem,
|
|
70
|
+
progress: ProblemProgress | None = None,
|
|
71
|
+
index: int | None = None,
|
|
72
|
+
total: int | None = None,
|
|
73
|
+
show_hints: bool = False,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Display a problem in a beautiful panel with clickable link.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
problem: The problem to display
|
|
79
|
+
progress: Optional progress data
|
|
80
|
+
index: Current problem index (for "Problem 1/5" display)
|
|
81
|
+
total: Total problems in session
|
|
82
|
+
show_hints: Whether to show hints
|
|
83
|
+
"""
|
|
84
|
+
lines: list[str] = []
|
|
85
|
+
|
|
86
|
+
# Title as clickable link
|
|
87
|
+
title_link = make_link(str(problem.url), problem.title)
|
|
88
|
+
lines.append(f"[bold cyan]{title_link}[/bold cyan]")
|
|
89
|
+
lines.append("")
|
|
90
|
+
|
|
91
|
+
# URL (also clickable, shown separately for clarity)
|
|
92
|
+
lines.append(f"[dim]{make_link(str(problem.url), str(problem.url))}[/dim]")
|
|
93
|
+
lines.append("")
|
|
94
|
+
|
|
95
|
+
# Metadata row
|
|
96
|
+
diff_style = DIFFICULTY_STYLES.get(problem.difficulty, Style())
|
|
97
|
+
lines.append(
|
|
98
|
+
f"[bold]Difficulty:[/bold] [{diff_style}]{problem.difficulty.value}[/] "
|
|
99
|
+
f"[bold]Category:[/bold] {problem.category}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Problem set info
|
|
103
|
+
if problem.problem_set != "custom":
|
|
104
|
+
lines.append(
|
|
105
|
+
f"[bold]Set:[/bold] {problem.problem_set} #{problem.problem_number}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Tags
|
|
109
|
+
if problem.tags:
|
|
110
|
+
tags_str = " ".join(f"[dim]#{tag}[/dim]" for tag in problem.tags[:6])
|
|
111
|
+
lines.append(f"[bold]Tags:[/bold] {tags_str}")
|
|
112
|
+
|
|
113
|
+
# Company tags
|
|
114
|
+
if problem.company_tags:
|
|
115
|
+
companies = ", ".join(problem.company_tags[:5])
|
|
116
|
+
if len(problem.company_tags) > 5:
|
|
117
|
+
companies += f" +{len(problem.company_tags) - 5} more"
|
|
118
|
+
lines.append(f"[bold]Companies:[/bold] {companies}")
|
|
119
|
+
|
|
120
|
+
# Description
|
|
121
|
+
if problem.description:
|
|
122
|
+
lines.append("")
|
|
123
|
+
lines.append(f"[italic]{problem.description}[/italic]")
|
|
124
|
+
|
|
125
|
+
# Progress info
|
|
126
|
+
if progress:
|
|
127
|
+
lines.append("")
|
|
128
|
+
lines.append("[bold]Your Progress:[/bold]")
|
|
129
|
+
status = (
|
|
130
|
+
"[green]Solved[/green]"
|
|
131
|
+
if progress.solved
|
|
132
|
+
else "[yellow]In Progress[/yellow]"
|
|
133
|
+
)
|
|
134
|
+
lines.append(f" Status: {status} | Attempts: {progress.attempts}")
|
|
135
|
+
lines.append(
|
|
136
|
+
f" EF: {progress.easiness_factor:.2f} | Interval: {progress.interval} days"
|
|
137
|
+
)
|
|
138
|
+
if progress.next_review:
|
|
139
|
+
lines.append(f" Next review: {progress.next_review.strftime('%Y-%m-%d')}")
|
|
140
|
+
|
|
141
|
+
# Hints (optional)
|
|
142
|
+
if show_hints and problem.hints:
|
|
143
|
+
lines.append("")
|
|
144
|
+
lines.append("[bold]Hints:[/bold]")
|
|
145
|
+
for i, hint in enumerate(problem.hints, 1):
|
|
146
|
+
lines.append(f" {i}. [dim]{hint}[/dim]")
|
|
147
|
+
|
|
148
|
+
# Build panel title
|
|
149
|
+
if index is not None and total is not None:
|
|
150
|
+
title = f"Problem {index}/{total}"
|
|
151
|
+
else:
|
|
152
|
+
title = "Problem"
|
|
153
|
+
|
|
154
|
+
panel = Panel(
|
|
155
|
+
"\n".join(lines),
|
|
156
|
+
title=f"[bold white]{title}[/bold white]",
|
|
157
|
+
border_style="blue",
|
|
158
|
+
box=box.ROUNDED,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
console.print(panel)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def prompt_open_browser(url: str, auto_open: bool = True) -> bool:
|
|
165
|
+
"""Prompt user to open problem in browser.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
url: URL to open
|
|
169
|
+
auto_open: If True, ask to open; if False, just inform
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
True if browser was opened
|
|
173
|
+
"""
|
|
174
|
+
console.print()
|
|
175
|
+
|
|
176
|
+
if auto_open:
|
|
177
|
+
open_it = Confirm.ask(
|
|
178
|
+
"[bold]Open in browser?[/bold]",
|
|
179
|
+
default=True,
|
|
180
|
+
)
|
|
181
|
+
if open_it:
|
|
182
|
+
webbrowser.open(str(url))
|
|
183
|
+
console.print(
|
|
184
|
+
"[dim]Opened in browser. Solve the problem, then return here.[/dim]"
|
|
185
|
+
)
|
|
186
|
+
return True
|
|
187
|
+
else:
|
|
188
|
+
console.print("[dim]Click the link above to open the problem.[/dim]")
|
|
189
|
+
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def prompt_quality_rating() -> int | None:
|
|
194
|
+
"""Prompt user for quality rating after reviewing a problem.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Quality rating (0-5) or None if user wants to quit
|
|
198
|
+
"""
|
|
199
|
+
console.print()
|
|
200
|
+
console.print("[bold]How well did you recall this problem?[/bold]")
|
|
201
|
+
console.print()
|
|
202
|
+
|
|
203
|
+
# Display rating options with colors
|
|
204
|
+
ratings = [
|
|
205
|
+
("5", "green", "Perfect - instant recall, no hesitation"),
|
|
206
|
+
("4", "green", "Good - correct, but had to think"),
|
|
207
|
+
("3", "yellow", "Hard - struggled but eventually solved"),
|
|
208
|
+
("2", "red", "Forgot - wrong, but solution was familiar"),
|
|
209
|
+
("1", "red", "Blackout - wrong, barely recognized solution"),
|
|
210
|
+
("0", "red", "Total blackout - no memory at all"),
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
for num, color, desc in ratings:
|
|
214
|
+
console.print(f" [{color}]{num}[/{color}] - {desc}")
|
|
215
|
+
|
|
216
|
+
console.print(" [dim]q[/dim] - Quit session")
|
|
217
|
+
console.print()
|
|
218
|
+
|
|
219
|
+
while True:
|
|
220
|
+
try:
|
|
221
|
+
response = console.input("[bold]Rating (0-5 or q): [/bold]").strip().lower()
|
|
222
|
+
|
|
223
|
+
if response == "q":
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
quality = int(response)
|
|
227
|
+
if 0 <= quality <= 5:
|
|
228
|
+
return quality
|
|
229
|
+
|
|
230
|
+
console.print("[red]Please enter a number between 0 and 5[/red]")
|
|
231
|
+
except ValueError:
|
|
232
|
+
console.print("[red]Invalid input. Enter 0-5 or 'q'[/red]")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def display_review_feedback(quality: int, next_interval: int) -> None:
|
|
236
|
+
"""Display feedback after a review.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
quality: The quality rating given
|
|
240
|
+
next_interval: Days until next review
|
|
241
|
+
"""
|
|
242
|
+
console.print()
|
|
243
|
+
|
|
244
|
+
if quality >= 4:
|
|
245
|
+
emoji = "[green]"
|
|
246
|
+
message = "Excellent!"
|
|
247
|
+
elif quality == 3:
|
|
248
|
+
emoji = "[yellow]"
|
|
249
|
+
message = "Good effort!"
|
|
250
|
+
else:
|
|
251
|
+
emoji = "[red]"
|
|
252
|
+
message = "Keep practicing!"
|
|
253
|
+
|
|
254
|
+
interval_text = "tomorrow" if next_interval == 1 else f"in {next_interval} days"
|
|
255
|
+
|
|
256
|
+
console.print(f"{emoji}{message}[/] Next review: {interval_text}")
|
|
257
|
+
console.print()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def display_stats(stats: Statistics) -> None:
|
|
261
|
+
"""Display comprehensive statistics in a beautiful format."""
|
|
262
|
+
# Main stats panel
|
|
263
|
+
main_lines = [
|
|
264
|
+
f"[bold]Total Problems:[/bold] {stats.total_problems}",
|
|
265
|
+
f"[bold]Solved:[/bold] [green]{stats.solved_problems}[/green] ({stats.solved_percentage}%)",
|
|
266
|
+
f"[bold]Reviewed:[/bold] {stats.reviewed_problems}",
|
|
267
|
+
"",
|
|
268
|
+
f"[bold]Due Today:[/bold] [yellow]{stats.due_today}[/yellow]",
|
|
269
|
+
f"[bold]Due This Week:[/bold] {stats.due_this_week}",
|
|
270
|
+
"",
|
|
271
|
+
f"[bold]Current Streak:[/bold] [cyan]{stats.current_streak}[/cyan] days",
|
|
272
|
+
f"[bold]Best Streak:[/bold] {stats.best_streak} days",
|
|
273
|
+
"",
|
|
274
|
+
f"[bold]Average EF:[/bold] {stats.average_easiness_factor}",
|
|
275
|
+
f"[bold]Total Reviews:[/bold] {stats.total_reviews}",
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
console.print(
|
|
279
|
+
Panel(
|
|
280
|
+
"\n".join(main_lines),
|
|
281
|
+
title="[bold white]Statistics[/bold white]",
|
|
282
|
+
border_style="green",
|
|
283
|
+
box=box.ROUNDED,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# Difficulty breakdown table
|
|
288
|
+
table = Table(title="Difficulty Breakdown", box=box.SIMPLE)
|
|
289
|
+
table.add_column("Difficulty", style="bold")
|
|
290
|
+
table.add_column("Solved", justify="right", style="green")
|
|
291
|
+
table.add_column("Total", justify="right")
|
|
292
|
+
table.add_column("Progress", justify="left")
|
|
293
|
+
|
|
294
|
+
difficulties = [
|
|
295
|
+
("Easy", stats.easy_solved, stats.easy_total, stats.easy_percentage, "green"),
|
|
296
|
+
(
|
|
297
|
+
"Medium",
|
|
298
|
+
stats.medium_solved,
|
|
299
|
+
stats.medium_total,
|
|
300
|
+
stats.medium_percentage,
|
|
301
|
+
"yellow",
|
|
302
|
+
),
|
|
303
|
+
("Hard", stats.hard_solved, stats.hard_total, stats.hard_percentage, "red"),
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
for name, solved, total, pct, color in difficulties:
|
|
307
|
+
# Create mini progress bar
|
|
308
|
+
filled = int(pct / 10)
|
|
309
|
+
bar = f"[{color}]{'█' * filled}[/{color}][dim]{'░' * (10 - filled)}[/dim]"
|
|
310
|
+
table.add_row(
|
|
311
|
+
f"[{color}]{name}[/{color}]",
|
|
312
|
+
str(solved),
|
|
313
|
+
str(total),
|
|
314
|
+
f"{bar} {pct:.0f}%",
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
console.print()
|
|
318
|
+
console.print(table)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def display_problem_list(
|
|
322
|
+
problems: list[tuple[Problem, ProblemProgress | None]],
|
|
323
|
+
show_url: bool = False,
|
|
324
|
+
) -> None:
|
|
325
|
+
"""Display a list of problems in a table.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
problems: List of (Problem, ProblemProgress or None) tuples
|
|
329
|
+
show_url: Whether to include URL column
|
|
330
|
+
"""
|
|
331
|
+
if not problems:
|
|
332
|
+
console.print("[yellow]No problems found.[/yellow]")
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
table = Table(title="Problems", box=box.SIMPLE)
|
|
336
|
+
table.add_column("#", style="dim", width=4)
|
|
337
|
+
table.add_column("Title", style="bold", max_width=40)
|
|
338
|
+
table.add_column("Diff", justify="center", width=8)
|
|
339
|
+
table.add_column("Category", max_width=25)
|
|
340
|
+
table.add_column("Status", justify="center", width=10)
|
|
341
|
+
table.add_column("EF", justify="right", width=5)
|
|
342
|
+
|
|
343
|
+
if show_url:
|
|
344
|
+
table.add_column("URL", max_width=30)
|
|
345
|
+
|
|
346
|
+
for problem, progress in problems:
|
|
347
|
+
diff_style = DIFFICULTY_STYLES.get(problem.difficulty, Style())
|
|
348
|
+
|
|
349
|
+
if progress:
|
|
350
|
+
if progress.solved:
|
|
351
|
+
status = "[green]Solved[/green]"
|
|
352
|
+
else:
|
|
353
|
+
status = f"[yellow]{progress.attempts}x[/yellow]"
|
|
354
|
+
ef = f"{progress.easiness_factor:.1f}"
|
|
355
|
+
else:
|
|
356
|
+
status = "[dim]New[/dim]"
|
|
357
|
+
ef = "-"
|
|
358
|
+
|
|
359
|
+
# Make title clickable
|
|
360
|
+
title_link = make_link(str(problem.url), problem.title[:38])
|
|
361
|
+
|
|
362
|
+
row = [
|
|
363
|
+
str(problem.id or "-"),
|
|
364
|
+
title_link,
|
|
365
|
+
f"[{diff_style}]{problem.difficulty.value}[/]",
|
|
366
|
+
problem.category[:23],
|
|
367
|
+
status,
|
|
368
|
+
ef,
|
|
369
|
+
]
|
|
370
|
+
|
|
371
|
+
if show_url:
|
|
372
|
+
row.append(str(problem.url)[:28])
|
|
373
|
+
|
|
374
|
+
table.add_row(*row)
|
|
375
|
+
|
|
376
|
+
console.print(table)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def display_session_summary(
|
|
380
|
+
reviewed: int,
|
|
381
|
+
total_due: int,
|
|
382
|
+
qualities: list[int],
|
|
383
|
+
) -> None:
|
|
384
|
+
"""Display summary at end of review session.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
reviewed: Number of problems reviewed
|
|
388
|
+
total_due: Total that were due
|
|
389
|
+
qualities: List of quality ratings given
|
|
390
|
+
"""
|
|
391
|
+
console.print()
|
|
392
|
+
|
|
393
|
+
if not qualities:
|
|
394
|
+
console.print("[yellow]No problems reviewed.[/yellow]")
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
avg_quality = sum(qualities) / len(qualities)
|
|
398
|
+
|
|
399
|
+
summary = [
|
|
400
|
+
"[bold]Session Complete![/bold]",
|
|
401
|
+
"",
|
|
402
|
+
f"Problems reviewed: [cyan]{reviewed}[/cyan]/{total_due}",
|
|
403
|
+
f"Average quality: [cyan]{avg_quality:.1f}[/cyan]/5",
|
|
404
|
+
]
|
|
405
|
+
|
|
406
|
+
# Add encouragement based on performance
|
|
407
|
+
if avg_quality >= 4:
|
|
408
|
+
summary.append("")
|
|
409
|
+
summary.append("[green]Excellent session! Keep up the great work![/green]")
|
|
410
|
+
elif avg_quality >= 3:
|
|
411
|
+
summary.append("")
|
|
412
|
+
summary.append("[yellow]Good progress! Keep practicing.[/yellow]")
|
|
413
|
+
else:
|
|
414
|
+
summary.append("")
|
|
415
|
+
summary.append(
|
|
416
|
+
"[yellow]Some tough ones today. You'll get them next time![/yellow]"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
console.print(
|
|
420
|
+
Panel(
|
|
421
|
+
"\n".join(summary),
|
|
422
|
+
border_style="cyan",
|
|
423
|
+
box=box.ROUNDED,
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def display_success(message: str) -> None:
|
|
429
|
+
"""Display a success message."""
|
|
430
|
+
console.print(f"[green]{message}[/green]")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def display_error(message: str) -> None:
|
|
434
|
+
"""Display an error message."""
|
|
435
|
+
console.print(f"[red]Error: {message}[/red]")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def display_warning(message: str) -> None:
|
|
439
|
+
"""Display a warning message."""
|
|
440
|
+
console.print(f"[yellow]Warning: {message}[/yellow]")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def display_info(message: str) -> None:
|
|
444
|
+
"""Display an info message."""
|
|
445
|
+
console.print(f"[dim]{message}[/dim]")
|