diff --git a/NozCli/Repl/ReplContext.cs b/NozCli/Repl/ReplContext.cs index df7822b..2b3089b 100644 --- a/NozCli/Repl/ReplContext.cs +++ b/NozCli/Repl/ReplContext.cs @@ -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 List NoteCache { get; set; } = []; - public bool Running { get; set; } = true; + public NozApiClient Api { get; } + public LocalConfig Config { get; } + public List 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 NotesInView() { - var prefix = PathPrefix(); - return prefix is null - ? NoteCache - : NoteCache.Where(n => n.Slug.StartsWith(prefix + "/")); + 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; + }); } - /// Resolves a short name (e.g. "hydroponik") to a full slug using the current path as prefix. - public string ResolveSlug(string nameOrSlug) + /// 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) { - // 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; + if (CurrentTopic is null) return slug; + var prefix = CurrentTopic + "/"; + return slug.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? slug[prefix.Length..] + : 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 FoldersInView() { - var prefix = PathPrefix(); - var depth = prefix is null ? 0 : prefix.Split('/').Length; - return NoteCache - .Where(n => prefix is null || n.Slug.StartsWith(prefix + "/")) - .Select(n => n.Slug.Split('/').ElementAtOrDefault(depth)) + 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() + .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; + } }