diff --git a/.gitignore b/.gitignore
index 426d76d..4f9edbb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -396,3 +396,143 @@ FodyWeavers.xsd
# JetBrains Rider
*.sln.iml
+
+### Intellij ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# AWS User-specific
+.idea/**/aws.xml
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# SonarLint plugin
+.idea/sonarlint/
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+### Intellij Patch ###
+# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
+
+# *.iml
+# modules.xml
+# .idea/misc.xml
+# *.ipr
+
+# Sonarlint plugin
+# https://plugins.jetbrains.com/plugin/7973-sonarlint
+.idea/**/sonarlint/
+
+# SonarQube Plugin
+# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
+.idea/**/sonarIssues.xml
+
+# Markdown Navigator plugin
+# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
+.idea/**/markdown-navigator.xml
+.idea/**/markdown-navigator-enh.xml
+.idea/**/markdown-navigator/
+
+# Cache file creation bug
+# See https://youtrack.jetbrains.com/issue/JBR-2257
+.idea/$CACHE_FILE$
+
+# CodeStream plugin
+# https://plugins.jetbrains.com/plugin/12206-codestream
+.idea/codestream.xml
+
+# Azure Toolkit for IntelliJ plugin
+# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
+.idea/**/azureSettings.xml
+
+### Rider ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+
+# AWS User-specific
+
+# Generated files
+
+# Sensitive or high-churn files
+
+# Gradle
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
diff --git a/README.md b/README.md
index b11596d..480f4eb 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,10 @@ It uses the [ReverseMarkdown](https://github.com/mysticmind/reversemarkdown-net)
## Setup
-1. Download one of the [releases](https://github.com/Stone-Red-Code/Markdowser/releases).
-1. Execute the `Markdowser-Installer.exe` file.
+1. Download the latest release
+ - GitHub (Windows) [releases](https://github.com/Stone-Red-Code/Markdowser/releases)
+ - Chocolatey (Windows): `choco install Markdowser`
+ - Snapcraft (Linux): `snap install Markdowser`
1. Enter a URL in the address bar.
1. Enjoy!
@@ -31,3 +33,4 @@ It uses the [ReverseMarkdown](https://github.com/mysticmind/reversemarkdown-net)
- [Icons.Avalonia](https://github.com/Projektanker/Icons.Avalonia) - [MIT](https://github.com/Projektanker/Icons.Avalonia/blob/main/LICENSE)
- [Semi.Avalonia](https://github.com/irihitech/Semi.Avalonia) - [MIT](https://github.com/irihitech/Semi.Avalonia/blob/main/LICENSE)
- [ReverseMarkdown](https://github.com/mysticmind/reversemarkdown-net) - [MIT](https://github.com/mysticmind/reversemarkdown-net/blob/master/LICENSE)
+- [Syroot.Windows.IO.KnownFolders](https://gitlab.com/Syroot/KnownFolders) - [MIT](https://gitlab.com/Syroot/KnownFolders/-/blob/master/LICENSE)
diff --git a/choco/ReadMe.md b/choco/ReadMe.md
new file mode 100644
index 0000000..3c09236
--- /dev/null
+++ b/choco/ReadMe.md
@@ -0,0 +1,133 @@
+## Summary
+How do I create packages? See https://docs.chocolatey.org/en-us/create/create-packages
+
+If you are submitting packages to the community feed (https://community.chocolatey.org)
+always try to ensure you have read, understood and adhere to the create
+packages wiki link above.
+
+## Automatic Packaging Updates?
+Consider making this package an automatic package, for the best
+maintainability over time. Read up at https://docs.chocolatey.org/en-us/create/automatic-packages
+
+## Shim Generation
+Any executables you include in the package or download (but don't call
+install against using the built-in functions) will be automatically shimmed.
+
+This means those executables will automatically be included on the path.
+Shim generation runs whether the package is self-contained or uses automation
+scripts.
+
+By default, these are considered console applications.
+
+If the application is a GUI, you should create an empty file next to the exe
+named 'name.exe.gui' e.g. 'bob.exe' would need a file named 'bob.exe.gui'.
+See https://docs.chocolatey.org/en-us/create/create-packages#how-do-i-set-up-shims-for-applications-that-have-a-gui
+
+If you want to ignore the executable, create an empty file next to the exe
+named 'name.exe.ignore' e.g. 'bob.exe' would need a file named
+'bob.exe.ignore'.
+See https://docs.chocolatey.org/en-us/create/create-packages#how-do-i-exclude-executables-from-getting-shims
+
+## Self-Contained?
+If you have a self-contained package, you can remove the automation scripts
+entirely and just include the executables, they will automatically get shimmed,
+which puts them on the path. Ensure you have the legal right to distribute
+the application though. See https://docs.chocolatey.org/en-us/information/legal.
+
+You should read up on the Shim Generation section to familiarize yourself
+on what to do with GUI applications and/or ignoring shims.
+
+## Automation Scripts
+You have a powerful use of Chocolatey, as you are using PowerShell. So you
+can do just about anything you need. Choco has some very handy built-in
+functions that you can use, these are sometimes called the helpers.
+
+### Built-In Functions
+https://docs.chocolatey.org/en-us/create/functions
+
+A note about a couple:
+* Get-ToolsLocation - used to get you the 'tools' root, which by default is set to 'c:\tools', not the chocolateyInstall bin folder - see https://docs.chocolatey.org/en-us/create/functions/get-toolslocation
+* Install-BinFile - used for non-exe files - executables are automatically shimmed... - see https://docs.chocolatey.org/en-us/create/functions/install-binfile
+* Uninstall-BinFile - used for non-exe files - executables are automatically shimmed - see https://docs.chocolatey.org/en-us/create/functions/uninstall-binfile
+
+### Getting package specific information
+Use the package parameters pattern - see https://docs.chocolatey.org/en-us/guides/create/parse-packageparameters-argument
+
+### Need to mount an ISO?
+https://docs.chocolatey.org/en-us/guides/create/mount-an-iso-in-chocolatey-package
+
+### Environment Variables
+Chocolatey makes a number of environment variables available (You can access any of these with $env:TheVariableNameBelow):
+
+ * TEMP/TMP - Overridden to the CacheLocation, but may be the same as the original TEMP folder
+ * ChocolateyInstall - Top level folder where Chocolatey is installed
+ * ChocolateyPackageName - The name of the package, equivalent to the `` field in the nuspec
+ * ChocolateyPackageTitle - The title of the package, equivalent to the `
` field in the nuspec
+ * ChocolateyPackageVersion - The normalized version of the package, equivalent to a normalized edition of the `` field in the nuspec
+ * ChocolateyPackageFolder - The top level location of the package folder - the folder where Chocolatey has downloaded and extracted the NuGet package, typically `C:\ProgramData\chocolatey\lib\packageName`.
+
+#### Advanced Environment Variables
+The following are more advanced settings:
+
+ * ChocolateyPackageParameters - Parameters to use with packaging, not the same as install arguments (which are passed directly to the native installer). Based on `--package-parameters`.
+ * CHOCOLATEY_VERSION - The version of Choco you normally see. Use if you are 'lighting' things up based on choco version. Otherwise take a dependency on the specific version you need.
+ * ChocolateyForceX86 = If available and set to 'true', then user has requested 32bit version. Automatically handled in built in Choco functions.
+ * OS_PLATFORM - Like Windows, macOS, Linux.
+ * OS_VERSION - The version of OS, like 10.0 something something for Windows.
+ * OS_NAME - The reported name of the OS.
+ * USER_NAME = The user name
+ * USER_DOMAIN = The user domain name (could also be local computer name)
+ * IS_PROCESSELEVATED = Is the process elevated?
+ * IS_SYSTEM = Is the user the system account?
+ * IS_REMOTEDESKTOP = Is the user in a terminal services session?
+ * ChocolateyToolsLocation - formerly 'ChocolateyBinRoot' ('ChocolateyBinRoot' will be removed with Chocolatey v2.0.0), this is where tools being installed outside of Chocolatey packaging will go.
+
+#### Set By Options and Configuration
+Some environment variables are set based on options that are passed, configuration and/or features that are turned on:
+
+ * ChocolateyEnvironmentDebug - Was `--debug` passed? If using the built-in PowerShell host, this is always true (but only logs debug messages to console if `--debug` was passed)
+ * ChocolateyEnvironmentVerbose - Was `--verbose` passed? If using the built-in PowerShell host, this is always true (but only logs verbose messages to console if `--verbose` was passed).
+ * ChocolateyExitOnRebootDetected - Are we exiting on a detected reboot? Set by ` --exit-when-reboot-detected` or the feature `exitOnRebootDetected`
+ * ChocolateyForce - Was `--force` passed?
+ * ChocolateyForceX86 - Was `-x86` passed?
+ * ChocolateyRequestTimeout - How long before a web request will time out. Set by config `webRequestTimeoutSeconds`
+ * ChocolateyResponseTimeout - How long to wait for a download to complete? Set by config `commandExecutionTimeoutSeconds`
+ * ChocolateyPowerShellHost - Are we using the built-in PowerShell host? Set by `--use-system-powershell` or the feature `powershellHost`
+
+#### Business Edition Variables
+
+ * ChocolateyInstallArgumentsSensitive - Encrypted arguments passed from command line `--install-arguments-sensitive` that are not logged anywhere.
+ * ChocolateyPackageParametersSensitive - Package parameters passed from command line `--package-parameters-sensitive` that are not logged anywhere.
+ * ChocolateyLicensedVersion - What version is the licensed edition on?
+ * ChocolateyLicenseType - What edition / type of the licensed edition is installed?
+ * USER_CONTEXT - The original user context - different when self-service is used (Licensed)
+
+#### Experimental Environment Variables
+The following are experimental or use not recommended:
+
+ * OS_IS64BIT = This may not return correctly - it may depend on the process the app is running under
+ * CHOCOLATEY_VERSION_PRODUCT = the version of Choco that may match CHOCOLATEY_VERSION but may be different - based on git describe
+ * IS_ADMIN = Is the user an administrator? But doesn't tell you if the process is elevated.
+ * IS_REMOTE = Is the user in a remote session?
+
+#### Not Useful Or Anti-Pattern If Used
+
+ * ChocolateyInstallOverride = Not for use in package automation scripts. Based on `--override-arguments` being passed.
+ * ChocolateyInstallArguments = The installer arguments meant for the native installer. You should use chocolateyPackageParameters instead. Based on `--install-arguments` being passed.
+ * ChocolateyIgnoreChecksums - Was `--ignore-checksums` passed or the feature `checksumFiles` turned off?
+ * ChocolateyAllowEmptyChecksums - Was `--allow-empty-checksums` passed or the feature `allowEmptyChecksums` turned on?
+ * ChocolateyAllowEmptyChecksumsSecure - Was `--allow-empty-checksums-secure` passed or the feature `allowEmptyChecksumsSecure` turned on?
+ * ChocolateyChecksum32 - Was `--download-checksum` passed?
+ * ChocolateyChecksumType32 - Was `--download-checksum-type` passed?
+ * ChocolateyChecksum64 - Was `--download-checksum-x64` passed?
+ * ChocolateyChecksumType64 - Was `--download-checksum-type-x64` passed?
+ * ChocolateyPackageExitCode - The exit code of the script that just ran - usually set by `Set-PowerShellExitCode`
+ * ChocolateyLastPathUpdate - Set by Chocolatey as part of install, but not used for anything in particular in packaging.
+ * ChocolateyProxyLocation - The explicit proxy location as set in the configuration `proxy`
+ * ChocolateyDownloadCache - Use available download cache? Set by `--skip-download-cache`, `--use-download-cache`, or feature `downloadCache`
+ * ChocolateyProxyBypassList - Explicitly set locations to ignore in configuration `proxyBypassList`
+ * ChocolateyProxyBypassOnLocal - Should the proxy bypass on local connections? Set based on configuration `proxyBypassOnLocal`
+ * http_proxy - Set by original `http_proxy` passthrough, or same as `ChocolateyProxyLocation` if explicitly set.
+ * https_proxy - Set by original `https_proxy` passthrough, or same as `ChocolateyProxyLocation` if explicitly set.
+ * no_proxy- Set by original `no_proxy` passthrough, or same as `ChocolateyProxyBypassList` if explicitly set.
+
diff --git a/choco/markdowser.nuspec b/choco/markdowser.nuspec
new file mode 100644
index 0000000..41672a0
--- /dev/null
+++ b/choco/markdowser.nuspec
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Markdowser
+
+
+
+ 1.0.0.0
+
+
+
+
+
+
+
+ Markdowser
+ Stone_Red
+
+ https://github.com/Stone-Red-Code/Markdowser
+
+
+
+
+
+ https://github.com/Stone-Red-Code/Markdowser/blob/main/LICENSE
+ true
+ https://github.com/Stone-Red-Code/Markdowser
+
+
+
+ Markdowser arp
+ A lightweight cross-platform IP scanner
+
+ **Setup**
+
+ 1. Download the latest release
+ - GitHub (Windows): https://github.com/Stone-Red-Code/Markdowser/releases
+ - Chocolatey (Windows): `choco install Markdowser`
+ - Snapcraft (Linux): `snap install Markdowser`
+ 2. Enter a URL in the address bar.
+ 3. Enjoy!
+
+ **Third party licenses**
+
+ - Avalonia (https://github.com/AvaloniaUI/Avalonia) - MIT (https://github.com/AvaloniaUI/Avalonia/blob/master/licence.md)
+ - Icons.Avalonia (https://github.com/Projektanker/Icons.Avalonia) - MIT (https://github.com/Projektanker/Icons.Avalonia/blob/main/LICENSE)
+ - Semi.Avalonia (https://github.com/irihitech/Semi.Avalonia) - MIT (https://github.com/irihitech/Semi.Avalonia/blob/main/LICENSE)
+ - ReverseMarkdown (https://github.com/mysticmind/reversemarkdown-net) - MIT (https://github.com/mysticmind/reversemarkdown-net/blob/master/LICENSE)
+ - Syroot.Windows.IO.KnownFolders (https://gitlab.com/Syroot/KnownFolders) - MIT (https://gitlab.com/Syroot/KnownFolders/-/blob/master/LICENSE)
+
+ https://github.com/Stone-Red-Code/Markdowser/releases/latest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/choco/tools/LICENSE.txt b/choco/tools/LICENSE.txt
new file mode 100644
index 0000000..348bd04
--- /dev/null
+++ b/choco/tools/LICENSE.txt
@@ -0,0 +1,23 @@
+From: https://github.com/Stone-Red-Code/Markdowser
+
+MIT License
+
+Copyright (c) 2024 Stone_Red
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/choco/tools/VERIFICATION.txt b/choco/tools/VERIFICATION.txt
new file mode 100644
index 0000000..cc6af99
--- /dev/null
+++ b/choco/tools/VERIFICATION.txt
@@ -0,0 +1,8 @@
+VERIFICATION
+Verification is intended to assist the Chocolatey moderators and community
+in verifying that this package's contents are trustworthy.
+
+Source Code: https://github.com/Stone-Red-Code/Markdowser
+Releases: https://github.com/Stone-Red-Code/Markdowser/releases
+Checksum: 5f956d038b46d035ef3b5e37a34ec865b6ababa4ec4b00fbba2fab60e9c8bb6583d70b5308a21e7643a28cae624158a27fd3a04e2043c555277a416529a2c06d
+Veify using: certutil -hashfile C:\ProgramData\Chocolatey\lib\markdowser\Markdowser.exe sha512
\ No newline at end of file
diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml
new file mode 100644
index 0000000..00cb8f1
--- /dev/null
+++ b/snap/snapcraft.yaml
@@ -0,0 +1,45 @@
+name: markdowser # you probably want to 'snapcraft register '
+version: "1.0.0.0" # just for humans, typically '1.2+git' or '1.3.2'
+grade: stable # must be 'stable' to release into candidate/stable channels
+summary: A lightweight cross-platform IP scanner # 79 char long summary
+description: |
+ **Setup**
+
+ 1. Download the latest release
+ - GitHub (Windows): https://github.com/Stone-Red-Code/Markdowser/releases
+ - Chocolatey (Windows): `choco install Markdowser`
+ - Snapcraft (Linux): `snap install Markdowser`
+ 2. Enter a URL in the address bar.
+ 3. Enjoy!
+
+ **Third party licenses**
+
+ - Avalonia (https://github.com/AvaloniaUI/Avalonia) - MIT (https://github.com/AvaloniaUI/Avalonia/blob/master/licence.md)
+ - Icons.Avalonia (https://github.com/Projektanker/Icons.Avalonia) - MIT (https://github.com/Projektanker/Icons.Avalonia/blob/main/LICENSE)
+ - Semi.Avalonia (https://github.com/irihitech/Semi.Avalonia) - MIT (https://github.com/irihitech/Semi.Avalonia/blob/main/LICENSE)
+ - ReverseMarkdown (https://github.com/mysticmind/reversemarkdown-net) - MIT (https://github.com/mysticmind/reversemarkdown-net/blob/master/LICENSE)
+ - Syroot.Windows.IO.KnownFolders (https://gitlab.com/Syroot/KnownFolders) - MIT (https://gitlab.com/Syroot/KnownFolders/-/blob/master/LICENSE)
+
+base: core22 # the base snap is the execution environment for this snap
+
+architectures:
+ - build-on: amd64
+ - build-on: arm64
+
+confinement: strict # use 'strict' once you have the right plugs and slots
+
+parts:
+ markdowser:
+ # See 'snapcraft plugins'
+ plugin: dotnet
+ dotnet-build-configuration: Release
+ dotnet-self-contained-runtime-identifier: linux-x64
+ source: ./src/Markdowser
+ build-packages:
+ - dotnet-sdk-8.0
+ stage-packages:
+ - libicu70
+
+apps:
+ markdowser:
+ command: markdowser
diff --git a/src/.idea/.idea.Markdowser/.idea/.name b/src/.idea/.idea.Markdowser/.idea/.name
new file mode 100644
index 0000000..3442041
--- /dev/null
+++ b/src/.idea/.idea.Markdowser/.idea/.name
@@ -0,0 +1 @@
+Markdowser
\ No newline at end of file
diff --git a/src/.idea/.idea.Markdowser/.idea/indexLayout.xml b/src/.idea/.idea.Markdowser/.idea/indexLayout.xml
new file mode 100644
index 0000000..7b08163
--- /dev/null
+++ b/src/.idea/.idea.Markdowser/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/.idea/.idea.Markdowser/.idea/projectSettingsUpdater.xml b/src/.idea/.idea.Markdowser/.idea/projectSettingsUpdater.xml
new file mode 100644
index 0000000..4bb9f4d
--- /dev/null
+++ b/src/.idea/.idea.Markdowser/.idea/projectSettingsUpdater.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/.idea/.idea.Markdowser/.idea/vcs.xml b/src/.idea/.idea.Markdowser/.idea/vcs.xml
new file mode 100644
index 0000000..6c0b863
--- /dev/null
+++ b/src/.idea/.idea.Markdowser/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/.idea/.idea.Markdowser/.idea/workspace.xml b/src/.idea/.idea.Markdowser/.idea/workspace.xml
new file mode 100644
index 0000000..760e254
--- /dev/null
+++ b/src/.idea/.idea.Markdowser/.idea/workspace.xml
@@ -0,0 +1,80 @@
+
+
+
+ Markdowser/Markdowser.csproj
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Markdowser/App.axaml.cs b/src/Markdowser/App.axaml.cs
index 1249b3b..9a29aca 100644
--- a/src/Markdowser/App.axaml.cs
+++ b/src/Markdowser/App.axaml.cs
@@ -3,10 +3,15 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Styling;
+using CuteUtils.Logging;
+
using Markdowser.Models;
+using Markdowser.Utilities;
using Markdowser.ViewModels;
using Markdowser.Views;
+using System.IO;
+
namespace Markdowser;
public partial class App : Application
@@ -18,6 +23,11 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
+ if (!Directory.Exists(Configuration.ApplicationDataPath))
+ {
+ _ = Directory.CreateDirectory(Configuration.ApplicationDataPath);
+ }
+
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
@@ -25,12 +35,23 @@ public partial class App : Application
DataContext = new MainWindowViewModel(),
};
+ // Clear log files
+ GlobalState.Logger.ClearLogFile(LogSeverity.Debug);
+ GlobalState.Logger.ClearLogFile(LogSeverity.Info);
+ GlobalState.Logger.ClearLogFile(LogSeverity.Warn);
+ GlobalState.Logger.ClearLogFile(LogSeverity.Error);
+ GlobalState.Logger.ClearLogFile(LogSeverity.Fatal);
+
desktop.MainWindow.Closing += (sender, e) =>
{
+ GlobalState.Logger.LogInfo("Saving settings...");
Settings.SaveSettings();
+ GlobalState.Logger.LogInfo("Settings saved.");
};
}
+ GlobalState.Logger.LogInfo("Application started.");
+
base.OnFrameworkInitializationCompleted();
if (Current is not null)
diff --git a/src/Markdowser/Commands/ChangeThemeCommand.cs b/src/Markdowser/Commands/ChangeThemeCommand.cs
index 074c100..fcc1ef2 100644
--- a/src/Markdowser/Commands/ChangeThemeCommand.cs
+++ b/src/Markdowser/Commands/ChangeThemeCommand.cs
@@ -30,7 +30,7 @@ internal class ChangeThemeCommand : ICommand
ThemeVariant theme = app.ActualThemeVariant;
app.RequestedThemeVariant = theme == ThemeVariant.Dark ? ThemeVariant.Light : ThemeVariant.Dark;
Settings.Current.DarkMode = app.RequestedThemeVariant == ThemeVariant.Dark;
- GlobalState.ReloadContent(this);
+ GlobalState.InvokeThemeChanged();
}
}
}
\ No newline at end of file
diff --git a/src/Markdowser/Commands/HomeCommand.cs b/src/Markdowser/Commands/HomeCommand.cs
index f58d239..9d083f0 100644
--- a/src/Markdowser/Commands/HomeCommand.cs
+++ b/src/Markdowser/Commands/HomeCommand.cs
@@ -21,6 +21,6 @@ internal class HomeCommand : ICommand
public void Execute(object? parameter)
{
- GlobalState.Url = Settings.Current.HomeUrl ?? string.Empty;
+ GlobalState.CurrentTabState.Url = Settings.Current.HomeUrl ?? string.Empty;
}
}
\ No newline at end of file
diff --git a/src/Markdowser/Commands/HyperlinkCommand.cs b/src/Markdowser/Commands/HyperlinkCommand.cs
index 6c97b5e..c6e95a0 100644
--- a/src/Markdowser/Commands/HyperlinkCommand.cs
+++ b/src/Markdowser/Commands/HyperlinkCommand.cs
@@ -2,6 +2,7 @@
using System;
using System.Diagnostics;
+using System.IO;
using System.Windows.Input;
namespace Markdowser.Commands;
@@ -23,19 +24,35 @@ public class HyperlinkCommand : ICommand
{
if (parameter is string url)
{
- Debug.WriteLine($"Hyperlink clicked: {url}");
+ GlobalState.Logger.LogDebug($"Hyperlink clicked: {url}");
if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
{
- GlobalState.Url = url;
+ GlobalState.CurrentTabState.Url = url;
}
else if (Uri.IsWellFormedUriString(url, UriKind.Relative) || url.StartsWith('/'))
{
- GlobalState.Url = new Uri(new Uri(GlobalState.Url), url).ToString();
+ GlobalState.CurrentTabState.Url = new Uri(new Uri(GlobalState.CurrentTabState.Url), url).ToString();
+ }
+ else if (File.Exists(url))
+ {
+ ProcessStartInfo processStartInfo = new()
+ {
+ FileName = url,
+ UseShellExecute = true
+ };
+ try
+ {
+ _ = Process.Start(processStartInfo);
+ }
+ catch (Exception ex)
+ {
+ GlobalState.Logger.LogError(ex.Message);
+ }
}
else
{
- Debug.WriteLine($"Invalid URL: {url}");
+ GlobalState.Logger.LogDebug($"Invalid URL: {url}");
}
}
}
diff --git a/src/Markdowser/Markdowser.csproj b/src/Markdowser/Markdowser.csproj
index 77074ed..e3b8058 100644
--- a/src/Markdowser/Markdowser.csproj
+++ b/src/Markdowser/Markdowser.csproj
@@ -7,6 +7,7 @@
app.manifesttrue0.1.0.0
+ false
@@ -30,11 +31,22 @@
+
+
+
+
+
+
+ CommonImageContentView.axaml
+
+
+ MarkdownContentView.axaml
+
diff --git a/src/Markdowser/Models/Settings.cs b/src/Markdowser/Models/Settings.cs
index 091854b..0dcb68e 100644
--- a/src/Markdowser/Models/Settings.cs
+++ b/src/Markdowser/Models/Settings.cs
@@ -1,6 +1,8 @@
using Markdowser.Utilities;
+using System;
using System.IO;
+using System.Reflection;
using System.Text.Json;
namespace Markdowser.Models;
@@ -11,8 +13,12 @@ public class Settings
public bool DarkMode { get; set; }
+ public string SearchEngineUrl { get; set; } = "https://html.duckduckgo.com/html/?kd=-1&k1=-1&q={0}";
+
public string? HomeUrl { get; set; }
+ public string UserAgent { get; set; } = $"{Assembly.GetExecutingAssembly().GetName().Name}/{Assembly.GetExecutingAssembly().GetName().Version?.ToString()}";
+
public static void SaveSettings()
{
if (!Directory.Exists(Configuration.ApplicationDataPath))
@@ -27,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/Models/TabState.cs b/src/Markdowser/Models/TabState.cs
new file mode 100644
index 0000000..c7ff6ae
--- /dev/null
+++ b/src/Markdowser/Models/TabState.cs
@@ -0,0 +1,41 @@
+using Markdowser.ViewModels;
+
+using System;
+using System.Collections.Generic;
+
+namespace Markdowser.Models;
+
+public class TabState
+{
+ public event EventHandler? UrlChanged;
+
+ private string url = string.Empty;
+
+ public Stack BackHistory { get; } = new();
+
+ public Stack ForwardHistory { get; } = new();
+
+ public string Url
+ {
+ get => url;
+ set
+ {
+ if (url != value)
+ {
+ BackHistory.Push(url);
+ ForwardHistory.Clear();
+ }
+
+ url = value;
+ UrlChanged?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ public ContentViewModelBase? Content { get; set; }
+
+ public void SetUrl(string url, object? sender = null)
+ {
+ this.url = url;
+ UrlChanged?.Invoke(sender ?? this, EventArgs.Empty);
+ }
+}
\ No newline at end of file
diff --git a/src/Markdowser/Processing/ContentProcessorManager.cs b/src/Markdowser/Processing/ContentProcessorManager.cs
new file mode 100644
index 0000000..ead7617
--- /dev/null
+++ b/src/Markdowser/Processing/ContentProcessorManager.cs
@@ -0,0 +1,24 @@
+using Markdowser.ViewModels;
+
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+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(HttpResponseMessage httpResponseMessage, IProgress 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
new file mode 100644
index 0000000..cd8e4b8
--- /dev/null
+++ b/src/Markdowser/Processing/IContentProcessor.cs
@@ -0,0 +1,19 @@
+using Markdowser.ViewModels;
+
+using System;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading.Tasks;
+
+namespace Markdowser.Processing;
+
+internal interface IContentProcessor
+{
+ string Name { get; }
+
+ string Description { get; }
+
+ bool CanProcess(HttpContentHeaders httpContentHeaders);
+
+ 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
new file mode 100644
index 0000000..029d6f6
--- /dev/null
+++ b/src/Markdowser/Processing/ProcessingProgress.cs
@@ -0,0 +1,12 @@
+namespace Markdowser.Processing;
+
+public readonly struct ProcessingProgress(long current, long total, string? message = null, bool isBytes = false)
+{
+ public long Current { get; } = current;
+ public long Total { get; } = total;
+ public string? Message { get; } = message;
+
+ public bool IsBytes { get; } = isBytes;
+
+ public int Percentage => (int)((double)Current / Total * 100);
+}
\ No newline at end of file
diff --git a/src/Markdowser/Processing/Processors/CommonImageProcessor.cs b/src/Markdowser/Processing/Processors/CommonImageProcessor.cs
new file mode 100644
index 0000000..50e2522
--- /dev/null
+++ b/src/Markdowser/Processing/Processors/CommonImageProcessor.cs
@@ -0,0 +1,31 @@
+using Markdowser.ViewModels;
+using Markdowser.ViewModels.Content;
+
+using System;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading.Tasks;
+
+namespace Markdowser.Processing.Processors;
+
+internal class CommonImageProcessor : IContentProcessor
+{
+ public string Name => "Common Image Processor";
+
+ public string Description => "Processes common image types (e.g. PNG, JPEG, GIF)";
+
+ public bool CanProcess(HttpContentHeaders httpContentHeaders)
+ {
+ if (httpContentHeaders.ContentType?.MediaType == "image/svg+xml")
+ {
+ return false;
+ }
+
+ return httpContentHeaders.ContentType?.MediaType?.StartsWith("image/") == true;
+ }
+
+ public async Task Process(HttpResponseMessage httpResponseMessage, IProgress progress)
+ {
+ return new CommonImageContentViewModel(httpResponseMessage.RequestMessage?.RequestUri?.Host?.ToString() ?? "Image", await httpResponseMessage.Content.ReadAsStreamAsync());
+ }
+}
\ No newline at end of file
diff --git a/src/Markdowser/Processing/Processors/DownloadProcessor.cs b/src/Markdowser/Processing/Processors/DownloadProcessor.cs
new file mode 100644
index 0000000..7973e72
--- /dev/null
+++ b/src/Markdowser/Processing/Processors/DownloadProcessor.cs
@@ -0,0 +1,88 @@
+using CuteUtils;
+
+using Markdowser.Utilities;
+using Markdowser.ViewModels;
+using Markdowser.ViewModels.Content;
+
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading.Tasks;
+
+namespace Markdowser.Processing.Processors;
+
+internal class DownloadProcessor : IContentProcessor
+{
+ public string Name => "Download Processor";
+
+ public string Description => "Processes downloadable content";
+
+ public bool CanProcess(HttpContentHeaders httpContentHeaders)
+ {
+ bool canProcess = httpContentHeaders.ContentDisposition?.FileName is not null;
+
+ // Check for common media types that are downloadable
+ if (!canProcess)
+ {
+ canProcess = httpContentHeaders.ContentType?.MediaType switch
+ {
+ "application/octet-stream" => true,
+ "application/force-download" => true,
+ "application/zip" => true,
+ "application/x-zip-compressed" => true,
+ "application/x-zip" => true,
+ "application/x-compressed" => true,
+ "multipart/x-zip" => true,
+ _ => false
+ };
+ }
+
+ return canProcess;
+ }
+
+ public async Task Process(HttpResponseMessage httpResponseMessage, IProgress progress)
+ {
+ string? fileName = httpResponseMessage.Content.Headers.ContentDisposition?.FileName;
+ long contentLength = httpResponseMessage.Content.Headers.ContentLength ?? 0;
+
+ fileName ??= httpResponseMessage.RequestMessage?.RequestUri?.Segments[^1].ToFileName();
+
+ if (string.IsNullOrWhiteSpace(fileName))
+ {
+ fileName = "download.bin";
+ }
+
+ string tempFileName = fileName + ".tmp";
+
+ GlobalState.Logger.LogDebug($"Downloading {fileName}...");
+
+ using Stream contentStream = await httpResponseMessage.Content.ReadAsStreamAsync();
+ using FileStream fileStream = new(Path.Combine(Configuration.DownloadPath, tempFileName), FileMode.Create, FileAccess.Write);
+
+ while (true)
+ {
+ byte[] buffer = new byte[81920];
+ int bytesRead = await contentStream.ReadAsync(buffer);
+
+ if (bytesRead == 0)
+ {
+ break;
+ }
+
+ await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead));
+
+ progress.Report(new ProcessingProgress(fileStream.Position, contentLength, message: $"Downloading \"{fileName}\"", true));
+ }
+
+ fileStream.Close();
+
+ File.Move(Path.Combine(Configuration.DownloadPath, tempFileName), Path.Combine(Configuration.DownloadPath, fileName), overwrite: true);
+
+ string content = $"# Downloaded File\n\nDownloaded file: [{fileName}]({Path.Combine(Configuration.DownloadPath, fileName)}) to {Configuration.DownloadPath}";
+ return new MarkdownContentViewModel(fileName, content)
+ {
+ Cacheable = false
+ };
+ }
+}
\ 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..a74b830
--- /dev/null
+++ b/src/Markdowser/Processing/Processors/HtmlProcessor.cs
@@ -0,0 +1,101 @@
+using HtmlAgilityPack;
+
+using Markdowser.Utilities;
+using Markdowser.ViewModels;
+using Markdowser.ViewModels.Content;
+
+using System;
+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 string Name => "HTML Processor";
+
+ public string Description => "Processes HTML content";
+
+ 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(Encoding.Default.GetByteCount(html.ToString()), length, "Downloading HTML...", true));
+ }
+
+ HtmlDocument htmlDoc = new HtmlDocument();
+ htmlDoc.LoadHtml(html.ToString());
+ string title = htmlDoc.DocumentNode.SelectSingleNode("html/head/title")?.InnerText?.Trim() ?? httpResponseMessage.RequestMessage?.RequestUri?.ToString() ?? "Untitled";
+
+ GlobalState.Logger.LogDebug("Converting HTML to markdown...");
+
+ string markdown = markdownConverter.Convert(html.ToString());
+
+ GlobalState.Logger.LogDebug("Processing markdown...");
+
+ int currentLine = 0;
+
+ string[] lines = markdown.Split('\n');
+
+ foreach (string line in lines)
+ {
+ progress.Report(new ProcessingProgress(currentLine, lines.Length, "Processing markdown..."));
+ 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())
+ {
+ RawContent = html.ToString()
+ };
+ }
+
+ [GeneratedRegex("!\\[(.*?)\\]\\((.*?)\\)")]
+ private static partial Regex MarkdownImage();
+}
\ No newline at end of file
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/BytesToString.cs b/src/Markdowser/Utilities/BytesToString.cs
new file mode 100644
index 0000000..4816ce3
--- /dev/null
+++ b/src/Markdowser/Utilities/BytesToString.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Globalization;
+
+namespace Markdowser.Utilities;
+
+internal static class BytesToString
+{
+ public static string Convert(long value)
+ {
+ string suffix;
+ double readable;
+ switch (Math.Abs(value))
+ {
+ case >= 0x1000000000000000:
+ suffix = "EiB";
+ readable = value >> 50;
+ break;
+
+ case >= 0x4000000000000:
+ suffix = "PiB";
+ readable = value >> 40;
+ break;
+
+ case >= 0x10000000000:
+ suffix = "TiB";
+ readable = value >> 30;
+ break;
+
+ case >= 0x40000000:
+ suffix = "GiB";
+ readable = value >> 20;
+ break;
+
+ case >= 0x100000:
+ suffix = "MiB";
+ readable = value >> 10;
+ break;
+
+ case >= 0x400:
+ suffix = "KiB";
+ readable = value;
+ break;
+
+ default:
+ return value.ToString("0 B");
+ }
+
+ return (readable / 1024).ToString("0.00 ", CultureInfo.InvariantCulture) + suffix;
+ }
+}
\ No newline at end of file
diff --git a/src/Markdowser/Utilities/CacheService.cs b/src/Markdowser/Utilities/CacheService.cs
new file mode 100644
index 0000000..69f255d
--- /dev/null
+++ b/src/Markdowser/Utilities/CacheService.cs
@@ -0,0 +1,27 @@
+using Markdowser.ViewModels;
+
+using System.Collections.Generic;
+
+namespace Markdowser.Utilities;
+
+public class CacheService
+{
+ private readonly Dictionary cache = [];
+
+ public ContentViewModelBase? Get(string url)
+ {
+ if (cache.TryGetValue(url, out ContentViewModelBase? value))
+ {
+ return value;
+ }
+ else
+ {
+ return null;
+ }
+ }
+
+ public void Set(string url, ContentViewModelBase content)
+ {
+ cache[url] = content;
+ }
+}
\ 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/Utilities/Configuration.cs b/src/Markdowser/Utilities/Configuration.cs
index f859e02..6975467 100644
--- a/src/Markdowser/Utilities/Configuration.cs
+++ b/src/Markdowser/Utilities/Configuration.cs
@@ -1,12 +1,39 @@
-using System;
+using Syroot.Windows.IO;
+
+using System;
using System.IO;
+using System.Runtime.InteropServices;
namespace Markdowser.Utilities;
internal static class Configuration
{
- public static string DownloadPath => Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
- public static string ApplicationDataPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), nameof(Markdowser));
+ public static string DownloadPath
+ {
+ get
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return KnownFolders.Downloads.Path;
+ }
+
+ return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Downloads");
+ }
+ }
+
+ public static string ApplicationDataPath
+ {
+ get
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "StoneRed", nameof(Markdowser));
+ }
+
+ return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StoneRed", nameof(Markdowser));
+ }
+ }
public static string SettingsFilePath => Path.Combine(ApplicationDataPath, "settings.json");
+ public static string LogFilePath => Path.Combine(ApplicationDataPath, $"{nameof(Markdowser)}.log");
}
\ No newline at end of file
diff --git a/src/Markdowser/Utilities/GlobalState.cs b/src/Markdowser/Utilities/GlobalState.cs
index 856fb75..a6acb90 100644
--- a/src/Markdowser/Utilities/GlobalState.cs
+++ b/src/Markdowser/Utilities/GlobalState.cs
@@ -1,51 +1,55 @@
-using Avalonia.Controls;
+using CuteUtils.Logging;
+
+using Markdowser.Models;
using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Diagnostics.CodeAnalysis;
namespace Markdowser.Utilities;
-[SuppressMessage("Major Code Smell", "S4220:Events should have proper arguments", Justification = "")]
internal static class GlobalState
{
- public static event EventHandler? UrlChanged;
+ public static event EventHandler? ThemeChanged;
- public static event EventHandler? ContentReload;
+ public static TabState CurrentTabState { get; internal set; } = new();
- private static string url = string.Empty;
-
- public static Stack BackHistory { get; } = new();
-
- public static Stack ForwardHistory { get; } = new();
-
- public static string Url
+ public static Logger Logger { get; } = new()
{
- get => url;
- set
+ Config = new()
{
- if (url != value)
+ DebugConfig = new()
{
- BackHistory.Push(url);
- ForwardHistory.Clear();
+ 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(),
}
-
- url = value;
- UrlChanged?.Invoke(null, url);
}
- }
+ };
- public static ObservableCollection Tabs { get; internal set; } = [new TabItem() { Header = "New Tab" }];
-
- internal static void SetUrl(object sender, string url)
+ public static void InvokeThemeChanged()
{
- GlobalState.url = url;
- UrlChanged?.Invoke(sender, url);
- }
-
- internal static void ReloadContent(object sender)
- {
- ContentReload?.Invoke(sender, EventArgs.Empty);
+ ThemeChanged?.Invoke(null, EventArgs.Empty);
}
}
\ No newline at end of file
diff --git a/src/Markdowser/Utilities/HttpPathResolver.cs b/src/Markdowser/Utilities/HttpPathResolver.cs
index f83a29c..cdc2c4b 100644
--- a/src/Markdowser/Utilities/HttpPathResolver.cs
+++ b/src/Markdowser/Utilities/HttpPathResolver.cs
@@ -1,10 +1,15 @@
-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;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
@@ -31,15 +36,27 @@ public class HttpPathResolver : IPathResolver
if (!Uri.IsWellFormedUriString(relativeOrAbsolutePath, UriKind.Absolute))
{
- relativeOrAbsolutePath = new Uri(new Uri(GlobalState.Url), relativeOrAbsolutePath).ToString();
+ relativeOrAbsolutePath = new Uri(new Uri(GlobalState.CurrentTabState.Url), relativeOrAbsolutePath).ToString();
}
- Debug.WriteLine($"Resolving image: {relativeOrAbsolutePath}");
+ GlobalState.Logger.LogDebug($"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 +72,14 @@ 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)));
+ }
+
+ GlobalState.Logger.LogError(message);
+ }
}
\ 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/Content/CommonImageContentViewModel.cs b/src/Markdowser/ViewModels/Content/CommonImageContentViewModel.cs
new file mode 100644
index 0000000..e6573c8
--- /dev/null
+++ b/src/Markdowser/ViewModels/Content/CommonImageContentViewModel.cs
@@ -0,0 +1,9 @@
+using Avalonia.Media.Imaging;
+
+using System.IO;
+
+namespace Markdowser.ViewModels.Content;
+internal class CommonImageContentViewModel(string title, Stream stream) : ContentViewModelBase(title)
+{
+ public Bitmap Image { get; set; } = Bitmap.DecodeToHeight(stream, 1080);
+}
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..f9ce93d
--- /dev/null
+++ b/src/Markdowser/ViewModels/ContentViewModelBase.cs
@@ -0,0 +1,16 @@
+using CuteUtils.Logging;
+
+using Markdowser.Utilities;
+
+namespace Markdowser.ViewModels;
+
+public abstract class ContentViewModelBase(string title)
+{
+ public string Title { get; } = title;
+
+ public string RawContent { get; init; } = string.Empty;
+
+ public bool Cacheable { get; init; } = true;
+
+ public Logger Logger => GlobalState.Logger;
+}
\ No newline at end of file
diff --git a/src/Markdowser/ViewModels/MainWindowViewModel.cs b/src/Markdowser/ViewModels/MainWindowViewModel.cs
index 2488d4a..bce5fe2 100644
--- a/src/Markdowser/ViewModels/MainWindowViewModel.cs
+++ b/src/Markdowser/ViewModels/MainWindowViewModel.cs
@@ -1,23 +1,21 @@
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;
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,45 +24,85 @@ 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 CacheService cacheService = new CacheService();
- private readonly StringBuilder html = new();
- private StringBuilder? content;
private TabItem currentTab = null!;
+ private WindowState windowState;
private bool showSidePanel;
private bool isBusy;
- private int progress;
- public ObservableCollection Tabs => GlobalState.Tabs;
- public string Title => $"{nameof(Markdowser)} - {Assembly.GetExecutingAssembly().GetName().Version?.ToString()}";
+ private double progress;
+ private double progressMax;
+ private string progressText;
+ public ObservableCollection Tabs { get; } = [new TabItem() { Header = "New Tab" }];
public TabItem CurrentTab
{
get => currentTab;
set
{
- if (currentTab is not null)
- {
- currentTab.Tag = Url;
- }
+ CurrentTabState.UrlChanged -= UrlChanged;
_ = this.RaiseAndSetIfChanged(ref currentTab!, value);
+ this.RaisePropertyChanged(nameof(CurrentTabState));
+ this.RaisePropertyChanged(nameof(Url));
+ this.RaisePropertyChanged(nameof(BackEnabled));
+ this.RaisePropertyChanged(nameof(ForwardEnabled));
- Url = currentTab?.Tag?.ToString() ?? string.Empty;
+ GlobalState.CurrentTabState = CurrentTabState;
+ CurrentTabState.UrlChanged += UrlChanged;
- FetchUrl();
+ if (CurrentTabState.Content is null)
+ {
+ FetchUrl(true);
+ }
+ else
+ {
+ Content = CurrentTabState.Content;
+ }
}
}
- public StringBuilder Content
+ public TabState CurrentTabState
{
- get => content ?? DefaultContent;
- set => this.RaiseAndSetIfChanged(ref content, value);
+ get
+ {
+ if (CurrentTab is null)
+ {
+ return new();
+ }
+
+ return Dispatcher.UIThread.Invoke(() =>
+ {
+ CurrentTab.Tag ??= new TabState();
+ return (TabState)CurrentTab.Tag;
+ });
+ }
}
+ public WindowState WindowState
+ {
+ get => windowState;
+ set => this.RaiseAndSetIfChanged(ref windowState, value);
+ }
+
+ public ContentViewModelBase Content
+ {
+ get => CurrentTabState.Content!;
+ set
+ {
+ CurrentTabState.Content = value;
+ this.RaisePropertyChanged();
+ this.RaisePropertyChanged(nameof(RawContent));
+ }
+ }
+
+ public string RawContent => string.IsNullOrWhiteSpace(Content.RawContent) ? "No raw content." : Content.RawContent;
+
public string Url
{
- get => GlobalState.Url;
- set => GlobalState.SetUrl(this, value);
+ get => CurrentTabState.Url;
+ set => CurrentTabState.SetUrl(value, this);
}
public bool ShowSidePanel
@@ -79,7 +117,7 @@ public partial class MainWindowViewModel : ViewModelBase
set => this.RaiseAndSetIfChanged(ref isBusy, value);
}
- public int Progress
+ public double Progress
{
get => progress;
set
@@ -89,21 +127,32 @@ public partial class MainWindowViewModel : ViewModelBase
}
}
+ public double ProgressMax
+ {
+ get => progressMax;
+ set => _ = this.RaiseAndSetIfChanged(ref progressMax, value);
+ }
+
+ public string ProgressText
+ {
+ get => progressText;
+ set => this.RaiseAndSetIfChanged(ref progressText, value);
+ }
+
public SettingsViewModel SettingsViewModel => new SettingsViewModel();
- public RawHtmlViewModel RawHtmlViewModel => new RawHtmlViewModel(html);
- public RawMarkdownViewModel RawMarkdownViewModel => new RawMarkdownViewModel(() => Content);
public bool CloseTabEnabled => Tabs.Count > 1;
- public bool BackEnabled => GlobalState.BackHistory.Count > 0;
- public bool ForwardEnabled => GlobalState.ForwardHistory.Count > 0;
+ public bool BackEnabled => CurrentTabState.BackHistory.Count > 0;
+ public bool ForwardEnabled => CurrentTabState.ForwardHistory.Count > 0;
public bool ProgressIndeterminate => Progress == 0;
- public ICommand Browse => ReactiveCommand.Create(FetchUrl);
+ public ICommand Browse => ReactiveCommand.Create(() => FetchUrl(true));
+ public ICommand Reload => ReactiveCommand.Create(() => FetchUrl(false));
public ICommand Back => ReactiveCommand.Create(() =>
{
- if (GlobalState.BackHistory.Count > 0)
+ if (CurrentTabState.BackHistory.Count > 0)
{
- GlobalState.ForwardHistory.Push(GlobalState.Url);
- Url = GlobalState.BackHistory.Pop();
+ CurrentTabState.ForwardHistory.Push(CurrentTabState.Url);
+ Url = CurrentTabState.BackHistory.Pop();
FetchUrl();
this.RaisePropertyChanged(nameof(BackEnabled));
@@ -113,10 +162,10 @@ public partial class MainWindowViewModel : ViewModelBase
public ICommand Forward => ReactiveCommand.Create(() =>
{
- if (GlobalState.ForwardHistory.Count > 0)
+ if (CurrentTabState.ForwardHistory.Count > 0)
{
- GlobalState.BackHistory.Push(GlobalState.Url);
- Url = GlobalState.ForwardHistory.Pop();
+ CurrentTabState.BackHistory.Push(CurrentTabState.Url);
+ Url = CurrentTabState.ForwardHistory.Pop();
FetchUrl();
this.RaisePropertyChanged(nameof(ForwardEnabled));
@@ -128,57 +177,60 @@ public partial class MainWindowViewModel : ViewModelBase
public ICommand CloseTab => ReactiveCommand.Create(() =>
{
+ if (IsBusy)
+ {
+ WindowNotificationManager.Show(new Notification("Busy", "The browser is currently busy.", NotificationType.Warning));
+ return;
+ }
+
if (Tabs.Count > 1)
{
int currentIndex = Tabs.IndexOf(CurrentTab);
- _ = Tabs.Remove(CurrentTab);
CurrentTab = currentIndex > 0 ? Tabs[currentIndex - 1] : Tabs[0];
+
+ Tabs.RemoveAt(currentIndex);
+
this.RaisePropertyChanged(nameof(CloseTabEnabled));
}
});
public ICommand NewTab => ReactiveCommand.Create(() =>
{
+ if (IsBusy)
+ {
+ WindowNotificationManager.Show(new Notification("Busy", "The browser is currently busy.", NotificationType.Warning));
+ return;
+ }
+
TabItem tab = new() { Header = "New Tab", Name = Guid.NewGuid().ToString() };
Tabs.Add(tab);
CurrentTab = tab;
this.RaisePropertyChanged(nameof(CloseTabEnabled));
});
- private StringBuilder DefaultContent => new StringBuilder()
+ public ICommand ToggleFullScreen => ReactiveCommand.Create(() => WindowState = WindowState == WindowState.FullScreen ? WindowState.Normal : WindowState.FullScreen);
+
+ private MarkdownContentViewModel DefaultContent => new("New Tab", new StringBuilder()
.AppendLine($"}-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;
+ Tabs[0].Tag = new TabState() { Content = DefaultContent };
- GlobalState.UrlChanged += (sender, url) =>
+ contentProcessorManager.RegisterProcessor(new HtmlProcessor());
+ contentProcessorManager.RegisterProcessor(new CommonImageProcessor());
+ contentProcessorManager.RegisterProcessor(new DownloadProcessor());
+
+ GlobalState.ThemeChanged += (s, e) =>
{
- this.RaisePropertyChanged(nameof(ForwardEnabled));
- this.RaisePropertyChanged(nameof(BackEnabled));
- this.RaisePropertyChanged(nameof(Url));
-
- if (sender == this)
- {
- return;
- }
-
- FetchUrl();
- };
-
- GlobalState.ContentReload += (sender, _) =>
- {
- this.RaisePropertyChanged(nameof(Content));
+ this.RaisePropertyChanged(nameof(Icon));
};
}
@@ -187,139 +239,172 @@ 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()
+ private void FetchUrl(bool useCache = true)
{
+ if (IsBusy)
+ {
+ WindowNotificationManager.Show(new Notification("Busy", "The browser is currently busy.", NotificationType.Warning));
+ return;
+ }
+
if (string.IsNullOrWhiteSpace(Url))
{
- content = null;
- _ = html.Clear();
- ContentChanged();
- Debug.WriteLine("URL is empty.");
+ Content = DefaultContent;
+ Logger.LogDebug("URL is empty.");
CurrentTab.Header = "New Tab";
return;
}
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
+ try
+ {
+ Url = string.Format(Settings.Current.SearchEngineUrl, Uri.EscapeDataString(Url));
+ }
+ catch (FormatException ex)
+ {
+ WindowNotificationManager.Show(new Notification("Invalid Search Engine URL", $"{ex.Message}", NotificationType.Error));
+ }
+ }
}
- content ??= new StringBuilder();
- _ = content.Clear();
- _ = html.Clear();
+ httpClient.DefaultRequestHeaders.UserAgent.Clear();
+ if (!httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(Settings.Current.UserAgent))
+ {
+ WindowNotificationManager.Show(new Notification("Invalid User Agent", "Failed to set user agent.", NotificationType.Error));
+ return;
+ }
IsBusy = true;
Progress = 0;
_ = Task.Run(async () =>
{
- Debug.WriteLine("Fetching URL...");
+ Logger.LogInfo($"Fetching URL: {Url}");
try
{
- HttpResponseMessage httpResponseMessage = await httpClient.GetAsync(Url);
-
- Url = httpResponseMessage.RequestMessage?.RequestUri?.ToString() ?? Url;
-
- if (!httpResponseMessage.IsSuccessStatusCode)
+ ContentViewModelBase? cachedContent = cacheService.Get(Url);
+ if (cachedContent is not null && useCache)
{
- IsBusy = false;
- _ = content.AppendLine($"# {(int)httpResponseMessage.StatusCode} {httpResponseMessage.StatusCode}");
- _ = content.AppendLine($"Failed to fetch URL: {httpResponseMessage.ReasonPhrase}");
- ContentChanged();
- return;
+ Content = cachedContent;
+ }
+ else
+ {
+ HttpResponseMessage httpResponseMessage = await httpClient.GetAsync(Url, HttpCompletionOption.ResponseHeadersRead);
+
+ string? newUrl = httpResponseMessage.RequestMessage?.RequestUri?.ToString();
+ string oldUrl = Url;
+
+ if (newUrl is not null && newUrl != 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;
+
+ 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;
+ }
+
+ Content = await contentProcessorManager.ProcessContent(httpResponseMessage, new Progress(UpdateProgress));
+
+ if (Content.Cacheable)
+ {
+ if (oldUrl != Url)
+ {
+ cacheService.Set(oldUrl, Content);
+ }
+
+ cacheService.Set(Url, Content);
+ }
}
- long length = httpResponseMessage.Content.Headers.ContentLength ?? 0;
+ Dispatcher.UIThread.Post(() => CurrentTab.Header = Content.Title);
- 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;
- });
+ Logger.LogInfo($"Fetched URL: {Url}");
}
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());
+
+ Logger.LogError(e.Message);
}
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}");
+
+ Logger.LogError(e.Message);
}
-
- 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()
+ private void UpdateProgress(ProcessingProgress progress)
{
- this.RaisePropertyChanged(nameof(Content));
- this.RaisePropertyChanged(nameof(RawHtmlViewModel));
- this.RaisePropertyChanged(nameof(RawMarkdownViewModel));
+ if (progress.IsBytes)
+ {
+ ProgressText = $"{progress.Message} ({{1:0}}%) {BytesToString.Convert((long)Progress)}/{BytesToString.Convert((long)ProgressMax)}";
+ }
+ else
+ {
+ ProgressText = $"{progress.Message} ({{1:0}}%) {{0}}/{{3}}";
+ }
+
+ ProgressMax = progress.Total;
+ Progress = progress.Current;
+ }
+
+ private void UrlChanged(object? sender, EventArgs e)
+ {
+ this.RaisePropertyChanged(nameof(ForwardEnabled));
+ this.RaisePropertyChanged(nameof(BackEnabled));
+ this.RaisePropertyChanged(nameof(Url));
+
+ if (sender == this)
+ {
+ return;
+ }
+
+ FetchUrl();
}
}
\ No newline at end of file
diff --git a/src/Markdowser/ViewModels/RawHtmlViewModel.cs b/src/Markdowser/ViewModels/RawHtmlViewModel.cs
deleted file mode 100644
index 1d4832d..0000000
--- a/src/Markdowser/ViewModels/RawHtmlViewModel.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using System.Text;
-
-namespace Markdowser.ViewModels;
-
-public class RawHtmlViewModel(StringBuilder html) : ViewModelBase
-{
- public StringBuilder Html => html;
-}
\ No newline at end of file
diff --git a/src/Markdowser/ViewModels/RawMarkdownViewModel.cs b/src/Markdowser/ViewModels/RawMarkdownViewModel.cs
deleted file mode 100644
index 60f7ad3..0000000
--- a/src/Markdowser/ViewModels/RawMarkdownViewModel.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using System;
-using System.Text;
-
-namespace Markdowser.ViewModels;
-
-public class RawMarkdownViewModel(Func markdown) : ViewModelBase
-{
- public StringBuilder Markdown => markdown();
-}
\ No newline at end of file
diff --git a/src/Markdowser/ViewModels/SettingsViewModel.cs b/src/Markdowser/ViewModels/SettingsViewModel.cs
index 3aa3d71..b4d9d9c 100644
--- a/src/Markdowser/ViewModels/SettingsViewModel.cs
+++ b/src/Markdowser/ViewModels/SettingsViewModel.cs
@@ -10,6 +10,18 @@ public class SettingsViewModel : ViewModelBase
set => Settings.Current.HomeUrl = value;
}
+ public string SearchEngineUrl
+ {
+ get => Settings.Current.SearchEngineUrl;
+ set => Settings.Current.SearchEngineUrl = value;
+ }
+
+ public string UserAgent
+ {
+ get => Settings.Current.UserAgent;
+ set => Settings.Current.UserAgent = value;
+ }
+
public bool DarkMode
{
get => Settings.Current.DarkMode;
diff --git a/src/Markdowser/ViewModels/ViewModelBase.cs b/src/Markdowser/ViewModels/ViewModelBase.cs
index 28a9281..41bcadd 100644
--- a/src/Markdowser/ViewModels/ViewModelBase.cs
+++ b/src/Markdowser/ViewModels/ViewModelBase.cs
@@ -1,6 +1,52 @@
-using ReactiveUI;
+using Avalonia.Controls;
+using Avalonia.Controls.Notifications;
+using Avalonia.Platform;
+
+using CuteUtils.Logging;
+
+using Markdowser.Models;
+using Markdowser.Utilities;
+
+using ReactiveUI;
+
+using System.Diagnostics;
+using System.Reflection;
namespace Markdowser.ViewModels;
+
public class ViewModelBase : ReactiveObject
{
-}
+ private WindowNotificationManager? windowNotificationManager;
+
+ public string Title
+ {
+ get
+ {
+ string title = nameof(Markdowser);
+ Assembly assembly = Assembly.GetExecutingAssembly();
+ FileVersionInfo fileVersionInfo = FileVersionInfo.GetVersionInfo(assembly.Location);
+ string? version = fileVersionInfo.ProductVersion;
+
+#if DEBUG
+ return $"{title} - Dev {version}";
+#else
+ return $"{title} - {version}";
+#endif
+ }
+ }
+
+ 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!;
+
+ public Logger Logger => GlobalState.Logger;
+
+ 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/CommonImageContentView.axaml b/src/Markdowser/Views/Content/CommonImageContentView.axaml
new file mode 100644
index 0000000..34c13f8
--- /dev/null
+++ b/src/Markdowser/Views/Content/CommonImageContentView.axaml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Markdowser/Views/Content/CommonImageContentView.axaml.cs b/src/Markdowser/Views/Content/CommonImageContentView.axaml.cs
new file mode 100644
index 0000000..5582bea
--- /dev/null
+++ b/src/Markdowser/Views/Content/CommonImageContentView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace Markdowser.Views.Content;
+
+public partial class CommonImageContentView : UserControl
+{
+ public CommonImageContentView()
+ {
+ InitializeComponent();
+ }
+}
\ 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..3dfdc1d
--- /dev/null
+++ b/src/Markdowser/Views/Content/MarkdownContentView.axaml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 16f5b4a..8361b8f 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="/Assets/Markdowser-Light-Transparent.ico"
- Title="{Binding Title}">
+ Icon="{Binding Icon}"
+ Title="{Binding Title}"
+ Loaded="Window_Loaded" WindowState="{Binding WindowState}">