- 43 tests across SlugGuard, ReplContext navigation, NotesCache.TimeAgo - SlugGuard moved to NozCli.Core.Security (shared between REPL and tests) - Bug fix: FoldersInView() now returns only actual sub-folders, not note segments (previously cd tab-completion suggested note names as navigable folders) - .forgejo/workflows/ci.yml: build + test on every push/PR
158 lines
5.7 KiB
C#
158 lines
5.7 KiB
C#
using NozCli.Core.Client;
|
|
using NozCli.Core.Config;
|
|
using NozCli.Core.Models;
|
|
|
|
namespace NozCli.Repl;
|
|
|
|
/// Shared mutable state across the REPL session.
|
|
/// Navigation model:
|
|
/// ~ → root, FoldersInView() returns distinct topics
|
|
/// ~/psychologie → topic view, notes filtered by topic
|
|
/// ~/biosysteme/subfolder → slug subfolder within a topic
|
|
public class ReplContext
|
|
{
|
|
public NozApiClient Api { get; }
|
|
public LocalConfig Config { get; }
|
|
public List<NoteInfo> NoteCache { get; set; } = [];
|
|
public bool Running { get; set; } = true;
|
|
|
|
// Navigation state
|
|
public string? CurrentTopic { get; set; } = null; // null = root
|
|
public string? CurrentSubPath { get; set; } = null; // sub-path within topic
|
|
|
|
public string CurrentPath =>
|
|
CurrentTopic is null ? "~" :
|
|
CurrentSubPath is null ? $"~/{CurrentTopic}" :
|
|
$"~/{CurrentTopic}/{CurrentSubPath}";
|
|
|
|
public ReplContext(NozApiClient api, LocalConfig config) { Api = api; Config = config; }
|
|
|
|
/// Notes filtered to the current topic (all notes at root).
|
|
public IEnumerable<NoteInfo> NotesInView()
|
|
{
|
|
if (CurrentTopic is null) return NoteCache;
|
|
|
|
var topicNotes = NoteCache.Where(n =>
|
|
string.Equals(n.Topic, CurrentTopic, StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (CurrentSubPath is null) return topicNotes;
|
|
|
|
// Within a subfolder: filter by the slug sub-path relative to topic root
|
|
return topicNotes.Where(n =>
|
|
{
|
|
var rel = SlugRelative(n.Slug);
|
|
return rel.StartsWith(CurrentSubPath + "/") || rel == CurrentSubPath;
|
|
});
|
|
}
|
|
|
|
/// Slug of a note relative to the topic root folder.
|
|
/// "biosysteme/bio-regenerative-systeme/hydroponik" under topic "biosysteme"
|
|
/// → "bio-regenerative-systeme/hydroponik"
|
|
public string SlugRelative(string slug)
|
|
{
|
|
if (CurrentTopic is null) return slug;
|
|
var prefix = CurrentTopic + "/";
|
|
return slug.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
|
? slug[prefix.Length..]
|
|
: slug;
|
|
}
|
|
|
|
/// Immediate sub-folders at the current navigation level.
|
|
/// At root: returns distinct topic names.
|
|
/// Within a topic: returns slug sub-segments one level below CurrentSubPath.
|
|
public IEnumerable<string> FoldersInView()
|
|
{
|
|
if (CurrentTopic is null)
|
|
return NoteCache
|
|
.Select(n => n.Topic)
|
|
.Where(t => !string.IsNullOrEmpty(t))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)!;
|
|
|
|
var depth = CurrentSubPath is null ? 0 : CurrentSubPath.Split('/').Length;
|
|
return NotesInView()
|
|
.Where(n => SlugRelative(n.Slug).Split('/').Length > depth + 1)
|
|
.Select(n => SlugRelative(n.Slug).Split('/').ElementAtOrDefault(depth))
|
|
.Where(s => s is not null)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)!;
|
|
}
|
|
|
|
/// Navigate into a topic or sub-folder segment.
|
|
public bool NavigateTo(string segment)
|
|
{
|
|
if (CurrentTopic is null)
|
|
{
|
|
// At root: segment must be a known topic
|
|
var topic = NoteCache
|
|
.Select(n => n.Topic)
|
|
.FirstOrDefault(t => string.Equals(t, segment, StringComparison.OrdinalIgnoreCase));
|
|
if (topic is null) return false;
|
|
CurrentTopic = topic;
|
|
CurrentSubPath = null;
|
|
return true;
|
|
}
|
|
|
|
// Within a topic: segment must be a valid slug sub-folder
|
|
var newSub = CurrentSubPath is null ? segment : $"{CurrentSubPath}/{segment}";
|
|
var hasNotes = NoteCache
|
|
.Where(n => string.Equals(n.Topic, CurrentTopic, StringComparison.OrdinalIgnoreCase))
|
|
.Any(n =>
|
|
{
|
|
var rel = SlugRelative(n.Slug);
|
|
return rel.StartsWith(newSub + "/") || rel == newSub;
|
|
});
|
|
|
|
if (!hasNotes) return false;
|
|
CurrentSubPath = newSub;
|
|
return true;
|
|
}
|
|
|
|
/// Navigate up one level.
|
|
public void NavigateUp()
|
|
{
|
|
if (CurrentSubPath is not null)
|
|
{
|
|
var parts = CurrentSubPath.Split('/');
|
|
CurrentSubPath = parts.Length > 1 ? string.Join('/', parts[..^1]) : null;
|
|
}
|
|
else
|
|
{
|
|
CurrentTopic = null;
|
|
}
|
|
}
|
|
|
|
/// Resolve a short name to a full slug for note commands (cat, edit, rm).
|
|
public string ResolveSlug(string nameOrSlug)
|
|
{
|
|
nameOrSlug = nameOrSlug.TrimEnd('/');
|
|
// Already a full known slug
|
|
if (NoteCache.Any(n => n.Slug == nameOrSlug)) return nameOrSlug;
|
|
|
|
if (CurrentTopic is null) return nameOrSlug;
|
|
|
|
// Try reconstructing the full slug from relative name
|
|
var topicPrefix = CurrentTopic + "/";
|
|
var sub = CurrentSubPath is null ? nameOrSlug : $"{CurrentSubPath}/{nameOrSlug}";
|
|
var full = topicPrefix + sub;
|
|
if (NoteCache.Any(n => n.Slug == full)) return full;
|
|
|
|
// Slug without topic prefix (note stored at root of noosphere)
|
|
if (NoteCache.Any(n => n.Slug == sub)) return sub;
|
|
|
|
return nameOrSlug;
|
|
}
|
|
|
|
/// Build a slug for a NEW note that doesn't exist yet.
|
|
public string ResolveNewSlug(string nameOrSlug)
|
|
{
|
|
nameOrSlug = nameOrSlug.TrimEnd('/');
|
|
if (nameOrSlug.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
|
nameOrSlug = nameOrSlug[..^3];
|
|
|
|
if (nameOrSlug.Contains('/')) return nameOrSlug;
|
|
|
|
if (CurrentTopic is null) return nameOrSlug;
|
|
|
|
var sub = CurrentSubPath is null ? nameOrSlug : $"{CurrentSubPath}/{nameOrSlug}";
|
|
return CurrentTopic + "/" + sub;
|
|
}
|
|
}
|