noz-cli/NozCli/Commands/SyncCommand.cs
kryptomrx ae8a961cfd feat(commands): Dual-editor (nano/code), new note, parallel sync pull
- edit: terminal editor (nano/vim) by default, -c flag opens VS Code/Zed
- edit/new: ResolveTerminal() + ResolveCode() auto-detect editors on PATH
- new: create note from template, opens editor, confirms before push
- sync: pull notes from server with Parallel.ForEachAsync (8 concurrent)
- sync: hash diff - only download notes changed since last sync
- sync --dry-run: preview without writing, --all to force full download
- push: hash diff against server cache, Progress bar, --all flag
2026-05-16 23:20:59 +02:00

141 lines
5 KiB
C#

using NozCli.Core.Client;
using NozCli.Core.Models;
using NozCli.Repl;
using Spectre.Console;
namespace NozCli.Commands;
/// sync <local-path> — 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 <local-path> [--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 <local-path> [--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<NoteInfo> enumerable)
candidates = enumerable.ToList();
var noteList = (List<NoteInfo>)candidates;
if (noteList.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No notes in current view.[/]");
return;
}
// Diff: which notes actually need downloading?
var toDownload = new List<NoteInfo>();
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<string>();
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");
}