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.Client;
using NozCli.Core.Config;
using NozCli.Core.Models; using NozCli.Core.Models;
namespace NozCli.Repl; namespace NozCli.Repl;
/// Shared mutable state across the REPL session. /// 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 class ReplContext
{ {
public NozApiClient Api { get; } public NozApiClient Api { get; }
public string CurrentPath { get; set; } = "~"; public LocalConfig Config { get; }
public List<NoteInfo> NoteCache { get; set; } = []; public List<NoteInfo> NoteCache { get; set; } = [];
public bool Running { get; set; } = true; 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 CurrentPath =>
public string? PathPrefix() CurrentTopic is null ? "~" :
{ CurrentSubPath is null ? $"~/{CurrentTopic}" :
if (CurrentPath is "~" or "~/noosphere") return null; $"~/{CurrentTopic}/{CurrentSubPath}";
var stripped = CurrentPath.Replace("~/noosphere/", "").Replace("~/noosphere", "");
return string.IsNullOrEmpty(stripped) ? null : stripped;
}
/// 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() public IEnumerable<NoteInfo> NotesInView()
{ {
var prefix = PathPrefix(); if (CurrentTopic is null) return NoteCache;
return prefix is null
? NoteCache var topicNotes = NoteCache.Where(n =>
: NoteCache.Where(n => n.Slug.StartsWith(prefix + "/")); 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;
});
} }
/// Resolves a short name (e.g. "hydroponik") to a full slug using the current path as prefix. /// Slug of a note relative to the topic root folder.
public string ResolveSlug(string nameOrSlug) /// "biosysteme/bio-regenerative-systeme/hydroponik" under topic "biosysteme"
/// → "bio-regenerative-systeme/hydroponik"
public string SlugRelative(string slug)
{ {
// Already a full slug if (CurrentTopic is null) return slug;
if (NoteCache.Any(n => n.Slug == nameOrSlug)) return nameOrSlug; var prefix = CurrentTopic + "/";
var prefix = PathPrefix(); return slug.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
var candidate = prefix is null ? nameOrSlug : $"{prefix}/{nameOrSlug}"; ? slug[prefix.Length..]
return NoteCache.Any(n => n.Slug == candidate) ? candidate : nameOrSlug; : slug;
} }
/// Immediate sub-segments (topics or subfolders) at current path. /// 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() public IEnumerable<string> FoldersInView()
{ {
var prefix = PathPrefix(); if (CurrentTopic is null)
var depth = prefix is null ? 0 : prefix.Split('/').Length; return NoteCache
return NoteCache .Select(n => n.Topic)
.Where(n => prefix is null || n.Slug.StartsWith(prefix + "/")) .Where(t => !string.IsNullOrEmpty(t))
.Select(n => n.Slug.Split('/').ElementAtOrDefault(depth)) .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) .Where(s => s is not null)
.Distinct(StringComparer.OrdinalIgnoreCase)!; .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;
}
} }