pockit 0.2.0__tar.gz

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.
pockit-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: pockit
3
+ Version: 0.2.0
4
+ Summary: A modern CLI for pockit-cloud storage
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: typer>=0.9
7
+ Requires-Dist: rich>=13
8
+ Requires-Dist: gradio-client>=1.0
@@ -0,0 +1,5 @@
1
+ """pockit — modern cloud storage CLI."""
2
+
3
+ from pockit.main import app
4
+
5
+ __all__ = ["app"]
@@ -0,0 +1,788 @@
1
+ """
2
+ pockit — a modern, interactive CLI for pockit-cloud storage.
3
+ """
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import platform
9
+ import shutil
10
+ import signal
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ import questionary
16
+ import typer
17
+ from gradio_client import Client, handle_file
18
+ from rich import box
19
+ from rich.console import Console
20
+ from rich.panel import Panel
21
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
22
+ from rich.table import Table
23
+ from rich.theme import Theme
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Theme & console
27
+ # ---------------------------------------------------------------------------
28
+
29
+ THEME = Theme(
30
+ {
31
+ "primary": "bold cyan",
32
+ "success": "bold green",
33
+ "error": "bold red",
34
+ "warning": "bold yellow",
35
+ "muted": "dim white",
36
+ "filename": "bold magenta",
37
+ "header": "bold white on #1a1a2e",
38
+ "label": "cyan",
39
+ }
40
+ )
41
+
42
+ console = Console(theme=THEME, highlight=False)
43
+
44
+ LOGO = """[bold cyan]
45
+ ██████╗ ██████╗ ██████╗██╗ ██╗██╗████████╗
46
+ ██╔══██╗██╔═══██╗██╔════╝██║ ██╔╝██║╚══██╔══╝
47
+ ██████╔╝██║ ██║██║ █████╔╝ ██║ ██║
48
+ ██╔═══╝ ██║ ██║██║ ██╔═██╗ ██║ ██║
49
+ ██║ ╚██████╔╝╚██████╗██║ ██╗██║ ██║
50
+ ╚═╝ ╚═════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝
51
+ [/bold cyan][muted] Cloud storage for developers[/muted]
52
+ (Don't have an account? Sign up at [underline]https://pockit-cloud.github.io/home/[/underline])
53
+ """
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Credential cache (~/.pockit/session.json)
57
+ # ---------------------------------------------------------------------------
58
+
59
+ CACHE_DIR = Path.home() / ".pockit"
60
+ CACHE_FILE = CACHE_DIR / "session.json"
61
+
62
+ QSTYLE = questionary.Style([
63
+ ("qmark", "fg:#00d7ff bold"),
64
+ ("question", "bold"),
65
+ ("answer", "fg:#ff79c6 bold"),
66
+ ("pointer", "fg:#00d7ff bold"),
67
+ ("highlighted", "fg:#00d7ff bold"),
68
+ ("selected", "fg:#50fa7b"),
69
+ ])
70
+
71
+
72
+ def _save_creds(user_id: str, password: str) -> None:
73
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
74
+ CACHE_FILE.write_text(json.dumps({"user_id": user_id, "password": password}))
75
+ CACHE_FILE.chmod(0o600)
76
+
77
+
78
+ def _load_creds() -> Optional[dict]:
79
+ if CACHE_FILE.exists():
80
+ try:
81
+ return json.loads(CACHE_FILE.read_text())
82
+ except Exception:
83
+ pass
84
+ return None
85
+
86
+
87
+ def _clear_creds() -> None:
88
+ if CACHE_FILE.exists():
89
+ CACHE_FILE.unlink()
90
+
91
+
92
+ def ensure_logged_in() -> dict:
93
+ """Return cached credentials, or interactively prompt + validate them."""
94
+ creds = _load_creds()
95
+ if creds:
96
+ return creds
97
+
98
+ console.print(LOGO)
99
+ console.print(" [muted]Please log in to continue.[/muted]\n")
100
+
101
+ user_id = questionary.text("👤 User ID:", style=QSTYLE).ask()
102
+ if not user_id:
103
+ raise typer.Exit()
104
+
105
+ password = questionary.password("🔑 Password:", style=QSTYLE).ask()
106
+ if not password:
107
+ raise typer.Exit()
108
+
109
+ user_id = user_id.strip()
110
+ password = password.strip()
111
+
112
+ with _spinner("Authenticating…"):
113
+ client = get_client()
114
+ result = client.predict(user_id, password, api_name="/login")
115
+
116
+ status = extract_status(result, 0)
117
+
118
+ # Allow-list: backend returns "Found N files." on success, "Access Denied" on failure.
119
+ SUCCESS_KEYWORDS = ("success", "welcome", "logged in", "login ok",
120
+ "authenticated", "found")
121
+ if not any(kw in status.lower() for kw in SUCCESS_KEYWORDS):
122
+ console.print(f"\n [error]✗ {status}[/error]\n")
123
+ raise typer.Exit(code=1)
124
+
125
+ _save_creds(user_id, password)
126
+ console.print(f"\n [success]✓ Logged in as [bold]{user_id}[/bold][/success]\n")
127
+ return {"user_id": user_id, "password": password}
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Gradio client factory
132
+ # ---------------------------------------------------------------------------
133
+
134
+ def get_client() -> Client:
135
+ """Return a fresh Gradio client on every call."""
136
+ try:
137
+ return Client("pockit-cloud/cli-backend", verbose=False)
138
+ except Exception as exc:
139
+ console.print(f"\n [error]✗ Could not reach the backend: {exc}[/error]\n")
140
+ raise typer.Exit(code=1)
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Response parsers
145
+ # ---------------------------------------------------------------------------
146
+
147
+ def extract_status(result, index: int = 0) -> str:
148
+ raw = result[index] if isinstance(result, (list, tuple)) else result
149
+ if isinstance(raw, dict):
150
+ for key in ("value", "choices"):
151
+ if key in raw:
152
+ inner = raw[key]
153
+ if isinstance(inner, list):
154
+ return ", ".join(str(v) for v in inner) if inner else "(empty)"
155
+ return str(inner)
156
+ return str(raw)
157
+ return str(raw) if raw is not None else "(no response)"
158
+
159
+
160
+ def extract_files(raw) -> list[str]:
161
+ # Unwrap Gradio metadata dict e.g. {'choices': [...], '__type__': 'update'}
162
+ if isinstance(raw, dict):
163
+ choices = raw.get("choices", raw.get("value", []))
164
+ raw = choices if isinstance(choices, list) else [str(choices)]
165
+ if not isinstance(raw, list):
166
+ return []
167
+ # Flatten one wrapper level: [[f1, f2, ...]] → [f1, f2, ...]
168
+ if len(raw) == 1 and isinstance(raw[0], list) and len(raw[0]) > 2:
169
+ raw = raw[0]
170
+ seen: set = set()
171
+ result: list[str] = []
172
+ for item in raw:
173
+ # Gradio [value, label] pair → take value at index 0
174
+ if isinstance(item, (list, tuple)) and len(item) >= 1:
175
+ s = str(item[0]).strip()
176
+ elif isinstance(item, dict):
177
+ s = str(item.get("value", item.get("label", ""))).strip()
178
+ else:
179
+ s = str(item).strip()
180
+ if s and s not in seen:
181
+ seen.add(s)
182
+ result.append(s)
183
+ return result
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # UI helpers
188
+ # ---------------------------------------------------------------------------
189
+
190
+ def _spinner(label: str) -> Progress:
191
+ return Progress(
192
+ SpinnerColumn(spinner_name="dots", style="primary"),
193
+ TextColumn(f"[muted]{label}[/muted]"),
194
+ transient=True,
195
+ console=console,
196
+ )
197
+
198
+
199
+ def _render_file_table(files: list[str], title: str = "Your Files") -> None:
200
+ table = Table(
201
+ box=box.ROUNDED,
202
+ border_style="cyan",
203
+ header_style="bold cyan",
204
+ title=f"[bold white]{title}[/bold white]",
205
+ title_justify="left",
206
+ show_lines=False,
207
+ padding=(0, 1),
208
+ )
209
+ table.add_column("#", style="muted", width=4, justify="right")
210
+ table.add_column("Filename", style="filename", min_width=30)
211
+ for i, name in enumerate(files, 1):
212
+ table.add_row(str(i), name)
213
+ console.print()
214
+ console.print(table)
215
+ console.print()
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Typer app
220
+ # ---------------------------------------------------------------------------
221
+
222
+ app = typer.Typer(
223
+ name="pockit",
224
+ help="☁ pockit-cloud — your files, anywhere.",
225
+ add_completion=False,
226
+ rich_markup_mode="rich",
227
+ pretty_exceptions_enable=False,
228
+ )
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # Commands
233
+ # ---------------------------------------------------------------------------
234
+
235
+ @app.command()
236
+ def login():
237
+ """🔑 Log in to pockit-cloud (saves session locally)."""
238
+ _clear_creds() # force re-auth even if already cached
239
+ ensure_logged_in()
240
+
241
+
242
+ @app.command()
243
+ def logout():
244
+ """🚪 Clear your saved session."""
245
+ _clear_creds()
246
+ console.print("\n [success]✓ Logged out.[/success]\n")
247
+
248
+
249
+ @app.command()
250
+ def ls():
251
+ """📂 List all files in your cloud storage."""
252
+ creds = ensure_logged_in()
253
+ client = get_client()
254
+
255
+ with _spinner("Fetching file list…"):
256
+ result = client.predict(creds["user_id"], creds["password"], api_name="/list_files")
257
+
258
+ status = extract_status(result, 0)
259
+ files = extract_files(result[1])
260
+
261
+ if "invalid" in status.lower():
262
+ console.print(f"\n [error]✗ {status}[/error]\n")
263
+ _clear_creds()
264
+ raise typer.Exit(code=1)
265
+
266
+ if not files:
267
+ console.print(Panel(
268
+ "[muted]Your cloud storage is empty.\nUpload something with [bold]pockit upload[/bold].[/muted]",
269
+ title="[header] ☁ Your Files [/header]",
270
+ border_style="cyan",
271
+ padding=(1, 2),
272
+ ))
273
+ return
274
+
275
+ _render_file_table(files, title=f"☁ Your Files ({len(files)} item{'s' if len(files) != 1 else ''})")
276
+
277
+
278
+ @app.command()
279
+ def download():
280
+ """📥 Interactively select and download a file."""
281
+ creds = ensure_logged_in()
282
+ client = get_client()
283
+
284
+ # 1. Fetch file list
285
+ with _spinner("Fetching file list…"):
286
+ response = client.predict(creds["user_id"], creds["password"], api_name="/list_files")
287
+
288
+ files = extract_files(response[1])
289
+
290
+ if not files:
291
+ console.print("\n [error]✗ No files found in your account.[/error]\n")
292
+ return
293
+
294
+ # 2. Interactive autocomplete picker
295
+ console.print()
296
+ selected_file = questionary.autocomplete(
297
+ "🔍 Select file to download:",
298
+ choices=files,
299
+ style=QSTYLE,
300
+ ).ask()
301
+
302
+ if not selected_file:
303
+ return
304
+
305
+ # 3. Ask where to save
306
+ default_dest = str(Path.cwd() / selected_file)
307
+ dest_str = questionary.text(
308
+ "💾 Save as:",
309
+ default=default_dest,
310
+ style=QSTYLE,
311
+ ).ask()
312
+
313
+ if not dest_str:
314
+ return
315
+
316
+ destination = Path(dest_str.strip())
317
+
318
+ # 4. Download
319
+ console.print(f"\n [muted]Requesting[/muted] [filename]{selected_file}[/filename][muted]…[/muted]")
320
+
321
+ with _spinner(f"Downloading {selected_file}…"):
322
+ result = client.predict(
323
+ creds["user_id"],
324
+ creds["password"],
325
+ selected_file,
326
+ api_name="/download_file",
327
+ )
328
+
329
+ # /download_file now returns a filepath (temp file on the Gradio server)
330
+ tmp_path = result[0] if isinstance(result, (list, tuple)) else result
331
+ tmp_path = str(tmp_path).strip() if tmp_path else ""
332
+
333
+ # 5. Validate the returned path
334
+ if not tmp_path:
335
+ console.print(f"\n [error]✗ Server did not return a file.[/error]\n")
336
+ return
337
+
338
+ tmp = Path(tmp_path)
339
+ if not tmp.exists():
340
+ console.print(f"\n [error]✗ Downloaded temp file not found: {tmp_path!r}[/error]\n")
341
+ return
342
+
343
+ file_size = tmp.stat().st_size
344
+
345
+ if file_size == 0:
346
+ console.print(
347
+ f"\n [error]✗ Download aborted — server returned an empty file.\n"
348
+ f" Check your credentials or that [filename]{selected_file}[/filename] exists.[/error]\n"
349
+ )
350
+ return
351
+
352
+ # 6. Copy temp file to destination
353
+ destination.parent.mkdir(parents=True, exist_ok=True)
354
+ shutil.copy2(tmp_path, destination)
355
+
356
+ console.print(Panel(
357
+ f"[label]File:[/label] [filename]{selected_file}[/filename]\n"
358
+ f"[label]Saved to:[/label] [bold white]{destination}[/bold white]\n"
359
+ f"[label]Size:[/label] {file_size / 1024:.1f} KB",
360
+ title="[header] ⬇ Download Complete [/header]",
361
+ border_style="green",
362
+ padding=(1, 2),
363
+ ))
364
+
365
+
366
+ @app.command()
367
+ def upload():
368
+ """⬆ Interactively select and upload a local file."""
369
+ creds = ensure_logged_in()
370
+
371
+ # 1. File path with tab-completion
372
+ console.print()
373
+ filepath_str = questionary.path(
374
+ "📁 Select file to upload:",
375
+ style=QSTYLE,
376
+ ).ask()
377
+
378
+ if not filepath_str:
379
+ return
380
+
381
+ filepath = Path(filepath_str.strip())
382
+ if not filepath.is_file():
383
+ console.print(f"\n [error]✗ File not found: {filepath}[/error]\n")
384
+ return
385
+
386
+ # 2. Optional custom cloud name
387
+ custom_name = questionary.text(
388
+ "✏️ Custom cloud name (leave blank to keep original):",
389
+ default="",
390
+ style=QSTYLE,
391
+ ).ask()
392
+
393
+ if custom_name is None:
394
+ return
395
+
396
+ custom_name = custom_name.strip()
397
+ display_name = custom_name or filepath.name
398
+ file_size_kb = filepath.stat().st_size / 1024
399
+
400
+ # 3. Upload with progress bar
401
+ with Progress(
402
+ SpinnerColumn(spinner_name="dots", style="primary"),
403
+ TextColumn(f"[muted]Uploading[/muted] [filename]{display_name}[/filename]…"),
404
+ BarColumn(bar_width=28, style="cyan", complete_style="green"),
405
+ transient=True,
406
+ console=console,
407
+ ) as progress:
408
+ progress.add_task("upload", total=None)
409
+ client = get_client()
410
+ result = client.predict(
411
+ creds["user_id"],
412
+ creds["password"],
413
+ handle_file(str(filepath)),
414
+ custom_name,
415
+ api_name="/upload_file",
416
+ )
417
+
418
+ status = extract_status(result, 0)
419
+
420
+ console.print(Panel(
421
+ f"[label]Local:[/label] [bold white]{filepath}[/bold white]\n"
422
+ f"[label]Name:[/label] [filename]{display_name}[/filename]\n"
423
+ f"[label]Size:[/label] {file_size_kb:.1f} KB\n"
424
+ f"[label]Status:[/label] {status}",
425
+ title="[header] ⬆ Upload Complete [/header]",
426
+ border_style="green",
427
+ padding=(1, 2),
428
+ ))
429
+
430
+
431
+ @app.command()
432
+ def chpasswd():
433
+ """🔒 Change your account password."""
434
+ creds = ensure_logged_in()
435
+
436
+ console.print()
437
+ new_password = questionary.password("🔑 New password:", style=QSTYLE).ask()
438
+ if not new_password:
439
+ return
440
+
441
+ confirm = questionary.password("🔑 Confirm new password:", style=QSTYLE).ask()
442
+ if not confirm:
443
+ return
444
+
445
+ if new_password.strip() != confirm.strip():
446
+ console.print("\n [error]✗ Passwords do not match.[/error]\n")
447
+ return
448
+
449
+ new_password = new_password.strip()
450
+
451
+ with _spinner("Updating password…"):
452
+ client = get_client()
453
+ result = client.predict(
454
+ creds["user_id"],
455
+ creds["password"],
456
+ new_password,
457
+ api_name="/change_password",
458
+ )
459
+
460
+ status = extract_status(result, 0)
461
+ ok = "invalid" not in status.lower() and "error" not in status.lower()
462
+
463
+ console.print(Panel(
464
+ f"[label]User:[/label] {creds['user_id']}\n[label]Status:[/label] {status}",
465
+ title=f"[header] {'✓' if ok else '✗'} Password Change [/header]",
466
+ border_style="green" if ok else "red",
467
+ padding=(1, 2),
468
+ ))
469
+
470
+ if ok:
471
+ _save_creds(creds["user_id"], new_password)
472
+
473
+
474
+ @app.command()
475
+ def delete():
476
+ """🗑 Interactively select and delete a file."""
477
+ creds = ensure_logged_in()
478
+ client = get_client()
479
+
480
+ # 1. Fetch file list
481
+ with _spinner("Fetching file list…"):
482
+ response = client.predict(creds["user_id"], creds["password"], api_name="/list_files")
483
+
484
+ files = extract_files(response[1])
485
+
486
+ if not files:
487
+ console.print("\n [error]✗ No files found in your account.[/error]\n")
488
+ return
489
+
490
+ # 2. Select file
491
+ console.print()
492
+ selected_file = questionary.select(
493
+ "🗑 Select file to delete:",
494
+ choices=files,
495
+ style=QSTYLE,
496
+ ).ask()
497
+
498
+ if not selected_file:
499
+ return
500
+
501
+ # 3. Confirm — shown in red so it feels serious
502
+ console.print()
503
+ confirmed = questionary.confirm(
504
+ f"⚠ Permanently delete '{selected_file}'? This cannot be undone.",
505
+ default=False,
506
+ style=questionary.Style([
507
+ ("qmark", "fg:#ff5555 bold"),
508
+ ("question", "bold"),
509
+ ("answer", "fg:#ff5555 bold"),
510
+ ]),
511
+ ).ask()
512
+
513
+ if not confirmed:
514
+ console.print("\n [muted]Cancelled.[/muted]\n")
515
+ return
516
+
517
+ # 4. Delete
518
+ with _spinner(f"Deleting {selected_file}…"):
519
+ result = client.predict(
520
+ creds["user_id"],
521
+ creds["password"],
522
+ selected_file,
523
+ api_name="/delete_file",
524
+ )
525
+
526
+ status = extract_status(result, 0)
527
+ ok = any(w in status.lower() for w in ("delet", "success", "removed"))
528
+
529
+ console.print(Panel(
530
+ f"[label]File:[/label] [filename]{selected_file}[/filename]\n"
531
+ f"[label]Status:[/label] {status}",
532
+ title=f"[header] {'🗑 Deleted' if ok else '✗ Delete Failed'} [/header]",
533
+ border_style="green" if ok else "red",
534
+ padding=(1, 2),
535
+ ))
536
+
537
+
538
+ # ---------------------------------------------------------------------------
539
+ # Magic Sync helpers
540
+ # ---------------------------------------------------------------------------
541
+
542
+ SYNC_INTERVAL = 10 # seconds between cloud polls
543
+
544
+
545
+ def _get_sync_folder() -> Path:
546
+ """Return the platform-appropriate Magic Sync folder, creating it if needed."""
547
+ system = platform.system()
548
+ if system == "Darwin":
549
+ folder = Path.home() / "Desktop" / "Pockit-magicSync"
550
+ elif system == "Windows":
551
+ folder = Path.home() / "Desktop" / "Pockit-magicSync"
552
+ else:
553
+ # Linux: respect XDG_DESKTOP_DIR, fall back to ~/Desktop, then ~/Pockit-magicSync
554
+ xdg = os.environ.get("XDG_DESKTOP_DIR", "")
555
+ if xdg and Path(xdg).is_dir():
556
+ folder = Path(xdg) / "Pockit-magicSync"
557
+ elif (Path.home() / "Desktop").is_dir():
558
+ folder = Path.home() / "Desktop" / "Pockit-magicSync"
559
+ else:
560
+ folder = Path.home() / "Pockit-magicSync"
561
+ folder.mkdir(parents=True, exist_ok=True)
562
+ return folder
563
+
564
+
565
+ def _file_hash(path: Path) -> str:
566
+ """MD5 hash of a local file's contents."""
567
+ h = hashlib.md5()
568
+ with open(path, "rb") as f:
569
+ for chunk in iter(lambda: f.read(65536), b""):
570
+ h.update(chunk)
571
+ return h.hexdigest()
572
+
573
+
574
+ def _local_snapshot(folder: Path) -> dict:
575
+ """Return {filename: md5_hash} for every file directly inside folder."""
576
+ snap = {}
577
+ for p in folder.iterdir():
578
+ if p.is_file() and not p.name.startswith(".pockit_tmp_"):
579
+ try:
580
+ snap[p.name] = _file_hash(p)
581
+ except OSError:
582
+ pass
583
+ return snap
584
+
585
+
586
+ def _cloud_list_sync(client: Client, creds: dict) -> list:
587
+ """Return list of filenames currently in the cloud (sync version)."""
588
+ try:
589
+ result = client.predict(creds["user_id"], creds["password"], api_name="/list_files")
590
+ return extract_files(result[1])
591
+ except Exception:
592
+ return []
593
+
594
+
595
+ def _cloud_download_sync(client: Client, creds: dict, filename: str, dest: Path) -> bool:
596
+ """Download a single cloud file to dest. Returns True on success."""
597
+ try:
598
+ result = client.predict(
599
+ creds["user_id"], creds["password"], filename,
600
+ api_name="/download_file",
601
+ )
602
+ tmp_path = result[0] if isinstance(result, (list, tuple)) else result
603
+ tmp_path = str(tmp_path).strip() if tmp_path else ""
604
+ if not tmp_path or not Path(tmp_path).exists():
605
+ return False
606
+ shutil.copy2(tmp_path, dest)
607
+ return True
608
+ except Exception:
609
+ return False
610
+
611
+
612
+ def _cloud_upload_sync(client: Client, creds: dict, filepath: Path) -> bool:
613
+ """Upload a local file to the cloud. Returns True on success."""
614
+ try:
615
+ result = client.predict(
616
+ creds["user_id"], creds["password"],
617
+ handle_file(str(filepath)),
618
+ filepath.name,
619
+ api_name="/upload_file",
620
+ )
621
+ status = extract_status(result, 0)
622
+ return "error" not in status.lower() and "invalid" not in status.lower()
623
+ except Exception:
624
+ return False
625
+
626
+
627
+ def _cloud_delete_sync(client: Client, creds: dict, filename: str) -> bool:
628
+ """Delete a file from the cloud. Returns True on success."""
629
+ try:
630
+ result = client.predict(
631
+ creds["user_id"], creds["password"], filename,
632
+ api_name="/delete_file",
633
+ )
634
+ status = extract_status(result, 0)
635
+ return any(w in status.lower() for w in ("delet", "success", "removed"))
636
+ except Exception:
637
+ return False
638
+
639
+
640
+ def _sync_log(icon: str, style: str, msg: str) -> None:
641
+ ts = time.strftime("%H:%M:%S")
642
+ console.print(f" [muted]{ts}[/muted] {icon} [{style}]{msg}[/{style}]")
643
+
644
+
645
+ # ---------------------------------------------------------------------------
646
+ # Magic Sync command
647
+ # ---------------------------------------------------------------------------
648
+
649
+ @app.command()
650
+ def magicsync():
651
+ """✨ Magic Sync — keep a local folder in sync with your cloud 24/7."""
652
+ creds = ensure_logged_in()
653
+ folder = _get_sync_folder()
654
+
655
+ console.print(Panel(
656
+ f"[label]Folder:[/label] [bold white]{folder}[/bold white]\n"
657
+ f"[label]User:[/label] [bold white]{creds['user_id']}[/bold white]\n\n"
658
+ f"[muted]Changes in the folder sync to the cloud, and vice versa.\n"
659
+ f"Press [bold]Ctrl+C[/bold] to stop.[/muted]",
660
+ title="[header] ✨ Magic Sync Active [/header]",
661
+ border_style="cyan",
662
+ padding=(1, 2),
663
+ ))
664
+
665
+ # Graceful Ctrl+C
666
+ stop = {"flag": False}
667
+
668
+ def _handle_sigint(sig, frame):
669
+ stop["flag"] = True
670
+
671
+ signal.signal(signal.SIGINT, _handle_sigint)
672
+
673
+ # ── Initial pull: download everything from cloud into the folder ─────────
674
+ client = get_client()
675
+ initial_cloud = _cloud_list_sync(client, creds)
676
+ for fname in initial_cloud:
677
+ dest = folder / fname
678
+ if not dest.exists():
679
+ ok = _cloud_download_sync(client, creds, fname, dest)
680
+ if ok:
681
+ _sync_log("⬇", "success", f"Initial pull: {fname}")
682
+ else:
683
+ _sync_log("✗", "error", f"Failed initial pull: {fname}")
684
+
685
+ # Seed state after initial pull
686
+ prev_local = _local_snapshot(folder)
687
+ prev_cloud = list(initial_cloud)
688
+
689
+ _sync_log("✓", "success", "Initial sync complete — watching for changes…")
690
+ console.print()
691
+
692
+ # ── Main loop ────────────────────────────────────────────────────────────
693
+ while not stop["flag"]:
694
+ # Sleep in small increments so Ctrl+C is responsive
695
+ for _ in range(SYNC_INTERVAL * 10):
696
+ if stop["flag"]:
697
+ break
698
+ time.sleep(0.1)
699
+
700
+ if stop["flag"]:
701
+ break
702
+
703
+ try:
704
+ client = get_client()
705
+ except Exception:
706
+ _sync_log("✗", "error", "Could not reach backend, retrying…")
707
+ continue
708
+
709
+ curr_local = _local_snapshot(folder)
710
+ curr_cloud = _cloud_list_sync(client, creds)
711
+ curr_cloud_set = set(curr_cloud)
712
+ prev_cloud_set = set(prev_cloud)
713
+ prev_local_set = set(prev_local)
714
+ curr_local_set = set(curr_local)
715
+
716
+ # Track files uploaded this cycle to avoid re-downloading them
717
+ uploaded_this_cycle: set = set()
718
+
719
+ # ── LOCAL → CLOUD ──────────────────────────────────────────────────
720
+
721
+ # New local files → upload
722
+ for fname in curr_local_set - prev_local_set:
723
+ ok = _cloud_upload_sync(client, creds, folder / fname)
724
+ uploaded_this_cycle.add(fname)
725
+ _sync_log("⬆", "success" if ok else "error",
726
+ f"{'Uploaded' if ok else 'Upload failed'}: {fname}")
727
+
728
+ # Modified local files → re-upload
729
+ for fname in curr_local_set & prev_local_set:
730
+ if curr_local[fname] != prev_local[fname]:
731
+ ok = _cloud_upload_sync(client, creds, folder / fname)
732
+ uploaded_this_cycle.add(fname)
733
+ _sync_log("⬆", "success" if ok else "error",
734
+ f"{'Updated cloud' if ok else 'Update failed'}: {fname}")
735
+
736
+ # Locally deleted files → delete from cloud
737
+ for fname in prev_local_set - curr_local_set:
738
+ if fname in curr_cloud_set:
739
+ ok = _cloud_delete_sync(client, creds, fname)
740
+ _sync_log("🗑", "warning" if ok else "error",
741
+ f"{'Deleted from cloud' if ok else 'Cloud delete failed'}: {fname}")
742
+
743
+ # ── CLOUD → LOCAL ──────────────────────────────────────────────────
744
+
745
+ # New cloud files → download
746
+ for fname in curr_cloud_set - prev_cloud_set:
747
+ if fname not in curr_local_set:
748
+ ok = _cloud_download_sync(client, creds, fname, folder / fname)
749
+ _sync_log("⬇", "success" if ok else "error",
750
+ f"{'Downloaded' if ok else 'Download failed'}: {fname}")
751
+
752
+ # Cloud-deleted files → delete locally
753
+ for fname in prev_cloud_set - curr_cloud_set:
754
+ local_path = folder / fname
755
+ if local_path.exists() and fname not in uploaded_this_cycle:
756
+ local_path.unlink()
757
+ _sync_log("🗑", "warning", f"Removed locally (deleted from cloud): {fname}")
758
+
759
+ # Files present in both cloud and local — check if cloud version changed
760
+ for fname in curr_cloud_set & curr_local_set - uploaded_this_cycle:
761
+ tmp_dest = folder / f".pockit_tmp_{fname}"
762
+ ok = _cloud_download_sync(client, creds, fname, tmp_dest)
763
+ if ok and tmp_dest.exists():
764
+ try:
765
+ remote_hash = _file_hash(tmp_dest)
766
+ local_hash = curr_local.get(fname, "")
767
+ if remote_hash != local_hash:
768
+ shutil.move(str(tmp_dest), str(folder / fname))
769
+ _sync_log("⬇", "success", f"Updated locally (cloud changed): {fname}")
770
+ else:
771
+ tmp_dest.unlink(missing_ok=True)
772
+ except OSError:
773
+ tmp_dest.unlink(missing_ok=True)
774
+ elif tmp_dest.exists():
775
+ tmp_dest.unlink(missing_ok=True)
776
+
777
+ # ── Update state ───────────────────────────────────────────────────
778
+ prev_local = _local_snapshot(folder)
779
+ prev_cloud = curr_cloud
780
+
781
+ console.print("\n [success]✓ Magic Sync stopped.[/success]\n")
782
+
783
+ # ---------------------------------------------------------------------------
784
+ # Entry point
785
+ # ---------------------------------------------------------------------------
786
+
787
+ if __name__ == "__main__":
788
+ app()
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: pockit
3
+ Version: 0.2.0
4
+ Summary: A modern CLI for pockit-cloud storage
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: typer>=0.9
7
+ Requires-Dist: rich>=13
8
+ Requires-Dist: gradio-client>=1.0
@@ -0,0 +1,9 @@
1
+ pyproject.toml
2
+ pockit/__init__.py
3
+ pockit/main.py
4
+ pockit.egg-info/PKG-INFO
5
+ pockit.egg-info/SOURCES.txt
6
+ pockit.egg-info/dependency_links.txt
7
+ pockit.egg-info/entry_points.txt
8
+ pockit.egg-info/requires.txt
9
+ pockit.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pockit = pockit.main:app
@@ -0,0 +1,3 @@
1
+ typer>=0.9
2
+ rich>=13
3
+ gradio-client>=1.0
@@ -0,0 +1 @@
1
+ pockit
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pockit"
7
+ version = "0.2.0"
8
+ description = "A modern CLI for pockit-cloud storage"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "typer>=0.9",
12
+ "rich>=13",
13
+ "gradio-client>=1.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ pockit = "pockit.main:app"
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["."]
21
+ include = ["pockit*"]
pockit-0.2.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+