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.
- auth.py +168 -0
- config.py +276 -0
- extractor.py +947 -0
- main.py +632 -0
- nia_sync-0.1.0.dist-info/METADATA +9 -0
- nia_sync-0.1.0.dist-info/RECORD +11 -0
- nia_sync-0.1.0.dist-info/WHEEL +5 -0
- nia_sync-0.1.0.dist-info/entry_points.txt +2 -0
- nia_sync-0.1.0.dist-info/top_level.txt +6 -0
- sync.py +192 -0
- watcher.py +304 -0
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,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,,
|