namespace MeetingAssistant.Workflow; internal sealed class WorkflowRulesEditorMarkdownLinkResolver { private static readonly string[] ImageUriSchemes = ["http", "https", "file"]; private readonly IReadOnlyList rootPaths; private readonly IReadOnlyList recursiveRootPaths; private readonly Dictionary resolvedFiles = new(StringComparer.OrdinalIgnoreCase); public WorkflowRulesEditorMarkdownLinkResolver( IEnumerable rootPaths, IEnumerable? recursiveRootPaths = null) { this.rootPaths = rootPaths .Where(path => !string.IsNullOrWhiteSpace(path)) .Select(path => Path.GetFullPath(path)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); this.recursiveRootPaths = (recursiveRootPaths ?? []) .Where(path => !string.IsNullOrWhiteSpace(path)) .Select(path => Path.GetFullPath(path)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); } public static WorkflowRulesEditorMarkdownLinkResolver Empty { get; } = new([]); public static WorkflowRulesEditorMarkdownLinkResolver FromOptions(MeetingAssistantOptions options) { var artifactRoots = new[] { VaultPath.Resolve(options.Vault, options.Vault.AssistantContextFolder), VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder), VaultPath.Resolve(options.Vault, options.Vault.SummariesFolder), VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder), VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder) }; return new WorkflowRulesEditorMarkdownLinkResolver([ ..artifactRoots, Environment.CurrentDirectory, AppContext.BaseDirectory ], artifactRoots); } public bool TryResolveImageUri(string? target, out Uri uri) { uri = null!; if (string.IsNullOrWhiteSpace(target)) { return false; } if (Uri.TryCreate(target, UriKind.Absolute, out uri!) && ImageUriSchemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase)) { return true; } if (TryResolveExistingFilePath(target, out var path)) { uri = new Uri(path); return true; } return false; } public string ResolveOpenTarget(string? target) { if (string.IsNullOrWhiteSpace(target)) { return ""; } if (Uri.TryCreate(target, UriKind.Absolute, out var uri)) { return uri.Scheme.Equals("file", StringComparison.OrdinalIgnoreCase) ? uri.LocalPath : uri.ToString(); } return TryResolveExistingFilePath(target, out var path) ? path : target; } private bool TryResolveExistingFilePath(string target, out string path) { path = ""; if (Path.IsPathRooted(target) && File.Exists(target)) { path = Path.GetFullPath(target); return true; } if (resolvedFiles.TryGetValue(target, out var cachedPath)) { path = cachedPath; return true; } if (TryResolveDirectRelativePath(target, out path) || TryFindRelativePathInRoots(target, out path)) { resolvedFiles[target] = path; return true; } return false; } private bool TryResolveDirectRelativePath(string target, out string path) { foreach (var root in rootPaths) { try { var candidate = Path.GetFullPath(target, root); if (File.Exists(candidate)) { path = candidate; return true; } } catch { } } path = ""; return false; } private bool TryFindRelativePathInRoots(string target, out string path) { path = ""; var normalizedSuffix = NormalizeRelativePath(target); if (string.IsNullOrWhiteSpace(normalizedSuffix)) { return false; } var fileName = Path.GetFileName(normalizedSuffix); if (string.IsNullOrWhiteSpace(fileName)) { return false; } foreach (var root in recursiveRootPaths.Where(Directory.Exists)) { try { var match = Directory .EnumerateFiles(root, fileName, SearchOption.AllDirectories) .Where(candidate => NormalizeRelativePath(Path.GetRelativePath(root, candidate)) .EndsWith(normalizedSuffix, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(File.GetLastWriteTimeUtc) .FirstOrDefault(); if (match is not null) { path = match; return true; } } catch { } } return false; } private static string NormalizeRelativePath(string path) { return path .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar) .TrimStart(Path.DirectorySeparatorChar); } }