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:
parent
b76896dc7a
commit
4248ff2255
1 changed files with 133 additions and 30 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue