using NozCli.Core.Client; using NozCli.Core.Models; using NozCli.Repl; using Spectre.Console; namespace NozCli.Commands; /// sync — pulls notes from the server into a local directory. /// Only downloads notes whose server hash differs from the local file hash. /// Uses Parallel.ForEachAsync (8 concurrent) for fast bulk downloads. public class SyncCommand : ICommand { public string Name => "sync"; public string Description => "Pull notes from server to a local directory (hash-diffed)"; public string Usage => "sync [--all] [--dry-run]"; private const int Concurrency = 8; public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default) { var localPath = args.FirstOrDefault(a => !a.StartsWith("--")); var force = args.Contains("--all"); var dryRun = args.Contains("--dry-run"); if (localPath is null) { AnsiConsole.MarkupLine("[red]Usage: sync [--all] [--dry-run][/]"); return; } localPath = Path.GetFullPath(localPath); // Determine which notes to sync (respect current topic/subpath) var candidates = ctx.CurrentTopic is null ? ctx.NoteCache : ctx.NotesInView().ToList(); if (candidates is IEnumerable enumerable) candidates = enumerable.ToList(); var noteList = (List)candidates; if (noteList.Count == 0) { AnsiConsole.MarkupLine("[yellow]No notes in current view.[/]"); return; } // Diff: which notes actually need downloading? var toDownload = new List(); foreach (var note in noteList) { var filePath = SlugToPath(localPath, note.Slug); if (!force && note.Hash is not null && File.Exists(filePath)) { var localContent = await File.ReadAllTextAsync(filePath, ct); var localHash = HashHelper.Sha256(localContent); if (localHash == note.Hash) continue; } toDownload.Add(note); } var unchanged = noteList.Count - toDownload.Count; if (toDownload.Count == 0) { AnsiConsole.MarkupLine($"[green]✓ All {noteList.Count} notes up to date.[/]"); return; } if (dryRun) { AnsiConsole.MarkupLine($"[dim]Dry run — would download {toDownload.Count} notes, {unchanged} unchanged.[/]"); foreach (var n in toDownload) AnsiConsole.MarkupLine($" [dim]↓[/] {n.Slug}"); return; } AnsiConsole.MarkupLine($"[dim]Downloading {toDownload.Count} notes · {unchanged} unchanged · concurrency {Concurrency}[/]"); AnsiConsole.WriteLine(); int downloaded = 0, failed = 0; var failedSlugs = new System.Collections.Concurrent.ConcurrentBag(); var lockObj = new object(); await AnsiConsole.Progress() .Columns( new TaskDescriptionColumn(), new ProgressBarColumn(), new PercentageColumn(), new SpinnerColumn()) .StartAsync(async progress => { var task = progress.AddTask($"[cyan]↓ syncing[/]", maxValue: toDownload.Count); await Parallel.ForEachAsync( toDownload, new ParallelOptions { MaxDegreeOfParallelism = Concurrency, CancellationToken = ct }, async (note, innerCt) => { try { var content = await ctx.Api.GetNoteAsync(note.Slug, innerCt); var filePath = SlugToPath(localPath, note.Slug); Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); await File.WriteAllTextAsync(filePath, content.Markdown, innerCt); lock (lockObj) downloaded++; } catch (Exception ex) when (ex is not OperationCanceledException) { failedSlugs.Add(note.Slug); lock (lockObj) failed++; } finally { task.Increment(1); } }); }); AnsiConsole.WriteLine(); AnsiConsole.MarkupLine( $"[green]↓ {downloaded} downloaded[/] " + $"[dim]{unchanged} unchanged[/]" + (failed > 0 ? $" [red]{failed} failed[/]" : "")); if (failed > 0) { foreach (var slug in failedSlugs.Order()) AnsiConsole.MarkupLine($" [red]✗[/] {slug}"); } } private static string SlugToPath(string baseDir, string slug) => Path.Combine(baseDir, slug.Replace('/', Path.DirectorySeparatorChar) + ".md"); }