refactor(repl): Topic-based navigation model

- Replace flat path string with CurrentTopic + CurrentSubPath
- SlugRelative() strips topic prefix for relative name resolution
- FoldersInView() returns topics at root, slug sub-segments within topic
- NotesInView() filters by topic and subpath
- NavigateTo() / NavigateUp() for cd navigation
- ResolveSlug() / ResolveNewSlug() for command argument resolution
This commit is contained in:
kryptomrx 2026-05-16 23:20:29 +02:00
parent b76896dc7a
commit 4248ff2255

View file

@ -1,54 +1,157 @@
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 string CurrentPath { get; set; } = "~";
public LocalConfig Config { get; }
public List<NoteInfo> NoteCache { get; set; } = [];
public bool Running { get; set; } = true;
public ReplContext(NozApiClient api) => Api = api;
// Navigation state
public string? CurrentTopic { get; set; } = null; // null = root
public string? CurrentSubPath { get; set; } = null; // sub-path within topic
/// Returns the topic/folder prefix implied by CurrentPath, or null if at root.
public string? PathPrefix()
{
if (CurrentPath is "~" or "~/noosphere") return null;
var stripped = CurrentPath.Replace("~/noosphere/", "").Replace("~/noosphere", "");
return string.IsNullOrEmpty(stripped) ? null : stripped;
}
public string CurrentPath =>
CurrentTopic is null ? "~" :
CurrentSubPath is null ? $"~/{CurrentTopic}" :
$"~/{CurrentTopic}/{CurrentSubPath}";
/// Notes visible at the current path level.
public ReplContext(NozApiClient api, LocalConfig config) { Api = api; Config = config; }
/// Notes filtered to the current topic (all notes at root).
public IEnumerable<NoteInfo> NotesInView()
{
var prefix = PathPrefix();
return prefix is null
? NoteCache
: NoteCache.Where(n => n.Slug.StartsWith(prefix + "/"));
}
if (CurrentTopic is null) return NoteCache;
/// Resolves a short name (e.g. "hydroponik") to a full slug using the current path as prefix.
public string ResolveSlug(string nameOrSlug)
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 =>
{
// Already a full slug
if (NoteCache.Any(n => n.Slug == nameOrSlug)) return nameOrSlug;
var prefix = PathPrefix();
var candidate = prefix is null ? nameOrSlug : $"{prefix}/{nameOrSlug}";
return NoteCache.Any(n => n.Slug == candidate) ? candidate : nameOrSlug;
var rel = SlugRelative(n.Slug);
return rel.StartsWith(CurrentSubPath + "/") || rel == CurrentSubPath;
});
}
/// Immediate sub-segments (topics or subfolders) at current path.
/// 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()
{
var prefix = PathPrefix();
var depth = prefix is null ? 0 : prefix.Split('/').Length;
if (CurrentTopic is null)
return NoteCache
.Where(n => prefix is null || n.Slug.StartsWith(prefix + "/"))
.Select(n => n.Slug.Split('/').ElementAtOrDefault(depth))
.Select(n => n.Topic)
.Where(t => !string.IsNullOrEmpty(t))
.Distinct(StringComparer.OrdinalIgnoreCase)!;
var depth = CurrentSubPath is null ? 0 : CurrentSubPath.Split('/').Length;
return NotesInView()
.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;
}
}