diff --git a/src/Markdowser/GlobalSuppressions.cs b/src/Markdowser/GlobalSuppressions.cs index 5814a4d..e7bc418 100644 --- a/src/Markdowser/GlobalSuppressions.cs +++ b/src/Markdowser/GlobalSuppressions.cs @@ -7,5 +7,4 @@ using System.Diagnostics.CodeAnalysis; [assembly: SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "")] [assembly: SuppressMessage("Minor Code Smell", "CS0067:The event 'ChangeThemeCommand.CanExecuteChanged' is never used", Justification = "", Scope = "member", Target = "~N:Markdowser.Commands")] -[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "")] -[assembly: SuppressMessage("Minor Code Smell", "S3267:Loops should be simplified with \"LINQ\" expressions", Justification = "", Scope = "member", Target = "~M:Markdowser.Processing.ContentProcessorManager.ProcessContent(System.Net.HttpWebResponse,System.IProgress{Markdowser.Processing.ProcessingProgress})~Markdowser.ViewModels.ContentViewModelBase")] \ No newline at end of file +[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "")] \ No newline at end of file diff --git a/src/Markdowser/Markdowser.csproj b/src/Markdowser/Markdowser.csproj index 47eb033..31f776b 100644 --- a/src/Markdowser/Markdowser.csproj +++ b/src/Markdowser/Markdowser.csproj @@ -39,7 +39,8 @@ - - + + MarkdownContentView.axaml + diff --git a/src/Markdowser/Processing/ContentProcessor.cs b/src/Markdowser/Processing/ContentProcessorManager.cs similarity index 51% rename from src/Markdowser/Processing/ContentProcessor.cs rename to src/Markdowser/Processing/ContentProcessorManager.cs index 638b59a..ead7617 100644 --- a/src/Markdowser/Processing/ContentProcessor.cs +++ b/src/Markdowser/Processing/ContentProcessorManager.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; -using System.Net; +using System.Net.Http; using System.Threading.Tasks; namespace Markdowser.Processing; @@ -16,9 +16,9 @@ internal class ContentProcessorManager processors.Add(processor); } - public Task ProcessContent(HttpWebResponse response, IProgress progress) + public Task ProcessContent(HttpResponseMessage httpResponseMessage, IProgress progress) { - IContentProcessor? processor = processors.Find(processor => processor.CanProcess(response)) ?? throw new InvalidOperationException("No processor found for the given response."); - return processor.Process(response, progress); + IContentProcessor? processor = processors.Find(processor => processor.CanProcess(httpResponseMessage.Content.Headers)) ?? throw new InvalidOperationException($"No processor found for content type {httpResponseMessage.Content.Headers.ContentType}"); + return processor.Process(httpResponseMessage, progress); } } \ No newline at end of file diff --git a/src/Markdowser/Processing/IContentProcessor.cs b/src/Markdowser/Processing/IContentProcessor.cs index 62ea995..09c6412 100644 --- a/src/Markdowser/Processing/IContentProcessor.cs +++ b/src/Markdowser/Processing/IContentProcessor.cs @@ -1,14 +1,15 @@ using Markdowser.ViewModels; using System; -using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; using System.Threading.Tasks; namespace Markdowser.Processing; internal interface IContentProcessor { - bool CanProcess(HttpWebResponse response); + bool CanProcess(HttpContentHeaders httpContentHeaders); - Task Process(HttpWebResponse response, IProgress progress); + Task Process(HttpResponseMessage httpResponseMessage, IProgress progress); } \ No newline at end of file diff --git a/src/Markdowser/Processing/ProcessingProgress.cs b/src/Markdowser/Processing/ProcessingProgress.cs index 6ea5107..4d7c68c 100644 --- a/src/Markdowser/Processing/ProcessingProgress.cs +++ b/src/Markdowser/Processing/ProcessingProgress.cs @@ -1,9 +1,10 @@ namespace Markdowser.Processing; -public readonly struct ProcessingProgress(int current, int total) +public readonly struct ProcessingProgress(long current, long total, string? message = null) { - public int Current { get; } = current; - public int Total { get; } = total; + public long Current { get; } = current; + public long Total { get; } = total; + public string? Message { get; } = message; - public double Percentage => (double)Current / Total * 100; + public int Percentage => (int)((double)Total / Current * 100); } \ No newline at end of file diff --git a/src/Markdowser/Processing/Processors/HtmlProcessor.cs b/src/Markdowser/Processing/Processors/HtmlProcessor.cs new file mode 100644 index 0000000..66d6c7d --- /dev/null +++ b/src/Markdowser/Processing/Processors/HtmlProcessor.cs @@ -0,0 +1,94 @@ +using HtmlAgilityPack; + +using Markdowser.ViewModels; +using Markdowser.ViewModels.Content; + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Markdowser.Processing.Processors; + +internal partial class HtmlProcessor : IContentProcessor +{ + private readonly ReverseMarkdown.Converter markdownConverter = new(); + + public HtmlProcessor() + { + markdownConverter.Config.UnknownTags = ReverseMarkdown.Config.UnknownTagsOption.Bypass; + markdownConverter.Config.SuppressDivNewlines = false; + markdownConverter.Config.SmartHrefHandling = false; + } + + public bool CanProcess(HttpContentHeaders httpContentHeaders) + { + return httpContentHeaders.ContentType?.MediaType == "text/html"; + } + + public async Task Process(HttpResponseMessage httpResponseMessage, IProgress progress) + { + StringBuilder html = new(); + StringBuilder finalMarkdown = new(); + + long length = httpResponseMessage.Content.Headers.ContentLength ?? 0; + + using Stream contentStream = await httpResponseMessage.Content.ReadAsStreamAsync(); + using StreamReader streamReader = new(contentStream); + + while (!streamReader.EndOfStream) + { + _ = html.AppendLine(await streamReader.ReadLineAsync()); + + progress.Report(new ProcessingProgress(length, contentStream.Position)); + } + + HtmlDocument htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(html.ToString()); + string title = htmlDoc.DocumentNode.SelectSingleNode("html/head/title")?.InnerText?.Trim() ?? httpResponseMessage.RequestMessage?.RequestUri?.ToString() ?? "Untitled"; + + Debug.WriteLine("Converting HTML to markdown..."); + + string markdown = markdownConverter.Convert(html.ToString()); + + Debug.WriteLine("Processing markdown..."); + + int currentLine = 0; + + string[] lines = markdown.Split('\n'); + + foreach (string line in lines) + { + progress.Report(new ProcessingProgress(lines.Length, currentLine)); + currentLine++; + + string trimmedLine = line.Trim(); + + if (trimmedLine.All(c => c == '#')) + { + _ = finalMarkdown.Append(trimmedLine); + continue; + } + else if (MarkdownImage().IsMatch(trimmedLine)) + { + _ = finalMarkdown.AppendLine() + .Append(trimmedLine) + .AppendLine() + .AppendLine(); + continue; + } + + _ = finalMarkdown.AppendLine(trimmedLine); + } + + return new MarkdownContentViewModel(title, finalMarkdown.ToString()); + } + + [GeneratedRegex("!\\[(.*?)\\]\\((.*?)\\)")] + private static partial Regex MarkdownImage(); +} \ No newline at end of file diff --git a/src/Markdowser/Utilities/HttpPathResolver.cs b/src/Markdowser/Utilities/HttpPathResolver.cs index f83a29c..d959dff 100644 --- a/src/Markdowser/Utilities/HttpPathResolver.cs +++ b/src/Markdowser/Utilities/HttpPathResolver.cs @@ -1,7 +1,13 @@ -using Avalonia.Platform; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Controls.Notifications; +using Avalonia.Platform; +using Avalonia.Threading; using Markdown.Avalonia.Utils; + using Markdowser.Models; + using System; using System.Collections.Generic; using System.Diagnostics; @@ -36,10 +42,22 @@ public class HttpPathResolver : IPathResolver Debug.WriteLine($"Resolving image: {relativeOrAbsolutePath}"); - HttpResponseMessage httpResponseMessage = await httpClient.GetAsync(relativeOrAbsolutePath)!; + HttpResponseMessage httpResponseMessage; + + try + { + httpResponseMessage = await httpClient.GetAsync(relativeOrAbsolutePath)!; + } + catch (Exception ex) + { + // error with inluding url and message + ShowError($"Failed to fetch image: {ex.Message}\n{relativeOrAbsolutePath} - "); + return GetLogo(); + } if (!httpResponseMessage.IsSuccessStatusCode) { + ShowError($"Failed to fetch image: {httpResponseMessage.ReasonPhrase}\n{relativeOrAbsolutePath}"); return GetLogo(); } @@ -55,4 +73,12 @@ public class HttpPathResolver : IPathResolver return AssetLoader.Open(new Uri("avares://Markdowser/Assets/Markdowser-Light-Transparent.png"))!; } + + private static void ShowError(string message) + { + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && desktop.MainWindow?.DataContext is ViewModels.MainWindowViewModel vm) + { + Dispatcher.UIThread.Post(() => vm.WindowNotificationManager.Show(new Notification("Error", message, NotificationType.Error))); + } + } } \ No newline at end of file diff --git a/src/Markdowser/ViewLocator.cs b/src/Markdowser/ViewLocator.cs index e4efd96..18a6e62 100644 --- a/src/Markdowser/ViewLocator.cs +++ b/src/Markdowser/ViewLocator.cs @@ -6,20 +6,22 @@ using Markdowser.ViewModels; using System; namespace Markdowser; + public class ViewLocator : IDataTemplate { - public Control? Build(object? data) { if (data is null) + { return null; + } - var name = data.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); - var type = Type.GetType(name); + string name = data.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); + Type? type = Type.GetType(name); if (type != null) { - var control = (Control)Activator.CreateInstance(type)!; + Control control = (Control)Activator.CreateInstance(type)!; control.DataContext = data; return control; } @@ -29,6 +31,6 @@ public class ViewLocator : IDataTemplate public bool Match(object? data) { - return data is ViewModelBase; + return data is ViewModelBase or ContentViewModelBase; } -} +} \ No newline at end of file diff --git a/src/Markdowser/ViewModels/MainWindowViewModel.cs b/src/Markdowser/ViewModels/MainWindowViewModel.cs index 9c06dfe..832ed25 100644 --- a/src/Markdowser/ViewModels/MainWindowViewModel.cs +++ b/src/Markdowser/ViewModels/MainWindowViewModel.cs @@ -1,10 +1,12 @@ using Avalonia.Controls; +using Avalonia.Controls.Notifications; using Avalonia.Threading; -using HtmlAgilityPack; - using Markdowser.Models; +using Markdowser.Processing; +using Markdowser.Processing.Processors; using Markdowser.Utilities; +using Markdowser.ViewModels.Content; using ReactiveUI; @@ -12,12 +14,9 @@ using System; using System.Collections.ObjectModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; using System.Net.Http; using System.Reflection; using System.Text; -using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Windows.Input; @@ -26,10 +25,9 @@ namespace Markdowser.ViewModels; public partial class MainWindowViewModel : ViewModelBase { private readonly HttpClient httpClient = new(new HttpClientHandler() { AllowAutoRedirect = true }); - private readonly ReverseMarkdown.Converter markdownConverter = new(); + private readonly ContentProcessorManager contentProcessorManager = new(); - private readonly StringBuilder html = new(); - private StringBuilder? content; + private ContentViewModelBase content; private TabItem currentTab = null!; private bool showSidePanel; private bool isBusy; @@ -54,9 +52,9 @@ public partial class MainWindowViewModel : ViewModelBase } } - public StringBuilder Content + public ContentViewModelBase Content { - get => content ?? DefaultContent; + get => content; set => this.RaiseAndSetIfChanged(ref content, value); } @@ -89,8 +87,8 @@ public partial class MainWindowViewModel : ViewModelBase } public SettingsViewModel SettingsViewModel => new SettingsViewModel(); - public RawHtmlViewModel RawHtmlViewModel => new RawHtmlViewModel(html); - public RawMarkdownViewModel RawMarkdownViewModel => new RawMarkdownViewModel(() => Content); + public RawHtmlViewModel RawHtmlViewModel => new RawHtmlViewModel(new()); + public RawMarkdownViewModel RawMarkdownViewModel => new RawMarkdownViewModel(() => new()); public bool CloseTabEnabled => Tabs.Count > 1; public bool BackEnabled => GlobalState.BackHistory.Count > 0; public bool ForwardEnabled => GlobalState.ForwardHistory.Count > 0; @@ -145,21 +143,21 @@ public partial class MainWindowViewModel : ViewModelBase this.RaisePropertyChanged(nameof(CloseTabEnabled)); }); - private StringBuilder DefaultContent => new StringBuilder() + private MarkdownContentViewModel DefaultContent => new("New Tab", new StringBuilder() .AppendLine($"![Logo](avares://Markdowser/Assets/Markdowser-{(Settings.Current.DarkMode ? "Dark" : "Light")}-Transparent.png)") .AppendLine() .AppendLine($"{nameof(Markdowser)} {Assembly.GetExecutingAssembly().GetName().Version?.ToString()}\n") .AppendLine("A markdown web browser.\n") - .AppendLine("[GitHub](https://github.me.stone-red.net/Markdowser)"); + .AppendLine("[GitHub](https://github.me.stone-red.net/Markdowser)").ToString()); public MainWindowViewModel() { httpClient.DefaultRequestHeaders.UserAgent.ParseAdd($"{nameof(Markdowser)}/{Assembly.GetExecutingAssembly().GetName().Version?.ToString()}"); httpClient.Timeout = TimeSpan.FromSeconds(10); - markdownConverter.Config.UnknownTags = ReverseMarkdown.Config.UnknownTagsOption.Bypass; - markdownConverter.Config.SuppressDivNewlines = false; - markdownConverter.Config.SmartHrefHandling = false; + content = DefaultContent; + + contentProcessorManager.RegisterProcessor(new HtmlProcessor()); GlobalState.UrlChanged += (sender, url) => { @@ -188,16 +186,11 @@ public partial class MainWindowViewModel : ViewModelBase return Uri.TryCreate(uriString, UriKind.Absolute, out uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps); } - [GeneratedRegex("!\\[(.*?)\\]\\((.*?)\\)")] - private static partial Regex MarkdownImage(); - private void FetchUrl() { if (string.IsNullOrWhiteSpace(Url)) { - content = null; - _ = html.Clear(); - ContentChanged(); + Content = DefaultContent; Debug.WriteLine("URL is empty."); CurrentTab.Header = "New Tab"; return; @@ -205,14 +198,17 @@ public partial class MainWindowViewModel : ViewModelBase if (!IsValidHttpUri(Url, out _)) { - // Search with duckduckgo - Url = $"https://duckduckgo.com/html/?kd=-1&k1=-1&q={Uri.EscapeDataString(Url)}"; + if (Url.StartsWith("//")) + { + Url = $"https:{Url}"; + } + else + { + // Search with duckduckgo + Url = $"https://duckduckgo.com/html/?kd=-1&k1=-1&q={Uri.EscapeDataString(Url)}"; + } } - content ??= new StringBuilder(); - _ = content.Clear(); - _ = html.Clear(); - IsBusy = true; Progress = 0; @@ -224,103 +220,65 @@ public partial class MainWindowViewModel : ViewModelBase { HttpResponseMessage httpResponseMessage = await httpClient.GetAsync(Url); - Url = httpResponseMessage.RequestMessage?.RequestUri?.ToString() ?? Url; + string? newUrl = httpResponseMessage.RequestMessage?.RequestUri?.ToString(); + + if (newUrl is not null && newUrl != Url) + { + string oldUrl = Url; + Dispatcher.UIThread.Post(() => WindowNotificationManager.Show(new Notification("Redirected", $"Redirected from\n{oldUrl}\nto\n{newUrl}", NotificationType.Warning))); + Url = newUrl; + } if (!httpResponseMessage.IsSuccessStatusCode) { IsBusy = false; - _ = content.AppendLine($"# {(int)httpResponseMessage.StatusCode} {httpResponseMessage.StatusCode}"); - _ = content.AppendLine($"Failed to fetch URL: {httpResponseMessage.ReasonPhrase}"); - ContentChanged(); + + StringBuilder errorMessage = new(); + + _ = errorMessage.AppendLine($"# {(int)httpResponseMessage.StatusCode} {httpResponseMessage.StatusCode}"); + _ = errorMessage.AppendLine($"Failed to fetch URL: {httpResponseMessage.ReasonPhrase}"); + + Content = new MarkdownContentViewModel("Error", errorMessage.ToString()); return; } - long length = httpResponseMessage.Content.Headers.ContentLength ?? 0; + Content = await contentProcessorManager.ProcessContent(httpResponseMessage, new Progress(p => Progress = p.Percentage)); - using Stream contentStream = await httpResponseMessage.Content.ReadAsStreamAsync(); - using StreamReader streamReader = new(contentStream); - - while (!streamReader.EndOfStream) - { - _ = html.AppendLine(await streamReader.ReadLineAsync()); - Progress = (int)((double)contentStream.Position / length * 100); - } - - HtmlDocument htmlDoc = new HtmlDocument(); - htmlDoc.LoadHtml(html.ToString()); - string title = htmlDoc.DocumentNode.SelectSingleNode("html/head/title")?.InnerText ?? Url; - - Dispatcher.UIThread.Post(() => - { - CurrentTab.Header = title?.Trim() ?? Url; - }); + Dispatcher.UIThread.Post(() => CurrentTab.Header = Content.Title); } catch (HttpRequestException e) { - IsBusy = false; + StringBuilder errorMessage = new(); if (e.StatusCode is not null) { - _ = content.AppendLine($"# {(int)e.StatusCode} {e.StatusCode}"); + _ = errorMessage.AppendLine($"# {(int)e.StatusCode} {e.StatusCode}"); + Dispatcher.UIThread.Post(() => CurrentTab.Header = $"Error: {(int)e.StatusCode} {e.StatusCode}"); + } + else + { + Dispatcher.UIThread.Post(() => CurrentTab.Header = $"Error: {e.GetType().Name}"); } - _ = content.AppendLine($"# {e.HttpRequestError}"); - _ = content.AppendLine($"Failed to fetch URL: {e.Message}"); - ContentChanged(); + _ = errorMessage.AppendLine($"# {e.HttpRequestError}"); + _ = errorMessage.AppendLine($"Failed to fetch URL: {e.Message}"); + + Content = new MarkdownContentViewModel("Error", errorMessage.ToString()); } catch (Exception e) { - IsBusy = false; - _ = content.AppendLine($"# {e.GetType().Name}"); - _ = content.AppendLine($"Failed to fetch URL: {e.Message}"); - ContentChanged(); + StringBuilder errorMessage = new(); + + _ = errorMessage.AppendLine($"# {e.GetType().Name}"); + _ = errorMessage.AppendLine($"Failed to fetch URL: {e.Message}"); + + Content = new MarkdownContentViewModel("Error", errorMessage.ToString()); + Dispatcher.UIThread.Post(() => CurrentTab.Header = $"Error: {e.GetType().Name}"); } - - Progress = 0; - - Debug.WriteLine("Converting HTML to markdown..."); - - string markdown = markdownConverter.Convert(html.ToString()); - - Debug.WriteLine("Processing markdown..."); - - int currentLine = 0; - - string[] lines = markdown.Split('\n'); - foreach (string line in lines) + finally { - Progress = (int)((double)currentLine / lines.Length * 100); - currentLine++; - - string trimmedLine = line.Trim(); - - if (trimmedLine.All(c => c == '#')) - { - _ = Content.Append(trimmedLine); - continue; - } - else if (MarkdownImage().IsMatch(trimmedLine)) - { - _ = content.AppendLine() - .Append(trimmedLine) - .AppendLine() - .AppendLine(); - continue; - } - - _ = content.AppendLine(trimmedLine); + IsBusy = false; } - - ContentChanged(); - - IsBusy = false; }); } - - private void ContentChanged() - { - this.RaisePropertyChanged(nameof(Content)); - this.RaisePropertyChanged(nameof(RawHtmlViewModel)); - this.RaisePropertyChanged(nameof(RawMarkdownViewModel)); - } } \ No newline at end of file diff --git a/src/Markdowser/ViewModels/ViewModelBase.cs b/src/Markdowser/ViewModels/ViewModelBase.cs index 249c5d2..5c0baf0 100644 --- a/src/Markdowser/ViewModels/ViewModelBase.cs +++ b/src/Markdowser/ViewModels/ViewModelBase.cs @@ -1,4 +1,5 @@ using Avalonia.Controls; +using Avalonia.Controls.Notifications; using Avalonia.Platform; using Markdowser.Models; @@ -11,7 +12,19 @@ namespace Markdowser.ViewModels; public class ViewModelBase : ReactiveObject { + private WindowNotificationManager? windowNotificationManager; public string Title => $"{nameof(Markdowser)} - {Assembly.GetExecutingAssembly().GetName().Version?.ToString()}"; public WindowIcon Icon => Settings.Current.DarkMode ? new WindowIcon(AssetLoader.Open(new("avares://Markdowser/Assets/Markdowser-Dark-Transparent.ico"))) : new WindowIcon(AssetLoader.Open(new("avares://Markdowser/Assets/Markdowser-Light-Transparent.ico"))); + + public WindowNotificationManager WindowNotificationManager => windowNotificationManager!; + + protected internal void InitializeWindowNotificationManager(Window window) + { + TopLevel? topLevel = TopLevel.GetTopLevel(window); + windowNotificationManager = new WindowNotificationManager(topLevel) + { + Position = NotificationPosition.TopRight + }; + } } \ No newline at end of file diff --git a/src/Markdowser/Views/Content/MarkdownContentView.axaml b/src/Markdowser/Views/Content/MarkdownContentView.axaml new file mode 100644 index 0000000..7ff5259 --- /dev/null +++ b/src/Markdowser/Views/Content/MarkdownContentView.axaml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Markdowser/Views/Content/MarkdownContentView.axaml.cs b/src/Markdowser/Views/Content/MarkdownContentView.axaml.cs new file mode 100644 index 0000000..2c088dd --- /dev/null +++ b/src/Markdowser/Views/Content/MarkdownContentView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace Markdowser.Views.Content; + +public partial class MarkdownContentView : UserControl +{ + public MarkdownContentView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Markdowser/Views/MainWindow.axaml b/src/Markdowser/Views/MainWindow.axaml index 6b8fef6..9e2d882 100644 --- a/src/Markdowser/Views/MainWindow.axaml +++ b/src/Markdowser/Views/MainWindow.axaml @@ -5,14 +5,13 @@ xmlns:utils="using:Markdowser.Utilities" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:md="https://github.com/whistyun/Markdown.Avalonia" - xmlns:ctxt="clr-namespace:ColorTextBlock.Avalonia;assembly=ColorTextBlock.Avalonia" xmlns:i="https://github.com/projektanker/icons.avalonia" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Markdowser.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Icon="{Binding Icon}" - Title="{Binding Title}"> + Title="{Binding Title}" + Loaded="Window_Loaded">