From 942e58ac0f681e904e395296f1ebe71146d1469c Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Wed, 3 Apr 2024 20:59:58 +0200 Subject: [PATCH 01/31] Add basic content processing structure --- src/Markdowser/GlobalSuppressions.cs | 3 ++- src/Markdowser/Markdowser.csproj | 5 ++++ src/Markdowser/Processing/ContentProcessor.cs | 24 +++++++++++++++++++ .../Processing/IContentProcessor.cs | 14 +++++++++++ .../Processing/ProcessingProgress.cs | 9 +++++++ src/Markdowser/Utilities/ColumnDefinition.cs | 2 +- .../Content/MarkdownContentViewModel.cs | 6 +++++ .../ViewModels/ContentViewModelBase.cs | 6 +++++ .../ViewModels/MainWindowViewModel.cs | 3 ++- src/Markdowser/ViewModels/ViewModelBase.cs | 15 ++++++++++-- src/Markdowser/Views/MainWindow.axaml | 2 +- 11 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 src/Markdowser/Processing/ContentProcessor.cs create mode 100644 src/Markdowser/Processing/IContentProcessor.cs create mode 100644 src/Markdowser/Processing/ProcessingProgress.cs create mode 100644 src/Markdowser/ViewModels/Content/MarkdownContentViewModel.cs create mode 100644 src/Markdowser/ViewModels/ContentViewModelBase.cs diff --git a/src/Markdowser/GlobalSuppressions.cs b/src/Markdowser/GlobalSuppressions.cs index e7bc418..5814a4d 100644 --- a/src/Markdowser/GlobalSuppressions.cs +++ b/src/Markdowser/GlobalSuppressions.cs @@ -7,4 +7,5 @@ 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 = "")] \ No newline at end of file +[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 diff --git a/src/Markdowser/Markdowser.csproj b/src/Markdowser/Markdowser.csproj index 77074ed..47eb033 100644 --- a/src/Markdowser/Markdowser.csproj +++ b/src/Markdowser/Markdowser.csproj @@ -37,4 +37,9 @@ + + + + + diff --git a/src/Markdowser/Processing/ContentProcessor.cs b/src/Markdowser/Processing/ContentProcessor.cs new file mode 100644 index 0000000..638b59a --- /dev/null +++ b/src/Markdowser/Processing/ContentProcessor.cs @@ -0,0 +1,24 @@ +using Markdowser.ViewModels; + +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; + +namespace Markdowser.Processing; + +internal class ContentProcessorManager +{ + private readonly List processors = []; + + public void RegisterProcessor(IContentProcessor processor) + { + processors.Add(processor); + } + + public Task ProcessContent(HttpWebResponse response, 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); + } +} \ No newline at end of file diff --git a/src/Markdowser/Processing/IContentProcessor.cs b/src/Markdowser/Processing/IContentProcessor.cs new file mode 100644 index 0000000..62ea995 --- /dev/null +++ b/src/Markdowser/Processing/IContentProcessor.cs @@ -0,0 +1,14 @@ +using Markdowser.ViewModels; + +using System; +using System.Net; +using System.Threading.Tasks; + +namespace Markdowser.Processing; + +internal interface IContentProcessor +{ + bool CanProcess(HttpWebResponse response); + + Task Process(HttpWebResponse response, IProgress progress); +} \ No newline at end of file diff --git a/src/Markdowser/Processing/ProcessingProgress.cs b/src/Markdowser/Processing/ProcessingProgress.cs new file mode 100644 index 0000000..6ea5107 --- /dev/null +++ b/src/Markdowser/Processing/ProcessingProgress.cs @@ -0,0 +1,9 @@ +namespace Markdowser.Processing; + +public readonly struct ProcessingProgress(int current, int total) +{ + public int Current { get; } = current; + public int Total { get; } = total; + + public double Percentage => (double)Current / Total * 100; +} \ No newline at end of file diff --git a/src/Markdowser/Utilities/ColumnDefinition.cs b/src/Markdowser/Utilities/ColumnDefinition.cs index 20caaa3..d0e7cf8 100644 --- a/src/Markdowser/Utilities/ColumnDefinition.cs +++ b/src/Markdowser/Utilities/ColumnDefinition.cs @@ -13,7 +13,7 @@ internal static class ColumnDefinition } else if (!visibility) { - _ = element.SetValue(LastWidthProperty, element.GetValue(Avalonia.Controls.ColumnDefinition.WidthProperty)); + _ = element.SetValue(LastWidthProperty!, element.GetValue(Avalonia.Controls.ColumnDefinition.WidthProperty)); _ = element.SetValue(Avalonia.Controls.ColumnDefinition.WidthProperty, ZeroWidth); } return visibility; diff --git a/src/Markdowser/ViewModels/Content/MarkdownContentViewModel.cs b/src/Markdowser/ViewModels/Content/MarkdownContentViewModel.cs new file mode 100644 index 0000000..3831b93 --- /dev/null +++ b/src/Markdowser/ViewModels/Content/MarkdownContentViewModel.cs @@ -0,0 +1,6 @@ +namespace Markdowser.ViewModels.Content; + +public class MarkdownContentViewModel(string title, string markdown) : ContentViewModelBase(title) +{ + public string Markdown { get; } = markdown; +} \ No newline at end of file diff --git a/src/Markdowser/ViewModels/ContentViewModelBase.cs b/src/Markdowser/ViewModels/ContentViewModelBase.cs new file mode 100644 index 0000000..95f0edf --- /dev/null +++ b/src/Markdowser/ViewModels/ContentViewModelBase.cs @@ -0,0 +1,6 @@ +namespace Markdowser.ViewModels; + +public abstract class ContentViewModelBase(string title) +{ + public string Title { get; } = title; +} \ No newline at end of file diff --git a/src/Markdowser/ViewModels/MainWindowViewModel.cs b/src/Markdowser/ViewModels/MainWindowViewModel.cs index 2488d4a..9c06dfe 100644 --- a/src/Markdowser/ViewModels/MainWindowViewModel.cs +++ b/src/Markdowser/ViewModels/MainWindowViewModel.cs @@ -35,7 +35,6 @@ public partial class MainWindowViewModel : ViewModelBase private bool isBusy; private int progress; public ObservableCollection Tabs => GlobalState.Tabs; - public string Title => $"{nameof(Markdowser)} - {Assembly.GetExecutingAssembly().GetName().Version?.ToString()}"; public TabItem CurrentTab { @@ -178,6 +177,8 @@ public partial class MainWindowViewModel : ViewModelBase GlobalState.ContentReload += (sender, _) => { + // Updatre icon when dark mode changes + this.RaisePropertyChanged(nameof(Icon)); this.RaisePropertyChanged(nameof(Content)); }; } diff --git a/src/Markdowser/ViewModels/ViewModelBase.cs b/src/Markdowser/ViewModels/ViewModelBase.cs index 28a9281..249c5d2 100644 --- a/src/Markdowser/ViewModels/ViewModelBase.cs +++ b/src/Markdowser/ViewModels/ViewModelBase.cs @@ -1,6 +1,17 @@ -using ReactiveUI; +using Avalonia.Controls; +using Avalonia.Platform; + +using Markdowser.Models; + +using ReactiveUI; + +using System.Reflection; namespace Markdowser.ViewModels; + public class ViewModelBase : ReactiveObject { -} + 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"))); +} \ No newline at end of file diff --git a/src/Markdowser/Views/MainWindow.axaml b/src/Markdowser/Views/MainWindow.axaml index 16f5b4a..6b8fef6 100644 --- a/src/Markdowser/Views/MainWindow.axaml +++ b/src/Markdowser/Views/MainWindow.axaml @@ -11,7 +11,7 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Markdowser.Views.MainWindow" x:DataType="vm:MainWindowViewModel" - Icon="/Assets/Markdowser-Light-Transparent.ico" + Icon="{Binding Icon}" Title="{Binding Title}"> From bb78a4a067a6597a3a12895c23f4ca2c1ad09727 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Thu, 4 Apr 2024 14:46:02 +0200 Subject: [PATCH 02/31] Implement text/html processor --- src/Markdowser/GlobalSuppressions.cs | 3 +- src/Markdowser/Markdowser.csproj | 5 +- ...rocessor.cs => ContentProcessorManager.cs} | 8 +- .../Processing/IContentProcessor.cs | 7 +- .../Processing/ProcessingProgress.cs | 9 +- .../Processing/Processors/HtmlProcessor.cs | 94 ++++++++++ src/Markdowser/Utilities/HttpPathResolver.cs | 30 +++- src/Markdowser/ViewLocator.cs | 14 +- .../ViewModels/MainWindowViewModel.cs | 166 +++++++----------- src/Markdowser/ViewModels/ViewModelBase.cs | 13 ++ .../Views/Content/MarkdownContentView.axaml | 71 ++++++++ .../Content/MarkdownContentView.axaml.cs | 11 ++ src/Markdowser/Views/MainWindow.axaml | 62 ++----- src/Markdowser/Views/MainWindow.axaml.cs | 8 + 14 files changed, 327 insertions(+), 174 deletions(-) rename src/Markdowser/Processing/{ContentProcessor.cs => ContentProcessorManager.cs} (51%) create mode 100644 src/Markdowser/Processing/Processors/HtmlProcessor.cs create mode 100644 src/Markdowser/Views/Content/MarkdownContentView.axaml create mode 100644 src/Markdowser/Views/Content/MarkdownContentView.axaml.cs 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"> + + diff --git a/src/Markdowser/Models/Settings.cs b/src/Markdowser/Models/Settings.cs index 1188d85..0dcb68e 100644 --- a/src/Markdowser/Models/Settings.cs +++ b/src/Markdowser/Models/Settings.cs @@ -1,5 +1,6 @@ using Markdowser.Utilities; +using System; using System.IO; using System.Reflection; using System.Text.Json; @@ -32,7 +33,15 @@ public class Settings { if (File.Exists(Configuration.SettingsFilePath)) { - return JsonSerializer.Deserialize(File.ReadAllText(Configuration.SettingsFilePath)) ?? new Settings(); + try + { + return JsonSerializer.Deserialize(File.ReadAllText(Configuration.SettingsFilePath)) ?? new Settings(); + } + catch (Exception ex) + { + GlobalState.Logger.LogWarn(ex.Message); + return new Settings(); + } } else { diff --git a/src/Markdowser/Processing/Processors/HtmlProcessor.cs b/src/Markdowser/Processing/Processors/HtmlProcessor.cs index 1de3f72..edaa421 100644 --- a/src/Markdowser/Processing/Processors/HtmlProcessor.cs +++ b/src/Markdowser/Processing/Processors/HtmlProcessor.cs @@ -1,10 +1,10 @@ using HtmlAgilityPack; +using Markdowser.Utilities; using Markdowser.ViewModels; using Markdowser.ViewModels.Content; using System; -using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; @@ -56,11 +56,11 @@ internal partial class HtmlProcessor : IContentProcessor 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..."); + GlobalState.Logger.LogDebug("Converting HTML to markdown..."); string markdown = markdownConverter.Convert(html.ToString()); - Debug.WriteLine("Processing markdown..."); + GlobalState.Logger.LogDebug("Processing markdown..."); int currentLine = 0; diff --git a/src/Markdowser/Program.cs b/src/Markdowser/Program.cs index 12b002f..9564c36 100644 --- a/src/Markdowser/Program.cs +++ b/src/Markdowser/Program.cs @@ -1,11 +1,17 @@ using Avalonia; using Avalonia.ReactiveUI; +using Markdowser.Utilities; + using Projektanker.Icons.Avalonia; using Projektanker.Icons.Avalonia.FontAwesome; using Projektanker.Icons.Avalonia.MaterialDesign; +using ReactiveUI; + using System; +using System.Reactive; +using System.Threading.Tasks; namespace Markdowser; @@ -17,8 +23,30 @@ internal static class Program [STAThread] public static void Main(string[] args) { - _ = BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); + AppDomain.CurrentDomain.UnhandledException += (sender, e) => + { + GlobalState.Logger.LogFatal(e.ExceptionObject.ToString() ?? "Unknown error."); + }; + + TaskScheduler.UnobservedTaskException += (sender, e) => + { + GlobalState.Logger.LogFatal(e.Exception.ToString()); + }; + + RxApp.DefaultExceptionHandler = Observer.Create(ex => + { + GlobalState.Logger.LogFatal(ex.ToString()); + }); + + try + { + _ = BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } + catch (Exception ex) + { + GlobalState.Logger.LogFatal(ex.ToString()); + } } // Avalonia configuration, don't remove; also used by visual designer. diff --git a/src/Markdowser/Utilities/GlobalState.cs b/src/Markdowser/Utilities/GlobalState.cs index 856fb75..38cc0f6 100644 --- a/src/Markdowser/Utilities/GlobalState.cs +++ b/src/Markdowser/Utilities/GlobalState.cs @@ -1,5 +1,7 @@ using Avalonia.Controls; +using CuteUtils.Logging; + using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -16,6 +18,42 @@ internal static class GlobalState private static string url = string.Empty; + public static Logger Logger { get; } = new() + { + Config = new() + { + DebugConfig = new() + { + LogTarget = LogTarget.DebugConsole + }, + InfoConfig = new() + { + LogTarget = LogTarget.DebugConsole | LogTarget.File, + FilePath = Configuration.LogFilePath + }, + WarnConfig = new() + { + LogTarget = LogTarget.DebugConsole | LogTarget.File, + FilePath = Configuration.LogFilePath + }, + ErrorConfig = new() + { + LogTarget = LogTarget.DebugConsole | LogTarget.File, + FilePath = Configuration.LogFilePath + }, + FatalConfig = new() + { + LogTarget = LogTarget.DebugConsole | LogTarget.File, + FilePath = Configuration.LogFilePath + }, + FormatConfig = new() + { + DebugConsoleFormat = new LogFormatBuilder().DateTime().Text(" ").LogSeverity(padding: -6).FilePath().Text(":").MemberName().Text(":").LineNumber().Text(Environment.NewLine).Message(), + FileFormat = new LogFormatBuilder().DateTime().Text(" ").LogSeverity(padding: -6).FilePath().Text(":").MemberName().Text(":").LineNumber().Text(Environment.NewLine).Message(), + } + } + }; + public static Stack BackHistory { get; } = new(); public static Stack ForwardHistory { get; } = new(); diff --git a/src/Markdowser/Utilities/HttpPathResolver.cs b/src/Markdowser/Utilities/HttpPathResolver.cs index d959dff..0761c35 100644 --- a/src/Markdowser/Utilities/HttpPathResolver.cs +++ b/src/Markdowser/Utilities/HttpPathResolver.cs @@ -10,7 +10,6 @@ using Markdowser.Models; using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; using System.Net.Http; using System.Threading.Tasks; @@ -40,7 +39,7 @@ public class HttpPathResolver : IPathResolver relativeOrAbsolutePath = new Uri(new Uri(GlobalState.Url), relativeOrAbsolutePath).ToString(); } - Debug.WriteLine($"Resolving image: {relativeOrAbsolutePath}"); + GlobalState.Logger.LogDebug($"Resolving image: {relativeOrAbsolutePath}"); HttpResponseMessage httpResponseMessage; diff --git a/src/Markdowser/ViewModels/ContentViewModelBase.cs b/src/Markdowser/ViewModels/ContentViewModelBase.cs index 95f0edf..13e720a 100644 --- a/src/Markdowser/ViewModels/ContentViewModelBase.cs +++ b/src/Markdowser/ViewModels/ContentViewModelBase.cs @@ -1,6 +1,12 @@ -namespace Markdowser.ViewModels; +using CuteUtils.Logging; + +using Markdowser.Utilities; + +namespace Markdowser.ViewModels; public abstract class ContentViewModelBase(string title) { public string Title { get; } = title; + + public Logger Logger { get; } = GlobalState.Logger; } \ No newline at end of file diff --git a/src/Markdowser/ViewModels/MainWindowViewModel.cs b/src/Markdowser/ViewModels/MainWindowViewModel.cs index 2c70421..437a0d0 100644 --- a/src/Markdowser/ViewModels/MainWindowViewModel.cs +++ b/src/Markdowser/ViewModels/MainWindowViewModel.cs @@ -12,7 +12,6 @@ using ReactiveUI; using System; using System.Collections.ObjectModel; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Net.Http; using System.Reflection; @@ -220,7 +219,7 @@ public partial class MainWindowViewModel : ViewModelBase if (string.IsNullOrWhiteSpace(Url)) { Content = DefaultContent; - Debug.WriteLine("URL is empty."); + Logger.LogDebug("URL is empty."); CurrentTab.Header = "New Tab"; return; } @@ -257,7 +256,7 @@ public partial class MainWindowViewModel : ViewModelBase _ = Task.Run(async () => { - Debug.WriteLine("Fetching URL..."); + Logger.LogInfo($"Fetching URL: {Url}"); try { @@ -288,6 +287,8 @@ public partial class MainWindowViewModel : ViewModelBase Content = await contentProcessorManager.ProcessContent(httpResponseMessage, new Progress(p => Progress = p.Percentage)); Dispatcher.UIThread.Post(() => CurrentTab.Header = Content.Title); + + Logger.LogInfo($"Fetched URL: {Url}"); } catch (HttpRequestException e) { @@ -307,6 +308,8 @@ public partial class MainWindowViewModel : ViewModelBase _ = errorMessage.AppendLine($"Failed to fetch URL: {e.Message}"); Content = new MarkdownContentViewModel("Error", errorMessage.ToString()); + + Logger.LogError(e.Message); } catch (Exception e) { @@ -317,6 +320,8 @@ public partial class MainWindowViewModel : ViewModelBase Content = new MarkdownContentViewModel("Error", errorMessage.ToString()); Dispatcher.UIThread.Post(() => CurrentTab.Header = $"Error: {e.GetType().Name}"); + + Logger.LogError(e.Message); } finally { diff --git a/src/Markdowser/ViewModels/ViewModelBase.cs b/src/Markdowser/ViewModels/ViewModelBase.cs index 5c0baf0..87a9fd4 100644 --- a/src/Markdowser/ViewModels/ViewModelBase.cs +++ b/src/Markdowser/ViewModels/ViewModelBase.cs @@ -2,7 +2,10 @@ using Avalonia.Controls.Notifications; using Avalonia.Platform; +using CuteUtils.Logging; + using Markdowser.Models; +using Markdowser.Utilities; using ReactiveUI; @@ -19,6 +22,8 @@ public class ViewModelBase : ReactiveObject public WindowNotificationManager WindowNotificationManager => windowNotificationManager!; + public Logger Logger => GlobalState.Logger; + protected internal void InitializeWindowNotificationManager(Window window) { TopLevel? topLevel = TopLevel.GetTopLevel(window); From 281a59165ce1287440cdd1578844b98e4a2cd25d Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Tue, 30 Apr 2024 18:25:23 +0200 Subject: [PATCH 17/31] Add logging to `HttpPathResolver.cs` --- src/Markdowser/Utilities/HttpPathResolver.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Markdowser/Utilities/HttpPathResolver.cs b/src/Markdowser/Utilities/HttpPathResolver.cs index 0761c35..a121f17 100644 --- a/src/Markdowser/Utilities/HttpPathResolver.cs +++ b/src/Markdowser/Utilities/HttpPathResolver.cs @@ -79,5 +79,7 @@ public class HttpPathResolver : IPathResolver { Dispatcher.UIThread.Post(() => vm.WindowNotificationManager.Show(new Notification("Error", message, NotificationType.Error))); } + + GlobalState.Logger.LogError(message); } } \ No newline at end of file From 9e259a72157b7d5a9d1c07d470fa08414d401259 Mon Sep 17 00:00:00 2001 From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com> Date: Thu, 2 May 2024 19:44:48 +0200 Subject: [PATCH 18/31] Fix alignment issues --- src/Markdowser/Views/MainWindow.axaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Markdowser/Views/MainWindow.axaml b/src/Markdowser/Views/MainWindow.axaml index 16f77a7..a3dc6ae 100644 --- a/src/Markdowser/Views/MainWindow.axaml +++ b/src/Markdowser/Views/MainWindow.axaml @@ -45,8 +45,8 @@ - - -