nia-sync 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.
main.py ADDED
@@ -0,0 +1,632 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Nia - Local Sync Engine
4
+
5
+ Keep your local folders and databases in sync with Nia cloud.
6
+ Real-time file watching with instant sync.
7
+
8
+ Usage:
9
+ nia # Start sync engine (default)
10
+ nia login # Authenticate with Nia
11
+ nia status # Show what's syncing
12
+ nia link ID PATH # Link a source to local folder
13
+ """
14
+ import os
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+ from rich.table import Table
19
+
20
+ from auth import login as do_login, logout as do_logout, is_authenticated, get_api_key
21
+ from config import get_sources, add_source, remove_source, enable_source_sync, NIA_SYNC_DIR, find_folder_path
22
+ from sync import sync_all_sources
23
+ from extractor import detect_source_type
24
+
25
+ app = typer.Typer(
26
+ name="nia",
27
+ help="[cyan]Nia Sync Engine[/cyan] — Keep local folders in sync with Nia cloud",
28
+ no_args_is_help=False,
29
+ rich_markup_mode="rich",
30
+ epilog="[dim]Quick start: [cyan]nia login[/cyan] → [cyan]nia status[/cyan] → [cyan]nia[/cyan][/dim]",
31
+ )
32
+ console = Console()
33
+
34
+
35
+ @app.callback(invoke_without_command=True)
36
+ def main(ctx: typer.Context):
37
+ """Start the sync daemon if no command specified."""
38
+ if ctx.invoked_subcommand is None:
39
+ # Default: start daemon mode with default values
40
+ daemon(watch=True, fallback_interval=600, refresh_interval=30)
41
+
42
+
43
+ @app.command()
44
+ def login():
45
+ """Authenticate with Nia using browser-based login."""
46
+ if is_authenticated():
47
+ console.print("[yellow]Already logged in.[/yellow]")
48
+ console.print(f"Config stored at: {NIA_SYNC_DIR}")
49
+ _check_local_sources()
50
+ return
51
+
52
+ console.print(Panel.fit(
53
+ "[bold cyan]Nia[/bold cyan]\n\n"
54
+ "Opening browser to authenticate...",
55
+ border_style="cyan",
56
+ ))
57
+
58
+ success = do_login()
59
+ if success:
60
+ console.print("[green]Successfully logged in![/green]")
61
+ _check_local_sources()
62
+ else:
63
+ console.print("[red]Login failed. Please try again.[/red]")
64
+ raise typer.Exit(1)
65
+
66
+
67
+ KNOWN_PATHS = {
68
+ "imessage": "~/Library/Messages/chat.db",
69
+ "safari_history": "~/Library/Safari/History.db",
70
+ "chrome_history": "~/Library/Application Support/Google/Chrome/Default/History",
71
+ "firefox_history": "~/Library/Application Support/Firefox/Profiles/*/places.sqlite",
72
+ }
73
+
74
+
75
+ def _check_local_sources():
76
+ """Check for indexed sources that exist locally and can be synced."""
77
+ sources = get_sources()
78
+ if not sources:
79
+ return
80
+
81
+ found_locally = []
82
+ need_sync_enable = []
83
+
84
+ for src in sources:
85
+ path = src.get("path")
86
+ detected_type = src.get("detected_type")
87
+
88
+ # If no path but known type, try standard path
89
+ if not path and detected_type and detected_type in KNOWN_PATHS:
90
+ path = KNOWN_PATHS[detected_type]
91
+ src["_detected_path"] = path
92
+
93
+ if not path:
94
+ continue
95
+
96
+ expanded = os.path.expanduser(path)
97
+ if os.path.exists(expanded):
98
+ found_locally.append(src)
99
+ src["_local_path"] = expanded
100
+ if not src.get("sync_enabled", False):
101
+ need_sync_enable.append(src)
102
+
103
+ if found_locally:
104
+ console.print(f"\n[green]Found {len(found_locally)} source(s) on this machine:[/green]")
105
+ for src in found_locally:
106
+ sync_status = "[green]✓ syncing[/green]" if src.get("sync_enabled") else "[yellow]○ not syncing[/yellow]"
107
+ console.print(f" • {src.get('display_name', 'Unknown')} {sync_status}")
108
+
109
+ if need_sync_enable:
110
+ console.print(f"\n[dim]Enabling sync for {len(need_sync_enable)} source(s)...[/dim]")
111
+ for src in need_sync_enable:
112
+ local_path = src.get("_local_path") or src.get("_detected_path")
113
+ if local_path and enable_source_sync(src["local_folder_id"], local_path):
114
+ console.print(f" [green]✓[/green] Enabled sync for {src.get('display_name')}")
115
+ console.print(f"\n[dim]Run [cyan]nia[/cyan] to start syncing.[/dim]")
116
+
117
+
118
+ @app.command()
119
+ def logout():
120
+ """Clear stored credentials."""
121
+ do_logout()
122
+ console.print("[green]✓ Logged out[/green]")
123
+
124
+
125
+ @app.command()
126
+ def upgrade():
127
+ """Upgrade Nia to the latest version."""
128
+ import subprocess
129
+ import sys
130
+
131
+ console.print("[dim]Checking for updates...[/dim]")
132
+
133
+ try:
134
+ result = subprocess.run(
135
+ [sys.executable, "-m", "pip", "install", "--upgrade", "nia-sync"],
136
+ capture_output=True,
137
+ text=True,
138
+ )
139
+
140
+ if "Successfully installed" in result.stdout:
141
+ console.print("[green]✓ Upgraded to latest version[/green]")
142
+ elif "Requirement already satisfied" in result.stdout:
143
+ console.print("[green]✓ Already on latest version[/green]")
144
+ else:
145
+ console.print("[yellow]No updates available[/yellow]")
146
+
147
+ except Exception as e:
148
+ console.print(f"[red]Upgrade failed: {e}[/red]")
149
+ console.print("[dim]Try manually: pip install --upgrade nia-sync[/dim]")
150
+ raise typer.Exit(1)
151
+
152
+
153
+ @app.command()
154
+ def status():
155
+ """Show sync status and configured sources."""
156
+ if not is_authenticated():
157
+ console.print("[red]Not logged in. Run [cyan]nia login[/cyan] first.[/red]")
158
+ raise typer.Exit(1)
159
+
160
+ console.print("[bold cyan]Nia Sync Status[/bold cyan]\n")
161
+
162
+ sources = get_sources()
163
+ if not sources:
164
+ console.print("[yellow]No sources configured.[/yellow]")
165
+ console.print("\n[dim]Add sources in the Nia web app, or run:[/dim] [cyan]nia add ~/path/to/folder[/cyan]")
166
+ return
167
+
168
+ table = Table(show_header=True)
169
+ table.add_column("ID", style="dim")
170
+ table.add_column("Name", style="cyan")
171
+ table.add_column("Path")
172
+ table.add_column("Type", style="green")
173
+ table.add_column("Status")
174
+
175
+ needs_link = []
176
+ for source in sources:
177
+ source_id = source.get("local_folder_id", "")[:8]
178
+ name = source.get("display_name", "")
179
+ path = source.get("path") or ""
180
+ detected_type = source.get("detected_type") or "folder"
181
+
182
+ # Check if source can be synced
183
+ if path:
184
+ expanded = os.path.expanduser(path)
185
+ if os.path.exists(expanded):
186
+ status = "[green]✓ ready[/green]"
187
+ else:
188
+ status = "[yellow]○ path not found[/yellow]"
189
+ else:
190
+ # Check if it's a known type we can auto-detect
191
+ if detected_type in KNOWN_PATHS:
192
+ known_path = os.path.expanduser(KNOWN_PATHS[detected_type])
193
+ if os.path.exists(known_path):
194
+ status = "[green]✓ ready[/green]"
195
+ path = KNOWN_PATHS[detected_type]
196
+ else:
197
+ status = "[yellow]○ not found locally[/yellow]"
198
+ else:
199
+ status = "[red]⚠ needs link[/red]"
200
+ needs_link.append(source)
201
+
202
+ table.add_row(source_id, name, path or "[dim]not set[/dim]", detected_type, status)
203
+
204
+ console.print(table)
205
+
206
+ if needs_link:
207
+ console.print(f"\n[yellow]{len(needs_link)} source(s) need to be linked to local paths.[/yellow]")
208
+ console.print("[dim]Use:[/dim] [cyan]nia link <ID> /path/to/folder[/cyan]")
209
+ else:
210
+ console.print("\n[dim]Run [cyan]nia[/cyan] to start syncing • [cyan]nia link <ID> <path>[/cyan] to link[/dim]")
211
+
212
+
213
+ @app.command(name="once")
214
+ def sync():
215
+ """Run a one-time sync (then exit)."""
216
+ if not is_authenticated():
217
+ console.print("[red]Not logged in. Run [cyan]nia login[/cyan] first.[/red]")
218
+ raise typer.Exit(1)
219
+
220
+ console.print("[bold]Starting sync...[/bold]")
221
+
222
+ sources = get_sources()
223
+ if not sources:
224
+ console.print("[yellow]No sources configured.[/yellow]")
225
+ console.print("Add sources in the Nia web app first.")
226
+ return
227
+
228
+ results = sync_all_sources(sources)
229
+
230
+ for result in results:
231
+ path = result.get("path", "unknown")
232
+ status = result.get("status", "unknown")
233
+ if status == "success":
234
+ added = result.get("added", 0)
235
+ console.print(f"[green]✓ {path}[/green] - {added} items synced")
236
+ else:
237
+ error = result.get("error", "unknown error")
238
+ console.print(f"[red]✗ {path}[/red] - {error}")
239
+
240
+
241
+ @app.command()
242
+ def add(path: str = typer.Argument(..., help="Path to sync (folder or database)")):
243
+ """Add a new source to sync."""
244
+ if not is_authenticated():
245
+ console.print("[red]Not logged in. Run [cyan]nia login[/cyan] first.[/red]")
246
+ raise typer.Exit(1)
247
+
248
+ # Expand path
249
+ expanded_path = os.path.expanduser(path)
250
+
251
+ # Check if path exists
252
+ if not os.path.exists(expanded_path):
253
+ console.print(f"[red]Path does not exist: {expanded_path}[/red]")
254
+ raise typer.Exit(1)
255
+
256
+ # Detect source type
257
+ detected_type = detect_source_type(expanded_path)
258
+ console.print(f"Detected type: [cyan]{detected_type}[/cyan]")
259
+
260
+ # Add source via API
261
+ result = add_source(path, detected_type)
262
+
263
+ if result:
264
+ console.print(f"[green]✓ Added:[/green] {result.get('display_name', path)}")
265
+ console.print(f"[dim]ID: {result.get('local_folder_id')[:8]}[/dim]")
266
+ console.print("\n[dim]Run [cyan]nia[/cyan] to start syncing.[/dim]")
267
+ else:
268
+ console.print("[red]Failed to add source.[/red]")
269
+ raise typer.Exit(1)
270
+
271
+
272
+ @app.command()
273
+ def remove(source_id: str = typer.Argument(..., help="Source ID (from 'nia status')")):
274
+ """Remove a source from syncing."""
275
+ if not is_authenticated():
276
+ console.print("[red]Not logged in. Run [cyan]nia login[/cyan] first.[/red]")
277
+ raise typer.Exit(1)
278
+
279
+ # Expand partial ID to full UUID
280
+ sources = get_sources()
281
+ matching = [s for s in sources if s.get("local_folder_id", "").startswith(source_id)]
282
+
283
+ if not matching:
284
+ console.print(f"[red]Source not found: {source_id}[/red]")
285
+ console.print("[dim]Run [cyan]nia status[/cyan] to see sources.[/dim]")
286
+ raise typer.Exit(1)
287
+
288
+ source = matching[0]
289
+ full_id = source["local_folder_id"]
290
+ display_name = source.get("display_name", source_id)
291
+
292
+ success = remove_source(full_id)
293
+
294
+ if success:
295
+ console.print(f"[green]✓ Removed:[/green] {display_name}")
296
+ else:
297
+ console.print("[red]Failed to remove. Check the ID with [cyan]nia status[/cyan][/red]")
298
+ raise typer.Exit(1)
299
+
300
+
301
+ @app.command()
302
+ def link(
303
+ source_id: str = typer.Argument(..., help="Source ID (from 'nia status')"),
304
+ path: str = typer.Argument(..., help="Local path to link"),
305
+ ):
306
+ """Link a cloud source to a local folder."""
307
+ if not is_authenticated():
308
+ console.print("[red]Not logged in. Run [cyan]nia login[/cyan] first.[/red]")
309
+ raise typer.Exit(1)
310
+
311
+ expanded_path = os.path.expanduser(path)
312
+
313
+ if not os.path.exists(expanded_path):
314
+ console.print(f"[red]Path not found: {expanded_path}[/red]")
315
+ raise typer.Exit(1)
316
+
317
+ sources = get_sources()
318
+ matching = [s for s in sources if s.get("local_folder_id", "").startswith(source_id)]
319
+
320
+ if not matching:
321
+ console.print(f"[red]Source not found: {source_id}[/red]")
322
+ console.print("[dim]Run [cyan]nia status[/cyan] to see sources.[/dim]")
323
+ raise typer.Exit(1)
324
+
325
+ source = matching[0]
326
+ full_id = source["local_folder_id"]
327
+
328
+ if enable_source_sync(full_id, expanded_path):
329
+ console.print(f"[green]✓ Linked:[/green] {source.get('display_name', source_id)} → {expanded_path}")
330
+ console.print("\n[dim]Run [cyan]nia[/cyan] to start syncing.[/dim]")
331
+ else:
332
+ console.print("[red]Failed to link.[/red]")
333
+ raise typer.Exit(1)
334
+
335
+
336
+ def _resolve_sources(sources: list[dict], log_discoveries: bool = False) -> list[dict]:
337
+ """Resolve paths for sources, auto-detecting known types and folders. Deduplicates by path."""
338
+ resolved = []
339
+ seen_paths = set()
340
+ auto_linked = []
341
+
342
+ for src in sources:
343
+ path = src.get("path")
344
+ detected_type = src.get("detected_type")
345
+ display_name = src.get("display_name", "")
346
+
347
+ # Priority 1: Use explicit path if set
348
+ # Priority 2: Known database types (iMessage, Safari, etc.)
349
+ # Priority 3: Auto-discover by folder name in watch directories
350
+
351
+ if not path and detected_type and detected_type in KNOWN_PATHS:
352
+ path = KNOWN_PATHS[detected_type]
353
+
354
+ if not path and display_name:
355
+ # Try to find folder by name in ~/Documents, ~/Projects, etc.
356
+ found_path = find_folder_path(display_name)
357
+ if found_path:
358
+ path = found_path
359
+ auto_linked.append((src, found_path))
360
+
361
+ if path:
362
+ expanded = os.path.abspath(os.path.expanduser(path))
363
+
364
+ if expanded in seen_paths:
365
+ continue
366
+
367
+ if os.path.exists(expanded):
368
+ src["path"] = expanded
369
+ resolved.append(src)
370
+ seen_paths.add(expanded)
371
+
372
+ # Auto-enable sync for discovered folders (call API to persist the path)
373
+ for src, found_path in auto_linked:
374
+ if src.get("path") == found_path and not src.get("sync_enabled"):
375
+ if enable_source_sync(src["local_folder_id"], found_path):
376
+ if log_discoveries:
377
+ console.print(f" [green]✓ Auto-discovered:[/green] {src.get('display_name')} → {found_path}")
378
+
379
+ return resolved
380
+
381
+
382
+ @app.command(name="start", hidden=True)
383
+ def daemon(
384
+ watch: bool = typer.Option(True, "--watch/--poll", help="File watching (default) or polling"),
385
+ fallback_interval: int = typer.Option(600, "--fallback", "-f", help="Fallback poll interval (seconds)"),
386
+ refresh_interval: int = typer.Option(30, "--refresh", "-r", help="Source refresh interval (seconds)"),
387
+ ):
388
+ """Start the Nia Sync Engine."""
389
+ import time
390
+ import signal
391
+ import threading
392
+ from sync import sync_source
393
+
394
+ if not is_authenticated():
395
+ console.print("[red]Not logged in.[/red] Run [cyan]nia login[/cyan] first.")
396
+ raise typer.Exit(1)
397
+
398
+ running = True
399
+ pending_syncs: set[str] = set() # source_ids pending sync
400
+ sync_lock = threading.Lock()
401
+ sources_by_id: dict[str, dict] = {}
402
+
403
+ def handle_signal(signum, frame):
404
+ nonlocal running
405
+ console.print("\n[dim]Stopping...[/dim]")
406
+ running = False
407
+
408
+ signal.signal(signal.SIGINT, handle_signal)
409
+ signal.signal(signal.SIGTERM, handle_signal)
410
+
411
+ def on_source_changed(source_id: str):
412
+ """Called by file watcher when changes detected."""
413
+ with sync_lock:
414
+ pending_syncs.add(source_id)
415
+
416
+ def sync_pending_sources():
417
+ """Process any pending syncs."""
418
+ with sync_lock:
419
+ to_sync = list(pending_syncs)
420
+ pending_syncs.clear()
421
+
422
+ if not to_sync:
423
+ return
424
+
425
+ # Only log if we're syncing
426
+ total_added = 0
427
+ errors = []
428
+
429
+ for source_id in to_sync:
430
+ if source_id not in sources_by_id:
431
+ continue
432
+
433
+ src = sources_by_id[source_id]
434
+ result = sync_source(src)
435
+
436
+ status = result.get("status", "unknown")
437
+
438
+ if status == "success":
439
+ added = result.get("added", 0)
440
+ if added > 0:
441
+ total_added += added
442
+ console.print(f"[green]✓ {src.get('display_name', 'Unknown')}[/green] - {added} items synced")
443
+ else:
444
+ error = result.get("error", "unknown error")
445
+ errors.append(f"{src.get('display_name', 'Unknown')}: {error}")
446
+
447
+ # Log errors
448
+ for err in errors:
449
+ console.print(f"[red]✗ {err}[/red]")
450
+
451
+ def refresh_sources(watcher=None, log_discoveries: bool = False) -> tuple[list[dict], list[str]]:
452
+ """Refresh sources from API and update watchers.
453
+
454
+ Returns:
455
+ Tuple of (resolved_sources, newly_added_source_ids)
456
+ """
457
+ nonlocal sources_by_id
458
+
459
+ sources = get_sources()
460
+ resolved = _resolve_sources(sources, log_discoveries=log_discoveries)
461
+
462
+ # Update sources dict
463
+ new_sources_by_id = {src["local_folder_id"]: src for src in resolved}
464
+ newly_added = []
465
+
466
+ # Add watchers for new sources
467
+ if watcher:
468
+ current_watching = set(watcher.watching)
469
+ new_source_ids = set(new_sources_by_id.keys())
470
+
471
+ # Add new watchers
472
+ for source_id in new_source_ids - current_watching:
473
+ src = new_sources_by_id[source_id]
474
+ if watcher.watch(source_id, src["path"], on_source_changed):
475
+ console.print(f" [dim]+ Watching {src.get('display_name', 'Unknown')}[/dim]")
476
+ newly_added.append(source_id)
477
+
478
+ # Remove old watchers (source deleted from UI)
479
+ for source_id in current_watching - new_source_ids:
480
+ old_name = sources_by_id.get(source_id, {}).get("display_name", source_id[:8])
481
+ watcher.unwatch(source_id)
482
+ console.print(f" [dim]- Stopped watching {old_name}[/dim]")
483
+
484
+ sources_by_id = new_sources_by_id
485
+ return resolved, newly_added
486
+
487
+ # Mode selection
488
+ if watch:
489
+ try:
490
+ from watcher import FileWatcher, DirectoryWatcher
491
+ watcher = FileWatcher(debounce_sec=2.0)
492
+ dir_watcher = DirectoryWatcher()
493
+ except ImportError:
494
+ console.print("[yellow]watchdog not installed, falling back to polling mode[/yellow]")
495
+ watch = False
496
+ watcher = None
497
+ dir_watcher = None
498
+ else:
499
+ watcher = None
500
+ dir_watcher = None
501
+
502
+ # Initial setup - log any auto-discovered folders
503
+ resolved, _ = refresh_sources(watcher, log_discoveries=True)
504
+
505
+ # Track unlinked source names for instant folder detection
506
+ def get_unlinked_names() -> set[str]:
507
+ """Get display names of sources without a local path."""
508
+ sources = get_sources()
509
+ return {
510
+ s.get("display_name", "").lower()
511
+ for s in sources
512
+ if not s.get("path") and s.get("display_name")
513
+ }
514
+
515
+ unlinked_names = get_unlinked_names()
516
+ refresh_triggered = threading.Event()
517
+
518
+ def on_new_folder(folder_name: str, folder_path: str):
519
+ """Called when a new folder is created in watched directories."""
520
+ if folder_name.lower() in unlinked_names:
521
+ console.print(f"[cyan]Detected new folder:[/cyan] {folder_name}")
522
+ refresh_triggered.set()
523
+
524
+ if watch and watcher:
525
+ mode_text = "real-time file watching"
526
+
527
+ # Start directory watcher for instant folder detection
528
+ if dir_watcher:
529
+ from config import DEFAULT_WATCH_DIRS
530
+ dir_watcher.watch(DEFAULT_WATCH_DIRS, on_new_folder)
531
+ dir_watcher.start()
532
+
533
+ console.print(Panel.fit(
534
+ f"[bold cyan]Nia Sync Engine[/bold cyan]\n\n"
535
+ f"[dim]●[/dim] {mode_text}\n"
536
+ f"[dim]●[/dim] {len(resolved)} source(s) active\n"
537
+ f"[dim]●[/dim] Auto-refresh every {refresh_interval}s\n\n"
538
+ f"[dim]Ctrl+C to stop[/dim]",
539
+ border_style="cyan",
540
+ ))
541
+
542
+ # Start file watcher
543
+ watcher.start()
544
+
545
+ # Do initial sync
546
+ for src in resolved:
547
+ pending_syncs.add(src["local_folder_id"])
548
+ sync_pending_sources()
549
+
550
+ # Main loop: process pending syncs + periodic refresh
551
+ last_refresh = time.time()
552
+
553
+ while running:
554
+ # Process any pending syncs from file watcher
555
+ sync_pending_sources()
556
+
557
+ # Instant refresh if new folder detected matching an unlinked source
558
+ if refresh_triggered.is_set():
559
+ refresh_triggered.clear()
560
+ _, newly_added = refresh_sources(watcher, log_discoveries=True)
561
+ if newly_added:
562
+ console.print(f"[green]Linked {len(newly_added)} new source(s)[/green]")
563
+ # Trigger initial sync for new sources
564
+ for source_id in newly_added:
565
+ pending_syncs.add(source_id)
566
+ unlinked_names.clear()
567
+ unlinked_names.update(get_unlinked_names())
568
+ last_refresh = time.time()
569
+
570
+ # Periodic refresh to pick up new sources from web UI
571
+ elif time.time() - last_refresh > refresh_interval:
572
+ _, newly_added = refresh_sources(watcher, log_discoveries=True)
573
+ if newly_added:
574
+ console.print(f"[green]Found {len(newly_added)} new source(s)[/green]")
575
+ # Trigger initial sync for new sources
576
+ for source_id in newly_added:
577
+ pending_syncs.add(source_id)
578
+ unlinked_names.clear()
579
+ unlinked_names.update(get_unlinked_names())
580
+ last_refresh = time.time()
581
+
582
+ time.sleep(0.5)
583
+
584
+ # Cleanup
585
+ watcher.stop()
586
+ if dir_watcher:
587
+ dir_watcher.stop()
588
+
589
+ else:
590
+ # Polling mode (fallback)
591
+ console.print(Panel.fit(
592
+ f"[bold cyan]Nia Sync Engine[/bold cyan] [dim](polling)[/dim]\n\n"
593
+ f"[dim]●[/dim] Sync every {fallback_interval // 60} min\n"
594
+ f"[dim]●[/dim] {len(resolved)} source(s) active\n\n"
595
+ f"[dim]Ctrl+C to stop[/dim]",
596
+ border_style="cyan",
597
+ ))
598
+
599
+ sync_count = 0
600
+ while running:
601
+ resolved, _ = refresh_sources()
602
+
603
+ sync_count += 1
604
+ console.print(f"\n[bold]Sync #{sync_count}[/bold] - {len(resolved)} source(s)")
605
+
606
+ if not resolved:
607
+ console.print("[dim]No syncable sources found locally.[/dim]")
608
+ else:
609
+ results = sync_all_sources(resolved)
610
+ for result in results:
611
+ path = result.get("path", "unknown")
612
+ status = result.get("status", "unknown")
613
+ if status == "success":
614
+ added = result.get("added", 0)
615
+ if added > 0:
616
+ console.print(f" [green]✓ {path}[/green] - {added} items")
617
+ else:
618
+ console.print(f" [dim]- {path}[/dim] - no changes")
619
+ else:
620
+ error = result.get("error", "unknown error")
621
+ console.print(f" [red]✗ {path}[/red] - {error}")
622
+
623
+ for _ in range(fallback_interval):
624
+ if not running:
625
+ break
626
+ time.sleep(1)
627
+
628
+ console.print("[green]✓ Stopped[/green]")
629
+
630
+
631
+ if __name__ == "__main__":
632
+ app()
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: nia-sync
3
+ Version: 0.1.0
4
+ Summary: Keep your local files in sync with Nia
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: typer>=0.9.0
7
+ Requires-Dist: rich>=13.0.0
8
+ Requires-Dist: httpx>=0.25.0
9
+ Requires-Dist: watchdog>=4.0.0
@@ -0,0 +1,11 @@
1
+ auth.py,sha256=_Q-wzLLtinoy8qtTihZNqdRAECPEzVjC5NgCXOLftJ8,5487
2
+ config.py,sha256=tJD3k3mBP9KORg4tX692uKk3Z92tEeYRI8jmgPjY5P0,7504
3
+ extractor.py,sha256=GxKmBTq04AxCjhtimlHZ2Gh3auvEEUyMHT-vM4YqWzI,28570
4
+ main.py,sha256=-ZWWs-5pQx_gR2QLwMrhbmuTKZo_8PQnVoExi85lqUU,22744
5
+ sync.py,sha256=iH9N22NEr2Nt5O6GeDP_X--G6IXhr8Hx3Z2xfJWkXc0,5667
6
+ watcher.py,sha256=BfdGwcfNDJiddUBMIPx61En2tSkLb3YnfxePKjSFQTc,9593
7
+ nia_sync-0.1.0.dist-info/METADATA,sha256=Qm4BGkM9fO0tiZg1AgKmA8-S_8f9674DtZZXBY_u0hc,240
8
+ nia_sync-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
9
+ nia_sync-0.1.0.dist-info/entry_points.txt,sha256=Fx8TIOgXqWdZzZEkEateDtcNfgnwuPW4jZTqlEUrHVs,33
10
+ nia_sync-0.1.0.dist-info/top_level.txt,sha256=_ZWBugSHWwSpLXYJAcF6TlWmzECu18k0y1-EX27jtBw,40
11
+ nia_sync-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nia = main:app
@@ -0,0 +1,6 @@
1
+ auth
2
+ config
3
+ extractor
4
+ main
5
+ sync
6
+ watcher