Compare commits
10 commits
979f5bd767
...
bfd702e470
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfd702e470 | ||
|
|
38bf1f75c8 | ||
|
|
8576cc83a2 | ||
|
|
58410f8d9f | ||
|
|
e9217ecb77 | ||
|
|
5a26de8a11 | ||
|
|
f45b31defa | ||
|
|
0eebffe745 | ||
|
|
db9aabd2b7 | ||
|
|
b971d45e78 |
55 changed files with 2423 additions and 100 deletions
27
.forgejo/workflows/ci.yml
Normal file
27
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --no-restore -c Release
|
||||
|
||||
- name: Test
|
||||
run: dotnet test NozCli.Tests/NozCli.Tests.csproj --no-build -c Release --verbosity normal
|
||||
81
CHANGELOG.md
81
CHANGELOG.md
|
|
@ -1,26 +1,81 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to noz-cli are documented here.
|
||||
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) — versioning follows [Semantic Versioning](https://semver.org/).
|
||||
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning: [Semantic Versioning](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] — 2026-05-17
|
||||
|
||||
### Added
|
||||
|
||||
**REPL commands**
|
||||
- `mv <old> <new>` — rename a note and atomically rewrite all inbound wikilinks and markdown links
|
||||
- `mkdir <name>` — create a folder by writing a `_index.md`; navigates into the new folder immediately
|
||||
- `link <source> <target>` — add a wikilink to the "Siehe auch" section (or create it); `--both` for bidirectional
|
||||
- `search <query>` — full-text search via Meilisearch; context-aware (defaults to current topic); `--topic`, `--limit` flags
|
||||
- `templates` — list available server-side note templates
|
||||
- `new --template <name> <slug>` — create a note from a template with `{{title}}`, `{{date}}`, `{{topic}}` placeholders
|
||||
- `cat --raw <slug>` — print raw markdown (default now renders via MarkdownRenderer)
|
||||
- `clear` — clear the terminal screen
|
||||
|
||||
**PowerShell module**
|
||||
- `Get-NozGraph` — all wikilink edges as `NozGraphEdge` objects; filterable by `-Source` / `-Target`
|
||||
- `Find-NozNote` — Meilisearch full-text search; `-Topic`, `-Limit`
|
||||
- `Measure-NozGarden` — garden-wide statistics: note counts by status/topic/lang, link count, orphan count
|
||||
- `Set-NozNoteStatus` — patch the `status:` frontmatter field in place; `SupportsShouldProcess`
|
||||
- `Get-NozOrphans` — notes with no incoming wikilinks; `-Topic` filter
|
||||
- `Rename-NozNote` — wraps `NoteRenamer` for pipeline use; `-PassThru`, `-WhatIf`
|
||||
- `Get-NozTemplate` — list server-side templates
|
||||
- `Add-NozLink` — add wikilinks between notes; `-Both`, `-Section`
|
||||
|
||||
**Server API routes** (noz Next.js app)
|
||||
- `GET /api/cli/graph` — full wikilink adjacency list from pre-built index
|
||||
- `GET /api/cli/search` — Meilisearch / local-search with `?q`, `?topic`, `?limit`
|
||||
- `PUT /api/cli/folders/[...path]` — create `_index.md` for a folder
|
||||
- `GET /api/cli/templates` — list templates from `_templates/`
|
||||
- `GET /api/cli/templates/[name]` — fetch a single template body
|
||||
- `PUT /api/cli/templates/[name]` — create or update a template
|
||||
|
||||
**Core library**
|
||||
- `NoteRenamer` — fetches inbound edges, creates new note, rewrites links, deletes old slug
|
||||
- `NoteLinkHelper` — `AddLink()` inserts wikilinks into existing sections or appends new ones; `ApplyPlaceholders()` for template rendering
|
||||
- `NozApiClient.GetGraphAsync()`, `SearchAsync()`, `GetTemplatesAsync()`, `GetTemplateAsync()`, `PutFolderIndexAsync()`
|
||||
|
||||
**Templates**
|
||||
- Four sample templates in `content/noosphere/_templates/`: `rezept`, `buch-notiz`, `konzept`, `meeting`
|
||||
|
||||
### Fixed
|
||||
- `walkNotes` now skips directories with `_` prefix (previously only filenames were checked — `_templates/` content was indexed as regular notes)
|
||||
- Notes listing API now includes the `date` field
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] — 2026-05-16
|
||||
|
||||
### Added
|
||||
- Interactive REPL with topic-based navigation (`cd`, `ls`, `ls -la`, `tree`)
|
||||
- `cat` — print raw markdown of a note
|
||||
- `edit` — open note in terminal editor (nano/vim) or VS Code (`-c` flag)
|
||||
- `new` — create a new note from a template
|
||||
- `rm` — delete a note
|
||||
- `sync` — pull notes from server to local directory (parallel, hash-diffed)
|
||||
- `push` — push local markdown files to server
|
||||
- `search` — full-text search via Meilisearch
|
||||
- `config` — view and set editor preferences
|
||||
- `version` — show version and git hash
|
||||
- JSON note cache (`~/.cache/noz/notes.json`) for instant startup + background refresh
|
||||
- Cache timestamp displayed in startup stats line
|
||||
- `cat` — render a note with MarkdownRenderer (headings, blockquotes, code blocks, inline formatting)
|
||||
- `edit` — open note in terminal editor (nano/vim) or VS Code / Zed / Sublime (`-c`)
|
||||
- `new` — create a note with frontmatter template, open in editor
|
||||
- `rm` — delete a note from server
|
||||
- `sync` — pull notes from server to local directory (parallel downloads, SHA-256 hash diffing, `--all`, `--dry-run`)
|
||||
- `push` — push local `.md` files to server with slug validation
|
||||
- `status` — diff between a local folder and the server
|
||||
- `config` — view and set `editor` / `code_editor` preferences
|
||||
- `version` — print version and git hash
|
||||
- `help` — list all commands with usage
|
||||
- `exit` / `quit` / `q` — quit the REPL
|
||||
- JSON note cache (`~/.cache/noz/notes.json`) — instant startup, background refresh, `chmod 600`, atomic write
|
||||
- Cache age shown in startup stats line ("vor 5 Min.")
|
||||
- Ctrl+C: idle → exit REPL; during command → cancel command only
|
||||
- Native AOT binary for macOS arm64 (~7.6 MB, no runtime required)
|
||||
- Tab completion for commands, slugs and folder segments
|
||||
- Tab completion for commands, slugs, and folder segments
|
||||
- `SlugGuard` — path traversal prevention for all slug inputs and file operations
|
||||
- `MarkdownRenderer` — segment-based inline escaping (prevents Spectre markup injection from note content)
|
||||
- PowerShell module `Noz` with `Get-NozNote`, `Get-NozNoteContent`, `Set-NozNote`, `New-NozNote`, `Remove-NozNote`
|
||||
- xUnit test suite (52 tests): `SlugGuardTests`, `ReplContextTests`, `NotesCacheTests`, `NoteRenamerTests`, `NoteLinkHelperTests`
|
||||
- Forgejo Actions CI pipeline (`.forgejo/workflows/ci.yml`): build + test on every push/PR
|
||||
- Version string from `AssemblyInformationalVersion` with embedded git short hash
|
||||
|
|
|
|||
43
Noz.PowerShell/Cmdlets/AddNozLinkCmdlet.cs
Normal file
43
Noz.PowerShell/Cmdlets/AddNozLinkCmdlet.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using System.Management.Automation;
|
||||
using NozCli.Core.Client;
|
||||
|
||||
namespace Noz.PowerShell.Cmdlets;
|
||||
|
||||
[Cmdlet(VerbsCommon.Add, "NozLink", SupportsShouldProcess = true)]
|
||||
public sealed class AddNozLinkCmdlet : NozCmdletBase
|
||||
{
|
||||
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
|
||||
public string Source { get; set; } = "";
|
||||
|
||||
[Parameter(Mandatory = true, Position = 1)]
|
||||
public string Target { get; set; } = "";
|
||||
|
||||
[Parameter]
|
||||
public SwitchParameter Both { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string Section { get; set; } = "Siehe auch";
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
if (!ShouldProcess(Source, $"Add-NozLink → {Target}")) return;
|
||||
|
||||
LinkOne(Source, Target);
|
||||
if (Both) LinkOne(Target, Source);
|
||||
}
|
||||
|
||||
private void LinkOne(string source, string target)
|
||||
{
|
||||
var note = Run(Api.GetNoteAsync(source));
|
||||
var updated = NoteLinkHelper.AddLink(note.Markdown, target, Section);
|
||||
|
||||
if (updated == note.Markdown)
|
||||
{
|
||||
WriteVerbose($"{source} → {target}: bereits verlinkt.");
|
||||
return;
|
||||
}
|
||||
|
||||
Run(Api.PutNoteAsync(source, updated));
|
||||
WriteVerbose($"{source} → {target}: verlinkt.");
|
||||
}
|
||||
}
|
||||
35
Noz.PowerShell/Cmdlets/FindNozNoteCmdlet.cs
Normal file
35
Noz.PowerShell/Cmdlets/FindNozNoteCmdlet.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using System.Management.Automation;
|
||||
|
||||
namespace Noz.PowerShell.Cmdlets;
|
||||
|
||||
[Cmdlet(VerbsCommon.Find, "NozNote")]
|
||||
[OutputType(typeof(NozSearchHit))]
|
||||
public sealed class FindNozNoteCmdlet : NozCmdletBase
|
||||
{
|
||||
[Parameter(Mandatory = true, Position = 0)]
|
||||
public string Query { get; set; } = "";
|
||||
|
||||
[Parameter]
|
||||
public string? Topic { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int Limit { get; set; } = 20;
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
var hits = Run(Api.SearchAsync(Query, Topic, Limit));
|
||||
|
||||
foreach (var h in hits)
|
||||
{
|
||||
WriteObject(new NozSearchHit
|
||||
{
|
||||
Slug = h.Slug,
|
||||
Title = h.Title,
|
||||
Description = h.Description,
|
||||
Topic = h.Topic,
|
||||
Status = h.Status,
|
||||
Lang = h.Lang,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
38
Noz.PowerShell/Cmdlets/GetNozGraphCmdlet.cs
Normal file
38
Noz.PowerShell/Cmdlets/GetNozGraphCmdlet.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System.Management.Automation;
|
||||
|
||||
namespace Noz.PowerShell.Cmdlets;
|
||||
|
||||
[Cmdlet(VerbsCommon.Get, "NozGraph")]
|
||||
[OutputType(typeof(NozGraphEdge))]
|
||||
public sealed class GetNozGraphCmdlet : NozCmdletBase
|
||||
{
|
||||
[Parameter]
|
||||
public string? Source { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? Target { get; set; }
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
var graph = Run(Api.GetGraphAsync());
|
||||
var nodeMap = graph.Nodes.ToDictionary(n => n.Id, n => n.Title);
|
||||
|
||||
var edges = graph.Edges.AsEnumerable();
|
||||
|
||||
if (Source is not null)
|
||||
edges = edges.Where(e => e.Source.Contains(Source, StringComparison.OrdinalIgnoreCase));
|
||||
if (Target is not null)
|
||||
edges = edges.Where(e => e.Target.Contains(Target, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
foreach (var e in edges)
|
||||
{
|
||||
WriteObject(new NozGraphEdge
|
||||
{
|
||||
Source = e.Source,
|
||||
Target = e.Target,
|
||||
SourceTitle = nodeMap.GetValueOrDefault(e.Source, e.Source),
|
||||
TargetTitle = nodeMap.GetValueOrDefault(e.Target, e.Target),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Noz.PowerShell/Cmdlets/GetNozNoteCmdlet.cs
Normal file
41
Noz.PowerShell/Cmdlets/GetNozNoteCmdlet.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
using System.Management.Automation;
|
||||
|
||||
namespace Noz.PowerShell.Cmdlets;
|
||||
|
||||
/// <summary>List notes from the noz server.</summary>
|
||||
/// <example>
|
||||
/// Get-NozNote
|
||||
/// Get-NozNote -Topic psychologie
|
||||
/// Get-NozNote -Status seed -Lang de
|
||||
/// Get-NozNote | Group-Object Topic | Sort-Object Count -Descending
|
||||
/// Get-NozNote | Export-Csv ~/notes.csv
|
||||
/// </example>
|
||||
[Cmdlet(VerbsCommon.Get, "NozNote")]
|
||||
[OutputType(typeof(NozNote))]
|
||||
public sealed class GetNozNoteCmdlet : NozCmdletBase
|
||||
{
|
||||
[Parameter(Position = 0)]
|
||||
public string? Topic { get; set; }
|
||||
|
||||
[Parameter]
|
||||
[ValidateSet("seed", "budding", "evergreen")]
|
||||
public string? Status { get; set; }
|
||||
|
||||
[Parameter]
|
||||
[ValidateSet("de", "en")]
|
||||
public string? Lang { get; set; }
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
var notes = Run(Api.GetNotesAsync());
|
||||
|
||||
var filtered = notes.AsEnumerable();
|
||||
|
||||
if (Topic is not null) filtered = filtered.Where(n => n.Topic == Topic);
|
||||
if (Status is not null) filtered = filtered.Where(n => n.Status == Status);
|
||||
if (Lang is not null) filtered = filtered.Where(n => n.Lang == Lang);
|
||||
|
||||
foreach (var note in filtered.OrderBy(n => n.Slug))
|
||||
WriteObject(NozNote.From(note));
|
||||
}
|
||||
}
|
||||
32
Noz.PowerShell/Cmdlets/GetNozNoteContentCmdlet.cs
Normal file
32
Noz.PowerShell/Cmdlets/GetNozNoteContentCmdlet.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using System.Management.Automation;
|
||||
|
||||
namespace Noz.PowerShell.Cmdlets;
|
||||
|
||||
/// <summary>Get the markdown content of a note.</summary>
|
||||
/// <example>
|
||||
/// Get-NozNoteContent -Slug psychologie/flow-theorie
|
||||
/// "psychologie/flow-theorie" | Get-NozNoteContent
|
||||
/// Get-NozNote -Topic psychologie | Get-NozNoteContent | Select-Object Slug, Markdown
|
||||
/// </example>
|
||||
[Cmdlet(VerbsCommon.Get, "NozNoteContent")]
|
||||
[OutputType(typeof(NozNoteContent))]
|
||||
public sealed class GetNozNoteContentCmdlet : NozCmdletBase
|
||||
{
|
||||
[Parameter(Mandatory = true, Position = 0,
|
||||
ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
|
||||
public string Slug { get; set; } = "";
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
var content = Run(Api.GetNoteAsync(Slug));
|
||||
WriteObject(new NozNoteContent { Slug = content.Slug, Markdown = content.Markdown });
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NozNoteContent
|
||||
{
|
||||
public string Slug { get; init; } = "";
|
||||
public string Markdown { get; init; } = "";
|
||||
|
||||
public override string ToString() => Slug;
|
||||
}
|
||||
31
Noz.PowerShell/Cmdlets/GetNozOrphansCmdlet.cs
Normal file
31
Noz.PowerShell/Cmdlets/GetNozOrphansCmdlet.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using System.Management.Automation;
|
||||
|
||||
namespace Noz.PowerShell.Cmdlets;
|
||||
|
||||
/// Returns notes that have no incoming wikilinks — good candidates for cleanup or linking.
|
||||
[Cmdlet(VerbsCommon.Get, "NozOrphans")]
|
||||
[OutputType(typeof(NozNote))]
|
||||
public sealed class GetNozOrphansCmdlet : NozCmdletBase
|
||||
{
|
||||
[Parameter]
|
||||
public string? Topic { get; set; }
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
var notesTask = Api.GetNotesAsync();
|
||||
var graphTask = Api.GetGraphAsync();
|
||||
|
||||
var notes = Run(notesTask);
|
||||
var graph = Run(graphTask);
|
||||
|
||||
var targets = new HashSet<string>(graph.Edges.Select(e => e.Target));
|
||||
|
||||
var orphans = notes.Where(n => !targets.Contains(n.Slug));
|
||||
|
||||
if (Topic is not null)
|
||||
orphans = orphans.Where(n => string.Equals(n.Topic, Topic, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
foreach (var n in orphans.OrderBy(n => n.Topic).ThenBy(n => n.Slug))
|
||||
WriteObject(NozNote.From(n));
|
||||
}
|
||||
}
|
||||
23
Noz.PowerShell/Cmdlets/GetNozTemplateCmdlet.cs
Normal file
23
Noz.PowerShell/Cmdlets/GetNozTemplateCmdlet.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using System.Management.Automation;
|
||||
|
||||
namespace Noz.PowerShell.Cmdlets;
|
||||
|
||||
[Cmdlet(VerbsCommon.Get, "NozTemplate")]
|
||||
[OutputType(typeof(NozTemplate))]
|
||||
public sealed class GetNozTemplateCmdlet : NozCmdletBase
|
||||
{
|
||||
[Parameter(Position = 0)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
var templates = Run(Api.GetTemplatesAsync());
|
||||
|
||||
var filtered = Name is null
|
||||
? templates
|
||||
: templates.Where(t => t.Name.Contains(Name, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
foreach (var t in filtered.OrderBy(t => t.Name))
|
||||
WriteObject(new NozTemplate { Name = t.Name, Description = t.Description });
|
||||
}
|
||||
}
|
||||
41
Noz.PowerShell/Cmdlets/MeasureNozGardenCmdlet.cs
Normal file
41
Noz.PowerShell/Cmdlets/MeasureNozGardenCmdlet.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
using System.Management.Automation;
|
||||
|
||||
namespace Noz.PowerShell.Cmdlets;
|
||||
|
||||
[Cmdlet(VerbsDiagnostic.Measure, "NozGarden")]
|
||||
[OutputType(typeof(NozGardenStats))]
|
||||
public sealed class MeasureNozGardenCmdlet : NozCmdletBase
|
||||
{
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
var notesTask = Api.GetNotesAsync();
|
||||
var graphTask = Api.GetGraphAsync();
|
||||
|
||||
var notes = Run(notesTask);
|
||||
var graph = Run(graphTask);
|
||||
|
||||
var targets = new HashSet<string>(graph.Edges.Select(e => e.Target));
|
||||
var orphans = notes.Count(n => !targets.Contains(n.Slug));
|
||||
|
||||
var byTopic = notes
|
||||
.GroupBy(n => n.Topic ?? "—")
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var byLang = notes
|
||||
.GroupBy(n => n.Lang)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
WriteObject(new NozGardenStats
|
||||
{
|
||||
TotalNotes = notes.Count,
|
||||
SeedCount = notes.Count(n => n.Status == "seed"),
|
||||
BuddingCount = notes.Count(n => n.Status == "budding"),
|
||||
EvergreenCount = notes.Count(n => n.Status == "evergreen"),
|
||||
TopicCount = byTopic.Count,
|
||||
LinkCount = graph.Edges.Count,
|
||||
OrphanCount = orphans,
|
||||
NotesByTopic = byTopic,
|
||||
NotesByLang = byLang,
|
||||
});
|
||||
}
|
||||
}
|
||||
59
Noz.PowerShell/Cmdlets/NewNozNoteCmdlet.cs
Normal file
59
Noz.PowerShell/Cmdlets/NewNozNoteCmdlet.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using System.Management.Automation;
|
||||
|
||||
namespace Noz.PowerShell.Cmdlets;
|
||||
|
||||
/// <summary>Create a new note on the server.</summary>
|
||||
/// <example>
|
||||
/// New-NozNote -Slug psychologie/neue-notiz -Title "Neue Notiz"
|
||||
/// New-NozNote -Slug biosysteme/pilze -Title "Pilze" -Topic biosysteme -Status budding -Lang de
|
||||
/// </example>
|
||||
[Cmdlet(VerbsCommon.New, "NozNote", SupportsShouldProcess = true)]
|
||||
[OutputType(typeof(NozNote))]
|
||||
public sealed class NewNozNoteCmdlet : NozCmdletBase
|
||||
{
|
||||
[Parameter(Mandatory = true, Position = 0)]
|
||||
public string Slug { get; set; } = "";
|
||||
|
||||
[Parameter(Mandatory = true, Position = 1)]
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
[Parameter]
|
||||
public string? Topic { get; set; }
|
||||
|
||||
[Parameter]
|
||||
[ValidateSet("seed", "budding", "evergreen")]
|
||||
public string Status { get; set; } = "seed";
|
||||
|
||||
[Parameter]
|
||||
[ValidateSet("de", "en")]
|
||||
public string Lang { get; set; } = "de";
|
||||
|
||||
[Parameter]
|
||||
public string Description { get; set; } = "";
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
if (!ShouldProcess(Slug, "Create note")) return;
|
||||
|
||||
var topic = Topic ?? (Slug.Contains('/') ? Slug.Split('/')[0] : Slug);
|
||||
var markdown = $"""
|
||||
---
|
||||
title: "{Title}"
|
||||
description: "{Description}"
|
||||
topic: {topic}
|
||||
status: {Status}
|
||||
lang: {Lang}
|
||||
---
|
||||
|
||||
# {Title}
|
||||
|
||||
""";
|
||||
|
||||
Run(Api.PutNoteAsync(Slug, markdown));
|
||||
|
||||
var notes = Run(Api.GetNotesAsync());
|
||||
var created = notes.FirstOrDefault(n => n.Slug == Slug);
|
||||
if (created is not null)
|
||||
WriteObject(NozNote.From(created));
|
||||
}
|
||||
}
|
||||
24
Noz.PowerShell/Cmdlets/RemoveNozNoteCmdlet.cs
Normal file
24
Noz.PowerShell/Cmdlets/RemoveNozNoteCmdlet.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
using System.Management.Automation;
|
||||
|
||||
namespace Noz.PowerShell.Cmdlets;
|
||||
|
||||
/// <summary>Delete a note from the server.</summary>
|
||||
/// <example>
|
||||
/// Remove-NozNote -Slug psychologie/alte-notiz
|
||||
/// Get-NozNote -Status seed | Where-Object Topic -eq "draft" | Remove-NozNote -WhatIf
|
||||
/// Get-NozNote -Status seed | Where-Object Topic -eq "draft" | Remove-NozNote -Confirm:$false
|
||||
/// </example>
|
||||
[Cmdlet(VerbsCommon.Remove, "NozNote", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High)]
|
||||
public sealed class RemoveNozNoteCmdlet : NozCmdletBase
|
||||
{
|
||||
[Parameter(Mandatory = true, Position = 0,
|
||||
ValueFromPipeline = true, ValueFromPipelineByPropertyName = true)]
|
||||
public string Slug { get; set; } = "";
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
if (!ShouldProcess(Slug, "Delete note")) return;
|
||||
Run(Api.DeleteNoteAsync(Slug));
|
||||
WriteVerbose($"Deleted: {Slug}");
|
||||
}
|
||||
}
|
||||
50
Noz.PowerShell/Cmdlets/RenameNozNoteCmdlet.cs
Normal file
50
Noz.PowerShell/Cmdlets/RenameNozNoteCmdlet.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
using System.Management.Automation;
|
||||
using NozCli.Core.Client;
|
||||
|
||||
namespace Noz.PowerShell.Cmdlets;
|
||||
|
||||
[Cmdlet(VerbsCommon.Rename, "NozNote", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.Medium)]
|
||||
[OutputType(typeof(NozNote))]
|
||||
public sealed class RenameNozNoteCmdlet : NozCmdletBase
|
||||
{
|
||||
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
|
||||
public string Slug { get; set; } = "";
|
||||
|
||||
[Parameter(Mandatory = true, Position = 1)]
|
||||
public string NewSlug { get; set; } = "";
|
||||
|
||||
[Parameter]
|
||||
public SwitchParameter PassThru { get; set; }
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
if (Slug == NewSlug)
|
||||
{
|
||||
WriteWarning("Quelle und Ziel sind identisch — nichts zu tun.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ShouldProcess(Slug, $"Rename-NozNote → {NewSlug}")) return;
|
||||
|
||||
var steps = new List<string>();
|
||||
|
||||
var result = Run(NoteRenamer.ExecuteAsync(
|
||||
Api, Slug, NewSlug,
|
||||
onProgress: msg => WriteVerbose(msg)));
|
||||
|
||||
WriteVerbose($"Fertig: {result.LinksUpdated} Link(s) aktualisiert.");
|
||||
|
||||
if (result.LinksUpdated > 0)
|
||||
{
|
||||
foreach (var updated in result.UpdatedNotes)
|
||||
WriteVerbose($" • {updated}");
|
||||
}
|
||||
|
||||
if (PassThru)
|
||||
{
|
||||
var notes = Run(Api.GetNotesAsync());
|
||||
var renamed = notes.Find(n => n.Slug == NewSlug);
|
||||
if (renamed is not null) WriteObject(NozNote.From(renamed));
|
||||
}
|
||||
}
|
||||
}
|
||||
31
Noz.PowerShell/Cmdlets/SetNozNoteCmdlet.cs
Normal file
31
Noz.PowerShell/Cmdlets/SetNozNoteCmdlet.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using System.Management.Automation;
|
||||
|
||||
namespace Noz.PowerShell.Cmdlets;
|
||||
|
||||
/// <summary>Update the markdown content of an existing note.</summary>
|
||||
/// <example>
|
||||
/// Set-NozNote -Slug psychologie/test -Markdown "# Test`n`nHello."
|
||||
/// Get-Content note.md -Raw | Set-NozNote -Slug psychologie/test
|
||||
/// </example>
|
||||
[Cmdlet(VerbsCommon.Set, "NozNote", SupportsShouldProcess = true)]
|
||||
[OutputType(typeof(NozNote))]
|
||||
public sealed class SetNozNoteCmdlet : NozCmdletBase
|
||||
{
|
||||
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
|
||||
public string Slug { get; set; } = "";
|
||||
|
||||
[Parameter(Mandatory = true, Position = 1, ValueFromPipeline = true)]
|
||||
public string Markdown { get; set; } = "";
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
if (!ShouldProcess(Slug, "Update note")) return;
|
||||
|
||||
Run(Api.PutNoteAsync(Slug, Markdown));
|
||||
|
||||
var notes = Run(Api.GetNotesAsync());
|
||||
var updated = notes.FirstOrDefault(n => n.Slug == Slug);
|
||||
if (updated is not null)
|
||||
WriteObject(NozNote.From(updated));
|
||||
}
|
||||
}
|
||||
49
Noz.PowerShell/Cmdlets/SetNozNoteStatusCmdlet.cs
Normal file
49
Noz.PowerShell/Cmdlets/SetNozNoteStatusCmdlet.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using System.Management.Automation;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Noz.PowerShell.Cmdlets;
|
||||
|
||||
[Cmdlet(VerbsCommon.Set, "NozNoteStatus", SupportsShouldProcess = true)]
|
||||
[OutputType(typeof(NozNote))]
|
||||
public sealed partial class SetNozNoteStatusCmdlet : NozCmdletBase
|
||||
{
|
||||
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
|
||||
public string Slug { get; set; } = "";
|
||||
|
||||
[Parameter(Mandatory = true, Position = 1)]
|
||||
[ValidateSet("seed", "budding", "evergreen")]
|
||||
public string Status { get; set; } = "";
|
||||
|
||||
[Parameter]
|
||||
public SwitchParameter PassThru { get; set; }
|
||||
|
||||
[GeneratedRegex(@"(?m)^(status:\s*)(?:seed|budding|evergreen)(\s*)$")]
|
||||
private static partial Regex StatusPattern();
|
||||
|
||||
protected override void ProcessRecord()
|
||||
{
|
||||
var content = Run(Api.GetNoteAsync(Slug));
|
||||
|
||||
var patched = StatusPattern().Replace(content.Markdown, $"$1{Status}$2");
|
||||
|
||||
if (patched == content.Markdown)
|
||||
{
|
||||
WriteVerbose($"{Slug}: status is already '{Status}', no change.");
|
||||
if (PassThru) WriteObject(Run(Api.GetNotesAsync()).Find(n => n.Slug == Slug) is { } n
|
||||
? NozNote.From(n) : null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ShouldProcess(Slug, $"Set status → {Status}")) return;
|
||||
|
||||
Run(Api.PutNoteAsync(Slug, patched));
|
||||
WriteVerbose($"{Slug}: status updated to '{Status}'.");
|
||||
|
||||
if (PassThru)
|
||||
{
|
||||
var notes = Run(Api.GetNotesAsync());
|
||||
var updated = notes.Find(n => n.Slug == Slug);
|
||||
if (updated is not null) WriteObject(NozNote.From(updated));
|
||||
}
|
||||
}
|
||||
}
|
||||
29
Noz.PowerShell/Install-NozModule.ps1
Normal file
29
Noz.PowerShell/Install-NozModule.ps1
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#Requires -Version 7.2
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds and installs the Noz PowerShell module for the current user.
|
||||
.EXAMPLE
|
||||
./Install-NozModule.ps1
|
||||
./Install-NozModule.ps1 -Verbose
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$moduleDir = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell' 'Modules' 'Noz'
|
||||
|
||||
Write-Host "Building Noz.PowerShell..." -ForegroundColor Cyan
|
||||
dotnet build "$PSScriptRoot/Noz.PowerShell.csproj" -c Release -v quiet
|
||||
|
||||
$src = "$PSScriptRoot/bin/Release/net8.0"
|
||||
|
||||
Write-Host "Installing to: $moduleDir" -ForegroundColor Cyan
|
||||
New-Item -ItemType Directory -Force -Path $moduleDir | Out-Null
|
||||
|
||||
# Copy DLLs and manifest
|
||||
Copy-Item "$src/Noz.PowerShell.dll" $moduleDir -Force
|
||||
Copy-Item "$src/NozCli.Core.dll" $moduleDir -Force
|
||||
Copy-Item "$PSScriptRoot/Noz.psd1" $moduleDir -Force
|
||||
|
||||
Write-Host "Done. Import with:" -ForegroundColor Green
|
||||
Write-Host " Import-Module Noz" -ForegroundColor White
|
||||
Write-Host " Get-NozNote -Topic psychologie" -ForegroundColor White
|
||||
20
Noz.PowerShell/Noz.PowerShell.csproj
Normal file
20
Noz.PowerShell/Noz.PowerShell.csproj
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NozCli.Core\NozCli.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="PowerShellStandard.Library" Version="5.1.1" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Noz.PowerShell</AssemblyName>
|
||||
<RootNamespace>Noz.PowerShell</RootNamespace>
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
34
Noz.PowerShell/Noz.psd1
Normal file
34
Noz.PowerShell/Noz.psd1
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
@{
|
||||
ModuleVersion = '0.1.0'
|
||||
GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
Author = 'Bruno Deanoz'
|
||||
Description = 'PowerShell module for noz — a self-hosted digital garden'
|
||||
PowerShellVersion = '7.2'
|
||||
RootModule = 'Noz.PowerShell.dll'
|
||||
|
||||
CmdletsToExport = @(
|
||||
'Get-NozNote',
|
||||
'Get-NozNoteContent',
|
||||
'Set-NozNote',
|
||||
'New-NozNote',
|
||||
'Remove-NozNote',
|
||||
'Get-NozGraph',
|
||||
'Find-NozNote',
|
||||
'Measure-NozGarden',
|
||||
'Set-NozNoteStatus',
|
||||
'Get-NozOrphans',
|
||||
'Rename-NozNote',
|
||||
'Get-NozTemplate',
|
||||
'Add-NozLink'
|
||||
)
|
||||
|
||||
FunctionsToExport = @()
|
||||
AliasesToExport = @()
|
||||
|
||||
PrivateData = @{
|
||||
PSData = @{
|
||||
Tags = @('noz', 'digital-garden', 'notes', 'markdown')
|
||||
ProjectUri = 'https://git.kryptomrx.de/krypto/noz-cli'
|
||||
}
|
||||
}
|
||||
}
|
||||
47
Noz.PowerShell/NozCmdletBase.cs
Normal file
47
Noz.PowerShell/NozCmdletBase.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using System.Management.Automation;
|
||||
using NozCli.Core.Client;
|
||||
using NozCli.Core.Config;
|
||||
|
||||
namespace Noz.PowerShell;
|
||||
|
||||
public abstract class NozCmdletBase : PSCmdlet
|
||||
{
|
||||
protected LocalConfig Config { get; private set; } = null!;
|
||||
protected NozApiClient Api { get; private set; } = null!;
|
||||
|
||||
protected override void BeginProcessing()
|
||||
{
|
||||
var cfg = LocalConfig.Load();
|
||||
if (cfg is null)
|
||||
ThrowTerminatingError(new ErrorRecord(
|
||||
new InvalidOperationException(
|
||||
"noz is not initialized. Run: noz init <server-url> <token>"),
|
||||
"NozNotInitialized",
|
||||
ErrorCategory.NotSpecified,
|
||||
targetObject: null));
|
||||
|
||||
Config = cfg!;
|
||||
Api = new NozApiClient(cfg!);
|
||||
}
|
||||
|
||||
protected override void StopProcessing() => Api?.Dispose();
|
||||
|
||||
protected T Run<T>(Task<T> task)
|
||||
{
|
||||
try { return task.GetAwaiter().GetResult(); }
|
||||
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(ex, "NozUnauthorized",
|
||||
ErrorCategory.AuthenticationError, null));
|
||||
throw;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
ThrowTerminatingError(new ErrorRecord(ex, "NozApiError",
|
||||
ErrorCategory.ConnectionError, null));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
protected void Run(Task task) => Run(task.ContinueWith(_ => true));
|
||||
}
|
||||
15
Noz.PowerShell/NozGardenStats.cs
Normal file
15
Noz.PowerShell/NozGardenStats.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
namespace Noz.PowerShell;
|
||||
|
||||
public sealed class NozGardenStats
|
||||
{
|
||||
public int TotalNotes { get; init; }
|
||||
public int SeedCount { get; init; }
|
||||
public int BuddingCount { get; init; }
|
||||
public int EvergreenCount { get; init; }
|
||||
public int TopicCount { get; init; }
|
||||
public int LinkCount { get; init; }
|
||||
public int OrphanCount { get; init; }
|
||||
|
||||
public Dictionary<string, int> NotesByTopic { get; init; } = new();
|
||||
public Dictionary<string, int> NotesByLang { get; init; } = new();
|
||||
}
|
||||
11
Noz.PowerShell/NozGraphEdge.cs
Normal file
11
Noz.PowerShell/NozGraphEdge.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
namespace Noz.PowerShell;
|
||||
|
||||
public sealed class NozGraphEdge
|
||||
{
|
||||
public string Source { get; init; } = "";
|
||||
public string Target { get; init; } = "";
|
||||
public string SourceTitle { get; init; } = "";
|
||||
public string TargetTitle { get; init; } = "";
|
||||
|
||||
public override string ToString() => $"{Source} → {Target}";
|
||||
}
|
||||
29
Noz.PowerShell/NozNote.cs
Normal file
29
Noz.PowerShell/NozNote.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
using NozCli.Core.Models;
|
||||
|
||||
namespace Noz.PowerShell;
|
||||
|
||||
/// PS-friendly output object for a noz note.
|
||||
/// Clean property set — no internal fields like Hash.
|
||||
public sealed class NozNote
|
||||
{
|
||||
public string Slug { get; init; } = "";
|
||||
public string Title { get; init; } = "";
|
||||
public string Status { get; init; } = "";
|
||||
public string Topic { get; init; } = "";
|
||||
public string Lang { get; init; } = "";
|
||||
public string? Date { get; init; }
|
||||
public bool Private { get; init; }
|
||||
|
||||
public override string ToString() => Slug;
|
||||
|
||||
internal static NozNote From(NoteInfo n) => new()
|
||||
{
|
||||
Slug = n.Slug,
|
||||
Title = n.Title,
|
||||
Status = n.Status,
|
||||
Topic = n.Topic,
|
||||
Lang = n.Lang,
|
||||
Date = n.Date,
|
||||
Private = n.Private,
|
||||
};
|
||||
}
|
||||
13
Noz.PowerShell/NozSearchHit.cs
Normal file
13
Noz.PowerShell/NozSearchHit.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace Noz.PowerShell;
|
||||
|
||||
public sealed class NozSearchHit
|
||||
{
|
||||
public string Slug { get; init; } = "";
|
||||
public string Title { get; init; } = "";
|
||||
public string? Description { get; init; }
|
||||
public string Topic { get; init; } = "";
|
||||
public string Status { get; init; } = "";
|
||||
public string Lang { get; init; } = "";
|
||||
|
||||
public override string ToString() => Slug;
|
||||
}
|
||||
9
Noz.PowerShell/NozTemplate.cs
Normal file
9
Noz.PowerShell/NozTemplate.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
namespace Noz.PowerShell;
|
||||
|
||||
public sealed class NozTemplate
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public string? Description { get; init; }
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
60
NozCli.Core/Client/NoteLinkHelper.cs
Normal file
60
NozCli.Core/Client/NoteLinkHelper.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NozCli.Core.Client;
|
||||
|
||||
public static class NoteLinkHelper
|
||||
{
|
||||
/// Inserts a wikilink to <paramref name="targetSlug"/> into <paramref name="markdown"/>.
|
||||
/// Appends to an existing section matching <paramref name="sectionHeader"/>,
|
||||
/// or creates that section at the end of the note if absent.
|
||||
public static string AddLink(string markdown, string targetSlug, string sectionHeader = "Siehe auch")
|
||||
{
|
||||
var link = $"- [[{targetSlug}]]";
|
||||
|
||||
// Find the section heading (## Siehe auch, ## See also, etc.)
|
||||
var sectionMatch = Regex.Match(
|
||||
markdown,
|
||||
$@"(?m)^##\s+{Regex.Escape(sectionHeader)}\s*$",
|
||||
RegexOptions.IgnoreCase);
|
||||
|
||||
if (sectionMatch.Success)
|
||||
{
|
||||
// Already has this link?
|
||||
if (markdown.Contains($"[[{targetSlug}]]", StringComparison.Ordinal))
|
||||
return markdown;
|
||||
|
||||
// Insert at the end of this section's content (before next ## heading or EOF)
|
||||
var afterSection = markdown.IndexOf('\n', sectionMatch.Index + sectionMatch.Length);
|
||||
if (afterSection < 0) afterSection = markdown.Length;
|
||||
|
||||
var nextHeading = Regex.Match(
|
||||
markdown[afterSection..],
|
||||
@"(?m)^##\s",
|
||||
RegexOptions.IgnoreCase);
|
||||
|
||||
var insertAt = nextHeading.Success
|
||||
? afterSection + nextHeading.Index
|
||||
: markdown.Length;
|
||||
|
||||
var before = markdown[..insertAt].TrimEnd();
|
||||
var after = markdown[insertAt..];
|
||||
|
||||
return before + "\n" + link + "\n" + (after.Length > 0 ? "\n" + after.TrimStart() : "");
|
||||
}
|
||||
|
||||
// No section found — create one at the end
|
||||
return markdown.TrimEnd() + $"\n\n## {sectionHeader}\n\n{link}\n";
|
||||
}
|
||||
|
||||
/// Replaces template placeholders with actual values.
|
||||
public static string ApplyPlaceholders(
|
||||
string template, string title, string topic, string lang, string status)
|
||||
{
|
||||
return template
|
||||
.Replace("{{title}}", title, StringComparison.Ordinal)
|
||||
.Replace("{{topic}}", topic, StringComparison.Ordinal)
|
||||
.Replace("{{lang}}", lang, StringComparison.Ordinal)
|
||||
.Replace("{{status}}", status, StringComparison.Ordinal)
|
||||
.Replace("{{date}}", DateTime.UtcNow.ToString("yyyy-MM-dd"), StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
125
NozCli.Core/Client/NoteRenamer.cs
Normal file
125
NozCli.Core/Client/NoteRenamer.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using NozCli.Core.Models;
|
||||
|
||||
namespace NozCli.Core.Client;
|
||||
|
||||
public static class NoteRenamer
|
||||
{
|
||||
public record Result(List<string> UpdatedNotes, int LinksUpdated);
|
||||
|
||||
/// Renames a note and rewrites all inbound wikilinks/markdown-links in one atomic sequence.
|
||||
/// Progress callback receives human-readable step descriptions.
|
||||
public static async Task<Result> ExecuteAsync(
|
||||
NozApiClient api,
|
||||
string oldSlug,
|
||||
string newSlug,
|
||||
Action<string>? onProgress = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// 1. Fetch inbound edges before we change anything
|
||||
onProgress?.Invoke("Hole Graph-Daten…");
|
||||
var graph = await api.GetGraphAsync(ct);
|
||||
var inbound = graph.Edges
|
||||
.Where(e => e.Target == oldSlug)
|
||||
.Select(e => e.Source)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
// 2. Fetch the original note
|
||||
onProgress?.Invoke($"Lese {oldSlug}…");
|
||||
var original = await api.GetNoteAsync(oldSlug, ct);
|
||||
|
||||
// 3. Patch frontmatter topic if the slug prefix (= topic) changes
|
||||
var patchedContent = PatchTopicIfNeeded(original.Markdown, oldSlug, newSlug);
|
||||
|
||||
// 4. Create note under new slug
|
||||
onProgress?.Invoke($"Erstelle {newSlug}…");
|
||||
await api.PutNoteAsync(newSlug, patchedContent, ct);
|
||||
|
||||
// 5. Update every note that links to the old slug
|
||||
var updatedNotes = new List<string>();
|
||||
foreach (var sourceSlug in inbound)
|
||||
{
|
||||
onProgress?.Invoke($"Aktualisiere Links in {sourceSlug}…");
|
||||
try
|
||||
{
|
||||
var src = await api.GetNoteAsync(sourceSlug, ct);
|
||||
var updated = ReplaceReferences(src.Markdown, oldSlug, newSlug);
|
||||
if (updated != src.Markdown)
|
||||
{
|
||||
await api.PutNoteAsync(sourceSlug, updated, ct);
|
||||
updatedNotes.Add(sourceSlug);
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
// Don't abort the whole rename if one note fails to update
|
||||
onProgress?.Invoke($" ⚠ {sourceSlug} konnte nicht aktualisiert werden");
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Delete old slug — do this last so we never lose data on partial failure
|
||||
onProgress?.Invoke($"Lösche {oldSlug}…");
|
||||
await api.DeleteNoteAsync(oldSlug, ct);
|
||||
|
||||
return new Result(updatedNotes, updatedNotes.Count);
|
||||
}
|
||||
|
||||
// ── Slug reference replacement ────────────────────────────────────────────
|
||||
|
||||
private static string ReplaceReferences(string markdown, string oldSlug, string newSlug)
|
||||
{
|
||||
// [[old-slug]] → [[new-slug]]
|
||||
// [[old-slug|Anzeigetext]] → [[new-slug|Anzeigetext]]
|
||||
markdown = Regex.Replace(
|
||||
markdown,
|
||||
@"\[\[" + Regex.Escape(oldSlug) + @"(\|[^\]]+)?\]\]",
|
||||
m => $"[[{newSlug}{m.Groups[1].Value}]]");
|
||||
|
||||
// [text](/noosphere/old-slug) → [text](/noosphere/new-slug)
|
||||
// [text](/noosphere/old-slug#anchor) → [text](/noosphere/new-slug#anchor)
|
||||
markdown = Regex.Replace(
|
||||
markdown,
|
||||
@"\[([^\]]+)\]\(/noosphere/" + Regex.Escape(oldSlug) + @"(#[^\)]*)?\)",
|
||||
m => $"[{m.Groups[1].Value}](/noosphere/{newSlug}{m.Groups[2].Value})");
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
// ── Frontmatter topic patch ───────────────────────────────────────────────
|
||||
|
||||
private static string PatchTopicIfNeeded(string markdown, string oldSlug, string newSlug)
|
||||
{
|
||||
var oldTopic = FirstSegment(oldSlug);
|
||||
var newTopic = FirstSegment(newSlug);
|
||||
|
||||
if (oldTopic == newTopic || oldTopic is null || newTopic is null)
|
||||
return markdown;
|
||||
|
||||
// Only patch within the frontmatter block
|
||||
var (fmStart, fmEnd) = FindFrontmatterBounds(markdown);
|
||||
if (fmStart < 0) return markdown;
|
||||
|
||||
var block = markdown[fmStart..fmEnd];
|
||||
var patched = Regex.Replace(
|
||||
block,
|
||||
@"(?m)^(topic:\s*)" + Regex.Escape(oldTopic) + @"(\s*)$",
|
||||
$"$1{newTopic}$2");
|
||||
|
||||
return markdown[..fmStart] + patched + markdown[fmEnd..];
|
||||
}
|
||||
|
||||
private static string? FirstSegment(string slug)
|
||||
{
|
||||
var idx = slug.IndexOf('/');
|
||||
return idx > 0 ? slug[..idx] : null;
|
||||
}
|
||||
|
||||
private static (int start, int end) FindFrontmatterBounds(string markdown)
|
||||
{
|
||||
if (!markdown.StartsWith("---", StringComparison.Ordinal)) return (-1, -1);
|
||||
var closeIdx = markdown.IndexOf("\n---", 3, StringComparison.Ordinal);
|
||||
if (closeIdx < 0) return (-1, -1);
|
||||
return (0, closeIdx + 4); // include closing ---
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +78,69 @@ public class NozApiClient : IDisposable
|
|||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<List<TemplateInfo>> GetTemplatesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var res = await _http.GetFromJsonAsync("api/cli/templates", NozJsonContext.Default.JsonElement, ct);
|
||||
return res.GetProperty("templates").EnumerateArray().Select(t => new TemplateInfo(
|
||||
Name: t.GetProperty("name").GetString()!,
|
||||
Description: t.TryGetProperty("description", out var d) ? d.GetString() : null
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
public async Task<TemplateContent?> GetTemplateAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
var res = await _http.GetAsync($"api/cli/templates/{Uri.EscapeDataString(name)}", ct);
|
||||
if (res.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
|
||||
res.EnsureSuccessStatusCode();
|
||||
var json = await res.Content.ReadFromJsonAsync(NozJsonContext.Default.JsonElement, ct);
|
||||
return new TemplateContent(
|
||||
Name: json.GetProperty("name").GetString()!,
|
||||
Description: json.TryGetProperty("description", out var d) ? d.GetString() : null,
|
||||
Body: json.GetProperty("body").GetString()!
|
||||
);
|
||||
}
|
||||
|
||||
public async Task PutFolderIndexAsync(
|
||||
string folderPath, string title, string? description = null, CancellationToken ct = default)
|
||||
{
|
||||
var body = JsonSerializer.Serialize(new FolderBody(title, description), NozJsonContext.Default.FolderBody);
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
var res = await _http.PutAsync($"api/cli/folders/{folderPath}", content, ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<GraphData> GetGraphAsync(CancellationToken ct = default)
|
||||
{
|
||||
var res = await _http.GetFromJsonAsync("api/cli/graph", NozJsonContext.Default.JsonElement, ct);
|
||||
var nodes = res.GetProperty("nodes").EnumerateArray().Select(n => new GraphNode(
|
||||
Id: n.GetProperty("id").GetString()!,
|
||||
Title: n.GetProperty("title").GetString()!,
|
||||
Topic: n.GetProperty("topic").GetString()!,
|
||||
Status: n.GetProperty("status").GetString()!
|
||||
)).ToList();
|
||||
var edges = res.GetProperty("edges").EnumerateArray().Select(e => new GraphEdge(
|
||||
Source: e.GetProperty("source").GetString()!,
|
||||
Target: e.GetProperty("target").GetString()!
|
||||
)).ToList();
|
||||
return new GraphData(nodes, edges);
|
||||
}
|
||||
|
||||
public async Task<List<SearchHit>> SearchAsync(
|
||||
string query, string? topic = null, int limit = 20, CancellationToken ct = default)
|
||||
{
|
||||
var url = $"api/cli/search?q={Uri.EscapeDataString(query)}&limit={limit}";
|
||||
if (topic is not null) url += $"&topic={Uri.EscapeDataString(topic)}";
|
||||
var res = await _http.GetFromJsonAsync(url, NozJsonContext.Default.JsonElement, ct);
|
||||
return res.GetProperty("hits").EnumerateArray().Select(h => new SearchHit(
|
||||
Slug: h.GetProperty("slug").GetString()!,
|
||||
Title: h.GetProperty("title").GetString()!,
|
||||
Description: h.TryGetProperty("description", out var d) ? d.GetString() : null,
|
||||
Topic: h.GetProperty("topic").GetString()!,
|
||||
Status: h.GetProperty("status").GetString()!,
|
||||
Lang: h.GetProperty("lang").GetString()!
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
public async Task RebuildIndexAsync(CancellationToken ct = default)
|
||||
{
|
||||
var res = await _http.PostAsync("api/cli/rebuild", null, ct);
|
||||
|
|
|
|||
5
NozCli.Core/Models/GraphData.cs
Normal file
5
NozCli.Core/Models/GraphData.cs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
namespace NozCli.Core.Models;
|
||||
|
||||
public record GraphNode(string Id, string Title, string Topic, string Status);
|
||||
public record GraphEdge(string Source, string Target);
|
||||
public record GraphData(List<GraphNode> Nodes, List<GraphEdge> Edges);
|
||||
10
NozCli.Core/Models/SearchHit.cs
Normal file
10
NozCli.Core/Models/SearchHit.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace NozCli.Core.Models;
|
||||
|
||||
public record SearchHit(
|
||||
string Slug,
|
||||
string Title,
|
||||
string? Description,
|
||||
string Topic,
|
||||
string Status,
|
||||
string Lang
|
||||
);
|
||||
4
NozCli.Core/Models/TemplateInfo.cs
Normal file
4
NozCli.Core/Models/TemplateInfo.cs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
namespace NozCli.Core.Models;
|
||||
|
||||
public record TemplateInfo(string Name, string? Description);
|
||||
public record TemplateContent(string Name, string? Description, string Body);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
|
|
|||
34
NozCli.Core/Security/SlugGuard.cs
Normal file
34
NozCli.Core/Security/SlugGuard.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NozCli.Core.Security;
|
||||
|
||||
/// Validates slugs before they are sent to the server or written to disk.
|
||||
public static partial class SlugGuard
|
||||
{
|
||||
// Valid slug: lowercase alphanumeric + hyphens, optionally separated by /
|
||||
// Examples: "hydroponik", "biosysteme/hydroponik", "a/b/c"
|
||||
[GeneratedRegex(@"^[a-z0-9][a-z0-9\-]*(?:/[a-z0-9][a-z0-9\-]*)*$")]
|
||||
private static partial Regex ValidSlugRegex();
|
||||
|
||||
/// Returns true if the slug is safe to use (no path traversal, valid chars).
|
||||
public static bool IsValid(string slug) =>
|
||||
!string.IsNullOrWhiteSpace(slug) &&
|
||||
!slug.Contains("..") &&
|
||||
!slug.Contains("//") &&
|
||||
ValidSlugRegex().IsMatch(slug);
|
||||
|
||||
/// Resolves a slug to a file path inside baseDir.
|
||||
/// Returns null if the resulting path would escape baseDir (path traversal).
|
||||
public static string? ToSafePath(string baseDir, string slug, string extension = ".md")
|
||||
{
|
||||
var rel = slug.Replace('/', Path.DirectorySeparatorChar) + extension;
|
||||
var fullPath = Path.GetFullPath(Path.Combine(baseDir, rel));
|
||||
var baseFull = Path.GetFullPath(baseDir);
|
||||
|
||||
// Ensure the resolved path stays strictly inside baseDir
|
||||
return fullPath.StartsWith(baseFull + Path.DirectorySeparatorChar,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? fullPath
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,14 @@ namespace NozCli.Core.Serialization;
|
|||
[JsonSerializable(typeof(NotesCacheFile))]
|
||||
[JsonSerializable(typeof(List<NoteInfo>))]
|
||||
[JsonSerializable(typeof(NoteInfo))]
|
||||
[JsonSerializable(typeof(GraphData))]
|
||||
[JsonSerializable(typeof(List<GraphNode>))]
|
||||
[JsonSerializable(typeof(List<GraphEdge>))]
|
||||
[JsonSerializable(typeof(List<SearchHit>))]
|
||||
[JsonSerializable(typeof(FolderBody))]
|
||||
[JsonSerializable(typeof(List<TemplateInfo>))]
|
||||
[JsonSerializable(typeof(TemplateInfo))]
|
||||
[JsonSerializable(typeof(TemplateContent))]
|
||||
public partial class NozJsonContext : JsonSerializerContext { }
|
||||
|
||||
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, WriteIndented = true)]
|
||||
|
|
@ -19,3 +27,4 @@ public partial class NozJsonContext : JsonSerializerContext { }
|
|||
internal partial class NozJsonContextPretty : JsonSerializerContext { }
|
||||
|
||||
public record PushBody(string Markdown);
|
||||
public record FolderBody(string Title, string? Description);
|
||||
|
|
|
|||
86
NozCli.Tests/NoteLinkHelperTests.cs
Normal file
86
NozCli.Tests/NoteLinkHelperTests.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using Xunit;
|
||||
using NozCli.Core.Client;
|
||||
|
||||
namespace NozCli.Tests;
|
||||
|
||||
public class NoteLinkHelperTests
|
||||
{
|
||||
// ── AddLink — neue Sektion ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AddLink_NoSection_AppendsSieheAuch()
|
||||
{
|
||||
var md = "# Notiz\n\nEtwas Text.";
|
||||
var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
|
||||
|
||||
Assert.Contains("## Siehe auch", result);
|
||||
Assert.Contains("- [[psychologie/flow]]", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLink_NoSection_BodyUntouched()
|
||||
{
|
||||
var md = "# Notiz\n\nEtwas Text.";
|
||||
var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
|
||||
|
||||
Assert.StartsWith("# Notiz\n\nEtwas Text.", result);
|
||||
}
|
||||
|
||||
// ── AddLink — existierende Sektion ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AddLink_ExistingSection_AppendsToSection()
|
||||
{
|
||||
var md = "# Notiz\n\nText.\n\n## Siehe auch\n\n- [[anderes/link]]\n";
|
||||
var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
|
||||
|
||||
Assert.Contains("- [[anderes/link]]", result);
|
||||
Assert.Contains("- [[psychologie/flow]]", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLink_AlreadyLinked_NoChange()
|
||||
{
|
||||
var md = "# Notiz\n\n## Siehe auch\n\n- [[psychologie/flow]]\n";
|
||||
var result = NoteLinkHelper.AddLink(md, "psychologie/flow");
|
||||
|
||||
Assert.Equal(md, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLink_ExistingSection_DoesNotBleedIntoNextSection()
|
||||
{
|
||||
var md = "# Notiz\n\n## Siehe auch\n\n- [[a]]\n\n## Weiteres\n\nAnderer Inhalt.\n";
|
||||
var result = NoteLinkHelper.AddLink(md, "b/new-note");
|
||||
|
||||
Assert.Contains("- [[b/new-note]]", result);
|
||||
// The new link should appear before "## Weiteres"
|
||||
var linkIdx = result.IndexOf("- [[b/new-note]]", StringComparison.Ordinal);
|
||||
var weitersIdx = result.IndexOf("## Weiteres", StringComparison.Ordinal);
|
||||
Assert.True(linkIdx < weitersIdx);
|
||||
}
|
||||
|
||||
// ── ApplyPlaceholders ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ApplyPlaceholders_ReplacesAllTokens()
|
||||
{
|
||||
var body = "# {{title}}\n\ntopic: {{topic}}\nlang: {{lang}}\nstatus: {{status}}";
|
||||
var result = NoteLinkHelper.ApplyPlaceholders(body, "Mein Rezept", "kochen", "de", "seed");
|
||||
|
||||
Assert.Contains("# Mein Rezept", result);
|
||||
Assert.Contains("topic: kochen", result);
|
||||
Assert.Contains("lang: de", result);
|
||||
Assert.Contains("status: seed", result);
|
||||
Assert.DoesNotContain("{{", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyPlaceholders_DateIsCurrentDay()
|
||||
{
|
||||
var body = "Datum: {{date}}";
|
||||
var result = NoteLinkHelper.ApplyPlaceholders(body, "", "", "", "");
|
||||
var today = DateTime.UtcNow.ToString("yyyy-MM-dd");
|
||||
Assert.Contains(today, result);
|
||||
}
|
||||
}
|
||||
102
NozCli.Tests/NoteRenamerTests.cs
Normal file
102
NozCli.Tests/NoteRenamerTests.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using Xunit;
|
||||
using System.Reflection;
|
||||
|
||||
namespace NozCli.Tests;
|
||||
|
||||
// Tests the private ReplaceReferences + PatchTopicIfNeeded methods via reflection
|
||||
// so we don't expose them publicly just for testing.
|
||||
public class NoteRenamerTests
|
||||
{
|
||||
private static string ReplaceReferences(string md, string oldSlug, string newSlug)
|
||||
{
|
||||
var method = typeof(NozCli.Core.Client.NoteRenamer)
|
||||
.GetMethod("ReplaceReferences", BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
return (string)method.Invoke(null, [md, oldSlug, newSlug])!;
|
||||
}
|
||||
|
||||
private static string PatchTopicIfNeeded(string md, string oldSlug, string newSlug)
|
||||
{
|
||||
var method = typeof(NozCli.Core.Client.NoteRenamer)
|
||||
.GetMethod("PatchTopicIfNeeded", BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
return (string)method.Invoke(null, [md, oldSlug, newSlug])!;
|
||||
}
|
||||
|
||||
// ── Wikilink replacement ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ReplaceReferences_BareWikilink_IsReplaced()
|
||||
{
|
||||
var md = "Siehe [[psychologie/flow]] für Details.";
|
||||
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
|
||||
Assert.Equal("Siehe [[psychologie/new-flow]] für Details.", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceReferences_WikilinkWithDisplay_PreservesDisplay()
|
||||
{
|
||||
var md = "Lies [[psychologie/flow|Flow-Zustand]] weiter.";
|
||||
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
|
||||
Assert.Equal("Lies [[psychologie/new-flow|Flow-Zustand]] weiter.", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceReferences_MarkdownLink_IsReplaced()
|
||||
{
|
||||
var md = "Siehe [Flow](/noosphere/psychologie/flow) hier.";
|
||||
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
|
||||
Assert.Equal("Siehe [Flow](/noosphere/psychologie/new-flow) hier.", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceReferences_MarkdownLinkWithAnchor_PreservesAnchor()
|
||||
{
|
||||
var md = "[Abschnitt](/noosphere/psychologie/flow#abschnitt-2)";
|
||||
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
|
||||
Assert.Equal("[Abschnitt](/noosphere/psychologie/new-flow#abschnitt-2)", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceReferences_UnrelatedLinks_AreUntouched()
|
||||
{
|
||||
var md = "[[other/note]] und [Ext](https://example.com/psychologie/flow)";
|
||||
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
|
||||
Assert.Equal(md, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceReferences_MultipleOccurrences_AllReplaced()
|
||||
{
|
||||
var md = "[[psychologie/flow]] und nochmal [[psychologie/flow|Flow]].";
|
||||
var result = ReplaceReferences(md, "psychologie/flow", "psychologie/new-flow");
|
||||
Assert.DoesNotContain("psychologie/flow]]", result);
|
||||
Assert.Equal(2, result.Split("psychologie/new-flow").Length - 1);
|
||||
}
|
||||
|
||||
// ── Topic frontmatter patch ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PatchTopic_SameTopic_NoChange()
|
||||
{
|
||||
var md = "---\ntitle: Flow\ntopic: psychologie\n---\nInhalt";
|
||||
var result = PatchTopicIfNeeded(md, "psychologie/flow", "psychologie/new-flow");
|
||||
Assert.Equal(md, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatchTopic_DifferentTopic_UpdatesFrontmatter()
|
||||
{
|
||||
var md = "---\ntitle: Flow\ntopic: psychologie\n---\nInhalt";
|
||||
var result = PatchTopicIfNeeded(md, "psychologie/flow", "biosysteme/flow");
|
||||
Assert.Contains("topic: biosysteme", result);
|
||||
Assert.DoesNotContain("topic: psychologie", result);
|
||||
Assert.Contains("Inhalt", result); // body untouched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PatchTopic_NoFrontmatter_NoChange()
|
||||
{
|
||||
var md = "# Kein Frontmatter\nNur Inhalt";
|
||||
var result = PatchTopicIfNeeded(md, "psychologie/flow", "biosysteme/flow");
|
||||
Assert.Equal(md, result);
|
||||
}
|
||||
}
|
||||
23
NozCli.Tests/NotesCacheTests.cs
Normal file
23
NozCli.Tests/NotesCacheTests.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
using Xunit;
|
||||
using NozCli.Repl;
|
||||
|
||||
namespace NozCli.Tests;
|
||||
|
||||
public class NotesCacheTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(10, "gerade eben")]
|
||||
[InlineData(59, "gerade eben")]
|
||||
[InlineData(60, "vor 1 Min.")]
|
||||
[InlineData(300, "vor 5 Min.")]
|
||||
[InlineData(3599, "vor 59 Min.")]
|
||||
[InlineData(3600, "vor 1 Std.")]
|
||||
[InlineData(7200, "vor 2 Std.")]
|
||||
[InlineData(86400, "vor 1 Tagen")]
|
||||
public void TimeAgo_ReturnsCorrectLabel(int secondsAgo, string expected)
|
||||
{
|
||||
var dt = DateTimeOffset.UtcNow.AddSeconds(-secondsAgo);
|
||||
var result = NotesCache.TimeAgo(dt);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
26
NozCli.Tests/NozCli.Tests.csproj
Normal file
26
NozCli.Tests/NozCli.Tests.csproj
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NozCli.Core\NozCli.Core.csproj" />
|
||||
<ProjectReference Include="..\NozCli\NozCli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- Disable AOT for the test project even though NozCli has PublishAot=true -->
|
||||
<PublishAot>false</PublishAot>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
165
NozCli.Tests/ReplContextTests.cs
Normal file
165
NozCli.Tests/ReplContextTests.cs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
using Xunit;
|
||||
using NozCli.Core.Config;
|
||||
using NozCli.Core.Models;
|
||||
using NozCli.Repl;
|
||||
|
||||
namespace NozCli.Tests;
|
||||
|
||||
public class ReplContextTests
|
||||
{
|
||||
// Builds a ReplContext with a fixed NoteCache — no API calls made.
|
||||
private static ReplContext BuildCtx(params NoteInfo[] notes)
|
||||
{
|
||||
var cfg = new LocalConfig { ServerUrl = "http://localhost:9999", Token = "test" };
|
||||
var ctx = new ReplContext(null!, cfg); // Api is null — not used in navigation
|
||||
ctx.NoteCache = [.. notes];
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static NoteInfo Note(string slug, string topic, string status = "seed") =>
|
||||
new(slug, $"Title {slug}", status, topic, "de", false, null);
|
||||
|
||||
// ── CurrentPath ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CurrentPath_AtRoot_IsWave()
|
||||
{
|
||||
var ctx = BuildCtx();
|
||||
Assert.Equal("~", ctx.CurrentPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentPath_InTopic_ShowsTopic()
|
||||
{
|
||||
var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
|
||||
ctx.NavigateTo("psychologie");
|
||||
Assert.Equal("~/psychologie", ctx.CurrentPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentPath_InSubfolder_ShowsFullPath()
|
||||
{
|
||||
var ctx = BuildCtx(Note("psychologie/sub/deep", "psychologie"));
|
||||
ctx.NavigateTo("psychologie");
|
||||
ctx.NavigateTo("sub");
|
||||
Assert.Equal("~/psychologie/sub", ctx.CurrentPath);
|
||||
}
|
||||
|
||||
// ── FoldersInView ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FoldersInView_AtRoot_ReturnsDistinctTopics()
|
||||
{
|
||||
var ctx = BuildCtx(
|
||||
Note("psychologie/a", "psychologie"),
|
||||
Note("psychologie/b", "psychologie"),
|
||||
Note("biosysteme/c", "biosysteme"));
|
||||
|
||||
var folders = ctx.FoldersInView().OrderBy(f => f).ToList();
|
||||
Assert.Equal(["biosysteme", "psychologie"], folders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FoldersInView_InTopic_ReturnsSubfolders()
|
||||
{
|
||||
var ctx = BuildCtx(
|
||||
Note("psychologie/sub/note", "psychologie"),
|
||||
Note("psychologie/flat-note", "psychologie"));
|
||||
|
||||
ctx.NavigateTo("psychologie");
|
||||
var folders = ctx.FoldersInView().ToList();
|
||||
|
||||
Assert.Contains("sub", folders);
|
||||
Assert.DoesNotContain("flat-note", folders);
|
||||
}
|
||||
|
||||
// ── NavigateTo / NavigateUp ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NavigateTo_ValidTopic_ReturnsTrue()
|
||||
{
|
||||
var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
|
||||
Assert.True(ctx.NavigateTo("psychologie"));
|
||||
Assert.Equal("psychologie", ctx.CurrentTopic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateTo_UnknownTopic_ReturnsFalse()
|
||||
{
|
||||
var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
|
||||
Assert.False(ctx.NavigateTo("doesnotexist"));
|
||||
Assert.Null(ctx.CurrentTopic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateUp_FromTopic_ReturnsToRoot()
|
||||
{
|
||||
var ctx = BuildCtx(Note("psychologie/test", "psychologie"));
|
||||
ctx.NavigateTo("psychologie");
|
||||
ctx.NavigateUp();
|
||||
Assert.Null(ctx.CurrentTopic);
|
||||
Assert.Equal("~", ctx.CurrentPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavigateUp_FromSubfolder_ReturnsToTopic()
|
||||
{
|
||||
var ctx = BuildCtx(Note("psychologie/sub/deep", "psychologie"));
|
||||
ctx.NavigateTo("psychologie");
|
||||
ctx.NavigateTo("sub");
|
||||
ctx.NavigateUp();
|
||||
|
||||
Assert.Equal("psychologie", ctx.CurrentTopic);
|
||||
Assert.Null(ctx.CurrentSubPath);
|
||||
}
|
||||
|
||||
// ── SlugRelative ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SlugRelative_InTopic_StripsTopicPrefix()
|
||||
{
|
||||
var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
|
||||
ctx.NavigateTo("psychologie");
|
||||
Assert.Equal("flow", ctx.SlugRelative("psychologie/flow"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlugRelative_AtRoot_ReturnsFullSlug()
|
||||
{
|
||||
var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
|
||||
Assert.Equal("psychologie/flow", ctx.SlugRelative("psychologie/flow"));
|
||||
}
|
||||
|
||||
// ── NotesInView ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NotesInView_InTopic_FiltersCorrectly()
|
||||
{
|
||||
var ctx = BuildCtx(
|
||||
Note("psychologie/a", "psychologie"),
|
||||
Note("biosysteme/b", "biosysteme"));
|
||||
|
||||
ctx.NavigateTo("psychologie");
|
||||
var notes = ctx.NotesInView().ToList();
|
||||
|
||||
Assert.Single(notes);
|
||||
Assert.Equal("psychologie/a", notes[0].Slug);
|
||||
}
|
||||
|
||||
// ── ResolveSlug ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ResolveSlug_ShortName_InTopic_ResolvesToFullSlug()
|
||||
{
|
||||
var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
|
||||
ctx.NavigateTo("psychologie");
|
||||
Assert.Equal("psychologie/flow", ctx.ResolveSlug("flow"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveSlug_AlreadyFullSlug_ReturnsUnchanged()
|
||||
{
|
||||
var ctx = BuildCtx(Note("psychologie/flow", "psychologie"));
|
||||
Assert.Equal("psychologie/flow", ctx.ResolveSlug("psychologie/flow"));
|
||||
}
|
||||
}
|
||||
68
NozCli.Tests/SlugGuardTests.cs
Normal file
68
NozCli.Tests/SlugGuardTests.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
using Xunit;
|
||||
using NozCli.Core.Security;
|
||||
|
||||
namespace NozCli.Tests;
|
||||
|
||||
public class SlugGuardTests
|
||||
{
|
||||
// ── IsValid ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("simple-note", true)]
|
||||
[InlineData("psychologie/test", true)]
|
||||
[InlineData("biosysteme/sub/deep", true)]
|
||||
[InlineData("note123", true)]
|
||||
[InlineData("a", true)]
|
||||
public void IsValid_AcceptsValidSlugs(string slug, bool expected)
|
||||
=> Assert.Equal(expected, SlugGuard.IsValid(slug));
|
||||
|
||||
[Theory]
|
||||
[InlineData("../../etc/passwd", false)] // path traversal
|
||||
[InlineData("../escape", false)] // path traversal
|
||||
[InlineData("note/../other", false)] // embedded traversal
|
||||
[InlineData("UPPERCASE", false)] // uppercase not allowed
|
||||
[InlineData("has space", false)] // spaces not allowed
|
||||
[InlineData("", false)] // empty
|
||||
[InlineData(" ", false)] // whitespace
|
||||
[InlineData("note//double", false)] // double slash
|
||||
[InlineData("/leading-slash", false)] // leading slash
|
||||
[InlineData("trailing/", false)] // trailing slash
|
||||
[InlineData("Sonderzeichen!", false)] // special chars
|
||||
public void IsValid_RejectsInvalidSlugs(string slug, bool expected)
|
||||
=> Assert.Equal(expected, SlugGuard.IsValid(slug));
|
||||
|
||||
// ── ToSafePath ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ToSafePath_ValidSlug_ReturnsPathInsideBase()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "noz-test");
|
||||
var result = SlugGuard.ToSafePath(baseDir, "psychologie/test");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.StartsWith(baseDir, result);
|
||||
Assert.EndsWith(".md", result);
|
||||
Assert.Contains("psychologie", result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("../../etc/passwd")]
|
||||
[InlineData("../escape")]
|
||||
[InlineData("sub/../../outside")]
|
||||
public void ToSafePath_TraversalSlug_ReturnsNull(string slug)
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "noz-test");
|
||||
Assert.Null(SlugGuard.ToSafePath(baseDir, slug));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToSafePath_NestedSlug_BuildsCorrectPath()
|
||||
{
|
||||
var baseDir = "/tmp/vault";
|
||||
var result = SlugGuard.ToSafePath(baseDir, "a/b/c");
|
||||
|
||||
Assert.NotNull(result);
|
||||
var expected = Path.Combine("/tmp/vault", "a", "b", "c") + ".md";
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using NozCli.Rendering;
|
||||
using NozCli.Repl;
|
||||
using Spectre.Console;
|
||||
|
||||
|
|
@ -6,13 +7,15 @@ namespace NozCli.Commands;
|
|||
public class CatCommand : ICommand
|
||||
{
|
||||
public string Name => "cat";
|
||||
public string Description => "Print the raw markdown of a note";
|
||||
public string Usage => "cat <slug>";
|
||||
public string Description => "Print a note — rendered by default, --raw for plain markdown";
|
||||
public string Usage => "cat [--raw] <slug>";
|
||||
|
||||
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||
{
|
||||
var slug = args.FirstOrDefault();
|
||||
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat <slug>[/]"); return; }
|
||||
var raw = args.Contains("--raw") || args.Contains("-r");
|
||||
var slug = args.FirstOrDefault(a => !a.StartsWith('-'));
|
||||
|
||||
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat [--raw] <slug>[/]"); return; }
|
||||
|
||||
slug = ctx.ResolveSlug(slug);
|
||||
|
||||
|
|
@ -20,6 +23,9 @@ public class CatCommand : ICommand
|
|||
await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
|
||||
note = await ctx.Api.GetNoteAsync(slug, ct));
|
||||
|
||||
if (raw)
|
||||
AnsiConsole.WriteLine(note.Markdown);
|
||||
else
|
||||
MarkdownRenderer.Render(note.Markdown);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ public class EditCommand : ICommand
|
|||
var tmp = Path.Combine(Path.GetTempPath(), $"noz-{slug.Replace('/', '-')}.md");
|
||||
await File.WriteAllTextAsync(tmp, note.Markdown);
|
||||
|
||||
string edited;
|
||||
try
|
||||
{
|
||||
var (editor, editorArgs) = useCode
|
||||
? ResolveCode(tmp, ctx.Config.CodeEditor)
|
||||
: ResolveTerminal(tmp, ctx.Config.Editor);
|
||||
|
|
@ -41,9 +44,14 @@ public class EditCommand : ICommand
|
|||
});
|
||||
proc?.WaitForExit();
|
||||
|
||||
var edited = await File.ReadAllTextAsync(tmp);
|
||||
edited = await File.ReadAllTextAsync(tmp);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tmp)) File.Delete(tmp);
|
||||
}
|
||||
|
||||
var newHash = Sha256(edited);
|
||||
File.Delete(tmp);
|
||||
|
||||
if (newHash == note.Hash)
|
||||
{
|
||||
|
|
|
|||
67
NozCli/Commands/LinkCommand.cs
Normal file
67
NozCli/Commands/LinkCommand.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using NozCli.Core.Client;
|
||||
using NozCli.Repl;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace NozCli.Commands;
|
||||
|
||||
public class LinkCommand : ICommand
|
||||
{
|
||||
public string Name => "link";
|
||||
public string Description => "Add a wikilink from one note to another (builds the graph)";
|
||||
public string Usage => "link <source> <target> [--both] [--section \"Siehe auch\"]";
|
||||
|
||||
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||
{
|
||||
var both = args.Contains("--both");
|
||||
var section = ParseFlag(args, "--section") ?? "Siehe auch";
|
||||
var positional = args.Where(a => !a.StartsWith('-')).ToArray();
|
||||
|
||||
if (positional.Length < 2)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Usage:[/] link <source> <target> [--both] [--section \"Heading\"]");
|
||||
return;
|
||||
}
|
||||
|
||||
var source = ctx.ResolveSlug(positional[0]);
|
||||
var target = ctx.ResolveSlug(positional[1]);
|
||||
|
||||
if (source == target)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Quelle und Ziel sind identisch.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
await LinkOneWay(ctx.Api, source, target, section, ct);
|
||||
|
||||
if (both)
|
||||
await LinkOneWay(ctx.Api, target, source, section, ct);
|
||||
}
|
||||
|
||||
private static async Task LinkOneWay(
|
||||
NozCli.Core.Client.NozApiClient api,
|
||||
string source, string target, string section, CancellationToken ct)
|
||||
{
|
||||
NozCli.Core.Models.NoteContent note = default!;
|
||||
await AnsiConsole.Status().StartAsync($"Lese {source}…", async _ =>
|
||||
note = await api.GetNoteAsync(source, ct));
|
||||
|
||||
var updated = NoteLinkHelper.AddLink(note.Markdown, target, section);
|
||||
|
||||
if (updated == note.Markdown)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[dim]{source}[/] → [dim]{target}[/] [yellow]bereits verlinkt[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
await AnsiConsole.Status().StartAsync($"Aktualisiere {source}…", async _ =>
|
||||
await api.PutNoteAsync(source, updated, ct));
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]✓[/] [cyan]{source}[/] → [cyan]{target}[/] verlinkt");
|
||||
}
|
||||
|
||||
private static string? ParseFlag(string[] args, string flag)
|
||||
{
|
||||
var idx = Array.IndexOf(args, flag);
|
||||
return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
|
||||
}
|
||||
}
|
||||
80
NozCli/Commands/MkdirCommand.cs
Normal file
80
NozCli/Commands/MkdirCommand.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
using NozCli.Core.Security;
|
||||
using NozCli.Repl;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace NozCli.Commands;
|
||||
|
||||
public class MkdirCommand : ICommand
|
||||
{
|
||||
public string Name => "mkdir";
|
||||
public string Description => "Create a new folder (writes _index.md)";
|
||||
public string Usage => "mkdir <folder-name>";
|
||||
|
||||
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||
{
|
||||
var folderArg = args.FirstOrDefault();
|
||||
if (folderArg is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Usage:[/] mkdir <folder-name>");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve full folder path relative to current navigation
|
||||
var folderPath = ResolveFolderPath(folderArg.TrimEnd('/'), ctx);
|
||||
|
||||
if (!SlugGuard.IsValid(folderPath))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Ungültiger Ordnername.[/] Nur Kleinbuchstaben, Ziffern und Bindestriche erlaubt.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Derive a readable title from the last segment
|
||||
var lastSegment = folderPath.Split('/')[^1];
|
||||
var title = ToTitle(lastSegment);
|
||||
|
||||
await AnsiConsole.Status().StartAsync($"Erstelle [cyan]{folderPath}[/]…", async _ =>
|
||||
await ctx.Api.PutFolderIndexAsync(folderPath, title, ct: ct));
|
||||
|
||||
// Navigate into the new folder
|
||||
NavigateInto(folderPath, ctx);
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]✓[/] Ordner erstellt → {ctx.CurrentPath}");
|
||||
AnsiConsole.MarkupLine($"[dim]Erstelle deine erste Notiz mit [white]new <name>[/][/]");
|
||||
}
|
||||
|
||||
private static string ResolveFolderPath(string segment, ReplContext ctx)
|
||||
{
|
||||
// Already a full multi-segment path (user typed full slug)
|
||||
if (segment.Contains('/')) return segment;
|
||||
|
||||
if (ctx.CurrentTopic is null)
|
||||
return segment;
|
||||
|
||||
if (ctx.CurrentSubPath is null)
|
||||
return $"{ctx.CurrentTopic}/{segment}";
|
||||
|
||||
return $"{ctx.CurrentTopic}/{ctx.CurrentSubPath}/{segment}";
|
||||
}
|
||||
|
||||
private static void NavigateInto(string folderPath, ReplContext ctx)
|
||||
{
|
||||
var parts = folderPath.Split('/');
|
||||
if (parts.Length == 1)
|
||||
{
|
||||
ctx.CurrentTopic = parts[0];
|
||||
ctx.CurrentSubPath = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx.CurrentTopic = parts[0];
|
||||
ctx.CurrentSubPath = string.Join('/', parts[1..]);
|
||||
}
|
||||
}
|
||||
|
||||
// "new-subfolder" → "New Subfolder"
|
||||
private static string ToTitle(string slug) =>
|
||||
string.Join(' ', slug.Split('-').Select(Capitalize));
|
||||
|
||||
private static string Capitalize(string s) =>
|
||||
s.Length == 0 ? s : char.ToUpperInvariant(s[0]) + s[1..];
|
||||
}
|
||||
88
NozCli/Commands/MvCommand.cs
Normal file
88
NozCli/Commands/MvCommand.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using NozCli.Core.Client;
|
||||
using NozCli.Core.Security;
|
||||
using NozCli.Repl;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace NozCli.Commands;
|
||||
|
||||
public class MvCommand : ICommand
|
||||
{
|
||||
public string Name => "mv";
|
||||
public string Description => "Rename a note and update all inbound links";
|
||||
public string Usage => "mv <old-name> <new-name>";
|
||||
|
||||
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||
{
|
||||
if (args.Length < 2)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Usage:[/] mv <old-name> <new-name>");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve short names to full slugs
|
||||
var oldSlug = ctx.ResolveSlug(args[0]);
|
||||
var newSlug = ctx.ResolveNewSlug(args[1]);
|
||||
|
||||
if (!SlugGuard.IsValid(oldSlug) || !SlugGuard.IsValid(newSlug))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Ungültiger Slug.[/] Nur Kleinbuchstaben, Ziffern und Bindestriche erlaubt.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldSlug == newSlug)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Quelle und Ziel sind identisch — nichts zu tun.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctx.NoteCache.Any(n => n.Slug == oldSlug))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Notiz nicht gefunden:[/] {oldSlug}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.NoteCache.Any(n => n.Slug == newSlug))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Ziel existiert bereits:[/] {newSlug}");
|
||||
return;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[dim]Umbenennen:[/] [cyan]{oldSlug}[/] → [green]{newSlug}[/]");
|
||||
|
||||
NoteRenamer.Result result = default!;
|
||||
|
||||
await AnsiConsole.Progress()
|
||||
.AutoClear(false)
|
||||
.HideCompleted(false)
|
||||
.StartAsync(async prog =>
|
||||
{
|
||||
var task = prog.AddTask("Umbenennen…", maxValue: 100);
|
||||
|
||||
result = await NoteRenamer.ExecuteAsync(
|
||||
ctx.Api, oldSlug, newSlug,
|
||||
onProgress: msg =>
|
||||
{
|
||||
task.Description = msg;
|
||||
task.Increment(20);
|
||||
},
|
||||
ct: ct);
|
||||
|
||||
task.Value = 100;
|
||||
task.Description = "Fertig";
|
||||
});
|
||||
|
||||
// Update local cache: swap old entry for a patched new one
|
||||
var oldEntry = ctx.NoteCache.FirstOrDefault(n => n.Slug == oldSlug);
|
||||
if (oldEntry is not null)
|
||||
{
|
||||
ctx.NoteCache.Remove(oldEntry);
|
||||
ctx.NoteCache.Add(oldEntry with { Slug = newSlug });
|
||||
}
|
||||
|
||||
var linkInfo = result.LinksUpdated > 0
|
||||
? $" [dim]· {result.LinksUpdated} Link(s) aktualisiert[/]"
|
||||
: "";
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]✓[/] {oldSlug} → {newSlug}{linkInfo}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using NozCli.Core.Client;
|
||||
using NozCli.Core.Models;
|
||||
using NozCli.Repl;
|
||||
using Spectre.Console;
|
||||
|
|
@ -11,19 +10,20 @@ 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 string Usage => "new [-c] [--template <name>] <slug-or-name>";
|
||||
|
||||
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||
{
|
||||
var useCode = args.Contains("-c") || args.Contains("--code");
|
||||
var templateName = ParseFlag(args, "--template") ?? ParseFlag(args, "-t");
|
||||
var nameArg = args.FirstOrDefault(a => !a.StartsWith('-'));
|
||||
if (nameArg is null) { AnsiConsole.MarkupLine("[red]Usage: new [-c] <slug-or-name>[/]"); return; }
|
||||
if (nameArg is null) { AnsiConsole.MarkupLine("[red]Usage: new [-c] [--template <name>] <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)[/]");
|
||||
AnsiConsole.MarkupLine($"[yellow]Notiz existiert bereits:[/] {slug} [dim](use 'edit' to modify it)[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -31,6 +31,27 @@ public class NewCommand : ICommand
|
|||
var title = ToTitle(slug.Split('/').Last());
|
||||
var lang = "de";
|
||||
|
||||
string noteBody;
|
||||
|
||||
if (templateName is not null)
|
||||
{
|
||||
TemplateContent? tmpl = null;
|
||||
await AnsiConsole.Status().StartAsync($"Lade Template '{templateName}'…", async _ =>
|
||||
tmpl = await ctx.Api.GetTemplateAsync(templateName, ct));
|
||||
|
||||
if (tmpl is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Template '{templateName}' nicht gefunden.[/] Verfügbare Templates mit [white]templates[/] anzeigen.");
|
||||
return;
|
||||
}
|
||||
|
||||
noteBody = NoteLinkHelper.ApplyPlaceholders(tmpl.Body, title, topic, lang, "seed");
|
||||
}
|
||||
else
|
||||
{
|
||||
noteBody = $"# {title}\n\n";
|
||||
}
|
||||
|
||||
var template = $"""
|
||||
---
|
||||
title: "{title}"
|
||||
|
|
@ -40,13 +61,15 @@ public class NewCommand : ICommand
|
|||
lang: {lang}
|
||||
---
|
||||
|
||||
# {title}
|
||||
|
||||
{noteBody.TrimStart()}
|
||||
""";
|
||||
|
||||
var tmp = Path.Combine(Path.GetTempPath(), $"noz-new-{slug.Replace('/', '-')}.md");
|
||||
await File.WriteAllTextAsync(tmp, template);
|
||||
|
||||
string markdown;
|
||||
try
|
||||
{
|
||||
var (editor, editorArgs) = useCode
|
||||
? EditCommand.ResolveCode(tmp, ctx.Config.CodeEditor)
|
||||
: EditCommand.ResolveTerminal(tmp, ctx.Config.Editor);
|
||||
|
|
@ -60,8 +83,12 @@ public class NewCommand : ICommand
|
|||
});
|
||||
proc?.WaitForExit();
|
||||
|
||||
var markdown = await File.ReadAllTextAsync(tmp);
|
||||
File.Delete(tmp);
|
||||
markdown = await File.ReadAllTextAsync(tmp);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tmp)) File.Delete(tmp);
|
||||
}
|
||||
|
||||
if (markdown.Trim() == template.Trim())
|
||||
{
|
||||
|
|
@ -85,6 +112,9 @@ public class NewCommand : ICommand
|
|||
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();
|
||||
private static string? ParseFlag(string[] args, string flag)
|
||||
{
|
||||
var idx = Array.IndexOf(args, flag);
|
||||
return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using NozCli.Repl;
|
||||
using NozCli.Core.Security;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace NozCli.Commands;
|
||||
|
|
@ -54,6 +55,13 @@ public class PushCommand : ICommand
|
|||
var relative = Path.GetRelativePath(localPath, file);
|
||||
var slug = relative.Replace(Path.DirectorySeparatorChar, '/').Replace(".md", "");
|
||||
|
||||
if (!SlugGuard.IsValid(slug))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]⚠ Skipped invalid slug:[/] {slug}");
|
||||
task.Increment(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
var markdown = await File.ReadAllTextAsync(file);
|
||||
var localHash = NozCli.Core.Client.HashHelper.Sha256(markdown);
|
||||
|
||||
|
|
|
|||
61
NozCli/Commands/SearchCommand.cs
Normal file
61
NozCli/Commands/SearchCommand.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using NozCli.Repl;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace NozCli.Commands;
|
||||
|
||||
public class SearchCommand : ICommand
|
||||
{
|
||||
public string Name => "search";
|
||||
public string Description => "Full-text search via Meilisearch";
|
||||
public string Usage => "search <query> [--topic <topic>] [--limit <n>]";
|
||||
|
||||
private static readonly Dictionary<string, string> StatusDot = new()
|
||||
{
|
||||
["seed"] = "[yellow]◦[/]",
|
||||
["budding"] = "[magenta]◈[/]",
|
||||
["evergreen"] = "[cyan]◉[/]",
|
||||
};
|
||||
|
||||
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||
{
|
||||
var topic = ParseFlag(args, "--topic") ?? ctx.CurrentTopic;
|
||||
var limitRaw = ParseFlag(args, "--limit");
|
||||
var limit = limitRaw is not null && int.TryParse(limitRaw, out var l) ? l : 10;
|
||||
var query = string.Join(' ', args.Where(a => !a.StartsWith('-') &&
|
||||
a != ParseFlag(args, "--topic") &&
|
||||
a != ParseFlag(args, "--limit")));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Usage:[/] search <query> [--topic <topic>] [--limit <n>]");
|
||||
return;
|
||||
}
|
||||
|
||||
List<NozCli.Core.Models.SearchHit> hits = [];
|
||||
await AnsiConsole.Status().StartAsync($"Suche nach »{query}«…", async _ =>
|
||||
hits = await ctx.Api.SearchAsync(query, topic, limit, ct));
|
||||
|
||||
if (hits.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[dim]Keine Treffer für »{Markup.Escape(query)}«.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[dim]{hits.Count} Treffer[/]\n");
|
||||
|
||||
foreach (var h in hits)
|
||||
{
|
||||
var dot = StatusDot.GetValueOrDefault(h.Status, "·");
|
||||
var desc = h.Description is { Length: > 0 } d ? $"\n [dim]{Markup.Escape(d)}[/]" : "";
|
||||
AnsiConsole.MarkupLine($" {dot} [cyan]{Markup.Escape(h.Slug)}[/] [white]{Markup.Escape(h.Title)}[/]{desc}");
|
||||
}
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
}
|
||||
|
||||
private static string? ParseFlag(string[] args, string flag)
|
||||
{
|
||||
var idx = Array.IndexOf(args, flag);
|
||||
return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using NozCli.Core.Client;
|
||||
using NozCli.Core.Models;
|
||||
using NozCli.Repl;
|
||||
using NozCli.Core.Security;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace NozCli.Commands;
|
||||
|
|
@ -51,7 +52,13 @@ public class SyncCommand : ICommand
|
|||
|
||||
foreach (var note in noteList)
|
||||
{
|
||||
var filePath = SlugToPath(localPath, note.Slug);
|
||||
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))
|
||||
{
|
||||
|
|
@ -104,7 +111,7 @@ public class SyncCommand : ICommand
|
|||
try
|
||||
{
|
||||
var content = await ctx.Api.GetNoteAsync(note.Slug, innerCt);
|
||||
var filePath = SlugToPath(localPath, note.Slug);
|
||||
var filePath = SlugGuard.ToSafePath(localPath, note.Slug)!;
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
|
||||
await File.WriteAllTextAsync(filePath, content.Markdown, innerCt);
|
||||
|
|
@ -136,6 +143,4 @@ public class SyncCommand : ICommand
|
|||
}
|
||||
}
|
||||
|
||||
private static string SlugToPath(string baseDir, string slug) =>
|
||||
Path.Combine(baseDir, slug.Replace('/', Path.DirectorySeparatorChar) + ".md");
|
||||
}
|
||||
|
|
|
|||
39
NozCli/Commands/TemplatesCommand.cs
Normal file
39
NozCli/Commands/TemplatesCommand.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
using NozCli.Repl;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace NozCli.Commands;
|
||||
|
||||
public class TemplatesCommand : ICommand
|
||||
{
|
||||
public string Name => "templates";
|
||||
public string Description => "List available note templates";
|
||||
public string Usage => "templates";
|
||||
|
||||
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||
{
|
||||
List<NozCli.Core.Models.TemplateInfo> templates = [];
|
||||
await AnsiConsole.Status().StartAsync("Lade Templates…", async _ =>
|
||||
templates = await ctx.Api.GetTemplatesAsync(ct));
|
||||
|
||||
if (templates.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[dim]Keine Templates vorhanden.[/]");
|
||||
AnsiConsole.MarkupLine("[dim]Lege Templates in [white]content/noosphere/_templates/<name>.md[/] an.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
var table = new Table()
|
||||
.AddColumn("[dim]Name[/]")
|
||||
.AddColumn("[dim]Beschreibung[/]")
|
||||
.Border(TableBorder.None)
|
||||
.HideHeaders();
|
||||
|
||||
foreach (var t in templates.OrderBy(t => t.Name))
|
||||
table.AddRow(
|
||||
$"[cyan]{Markup.Escape(t.Name)}[/]",
|
||||
Markup.Escape(t.Description ?? "—"));
|
||||
|
||||
AnsiConsole.Write(table);
|
||||
AnsiConsole.MarkupLine($"[dim]Verwenden mit:[/] [white]new --template <name> <notiz-name>[/]");
|
||||
}
|
||||
}
|
||||
195
NozCli/Rendering/MarkdownRenderer.cs
Normal file
195
NozCli/Rendering/MarkdownRenderer.cs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace NozCli.Rendering;
|
||||
|
||||
/// Terminal markdown renderer — converts common markdown to Spectre.Console output.
|
||||
/// Handles: headings, bold/italic, inline code, fenced code blocks,
|
||||
/// bullet/numbered lists, blockquotes, horizontal rules.
|
||||
public static partial class MarkdownRenderer
|
||||
{
|
||||
public static void Render(string markdown)
|
||||
{
|
||||
var lines = markdown.ReplaceLineEndings("\n").Split('\n');
|
||||
var inCode = false;
|
||||
var codeLang = "";
|
||||
var codeBlock = new StringBuilder();
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
|
||||
// ── Fenced code block ────────────────────────────────────────────
|
||||
if (line.TrimStart().StartsWith("```"))
|
||||
{
|
||||
if (!inCode)
|
||||
{
|
||||
inCode = true;
|
||||
codeLang = line.TrimStart()[3..].Trim();
|
||||
codeBlock.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
inCode = false;
|
||||
RenderCodeBlock(codeBlock.ToString().TrimEnd(), codeLang);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCode)
|
||||
{
|
||||
codeBlock.AppendLine(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Horizontal rule ──────────────────────────────────────────────
|
||||
if (HrRegex().IsMatch(line))
|
||||
{
|
||||
AnsiConsole.Write(new Rule() { Style = new Style(new Color(60, 60, 80)) });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Headings ─────────────────────────────────────────────────────
|
||||
if (line.StartsWith("### "))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"\n [bold white]{Markup.Escape(line[4..])}[/]");
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("## "))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"\n[bold cyan]{Markup.Escape(line[3..])}[/]");
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("# "))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"\n[bold underline cyan]{Markup.Escape(line[2..])}[/]\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Blockquote ───────────────────────────────────────────────────
|
||||
if (line.StartsWith("> "))
|
||||
{
|
||||
var content = ApplyInline(line[2..]);
|
||||
AnsiConsole.MarkupLine($"[grey46]┃[/] [dim italic]{content}[/]");
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Bullet list ──────────────────────────────────────────────────
|
||||
var bulletMatch = BulletRegex().Match(line);
|
||||
if (bulletMatch.Success)
|
||||
{
|
||||
var indent = bulletMatch.Groups[1].Value.Length / 2;
|
||||
var content = ApplyInline(bulletMatch.Groups[2].Value);
|
||||
var pad = new string(' ', indent * 2);
|
||||
AnsiConsole.MarkupLine($"{pad} [dim cyan]•[/] {content}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Numbered list ────────────────────────────────────────────────
|
||||
var numMatch = NumberedRegex().Match(line);
|
||||
if (numMatch.Success)
|
||||
{
|
||||
var num = numMatch.Groups[1].Value;
|
||||
var content = ApplyInline(numMatch.Groups[2].Value);
|
||||
AnsiConsole.MarkupLine($" [dim cyan]{num}.[/] {content}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Empty line ───────────────────────────────────────────────────
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Paragraph ────────────────────────────────────────────────────
|
||||
AnsiConsole.MarkupLine($" {ApplyInline(line)}");
|
||||
}
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
}
|
||||
|
||||
// ── Code block rendering ─────────────────────────────────────────────────
|
||||
|
||||
private static void RenderCodeBlock(string code, string lang)
|
||||
{
|
||||
var header = lang.Length > 0 ? $"[dim]{Markup.Escape(lang)}[/]" : "[dim]code[/]";
|
||||
var panel = new Panel(new Text(code, new Style(new Color(200, 200, 220))))
|
||||
{
|
||||
Header = new PanelHeader($" {header} "),
|
||||
Border = BoxBorder.Rounded,
|
||||
BorderStyle = new Style(new Color(60, 60, 80)),
|
||||
Padding = new Padding(1, 0),
|
||||
};
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.Write(panel);
|
||||
}
|
||||
|
||||
// ── Inline formatting ────────────────────────────────────────────────────
|
||||
|
||||
private static string ApplyInline(string raw)
|
||||
{
|
||||
// Collect all pattern matches, escape text between them.
|
||||
// This ensures brackets/special chars in regular text don't break Spectre markup.
|
||||
(Regex regex, Func<Match, string> format)[] patterns =
|
||||
[
|
||||
(InlineCodeRegex(), m => $"[bold cyan on grey7] {Markup.Escape(m.Groups[1].Value)} [/]"),
|
||||
(BoldRegex(), m => $"[bold]{Markup.Escape(m.Groups[1].Value)}[/]"),
|
||||
(ItalicRegex(), m => $"[italic]{Markup.Escape(m.Groups[1].Value)}[/]"),
|
||||
];
|
||||
|
||||
var spans = new List<(int start, int end, string replacement)>();
|
||||
|
||||
foreach (var (regex, format) in patterns)
|
||||
{
|
||||
foreach (Match m in regex.Matches(raw))
|
||||
{
|
||||
// Skip if overlapping with an already-claimed span
|
||||
if (spans.Exists(s => m.Index < s.end && m.Index + m.Length > s.start))
|
||||
continue;
|
||||
spans.Add((m.Index, m.Index + m.Length, format(m)));
|
||||
}
|
||||
}
|
||||
|
||||
spans.Sort((a, b) => a.start.CompareTo(b.start));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var pos = 0;
|
||||
|
||||
foreach (var (start, end, replacement) in spans)
|
||||
{
|
||||
if (pos < start)
|
||||
sb.Append(Markup.Escape(raw[pos..start]));
|
||||
sb.Append(replacement);
|
||||
pos = end;
|
||||
}
|
||||
|
||||
if (pos < raw.Length)
|
||||
sb.Append(Markup.Escape(raw[pos..]));
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ── Compiled regexes (AOT-safe via [GeneratedRegex]) ────────────────────
|
||||
|
||||
[GeneratedRegex(@"^[-*_]{3,}\s*$")]
|
||||
private static partial Regex HrRegex();
|
||||
|
||||
[GeneratedRegex(@"^( *)[-*+] (.+)$")]
|
||||
private static partial Regex BulletRegex();
|
||||
|
||||
[GeneratedRegex(@"^(\d+)\. (.+)$")]
|
||||
private static partial Regex NumberedRegex();
|
||||
|
||||
[GeneratedRegex(@"`([^`]+)`")]
|
||||
private static partial Regex InlineCodeRegex();
|
||||
|
||||
[GeneratedRegex(@"\*\*([^*]+)\*\*")]
|
||||
private static partial Regex BoldRegex();
|
||||
|
||||
[GeneratedRegex(@"(?<!\*)\*(?!\*)([^*]+)\*(?!\*)")]
|
||||
private static partial Regex ItalicRegex();
|
||||
}
|
||||
|
|
@ -23,6 +23,11 @@ public class CommandRegistry
|
|||
new RmCommand(),
|
||||
new ConfigCommand(),
|
||||
new TreeCommand(),
|
||||
new MkdirCommand(),
|
||||
new MvCommand(),
|
||||
new SearchCommand(),
|
||||
new LinkCommand(),
|
||||
new TemplatesCommand(),
|
||||
new VersionCommand(),
|
||||
new ClearCommand(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ public static class NotesCache
|
|||
var tmp = CachePath + ".tmp";
|
||||
await File.WriteAllTextAsync(tmp, json);
|
||||
File.Move(tmp, CachePath, overwrite: true);
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
File.SetUnixFileMode(CachePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch { /* cache write failures are non-fatal */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ public class ReplContext
|
|||
|
||||
var depth = CurrentSubPath is null ? 0 : CurrentSubPath.Split('/').Length;
|
||||
return NotesInView()
|
||||
.Where(n => SlugRelative(n.Slug).Split('/').Length > depth + 1)
|
||||
.Select(n => SlugRelative(n.Slug).Split('/').ElementAtOrDefault(depth))
|
||||
.Where(s => s is not null)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)!;
|
||||
|
|
|
|||
239
README.md
239
README.md
|
|
@ -1,79 +1,214 @@
|
|||
# noz-cli
|
||||
|
||||
A terminal REPL for [noz](https://github.com/kryptomrx/noz) — a self-hosted digital garden built with Next.js.
|
||||
A terminal REPL and PowerShell module for [noz](https://git.kryptomrx.de/krypto/noz) — a self-hosted digital garden built with Next.js.
|
||||
|
||||
Navigate, read, and edit your notes from the command line. Native AOT binary, no .NET runtime required.
|
||||
Navigate, create, and manage your notes from the command line. Ships as a Native AOT binary (~7.6 MB, no .NET runtime required) and a PowerShell module for scripting and automation.
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
Download the latest binary from [Releases](../../releases) and place it on your PATH:
|
||||
### macOS / Linux — binary
|
||||
|
||||
Download from [Releases](../../releases) and place on your PATH:
|
||||
|
||||
```bash
|
||||
# macOS (Apple Silicon)
|
||||
curl -L https://github.com/kryptomrx/noz-cli/releases/latest/download/noz-osx-arm64 -o /usr/local/bin/noz
|
||||
chmod +x /usr/local/bin/noz
|
||||
# macOS Apple Silicon
|
||||
curl -L https://git.kryptomrx.de/krypto/noz-cli/releases/latest/download/noz-osx-arm64 \
|
||||
-o /usr/local/bin/noz && chmod +x /usr/local/bin/noz
|
||||
```
|
||||
|
||||
### PowerShell module
|
||||
|
||||
Requires PowerShell 7.2+ and .NET 8.
|
||||
|
||||
```powershell
|
||||
git clone https://git.kryptomrx.de/krypto/noz-cli
|
||||
cd noz-cli
|
||||
./Noz.PowerShell/Install-NozModule.ps1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
noz init https://your-noz-instance.com your-api-token
|
||||
```
|
||||
|
||||
This writes `~/.config/noz/config.json`.
|
||||
Writes `~/.config/noz/config.json`. The token is the CLI token configured in your `noosphere.toml` under `[cli]`.
|
||||
|
||||
## Usage
|
||||
---
|
||||
|
||||
```
|
||||
## REPL
|
||||
|
||||
```bash
|
||||
noz
|
||||
```
|
||||
|
||||
Starts the interactive REPL. Type `help` for a list of commands.
|
||||
Starts the interactive REPL. A local note cache (`~/.cache/noz/notes.json`) is used for instant startup; a background refresh runs silently.
|
||||
|
||||
### Navigation
|
||||
|
||||
```
|
||||
~ » ls # list topics
|
||||
~ » cd psychologie # enter a topic
|
||||
~/psychologie » ls -la # detailed view with status, date, title
|
||||
~/psychologie » tree # full hierarchy as a tree
|
||||
~/psychologie » cd .. # go up
|
||||
```
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `ls` | List topics (at root) or notes in the current folder |
|
||||
| `ls -la` | Detailed list with status, date, language |
|
||||
| `cd <topic>` | Enter a topic or sub-folder |
|
||||
| `cd ..` | Go up one level |
|
||||
| `tree` | Full hierarchy as a tree |
|
||||
|
||||
### Notes
|
||||
|
||||
```
|
||||
~ » cat psychologie/flow-theorie # print raw markdown
|
||||
~ » edit flow-theorie # open in terminal editor
|
||||
~ » edit -c flow-theorie # open in VS Code
|
||||
~ » new meine-neue-notiz # create note from template
|
||||
~ » rm alte-notiz # delete a note
|
||||
```
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `cat <slug>` | Print a note (rendered markdown) |
|
||||
| `cat --raw <slug>` | Print raw markdown |
|
||||
| `new <name>` | Create a note and open in editor |
|
||||
| `new -c <name>` | Create a note and open in VS Code |
|
||||
| `new --template <name> <slug>` | Create from a template |
|
||||
| `edit <slug>` | Edit a note in terminal editor |
|
||||
| `edit -c <slug>` | Edit in VS Code |
|
||||
| `mv <old> <new>` | Rename a note — rewrites all inbound wikilinks |
|
||||
| `rm <slug>` | Delete a note |
|
||||
| `search <query>` | Full-text search (Meilisearch) |
|
||||
| `search <query> --topic <topic>` | Search within a topic |
|
||||
|
||||
### Folders
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `mkdir <name>` | Create a folder (writes `_index.md`, navigates in) |
|
||||
|
||||
### Linking
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `link <source> <target>` | Add a wikilink from source to target |
|
||||
| `link <source> <target> --both` | Bidirectional link |
|
||||
|
||||
### Templates
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `templates` | List available templates |
|
||||
| `new --template <name> <slug>` | Create a note from a template |
|
||||
|
||||
Templates live in `content/noosphere/_templates/<name>.md` on the server. Placeholders: `{{title}}`, `{{topic}}`, `{{date}}`, `{{status}}`, `{{lang}}`.
|
||||
|
||||
### Sync
|
||||
|
||||
```
|
||||
~ » sync ~/vault # pull changed notes to local folder
|
||||
~ » sync ~/vault --all # pull everything (ignore local hashes)
|
||||
~ » sync ~/vault --dry-run # preview what would be downloaded
|
||||
~ » push ~/vault # push local .md files to server
|
||||
```
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `sync <local-path>` | Pull changed notes from server to local folder |
|
||||
| `sync <local-path> --all` | Pull everything (ignore local hashes) |
|
||||
| `sync <local-path> --dry-run` | Preview what would be downloaded |
|
||||
| `push <local-path>` | Push local `.md` files to server |
|
||||
| `status <local-path>` | Show diff between local folder and server |
|
||||
|
||||
### Other
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `version` | Show version and git hash |
|
||||
| `config` | Show editor settings |
|
||||
| `config editor <name>` | Set terminal editor |
|
||||
| `config code-editor <name>` | Set GUI editor |
|
||||
| `clear` | Clear the screen |
|
||||
| `help` | List all commands |
|
||||
| `exit` | Quit |
|
||||
|
||||
**Ctrl+C** while idle exits the REPL. During a command it cancels only that command.
|
||||
|
||||
---
|
||||
|
||||
## PowerShell Module
|
||||
|
||||
After installing, import with:
|
||||
|
||||
```powershell
|
||||
Import-Module Noz
|
||||
```
|
||||
~ » version # show version + git hash
|
||||
~ » config # show editor settings
|
||||
~ » config editor nano # set terminal editor
|
||||
~ » config code-editor code # set GUI editor
|
||||
~ » exit
|
||||
|
||||
### Note commands
|
||||
|
||||
```powershell
|
||||
# List and filter notes
|
||||
Get-NozNote
|
||||
Get-NozNote -Topic psychologie
|
||||
Get-NozNote -Status evergreen
|
||||
Get-NozNote -Lang de
|
||||
|
||||
# Read content
|
||||
Get-NozNoteContent -Slug psychologie/flow-theorie
|
||||
|
||||
# Create / update / delete
|
||||
New-NozNote -Slug kochen/pasta -Title "Pasta Carbonara" -Topic kochen
|
||||
Set-NozNote -Slug psychologie/flow-theorie -Markdown $content
|
||||
Remove-NozNote -Slug alte-notiz
|
||||
|
||||
# Rename (rewrites all inbound links)
|
||||
Rename-NozNote -Slug psychologie/old -NewSlug psychologie/new
|
||||
|
||||
# Change status
|
||||
Set-NozNoteStatus -Slug psychologie/flow-theorie -Status evergreen
|
||||
```
|
||||
|
||||
### Search
|
||||
|
||||
```powershell
|
||||
Find-NozNote "flow"
|
||||
Find-NozNote "hydroponik" -Topic biosysteme -Limit 5
|
||||
```
|
||||
|
||||
### Graph & analysis
|
||||
|
||||
```powershell
|
||||
# All wikilink edges
|
||||
Get-NozGraph
|
||||
Get-NozGraph -Source psychologie/flow
|
||||
|
||||
# Notes with no incoming links
|
||||
Get-NozOrphans
|
||||
Get-NozOrphans -Topic psychologie
|
||||
|
||||
# Garden-wide statistics
|
||||
Measure-NozGarden
|
||||
|
||||
# Link two notes
|
||||
Add-NozLink -Source psychologie/flow -Target biosysteme/brs-konzept
|
||||
Add-NozLink -Source a -Target b -Both
|
||||
```
|
||||
|
||||
### Templates
|
||||
|
||||
```powershell
|
||||
Get-NozTemplate
|
||||
Get-NozTemplate rezept
|
||||
```
|
||||
|
||||
### Pipeline examples
|
||||
|
||||
```powershell
|
||||
# Promote all seed notes in a topic to budding
|
||||
Get-NozNote -Topic psychologie -Status seed |
|
||||
Set-NozNoteStatus -Status budding -WhatIf
|
||||
|
||||
# Export all evergreen notes as markdown files
|
||||
Get-NozNote -Status evergreen |
|
||||
ForEach-Object { Get-NozNoteContent $_.Slug } |
|
||||
ForEach-Object { Set-Content "$($_.Slug).md" $_.Markdown }
|
||||
|
||||
# Find orphans and link them to a topic overview
|
||||
Get-NozOrphans -Topic biosysteme |
|
||||
Add-NozLink -Target biosysteme/uebersicht
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Editor preferences are stored in `~/.config/noz/config.json`:
|
||||
`~/.config/noz/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -84,19 +219,41 @@ Editor preferences are stored in `~/.config/noz/config.json`:
|
|||
}
|
||||
```
|
||||
|
||||
Editor is auto-detected from `$EDITOR` if not set. VS Code, Zed, and Sublime Text are detected automatically for `-c` mode.
|
||||
|
||||
---
|
||||
|
||||
## Server-side setup
|
||||
|
||||
The CLI needs the `[cli]` block in your `noosphere.toml`:
|
||||
|
||||
```toml
|
||||
[cli]
|
||||
token = "your-secret-token"
|
||||
allow_push = true
|
||||
allow_delete = true
|
||||
allow_vault = false
|
||||
allow_rebuild = false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Building from source
|
||||
|
||||
Requires [.NET 10 SDK](https://dotnet.microsoft.com/download) and `clang`.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kryptomrx/noz-cli
|
||||
git clone https://git.kryptomrx.de/krypto/noz-cli
|
||||
cd noz-cli
|
||||
|
||||
# Run (no AOT)
|
||||
# Run without AOT
|
||||
dotnet run --project NozCli
|
||||
|
||||
# Native AOT binary (macOS arm64)
|
||||
# Native AOT binary — macOS Apple Silicon
|
||||
dotnet publish NozCli/NozCli.csproj -r osx-arm64 -c Release
|
||||
|
||||
# Run tests
|
||||
dotnet test NozCli.Tests/NozCli.Tests.csproj -c Release
|
||||
```
|
||||
|
||||
On macOS, AOT linking requires OpenSSL via Homebrew:
|
||||
|
|
@ -105,7 +262,9 @@ On macOS, AOT linking requires OpenSSL via Homebrew:
|
|||
brew install openssl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[Polyform Noncommercial 1.0.0](LICENSE) — free for personal and non-commercial use.
|
||||
For commercial licensing, contact [Bruno Deanoz](mailto:bruno.deanoz1@gmail.com).
|
||||
Commercial licensing: [bruno.deanoz@protonmail.com](mailto:bruno.deanoz@protonmail.com).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<Solution>
|
||||
<Project Path="NozCli.Core/NozCli.Core.csproj" />
|
||||
<Project Path="NozCli/NozCli.csproj" />
|
||||
<Project Path="Noz.PowerShell/Noz.PowerShell.csproj" />
|
||||
<Project Path="NozCli.Tests/NozCli.Tests.csproj" />
|
||||
</Solution>
|
||||
|
|
|
|||
Loading…
Reference in a new issue