noz-cli/NozCli/Commands/SyncCommand.cs
kryptomrx 0eebffe745 test: xUnit test suite and Forgejo CI pipeline
- 43 tests across SlugGuard, ReplContext navigation, NotesCache.TimeAgo
- SlugGuard moved to NozCli.Core.Security (shared between REPL and tests)
- Bug fix: FoldersInView() now returns only actual sub-folders, not note segments
  (previously cd tab-completion suggested note names as navigable folders)
- .forgejo/workflows/ci.yml: build + test on every push/PR
2026-05-16 23:53:40 +02:00

146 lines
5.1 KiB
C#

using NozCli.Core.Client;
using NozCli.Core.Models;
using NozCli.Repl;
using NozCli.Core.Security;
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)
{
if (!SlugGuard.IsValid(note.Slug))
{
AnsiConsole.MarkupLine($"[yellow]⚠ Skipped unsafe slug from server:[/] {note.Slug}");
continue;
}
var filePath = SlugGuard.ToSafePath(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 = SlugGuard.ToSafePath(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}");
}
}
}