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
|
# Changelog
|
||||||
|
|
||||||
All notable changes to noz-cli are documented here.
|
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]
|
## [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
|
## [0.1.0] — 2026-05-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Interactive REPL with topic-based navigation (`cd`, `ls`, `ls -la`, `tree`)
|
- Interactive REPL with topic-based navigation (`cd`, `ls`, `ls -la`, `tree`)
|
||||||
- `cat` — print raw markdown of a note
|
- `cat` — render a note with MarkdownRenderer (headings, blockquotes, code blocks, inline formatting)
|
||||||
- `edit` — open note in terminal editor (nano/vim) or VS Code (`-c` flag)
|
- `edit` — open note in terminal editor (nano/vim) or VS Code / Zed / Sublime (`-c`)
|
||||||
- `new` — create a new note from a template
|
- `new` — create a note with frontmatter template, open in editor
|
||||||
- `rm` — delete a note
|
- `rm` — delete a note from server
|
||||||
- `sync` — pull notes from server to local directory (parallel, hash-diffed)
|
- `sync` — pull notes from server to local directory (parallel downloads, SHA-256 hash diffing, `--all`, `--dry-run`)
|
||||||
- `push` — push local markdown files to server
|
- `push` — push local `.md` files to server with slug validation
|
||||||
- `search` — full-text search via Meilisearch
|
- `status` — diff between a local folder and the server
|
||||||
- `config` — view and set editor preferences
|
- `config` — view and set `editor` / `code_editor` preferences
|
||||||
- `version` — show version and git hash
|
- `version` — print version and git hash
|
||||||
- JSON note cache (`~/.cache/noz/notes.json`) for instant startup + background refresh
|
- `help` — list all commands with usage
|
||||||
- Cache timestamp displayed in startup stats line
|
- `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)
|
- 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();
|
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)
|
public async Task RebuildIndexAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var res = await _http.PostAsync("api/cli/rebuild", null, ct);
|
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">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</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(NotesCacheFile))]
|
||||||
[JsonSerializable(typeof(List<NoteInfo>))]
|
[JsonSerializable(typeof(List<NoteInfo>))]
|
||||||
[JsonSerializable(typeof(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 { }
|
public partial class NozJsonContext : JsonSerializerContext { }
|
||||||
|
|
||||||
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, WriteIndented = true)]
|
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower, WriteIndented = true)]
|
||||||
|
|
@ -19,3 +27,4 @@ public partial class NozJsonContext : JsonSerializerContext { }
|
||||||
internal partial class NozJsonContextPretty : JsonSerializerContext { }
|
internal partial class NozJsonContextPretty : JsonSerializerContext { }
|
||||||
|
|
||||||
public record PushBody(string Markdown);
|
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 NozCli.Repl;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
|
|
@ -6,13 +7,15 @@ namespace NozCli.Commands;
|
||||||
public class CatCommand : ICommand
|
public class CatCommand : ICommand
|
||||||
{
|
{
|
||||||
public string Name => "cat";
|
public string Name => "cat";
|
||||||
public string Description => "Print the raw markdown of a note";
|
public string Description => "Print a note — rendered by default, --raw for plain markdown";
|
||||||
public string Usage => "cat <slug>";
|
public string Usage => "cat [--raw] <slug>";
|
||||||
|
|
||||||
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var slug = args.FirstOrDefault();
|
var raw = args.Contains("--raw") || args.Contains("-r");
|
||||||
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat <slug>[/]"); return; }
|
var slug = args.FirstOrDefault(a => !a.StartsWith('-'));
|
||||||
|
|
||||||
|
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat [--raw] <slug>[/]"); return; }
|
||||||
|
|
||||||
slug = ctx.ResolveSlug(slug);
|
slug = ctx.ResolveSlug(slug);
|
||||||
|
|
||||||
|
|
@ -20,6 +23,9 @@ public class CatCommand : ICommand
|
||||||
await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
|
await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
|
||||||
note = await ctx.Api.GetNoteAsync(slug, ct));
|
note = await ctx.Api.GetNoteAsync(slug, ct));
|
||||||
|
|
||||||
|
if (raw)
|
||||||
AnsiConsole.WriteLine(note.Markdown);
|
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");
|
var tmp = Path.Combine(Path.GetTempPath(), $"noz-{slug.Replace('/', '-')}.md");
|
||||||
await File.WriteAllTextAsync(tmp, note.Markdown);
|
await File.WriteAllTextAsync(tmp, note.Markdown);
|
||||||
|
|
||||||
|
string edited;
|
||||||
|
try
|
||||||
|
{
|
||||||
var (editor, editorArgs) = useCode
|
var (editor, editorArgs) = useCode
|
||||||
? ResolveCode(tmp, ctx.Config.CodeEditor)
|
? ResolveCode(tmp, ctx.Config.CodeEditor)
|
||||||
: ResolveTerminal(tmp, ctx.Config.Editor);
|
: ResolveTerminal(tmp, ctx.Config.Editor);
|
||||||
|
|
@ -41,9 +44,14 @@ public class EditCommand : ICommand
|
||||||
});
|
});
|
||||||
proc?.WaitForExit();
|
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);
|
var newHash = Sha256(edited);
|
||||||
File.Delete(tmp);
|
|
||||||
|
|
||||||
if (newHash == note.Hash)
|
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.Diagnostics;
|
||||||
using System.Security.Cryptography;
|
using NozCli.Core.Client;
|
||||||
using System.Text;
|
|
||||||
using NozCli.Core.Models;
|
using NozCli.Core.Models;
|
||||||
using NozCli.Repl;
|
using NozCli.Repl;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
@ -11,19 +10,20 @@ public class NewCommand : ICommand
|
||||||
{
|
{
|
||||||
public string Name => "new";
|
public string Name => "new";
|
||||||
public string Description => "Create a new note and open it in nano (or -c for VS Code)";
|
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)
|
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var useCode = args.Contains("-c") || args.Contains("--code");
|
var useCode = args.Contains("-c") || args.Contains("--code");
|
||||||
|
var templateName = ParseFlag(args, "--template") ?? ParseFlag(args, "-t");
|
||||||
var nameArg = args.FirstOrDefault(a => !a.StartsWith('-'));
|
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);
|
var slug = ctx.ResolveNewSlug(nameArg);
|
||||||
|
|
||||||
if (ctx.NoteCache.Any(n => n.Slug == slug))
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,6 +31,27 @@ public class NewCommand : ICommand
|
||||||
var title = ToTitle(slug.Split('/').Last());
|
var title = ToTitle(slug.Split('/').Last());
|
||||||
var lang = "de";
|
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 = $"""
|
var template = $"""
|
||||||
---
|
---
|
||||||
title: "{title}"
|
title: "{title}"
|
||||||
|
|
@ -40,13 +61,15 @@ public class NewCommand : ICommand
|
||||||
lang: {lang}
|
lang: {lang}
|
||||||
---
|
---
|
||||||
|
|
||||||
# {title}
|
{noteBody.TrimStart()}
|
||||||
|
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var tmp = Path.Combine(Path.GetTempPath(), $"noz-new-{slug.Replace('/', '-')}.md");
|
var tmp = Path.Combine(Path.GetTempPath(), $"noz-new-{slug.Replace('/', '-')}.md");
|
||||||
await File.WriteAllTextAsync(tmp, template);
|
await File.WriteAllTextAsync(tmp, template);
|
||||||
|
|
||||||
|
string markdown;
|
||||||
|
try
|
||||||
|
{
|
||||||
var (editor, editorArgs) = useCode
|
var (editor, editorArgs) = useCode
|
||||||
? EditCommand.ResolveCode(tmp, ctx.Config.CodeEditor)
|
? EditCommand.ResolveCode(tmp, ctx.Config.CodeEditor)
|
||||||
: EditCommand.ResolveTerminal(tmp, ctx.Config.Editor);
|
: EditCommand.ResolveTerminal(tmp, ctx.Config.Editor);
|
||||||
|
|
@ -60,8 +83,12 @@ public class NewCommand : ICommand
|
||||||
});
|
});
|
||||||
proc?.WaitForExit();
|
proc?.WaitForExit();
|
||||||
|
|
||||||
var markdown = await File.ReadAllTextAsync(tmp);
|
markdown = await File.ReadAllTextAsync(tmp);
|
||||||
File.Delete(tmp);
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(tmp)) File.Delete(tmp);
|
||||||
|
}
|
||||||
|
|
||||||
if (markdown.Trim() == template.Trim())
|
if (markdown.Trim() == template.Trim())
|
||||||
{
|
{
|
||||||
|
|
@ -85,6 +112,9 @@ public class NewCommand : ICommand
|
||||||
string.Join(' ', slug.Split('-').Select(w =>
|
string.Join(' ', slug.Split('-').Select(w =>
|
||||||
w.Length > 0 ? char.ToUpper(w[0]) + w[1..] : w));
|
w.Length > 0 ? char.ToUpper(w[0]) + w[1..] : w));
|
||||||
|
|
||||||
private static string Sha256(string text) =>
|
private static string? ParseFlag(string[] args, string flag)
|
||||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text))).ToLower();
|
{
|
||||||
|
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.Repl;
|
||||||
|
using NozCli.Core.Security;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
namespace NozCli.Commands;
|
namespace NozCli.Commands;
|
||||||
|
|
@ -54,6 +55,13 @@ public class PushCommand : ICommand
|
||||||
var relative = Path.GetRelativePath(localPath, file);
|
var relative = Path.GetRelativePath(localPath, file);
|
||||||
var slug = relative.Replace(Path.DirectorySeparatorChar, '/').Replace(".md", "");
|
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 markdown = await File.ReadAllTextAsync(file);
|
||||||
var localHash = NozCli.Core.Client.HashHelper.Sha256(markdown);
|
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.Client;
|
||||||
using NozCli.Core.Models;
|
using NozCli.Core.Models;
|
||||||
using NozCli.Repl;
|
using NozCli.Repl;
|
||||||
|
using NozCli.Core.Security;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
namespace NozCli.Commands;
|
namespace NozCli.Commands;
|
||||||
|
|
@ -51,7 +52,13 @@ public class SyncCommand : ICommand
|
||||||
|
|
||||||
foreach (var note in noteList)
|
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))
|
if (!force && note.Hash is not null && File.Exists(filePath))
|
||||||
{
|
{
|
||||||
|
|
@ -104,7 +111,7 @@ public class SyncCommand : ICommand
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var content = await ctx.Api.GetNoteAsync(note.Slug, innerCt);
|
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)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
|
||||||
await File.WriteAllTextAsync(filePath, content.Markdown, innerCt);
|
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 RmCommand(),
|
||||||
new ConfigCommand(),
|
new ConfigCommand(),
|
||||||
new TreeCommand(),
|
new TreeCommand(),
|
||||||
|
new MkdirCommand(),
|
||||||
|
new MvCommand(),
|
||||||
|
new SearchCommand(),
|
||||||
|
new LinkCommand(),
|
||||||
|
new TemplatesCommand(),
|
||||||
new VersionCommand(),
|
new VersionCommand(),
|
||||||
new ClearCommand(),
|
new ClearCommand(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ public static class NotesCache
|
||||||
var tmp = CachePath + ".tmp";
|
var tmp = CachePath + ".tmp";
|
||||||
await File.WriteAllTextAsync(tmp, json);
|
await File.WriteAllTextAsync(tmp, json);
|
||||||
File.Move(tmp, CachePath, overwrite: true);
|
File.Move(tmp, CachePath, overwrite: true);
|
||||||
|
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
File.SetUnixFileMode(CachePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||||
}
|
}
|
||||||
catch { /* cache write failures are non-fatal */ }
|
catch { /* cache write failures are non-fatal */ }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ public class ReplContext
|
||||||
|
|
||||||
var depth = CurrentSubPath is null ? 0 : CurrentSubPath.Split('/').Length;
|
var depth = CurrentSubPath is null ? 0 : CurrentSubPath.Split('/').Length;
|
||||||
return NotesInView()
|
return NotesInView()
|
||||||
|
.Where(n => SlugRelative(n.Slug).Split('/').Length > depth + 1)
|
||||||
.Select(n => SlugRelative(n.Slug).Split('/').ElementAtOrDefault(depth))
|
.Select(n => SlugRelative(n.Slug).Split('/').ElementAtOrDefault(depth))
|
||||||
.Where(s => s is not null)
|
.Where(s => s is not null)
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)!;
|
.Distinct(StringComparer.OrdinalIgnoreCase)!;
|
||||||
|
|
|
||||||
239
README.md
239
README.md
|
|
@ -1,79 +1,214 @@
|
||||||
# noz-cli
|
# 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
|
## 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
|
```bash
|
||||||
# macOS (Apple Silicon)
|
# macOS Apple Silicon
|
||||||
curl -L https://github.com/kryptomrx/noz-cli/releases/latest/download/noz-osx-arm64 -o /usr/local/bin/noz
|
curl -L https://git.kryptomrx.de/krypto/noz-cli/releases/latest/download/noz-osx-arm64 \
|
||||||
chmod +x /usr/local/bin/noz
|
-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
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
noz init https://your-noz-instance.com your-api-token
|
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
|
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
|
### Navigation
|
||||||
|
|
||||||
```
|
| Command | Description |
|
||||||
~ » ls # list topics
|
|---------|-------------|
|
||||||
~ » cd psychologie # enter a topic
|
| `ls` | List topics (at root) or notes in the current folder |
|
||||||
~/psychologie » ls -la # detailed view with status, date, title
|
| `ls -la` | Detailed list with status, date, language |
|
||||||
~/psychologie » tree # full hierarchy as a tree
|
| `cd <topic>` | Enter a topic or sub-folder |
|
||||||
~/psychologie » cd .. # go up
|
| `cd ..` | Go up one level |
|
||||||
```
|
| `tree` | Full hierarchy as a tree |
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
```
|
| Command | Description |
|
||||||
~ » cat psychologie/flow-theorie # print raw markdown
|
|---------|-------------|
|
||||||
~ » edit flow-theorie # open in terminal editor
|
| `cat <slug>` | Print a note (rendered markdown) |
|
||||||
~ » edit -c flow-theorie # open in VS Code
|
| `cat --raw <slug>` | Print raw markdown |
|
||||||
~ » new meine-neue-notiz # create note from template
|
| `new <name>` | Create a note and open in editor |
|
||||||
~ » rm alte-notiz # delete a note
|
| `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
|
||||||
|
|
||||||
```
|
| Command | Description |
|
||||||
~ » sync ~/vault # pull changed notes to local folder
|
|---------|-------------|
|
||||||
~ » sync ~/vault --all # pull everything (ignore local hashes)
|
| `sync <local-path>` | Pull changed notes from server to local folder |
|
||||||
~ » sync ~/vault --dry-run # preview what would be downloaded
|
| `sync <local-path> --all` | Pull everything (ignore local hashes) |
|
||||||
~ » push ~/vault # push local .md files to server
|
| `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
|
### 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
|
### Note commands
|
||||||
~ » config editor nano # set terminal editor
|
|
||||||
~ » config code-editor code # set GUI editor
|
```powershell
|
||||||
~ » exit
|
# 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
|
## Configuration
|
||||||
|
|
||||||
Editor preferences are stored in `~/.config/noz/config.json`:
|
`~/.config/noz/config.json`:
|
||||||
|
|
||||||
```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
|
## Building from source
|
||||||
|
|
||||||
Requires [.NET 10 SDK](https://dotnet.microsoft.com/download) and `clang`.
|
Requires [.NET 10 SDK](https://dotnet.microsoft.com/download) and `clang`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/kryptomrx/noz-cli
|
git clone https://git.kryptomrx.de/krypto/noz-cli
|
||||||
cd noz-cli
|
cd noz-cli
|
||||||
|
|
||||||
# Run (no AOT)
|
# Run without AOT
|
||||||
dotnet run --project NozCli
|
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
|
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:
|
On macOS, AOT linking requires OpenSSL via Homebrew:
|
||||||
|
|
@ -105,7 +262,9 @@ On macOS, AOT linking requires OpenSSL via Homebrew:
|
||||||
brew install openssl
|
brew install openssl
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[Polyform Noncommercial 1.0.0](LICENSE) — free for personal and non-commercial use.
|
[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>
|
<Solution>
|
||||||
<Project Path="NozCli.Core/NozCli.Core.csproj" />
|
<Project Path="NozCli.Core/NozCli.Core.csproj" />
|
||||||
<Project Path="NozCli/NozCli.csproj" />
|
<Project Path="NozCli/NozCli.csproj" />
|
||||||
|
<Project Path="Noz.PowerShell/Noz.PowerShell.csproj" />
|
||||||
|
<Project Path="NozCli.Tests/NozCli.Tests.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue