televault 0.1.0__py3-none-any.whl → 2.0.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.
televault/tui.py ADDED
@@ -0,0 +1,632 @@
1
+ """Textual TUI for TeleVault."""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ from rich.console import Console
7
+ from textual.app import App, ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Container, Horizontal, Vertical
10
+ from textual.reactive import reactive
11
+ from textual.screen import Screen
12
+ from textual.widgets import (
13
+ Button,
14
+ DataTable,
15
+ Footer,
16
+ Header,
17
+ Input,
18
+ Label,
19
+ Static,
20
+ )
21
+
22
+ from .cli import format_size
23
+ from .config import Config
24
+ from .core import TeleVault
25
+
26
+ console = Console()
27
+
28
+
29
+ class VaultApp(App):
30
+ """Main TeleVault TUI Application."""
31
+
32
+ CSS = """
33
+ Screen {
34
+ align: center middle;
35
+ }
36
+
37
+ #main-container {
38
+ width: 100%;
39
+ height: 100%;
40
+ }
41
+
42
+ #sidebar {
43
+ width: 25;
44
+ height: 100%;
45
+ dock: left;
46
+ background: $surface-darken-1;
47
+ padding: 1;
48
+ }
49
+
50
+ #content {
51
+ width: 100%;
52
+ height: 100%;
53
+ padding: 1;
54
+ }
55
+
56
+ .sidebar-button {
57
+ width: 100%;
58
+ margin: 1 0;
59
+ }
60
+
61
+ .stats-box {
62
+ height: auto;
63
+ padding: 1;
64
+ background: $surface-darken-2;
65
+ border: solid $primary;
66
+ margin: 1 0;
67
+ }
68
+
69
+ DataTable {
70
+ height: 100%;
71
+ }
72
+
73
+ #status-bar {
74
+ dock: bottom;
75
+ height: 3;
76
+ background: $surface-darken-1;
77
+ color: $text;
78
+ padding: 0 2;
79
+ content-align: center middle;
80
+ }
81
+
82
+ #search-input {
83
+ width: 100%;
84
+ margin: 1 0;
85
+ }
86
+
87
+ .title {
88
+ text-align: center;
89
+ text-style: bold;
90
+ color: $primary;
91
+ }
92
+
93
+ ProgressBar {
94
+ width: 100%;
95
+ margin: 1 0;
96
+ }
97
+
98
+ #login-screen {
99
+ align: center middle;
100
+ }
101
+
102
+ .login-container {
103
+ width: 60;
104
+ height: auto;
105
+ border: solid $primary;
106
+ padding: 2;
107
+ background: $surface;
108
+ }
109
+
110
+ .info-text {
111
+ color: $text-muted;
112
+ text-align: center;
113
+ }
114
+ """
115
+
116
+ BINDINGS = [
117
+ Binding("q", "quit", "Quit", show=True),
118
+ Binding("r", "refresh", "Refresh", show=True),
119
+ Binding("u", "upload", "Upload", show=True),
120
+ Binding("d", "download", "Download", show=True),
121
+ Binding("delete", "delete", "Delete", show=True),
122
+ Binding("s", "search", "Search", show=True),
123
+ Binding("l", "login", "Login", show=True),
124
+ ]
125
+
126
+ vault = reactive(None)
127
+ files = reactive([])
128
+ status_message = reactive("Ready")
129
+ is_authenticated = reactive(False)
130
+
131
+ def __init__(self):
132
+ super().__init__()
133
+ self.vault_instance = None
134
+ self.config = Config.load_or_create()
135
+ self.selected_file = None
136
+
137
+ def compose(self) -> ComposeResult:
138
+ """Compose the main UI."""
139
+ yield Header(show_clock=True)
140
+
141
+ if not self.is_authenticated:
142
+ yield from self._compose_login_screen()
143
+ else:
144
+ yield from self._compose_main_screen()
145
+
146
+ yield Footer()
147
+
148
+ def _compose_login_screen(self) -> ComposeResult:
149
+ """Compose the login screen."""
150
+ with Container(id="login-screen"), Container(classes="login-container"):
151
+ yield Label("TeleVault", classes="title")
152
+ yield Label("")
153
+ yield Label("Welcome to TeleVault", classes="info-text")
154
+ yield Label("Your encrypted Telegram cloud storage", classes="info-text")
155
+ yield Label("")
156
+
157
+ yield Label("Status: Not Authenticated", id="auth-status")
158
+ yield Label("")
159
+
160
+ with Horizontal():
161
+ yield Button("🔐 Login", id="btn-login", variant="primary")
162
+ yield Button("❌ Exit", id="btn-exit", variant="error")
163
+
164
+ yield Label("")
165
+ yield Label("Press Ctrl+C to exit anytime", classes="info-text")
166
+
167
+ def _compose_main_screen(self) -> ComposeResult:
168
+ """Compose the main application screen."""
169
+ with Container(id="main-container"):
170
+ # Sidebar
171
+ with Vertical(id="sidebar"):
172
+ yield Label("📁 TeleVault", classes="title")
173
+ yield Label("")
174
+
175
+ # Stats
176
+ with Container(classes="stats-box"):
177
+ yield Label("📊 Statistics", classes="title")
178
+ yield Label("Files: 0", id="stat-files")
179
+ yield Label("Total Size: 0 B", id="stat-size")
180
+ yield Label("Storage: -", id="stat-storage")
181
+
182
+ yield Label("")
183
+ yield Button("📤 Upload", id="btn-upload", classes="sidebar-button")
184
+ yield Button("🔍 Search", id="btn-search", classes="sidebar-button")
185
+ yield Button("🔄 Refresh", id="btn-refresh", classes="sidebar-button")
186
+ yield Button("ℹ️ Status", id="btn-status", classes="sidebar-button")
187
+ yield Button("👤 Whoami", id="btn-whoami", classes="sidebar-button")
188
+ yield Button("🔓 Logout", id="btn-logout", classes="sidebar-button")
189
+
190
+ # Main content
191
+ with Vertical(id="content"):
192
+ yield Label("📁 File Browser", classes="title")
193
+ yield Input(placeholder="Search files...", id="search-input")
194
+
195
+ # File table
196
+ table = DataTable(id="file-table")
197
+ table.add_columns("ID", "Name", "Size", "Chunks", "Encrypted", "Actions")
198
+ table.cursor_type = "row"
199
+ table.zebra_stripes = True
200
+ yield table
201
+
202
+ # Status bar
203
+ yield Static(
204
+ "Ready - Press 'q' to quit, 'u' to upload, 'd' to download", id="status-bar"
205
+ )
206
+
207
+ async def on_mount(self) -> None:
208
+ """Handle app mount."""
209
+ self.title = "TeleVault - Encrypted Cloud Storage"
210
+
211
+ # Check authentication on mount
212
+ await self._check_auth()
213
+
214
+ async def _check_auth(self) -> None:
215
+ """Check if user is authenticated."""
216
+ try:
217
+ self.vault_instance = TeleVault()
218
+ await self.vault_instance.connect(skip_channel=True)
219
+
220
+ if await self.vault_instance.is_authenticated():
221
+ self.is_authenticated = True
222
+ await self.vault_instance.disconnect()
223
+ self.refresh(layout=True)
224
+ await self._load_files()
225
+ else:
226
+ await self.vault_instance.disconnect()
227
+ self.is_authenticated = False
228
+ self.status_message = "Not logged in - Press 'l' to login"
229
+ except Exception as e:
230
+ self.status_message = f"Error: {str(e)}"
231
+ self.is_authenticated = False
232
+
233
+ async def _load_files(self) -> None:
234
+ """Load files from vault."""
235
+ if not self.is_authenticated:
236
+ return
237
+
238
+ try:
239
+ self.status_message = "Loading files..."
240
+ vault = TeleVault()
241
+ await vault.connect()
242
+
243
+ files = await vault.list_files()
244
+ self.files = files
245
+
246
+ # Update table
247
+ table = self.query_one("#file-table", DataTable)
248
+ table.clear()
249
+
250
+ for f in files:
251
+ table.add_row(
252
+ f.id[:8],
253
+ f.name[:40] + "..." if len(f.name) > 40 else f.name,
254
+ format_size(f.size),
255
+ str(f.chunk_count),
256
+ "🔒" if f.encrypted else "📄",
257
+ "[Enter] Download | [Del] Delete",
258
+ )
259
+
260
+ # Update stats
261
+ total_size = sum(f.size for f in files)
262
+ self.query_one("#stat-files", Label).update(f"Files: {len(files)}")
263
+ self.query_one("#stat-size", Label).update(f"Total Size: {format_size(total_size)}")
264
+
265
+ await vault.disconnect()
266
+ self.status_message = f"Loaded {len(files)} files"
267
+
268
+ except Exception as e:
269
+ self.status_message = f"Error loading files: {str(e)}"
270
+
271
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
272
+ """Handle button presses."""
273
+ button_id = event.button.id
274
+
275
+ if button_id == "btn-login":
276
+ await self._do_login()
277
+ elif button_id == "btn-exit":
278
+ self.exit()
279
+ elif button_id == "btn-upload":
280
+ await self._do_upload()
281
+ elif button_id == "btn-search":
282
+ await self._do_search()
283
+ elif button_id == "btn-refresh":
284
+ await self._load_files()
285
+ elif button_id == "btn-status":
286
+ await self._show_status()
287
+ elif button_id == "btn-whoami":
288
+ await self._show_whoami()
289
+ elif button_id == "btn-logout":
290
+ await self._do_logout()
291
+
292
+ async def _do_login(self) -> None:
293
+ """Show login dialog."""
294
+ self.push_screen(LoginScreen())
295
+
296
+ async def _do_upload(self) -> None:
297
+ """Show upload dialog."""
298
+ if not self.is_authenticated:
299
+ self.notify("Please login first", severity="error")
300
+ return
301
+ self.push_screen(UploadScreen())
302
+
303
+ async def _do_search(self) -> None:
304
+ """Focus search input."""
305
+ if not self.is_authenticated:
306
+ self.notify("Please login first", severity="error")
307
+ return
308
+ self.query_one("#search-input", Input).focus()
309
+
310
+ async def _show_status(self) -> None:
311
+ """Show vault status."""
312
+ if not self.is_authenticated:
313
+ self.notify("Please login first", severity="error")
314
+ return
315
+
316
+ try:
317
+ vault = TeleVault()
318
+ await vault.connect()
319
+ status = await vault.get_status()
320
+ await vault.disconnect()
321
+
322
+ message = f"""
323
+ 📊 Vault Status
324
+
325
+ Channel ID: {status["channel_id"]}
326
+ Files: {status["file_count"]}
327
+ Total Size: {format_size(status["total_size"])}
328
+ Stored Size: {format_size(status["stored_size"])}
329
+ Compression: {status["compression_ratio"]:.1%}
330
+ """.strip()
331
+
332
+ self.notify(message, title="Status", timeout=10)
333
+ except Exception as e:
334
+ self.notify(f"Error: {str(e)}", severity="error")
335
+
336
+ async def _show_whoami(self) -> None:
337
+ """Show current user info."""
338
+ if not self.is_authenticated:
339
+ self.notify("Please login first", severity="error")
340
+ return
341
+
342
+ try:
343
+ vault = TeleVault()
344
+ await vault.connect()
345
+ me = await vault.telegram._client.get_me()
346
+ await vault.disconnect()
347
+
348
+ if me:
349
+ name = f"{me.first_name or ''} {me.last_name or ''}".strip()
350
+ username = f"@{me.username}" if me.username else "No username"
351
+
352
+ message = f"""
353
+ 👤 Account Info
354
+
355
+ Name: {name}
356
+ Username: {username}
357
+ ID: {me.id}
358
+ """.strip()
359
+
360
+ self.notify(message, title="Whoami", timeout=10)
361
+ except Exception as e:
362
+ self.notify(f"Error: {str(e)}", severity="error")
363
+
364
+ async def _do_logout(self) -> None:
365
+ """Logout user."""
366
+ config_dir = Path.home() / ".config" / "televault"
367
+ telegram_config = config_dir / "telegram.json"
368
+
369
+ if telegram_config.exists():
370
+ telegram_config.unlink()
371
+ self.is_authenticated = False
372
+ self.files = []
373
+ self.refresh(layout=True)
374
+ self.notify("Logged out successfully", severity="information")
375
+ else:
376
+ self.notify("Not logged in", severity="warning")
377
+
378
+ def action_refresh(self) -> None:
379
+ """Refresh action."""
380
+ asyncio.create_task(self._load_files())
381
+
382
+ def action_upload(self) -> None:
383
+ """Upload action."""
384
+ asyncio.create_task(self._do_upload())
385
+
386
+ def action_download(self) -> None:
387
+ """Download action."""
388
+ self.notify("Select a file and press Enter to download", severity="information")
389
+
390
+ def action_delete(self) -> None:
391
+ """Delete action."""
392
+ self.notify("Select a file and press Delete to remove", severity="information")
393
+
394
+ def action_search(self) -> None:
395
+ """Search action."""
396
+ asyncio.create_task(self._do_search())
397
+
398
+ def action_login(self) -> None:
399
+ """Login action."""
400
+ asyncio.create_task(self._do_login())
401
+
402
+ async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
403
+ """Handle file selection."""
404
+ if not self.files:
405
+ return
406
+
407
+ row_index = event.cursor_row
408
+ if 0 <= row_index < len(self.files):
409
+ self.selected_file = self.files[row_index]
410
+ await self._download_file(self.selected_file)
411
+
412
+ async def _download_file(self, file_metadata) -> None:
413
+ """Download selected file."""
414
+ self.push_screen(DownloadScreen(file_metadata))
415
+
416
+ def watch_status_message(self, message: str) -> None:
417
+ """Update status bar when message changes."""
418
+ try:
419
+ status_bar = self.query_one("#status-bar", Static)
420
+ status_bar.update(message)
421
+ except Exception:
422
+ pass
423
+
424
+
425
+ class LoginScreen(Screen):
426
+ """Login screen for authentication."""
427
+
428
+ def compose(self) -> ComposeResult:
429
+ with Container(classes="login-container"):
430
+ yield Label("🔐 Login to Telegram", classes="title")
431
+ yield Label("")
432
+ yield Label("Enter your phone number:", classes="info-text")
433
+ yield Input(placeholder="+1234567890", id="phone-input")
434
+ yield Label("")
435
+ yield Button("Send Code", id="btn-send-code", variant="primary")
436
+ yield Button("Cancel", id="btn-cancel")
437
+
438
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
439
+ if event.button.id == "btn-send-code":
440
+ phone = self.query_one("#phone-input", Input).value
441
+ if phone:
442
+ await self._do_login(phone)
443
+ elif event.button.id == "btn-cancel":
444
+ self.app.pop_screen()
445
+
446
+ async def _do_login(self, phone: str) -> None:
447
+ """Perform login."""
448
+ try:
449
+ self.app.notify("Connecting to Telegram...")
450
+
451
+ vault = TeleVault()
452
+ await vault.connect(skip_channel=True)
453
+
454
+ if await vault.is_authenticated():
455
+ self.app.notify("Already logged in!")
456
+ self.app.is_authenticated = True
457
+ self.app.refresh(layout=True)
458
+ await vault.disconnect()
459
+ self.app.pop_screen()
460
+ return
461
+
462
+ # Show code input screen
463
+ self.app.push_screen(CodeScreen(vault, phone))
464
+
465
+ except Exception as e:
466
+ self.app.notify(f"Login error: {str(e)}", severity="error")
467
+
468
+
469
+ class CodeScreen(Screen):
470
+ """Screen for entering verification code."""
471
+
472
+ def __init__(self, vault: TeleVault, phone: str):
473
+ super().__init__()
474
+ self.vault = vault
475
+ self.phone = phone
476
+
477
+ def compose(self) -> ComposeResult:
478
+ with Container(classes="login-container"):
479
+ yield Label("📱 Verification Code", classes="title")
480
+ yield Label("")
481
+ yield Label(f"Enter the code sent to {self.phone}:", classes="info-text")
482
+ yield Input(placeholder="12345", id="code-input")
483
+ yield Label("")
484
+ yield Button("Verify", id="btn-verify", variant="primary")
485
+ yield Button("Back", id="btn-back")
486
+
487
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
488
+ if event.button.id == "btn-verify":
489
+ code = self.query_one("#code-input", Input).value
490
+ if code:
491
+ await self._verify_code(code)
492
+ elif event.button.id == "btn-back":
493
+ await self.vault.disconnect()
494
+ self.app.pop_screen()
495
+
496
+ async def _verify_code(self, code: str) -> None:
497
+ """Verify the code."""
498
+ try:
499
+ await self.vault.telegram._client.sign_in(self.phone, code)
500
+
501
+ # Save session
502
+ session_string = self.vault.telegram._client.session.save()
503
+ from .telegram import TelegramConfig
504
+
505
+ config = TelegramConfig.from_env()
506
+ config.session_string = session_string
507
+ config.save()
508
+
509
+ self.app.notify("✓ Login successful!")
510
+ self.app.is_authenticated = True
511
+ self.app.refresh(layout=True)
512
+ await self.vault.disconnect()
513
+ self.app.pop_screen()
514
+ self.app.pop_screen() # Pop login screen too
515
+
516
+ except Exception as e:
517
+ self.app.notify(f"Verification failed: {str(e)}", severity="error")
518
+
519
+
520
+ class UploadScreen(Screen):
521
+ """Screen for uploading files."""
522
+
523
+ def compose(self) -> ComposeResult:
524
+ with Container(classes="login-container"):
525
+ yield Label("📤 Upload File", classes="title")
526
+ yield Label("")
527
+ yield Label("Enter file path:", classes="info-text")
528
+ yield Input(placeholder="/path/to/file", id="path-input")
529
+ yield Label("")
530
+ yield Label("Password (optional):", classes="info-text")
531
+ yield Input(
532
+ placeholder="Leave empty to use env var", id="password-input", password=True
533
+ )
534
+ yield Label("")
535
+ with Horizontal():
536
+ yield Button("Upload", id="btn-do-upload", variant="primary")
537
+ yield Button("Cancel", id="btn-cancel")
538
+
539
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
540
+ if event.button.id == "btn-do-upload":
541
+ path = self.query_one("#path-input", Input).value
542
+ password = self.query_one("#password-input", Input).value
543
+ if path:
544
+ await self._upload_file(path, password or None)
545
+ elif event.button.id == "btn-cancel":
546
+ self.app.pop_screen()
547
+
548
+ async def _upload_file(self, path: str, password: str | None) -> None:
549
+ """Upload the file."""
550
+ try:
551
+ self.app.notify(f"Uploading {Path(path).name}...")
552
+
553
+ vault = TeleVault(password=password)
554
+ await vault.connect()
555
+
556
+ metadata = await vault.upload(path)
557
+ await vault.disconnect()
558
+
559
+ self.app.notify(f"✓ Uploaded: {metadata.name}")
560
+ self.app.pop_screen()
561
+
562
+ # Refresh file list
563
+ await self.app._load_files()
564
+
565
+ except Exception as e:
566
+ self.app.notify(f"Upload failed: {str(e)}", severity="error")
567
+
568
+
569
+ class DownloadScreen(Screen):
570
+ """Screen for downloading files."""
571
+
572
+ def __init__(self, file_metadata):
573
+ super().__init__()
574
+ self.file_metadata = file_metadata
575
+
576
+ def compose(self) -> ComposeResult:
577
+ with Container(classes="login-container"):
578
+ yield Label("📥 Download File", classes="title")
579
+ yield Label("")
580
+ yield Label(f"File: {self.file_metadata.name}", classes="info-text")
581
+ yield Label(f"Size: {format_size(self.file_metadata.size)}")
582
+ yield Label("")
583
+ yield Label("Output path (optional):", classes="info-text")
584
+ yield Input(placeholder="Current directory", id="output-input")
585
+ yield Label("")
586
+ if self.file_metadata.encrypted:
587
+ yield Label("Password:", classes="info-text")
588
+ yield Input(
589
+ placeholder="Enter decryption password", id="password-input", password=True
590
+ )
591
+ yield Label("")
592
+ with Horizontal():
593
+ yield Button("Download", id="btn-do-download", variant="primary")
594
+ yield Button("Cancel", id="btn-cancel")
595
+
596
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
597
+ if event.button.id == "btn-do-download":
598
+ output = self.query_one("#output-input", Input).value
599
+ password_input = (
600
+ self.query_one("#password-input", Input) if self.file_metadata.encrypted else None
601
+ )
602
+ password = password_input.value if password_input else None
603
+ await self._download_file(output or None, password or None)
604
+ elif event.button.id == "btn-cancel":
605
+ self.app.pop_screen()
606
+
607
+ async def _download_file(self, output: str | None, password: str | None) -> None:
608
+ """Download the file."""
609
+ try:
610
+ self.app.notify(f"Downloading {self.file_metadata.name}...")
611
+
612
+ vault = TeleVault(password=password)
613
+ await vault.connect()
614
+
615
+ output_path = await vault.download(self.file_metadata.id, output_path=output)
616
+ await vault.disconnect()
617
+
618
+ self.app.notify(f"✓ Downloaded to: {output_path}")
619
+ self.app.pop_screen()
620
+
621
+ except Exception as e:
622
+ self.app.notify(f"Download failed: {str(e)}", severity="error")
623
+
624
+
625
+ def run_tui():
626
+ """Run the TUI application."""
627
+ app = VaultApp()
628
+ app.run()
629
+
630
+
631
+ if __name__ == "__main__":
632
+ run_tui()