feat: link and templates commands with note linking helper
NoteLinkHelper inserts wikilinks into an existing 'Siehe auch' section
or appends one. ApplyPlaceholders fills {{title}}/{{date}}/{{topic}} in
template bodies. new --template <name> fetches a server-side template
before opening the editor. SlugGuard moved to NozCli.Core in a previous
session — remove stale copy from NozCli.
This commit is contained in:
parent
e9217ecb77
commit
58410f8d9f
11 changed files with 376 additions and 46 deletions
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.");
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
using NozCli.Rendering;
|
||||
using NozCli.Repl;
|
||||
using Spectre.Console;
|
||||
|
||||
|
|
@ -6,13 +7,15 @@ namespace NozCli.Commands;
|
|||
public class CatCommand : ICommand
|
||||
{
|
||||
public string Name => "cat";
|
||||
public string Description => "Print the raw markdown of a note";
|
||||
public string Usage => "cat <slug>";
|
||||
public string Description => "Print a note — rendered by default, --raw for plain markdown";
|
||||
public string Usage => "cat [--raw] <slug>";
|
||||
|
||||
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||
{
|
||||
var slug = args.FirstOrDefault();
|
||||
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat <slug>[/]"); return; }
|
||||
var raw = args.Contains("--raw") || args.Contains("-r");
|
||||
var slug = args.FirstOrDefault(a => !a.StartsWith('-'));
|
||||
|
||||
if (slug is null) { AnsiConsole.MarkupLine("[red]Usage: cat [--raw] <slug>[/]"); return; }
|
||||
|
||||
slug = ctx.ResolveSlug(slug);
|
||||
|
||||
|
|
@ -20,6 +23,9 @@ public class CatCommand : ICommand
|
|||
await AnsiConsole.Status().StartAsync("Fetching…", async _ =>
|
||||
note = await ctx.Api.GetNoteAsync(slug, ct));
|
||||
|
||||
AnsiConsole.WriteLine(note.Markdown);
|
||||
if (raw)
|
||||
AnsiConsole.WriteLine(note.Markdown);
|
||||
else
|
||||
MarkdownRenderer.Render(note.Markdown);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Diagnostics;
|
||||
using NozCli.Core.Client;
|
||||
using NozCli.Core.Models;
|
||||
using NozCli.Repl;
|
||||
using Spectre.Console;
|
||||
|
|
@ -9,19 +10,20 @@ public class NewCommand : ICommand
|
|||
{
|
||||
public string Name => "new";
|
||||
public string Description => "Create a new note and open it in nano (or -c for VS Code)";
|
||||
public string Usage => "new [-c] <slug-or-name>";
|
||||
public string Usage => "new [-c] [--template <name>] <slug-or-name>";
|
||||
|
||||
public async Task ExecuteAsync(string[] args, ReplContext ctx, CancellationToken ct = default)
|
||||
{
|
||||
var useCode = args.Contains("-c") || args.Contains("--code");
|
||||
var nameArg = args.FirstOrDefault(a => !a.StartsWith('-'));
|
||||
if (nameArg is null) { AnsiConsole.MarkupLine("[red]Usage: new [-c] <slug-or-name>[/]"); return; }
|
||||
var useCode = args.Contains("-c") || args.Contains("--code");
|
||||
var templateName = ParseFlag(args, "--template") ?? ParseFlag(args, "-t");
|
||||
var nameArg = args.FirstOrDefault(a => !a.StartsWith('-'));
|
||||
if (nameArg is null) { AnsiConsole.MarkupLine("[red]Usage: new [-c] [--template <name>] <slug-or-name>[/]"); return; }
|
||||
|
||||
var slug = ctx.ResolveNewSlug(nameArg);
|
||||
|
||||
if (ctx.NoteCache.Any(n => n.Slug == slug))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[yellow]Note already exists:[/] {slug} [dim](use 'edit' to modify it)[/]");
|
||||
AnsiConsole.MarkupLine($"[yellow]Notiz existiert bereits:[/] {slug} [dim](use 'edit' to modify it)[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -29,6 +31,27 @@ public class NewCommand : ICommand
|
|||
var title = ToTitle(slug.Split('/').Last());
|
||||
var lang = "de";
|
||||
|
||||
string noteBody;
|
||||
|
||||
if (templateName is not null)
|
||||
{
|
||||
TemplateContent? tmpl = null;
|
||||
await AnsiConsole.Status().StartAsync($"Lade Template '{templateName}'…", async _ =>
|
||||
tmpl = await ctx.Api.GetTemplateAsync(templateName, ct));
|
||||
|
||||
if (tmpl is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Template '{templateName}' nicht gefunden.[/] Verfügbare Templates mit [white]templates[/] anzeigen.");
|
||||
return;
|
||||
}
|
||||
|
||||
noteBody = NoteLinkHelper.ApplyPlaceholders(tmpl.Body, title, topic, lang, "seed");
|
||||
}
|
||||
else
|
||||
{
|
||||
noteBody = $"# {title}\n\n";
|
||||
}
|
||||
|
||||
var template = $"""
|
||||
---
|
||||
title: "{title}"
|
||||
|
|
@ -38,8 +61,7 @@ public class NewCommand : ICommand
|
|||
lang: {lang}
|
||||
---
|
||||
|
||||
# {title}
|
||||
|
||||
{noteBody.TrimStart()}
|
||||
""";
|
||||
|
||||
var tmp = Path.Combine(Path.GetTempPath(), $"noz-new-{slug.Replace('/', '-')}.md");
|
||||
|
|
@ -90,4 +112,9 @@ public class NewCommand : ICommand
|
|||
string.Join(' ', slug.Split('-').Select(w =>
|
||||
w.Length > 0 ? char.ToUpper(w[0]) + w[1..] : w));
|
||||
|
||||
private static string? ParseFlag(string[] args, string flag)
|
||||
{
|
||||
var idx = Array.IndexOf(args, flag);
|
||||
return idx >= 0 && idx + 1 < args.Length ? args[idx + 1] : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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>[/]");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NozCli.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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue