#nullable enable using System.Globalization; using System.IO; using Newtonsoft.Json; namespace T3.Editor.Gui.Help; /// /// Loads and caches the documentation cross-reference indices that ship under /// .help/references/indices/ (built by the video-analysis pipeline): the operator/ui → meet-up /// segment spine (mentions.json) and the video catalog (videos.json). Resolved via /// so a dev checkout reads the live repo files and a release reads the copy /// placed next to the binaries. /// internal static class HelpIndex { /// A single meet-up / tutorial segment that discusses a topic, with its relevancy axes. internal sealed record OnlineVideoSegment( string VideoId, int StartSecond, int DurationSeconds, string Url, string? Depth, string? Style, string? Purpose, int? Confidence, string Note, bool IsFocus, int MomentCount); /// Catalog entry for a referenced video. internal sealed record VideoInfo( string Id, string Type, DateTime? Date, string Title, string Url, int DurationSeconds); /// A UI topic (editor concept/component) from the help index — the target of a [ui:Id] link. internal sealed record TopicInfo(string Id, string Term, string Doc); /// Topic metadata from the index; the doc body is loaded on demand from . private readonly record struct TopicMeta(string Id, string Term, string? DocFile); /// Resolves a ui:<id> key (as written in a [ui:Id] link) to its topic. The doc /// body is read from its docFile on first request and cached for the session. internal static bool TryGetTopic(string topicKey, out TopicInfo? topic) { EnsureLoaded(); if (_resolvedTopics.TryGetValue(topicKey, out topic)) return true; if (!_topicMetaByKey.TryGetValue(topicKey, out var meta)) { topic = null; return false; } topic = new TopicInfo(meta.Id, meta.Term, LoadTopicDoc(meta.DocFile)); _resolvedTopics[topicKey] = topic; return true; } private static string LoadTopicDoc(string? docFile) { if (string.IsNullOrEmpty(docFile)) return ""; try { var path = Path.Combine(ShippedContent.ResolveDirectory(".help"), docFile.Replace('/', Path.DirectorySeparatorChar)); return File.Exists(path) ? File.ReadAllText(path) : ""; } catch (Exception e) { Log.Debug($"Could not read topic doc '{docFile}': {e.Message}"); return ""; } } /// Meet-up segments discussing a mention key (op:<fullpath> or ui:<id>), newest data first as authored. internal static IReadOnlyList GetSegments(string mentionKey) { EnsureLoaded(); return _segmentsByKey.TryGetValue(mentionKey, out var segments) ? segments : Array.Empty(); } internal static bool TryGetVideo(string id, out VideoInfo? video) => _videosById.TryGetValue(id, out video); private static void EnsureLoaded() { if (_loaded) return; _loaded = true; // Set first: a parse failure should not retry the read every frame. var directory = ShippedContent.ResolveDirectory(".help", "references", "indices"); LoadVideos(Path.Combine(directory, "videos.json")); LoadMentions(Path.Combine(directory, "mentions.json")); LoadTopics(Path.Combine(directory, "topics.json")); } private static void LoadTopics(string path) { if (!File.Exists(path)) return; try { var file = JsonConvert.DeserializeObject(File.ReadAllText(path)); if (file?.Topics == null) return; foreach (var (key, dto) in file.Topics) // key is already "ui:", matching a [ui:Id] link { if (!string.IsNullOrEmpty(key)) _topicMetaByKey[key] = new TopicMeta(key, dto.Term ?? key, dto.DocFile); } } catch (Exception e) { Log.Debug($"Could not read help topic index from {path}: {e.Message}"); } } private static void LoadVideos(string path) { if (!File.Exists(path)) return; try { var file = JsonConvert.DeserializeObject(File.ReadAllText(path)); if (file?.Videos == null) return; foreach (var dto in file.Videos) { if (string.IsNullOrEmpty(dto.Id)) continue; _videosById[dto.Id] = new VideoInfo(dto.Id, dto.Type ?? "", ParseDate(dto.Date), dto.Title ?? "", dto.Url ?? "", ParseDurationSeconds(dto.Duration)); } } catch (Exception e) { Log.Debug($"Could not read help video index from {path}: {e.Message}"); } } private static void LoadMentions(string path) { if (!File.Exists(path)) return; try { var byKey = JsonConvert.DeserializeObject>>(File.ReadAllText(path)); if (byKey == null) return; foreach (var (key, dtoList) in byKey) { var segments = new List(dtoList.Count); foreach (var dto in dtoList) { if (string.IsNullOrEmpty(dto.Video)) continue; segments.Add(new OnlineVideoSegment(dto.Video, dto.StartSecond, dto.Duration, dto.Url ?? "", dto.Depth, dto.Style, dto.Purpose, dto.Confidence, dto.Note ?? "", dto.Focus, dto.MomentCount)); } _segmentsByKey[key] = segments; } } catch (Exception e) { Log.Debug($"Could not read help mention index from {path}: {e.Message}"); } } private static DateTime? ParseDate(string? text) { return DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? date : null; } /// Parses a "h:mm:ss" / "m:ss" duration label into total seconds. internal static int ParseDurationSeconds(string? text) { if (string.IsNullOrEmpty(text)) return 0; var seconds = 0; foreach (var part in text.Split(':')) { if (!int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) return 0; seconds = seconds * 60 + value; } return seconds; } private sealed class VideosFileDto { [JsonProperty("videos")] public List? Videos { get; set; } } private sealed class VideoDto { [JsonProperty("id")] public string? Id { get; set; } [JsonProperty("type")] public string? Type { get; set; } [JsonProperty("date")] public string? Date { get; set; } [JsonProperty("title")] public string? Title { get; set; } [JsonProperty("url")] public string? Url { get; set; } [JsonProperty("duration")] public string? Duration { get; set; } } private sealed class SegmentDto { [JsonProperty("video")] public string? Video { get; set; } [JsonProperty("startSecond")] public int StartSecond { get; set; } [JsonProperty("duration")] public int Duration { get; set; } [JsonProperty("url")] public string? Url { get; set; } [JsonProperty("depth")] public string? Depth { get; set; } [JsonProperty("style")] public string? Style { get; set; } [JsonProperty("purpose")] public string? Purpose { get; set; } [JsonProperty("confidence")] public int? Confidence { get; set; } [JsonProperty("note")] public string? Note { get; set; } [JsonProperty("focus")] public bool Focus { get; set; } [JsonProperty("momentCount")] public int MomentCount { get; set; } } private sealed class TopicsFileDto { [JsonProperty("topics")] public Dictionary? Topics { get; set; } } private sealed class TopicDto { [JsonProperty("term")] public string? Term { get; set; } [JsonProperty("docFile")] public string? DocFile { get; set; } } private static bool _loaded; private static readonly Dictionary> _segmentsByKey = new(); private static readonly Dictionary _videosById = new(); private static readonly Dictionary _topicMetaByKey = new(); private static readonly Dictionary _resolvedTopics = new(); }