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
This commit is contained in:
parent
cf54165735
commit
ae8a961cfd
4 changed files with 270 additions and 49 deletions
|
|
@ -9,40 +9,40 @@ namespace NozCli.Commands;
|
||||||
public class EditCommand : ICommand
|
public class EditCommand : ICommand
|
||||||
{
|
{
|
||||||
public string Name => "edit";
|
public string Name => "edit";
|
||||||
public string Description => "Open a note in your default editor, then offer to push";
|
public string Description => "Open a note in nano (or -c for VS Code)";
|
||||||
public string Usage => "edit <slug>";
|
public string Usage => "edit [-c] <slug>";
|
||||||
|
|
||||||
public async Task ExecuteAsync(string[] args, ReplContext ctx)
|
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var slug = args.FirstOrDefault();
|
var useCode = args.Contains("-c") || args.Contains("--code");
|
||||||
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: edit <slug>[/]"); return; }
|
var slug = args.FirstOrDefault(a => !a.StartsWith('-'));
|
||||||
|
|
||||||
|
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: edit [-c] <slug>[/]"); return; }
|
||||||
|
|
||||||
slug = ctx.ResolveSlug(slug);
|
slug = ctx.ResolveSlug(slug);
|
||||||
|
|
||||||
// 1. Fetch note from server
|
|
||||||
NozCli.Core.Models.NoteContent note = default!;
|
NozCli.Core.Models.NoteContent note = default!;
|
||||||
await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
|
await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
|
||||||
note = await ctx.Api.GetNoteAsync(slug));
|
note = await ctx.Api.GetNoteAsync(slug, ct));
|
||||||
|
|
||||||
// 2. Write to temp file
|
|
||||||
var tmp = Path.Combine(Path.GetTempPath(), $"noz-{slug.Replace('/', '-')}.md");
|
var tmp = Path.Combine(Path.GetTempPath(), $"noz-{slug.Replace('/', '-')}.md");
|
||||||
await File.WriteAllTextAsync(tmp, note.Markdown);
|
await File.WriteAllTextAsync(tmp, note.Markdown);
|
||||||
|
|
||||||
// 3. Open editor
|
var (editor, editorArgs) = useCode
|
||||||
var editor = ResolveEditor();
|
? ResolveCode(tmp, ctx.Config.CodeEditor)
|
||||||
|
: ResolveTerminal(tmp, ctx.Config.Editor);
|
||||||
|
|
||||||
AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]");
|
AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]");
|
||||||
var proc = Process.Start(new ProcessStartInfo
|
var proc = Process.Start(new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = editor,
|
FileName = editor,
|
||||||
Arguments = $"\"{tmp}\"",
|
Arguments = editorArgs,
|
||||||
UseShellExecute = !OperatingSystem.IsWindows(),
|
UseShellExecute = false,
|
||||||
});
|
});
|
||||||
proc?.WaitForExit();
|
proc?.WaitForExit();
|
||||||
|
|
||||||
// 4. Compare hashes
|
|
||||||
var edited = await File.ReadAllTextAsync(tmp);
|
var edited = await File.ReadAllTextAsync(tmp);
|
||||||
var newHash = HashContent(edited);
|
var newHash = Sha256(edited);
|
||||||
|
|
||||||
File.Delete(tmp);
|
File.Delete(tmp);
|
||||||
|
|
||||||
if (newHash == note.Hash)
|
if (newHash == note.Hash)
|
||||||
|
|
@ -51,47 +51,61 @@ public class EditCommand : ICommand
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Ask to push
|
|
||||||
AnsiConsole.MarkupLine("[green]Changes detected.[/]");
|
AnsiConsole.MarkupLine("[green]Changes detected.[/]");
|
||||||
var push = AnsiConsole.Confirm("Push changes to server?");
|
var push = AnsiConsole.Confirm("Push changes to server?");
|
||||||
if (!push) { AnsiConsole.MarkupLine("[dim]Discarded.[/]"); return; }
|
if (!push) { AnsiConsole.MarkupLine("[dim]Discarded.[/]"); return; }
|
||||||
|
|
||||||
await AnsiConsole.Status().StartAsync("Pushing…", async _ =>
|
await AnsiConsole.Status().StartAsync("Pushing…", async _ =>
|
||||||
await ctx.Api.PutNoteAsync(slug, edited));
|
{
|
||||||
|
await ctx.Api.PutNoteAsync(slug, edited, ct);
|
||||||
|
ctx.NoteCache = await ctx.Api.GetNotesAsync(ct);
|
||||||
|
});
|
||||||
|
|
||||||
AnsiConsole.MarkupLine($"[green]✓[/] {slug} updated");
|
AnsiConsole.MarkupLine($"[green]✓[/] {slug} updated");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveEditor()
|
// nano (or configured terminal editor) — quick edits
|
||||||
|
internal static (string, string) ResolveTerminal(string filePath, string? configured)
|
||||||
{
|
{
|
||||||
// Priority: $EDITOR → $VISUAL → VS Code → nano → vi
|
var editor = configured
|
||||||
var env = Environment.GetEnvironmentVariable("EDITOR")
|
?? Environment.GetEnvironmentVariable("EDITOR")
|
||||||
?? Environment.GetEnvironmentVariable("VISUAL");
|
?? (IsOnPath("nano") ? "nano" : IsOnPath("vi") ? "vi" : "nano");
|
||||||
if (env is not null) return env;
|
return (editor, $"\"{filePath}\"");
|
||||||
|
|
||||||
foreach (var candidate in new[] { "code", "nano", "vi", "notepad" })
|
|
||||||
if (IsOnPath(candidate)) return candidate;
|
|
||||||
|
|
||||||
return "nano";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsOnPath(string name)
|
// -c editor: configured, or auto-detect code/zed/etc.
|
||||||
|
// GUI editors that need a --wait flag are detected by name.
|
||||||
|
internal static (string, string) ResolveCode(string filePath, string? configured)
|
||||||
|
{
|
||||||
|
var editor = configured
|
||||||
|
?? (IsOnPath("code") ? "code" :
|
||||||
|
IsOnPath("code-insiders")? "code-insiders":
|
||||||
|
IsOnPath("zed") ? "zed" :
|
||||||
|
IsOnPath("subl") ? "subl" : "code");
|
||||||
|
|
||||||
|
// These editors need --wait to block until the file is closed
|
||||||
|
var needsWait = editor is "code" or "code-insiders" or "subl";
|
||||||
|
var fileArg = $"\"{filePath}\"";
|
||||||
|
return (editor, needsWait ? $"--wait {fileArg}" : fileArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsOnPath(string name)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = Process.Start(new ProcessStartInfo
|
using var p = Process.Start(new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = OperatingSystem.IsWindows() ? "where" : "which",
|
FileName = OperatingSystem.IsWindows() ? "where" : "which",
|
||||||
Arguments = name,
|
Arguments = name,
|
||||||
RedirectStandardOutput = true,
|
RedirectStandardOutput = true,
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
});
|
});
|
||||||
result?.WaitForExit();
|
p?.WaitForExit();
|
||||||
return result?.ExitCode == 0;
|
return p?.ExitCode == 0;
|
||||||
}
|
}
|
||||||
catch { return false; }
|
catch { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string HashContent(string content) =>
|
private static string Sha256(string content) =>
|
||||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))).ToLower();
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))).ToLower();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
NozCli/Commands/NewCommand.cs
Normal file
90
NozCli/Commands/NewCommand.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using NozCli.Core.Models;
|
||||||
|
using NozCli.Repl;
|
||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
namespace NozCli.Commands;
|
||||||
|
|
||||||
|
public class NewCommand : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "new";
|
||||||
|
public string Description => "Create a new note and open it in nano (or -c for VS Code)";
|
||||||
|
public string Usage => "new [-c] <slug-or-name>";
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var useCode = args.Contains("-c") || args.Contains("--code");
|
||||||
|
var nameArg = args.FirstOrDefault(a => !a.StartsWith('-'));
|
||||||
|
if (nameArg is null) { AnsiConsole.MarkupLine("[red]Usage: new [-c] <slug-or-name>[/]"); return; }
|
||||||
|
|
||||||
|
var slug = ctx.ResolveNewSlug(nameArg);
|
||||||
|
|
||||||
|
if (ctx.NoteCache.Any(n => n.Slug == slug))
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[yellow]Note already exists:[/] {slug} [dim](use 'edit' to modify it)[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var topic = ctx.CurrentTopic ?? (slug.Contains('/') ? slug.Split('/')[0] : slug);
|
||||||
|
var title = ToTitle(slug.Split('/').Last());
|
||||||
|
var lang = "de";
|
||||||
|
|
||||||
|
var template = $"""
|
||||||
|
---
|
||||||
|
title: "{title}"
|
||||||
|
description: ""
|
||||||
|
topic: {topic}
|
||||||
|
status: seed
|
||||||
|
lang: {lang}
|
||||||
|
---
|
||||||
|
|
||||||
|
# {title}
|
||||||
|
|
||||||
|
""";
|
||||||
|
|
||||||
|
var tmp = Path.Combine(Path.GetTempPath(), $"noz-new-{slug.Replace('/', '-')}.md");
|
||||||
|
await File.WriteAllTextAsync(tmp, template);
|
||||||
|
|
||||||
|
var (editor, editorArgs) = useCode
|
||||||
|
? EditCommand.ResolveCode(tmp, ctx.Config.CodeEditor)
|
||||||
|
: EditCommand.ResolveTerminal(tmp, ctx.Config.Editor);
|
||||||
|
|
||||||
|
AnsiConsole.MarkupLine($"[dim]Opening {editor} …[/]");
|
||||||
|
var proc = Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = editor,
|
||||||
|
Arguments = editorArgs,
|
||||||
|
UseShellExecute = false,
|
||||||
|
});
|
||||||
|
proc?.WaitForExit();
|
||||||
|
|
||||||
|
var markdown = await File.ReadAllTextAsync(tmp);
|
||||||
|
File.Delete(tmp);
|
||||||
|
|
||||||
|
if (markdown.Trim() == template.Trim())
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine("[dim]No changes — note not created.[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var push = AnsiConsole.Confirm($"Push [cyan]{slug}[/] to server?");
|
||||||
|
if (!push) { AnsiConsole.MarkupLine("[dim]Discarded.[/]"); return; }
|
||||||
|
|
||||||
|
await AnsiConsole.Status().StartAsync("Creating…", async _ =>
|
||||||
|
{
|
||||||
|
await ctx.Api.PutNoteAsync(slug, markdown, ct);
|
||||||
|
ctx.NoteCache = await ctx.Api.GetNotesAsync(ct);
|
||||||
|
});
|
||||||
|
|
||||||
|
AnsiConsole.MarkupLine($"[green]✓[/] {slug} created");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToTitle(string slug) =>
|
||||||
|
string.Join(' ', slug.Split('-').Select(w =>
|
||||||
|
w.Length > 0 ? char.ToUpper(w[0]) + w[1..] : w));
|
||||||
|
|
||||||
|
private static string Sha256(string text) =>
|
||||||
|
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLower();
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ public class PushCommand : ICommand
|
||||||
public string Description => "Push local markdown files to the server";
|
public string Description => "Push local markdown files to the server";
|
||||||
public string Usage => "push <local-path> [--all]";
|
public string Usage => "push <local-path> [--all]";
|
||||||
|
|
||||||
public async Task ExecuteAsync(string[] args, ReplContext ctx)
|
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var localPath = args.FirstOrDefault(a => !a.StartsWith("--"));
|
var localPath = args.FirstOrDefault(a => !a.StartsWith("--"));
|
||||||
var force = args.Contains("--all");
|
var force = args.Contains("--all");
|
||||||
|
|
@ -66,7 +66,7 @@ public class PushCommand : ICommand
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ctx.Api.PutNoteAsync(slug, markdown);
|
await ctx.Api.PutNoteAsync(slug, markdown, ct);
|
||||||
pushed++;
|
pushed++;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -82,6 +82,6 @@ public class PushCommand : ICommand
|
||||||
AnsiConsole.MarkupLine($"[green]↑ {pushed} pushed[/] [dim]{skipped} unchanged {(failed > 0 ? $"[red]{failed} failed[/]" : "")}[/]");
|
AnsiConsole.MarkupLine($"[green]↑ {pushed} pushed[/] [dim]{skipped} unchanged {(failed > 0 ? $"[red]{failed} failed[/]" : "")}[/]");
|
||||||
|
|
||||||
// Refresh note cache
|
// Refresh note cache
|
||||||
ctx.NoteCache = await ctx.Api.GetNotesAsync();
|
ctx.NoteCache = await ctx.Api.GetNotesAsync(ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,141 @@
|
||||||
|
using NozCli.Core.Client;
|
||||||
|
using NozCli.Core.Models;
|
||||||
using NozCli.Repl;
|
using NozCli.Repl;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
namespace NozCli.Commands;
|
namespace NozCli.Commands;
|
||||||
|
|
||||||
/// sync = push + rebuild index
|
/// 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 class SyncCommand : ICommand
|
||||||
{
|
{
|
||||||
public string Name => "sync";
|
public string Name => "sync";
|
||||||
public string Description => "Push notes and rebuild the server index";
|
public string Description => "Pull notes from server to a local directory (hash-diffed)";
|
||||||
public string Usage => "sync <local-path>";
|
public string Usage => "sync <local-path> [--all] [--dry-run]";
|
||||||
|
|
||||||
private readonly PushCommand _push = new();
|
private const int Concurrency = 8;
|
||||||
|
|
||||||
public async Task ExecuteAsync(string[] args, ReplContext ctx)
|
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await _push.ExecuteAsync(args, ctx);
|
var localPath = args.FirstOrDefault(a => !a.StartsWith("--"));
|
||||||
|
var force = args.Contains("--all");
|
||||||
|
var dryRun = args.Contains("--dry-run");
|
||||||
|
|
||||||
await AnsiConsole.Status().StartAsync("Triggering index rebuild…", async _ =>
|
if (localPath is null)
|
||||||
await ctx.Api.RebuildIndexAsync());
|
{
|
||||||
|
AnsiConsole.MarkupLine("[red]Usage: sync <local-path> [--all] [--dry-run][/]");
|
||||||
AnsiConsole.MarkupLine("[green]✓ Index rebuild queued[/]");
|
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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue