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 `<version />` 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 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Read this before creating packages: https://docs.chocolatey.org/en-us/create/create-packages --> +<!-- It is especially important to read the above link to understand additional requirements when +publishing packages to the community feed aka dot org (https://community.chocolatey.org/packages). --> + +<!-- Test your packages in a test environment: +https://github.com/chocolatey/chocolatey-test-environment --> + +<!-- +This is a nuspec. It mostly adheres to https://docs.nuget.org/create/Nuspec-Reference. Chocolatey +uses a special version of NuGet.Core that allows us to do more than was initially possible. As such +there are certain things to be aware of: + +* the package xmlns schema url may cause issues with nuget.exe +* Any of the following elements can ONLY be used by choco tools - projectSourceUrl, docsUrl, +mailingListUrl, bugTrackerUrl, packageSourceUrl, provides, conflicts, replaces +* nuget.exe can still install packages with those elements but they are ignored. Any authoring +tools or commands will error on those elements +--> + +<!-- You can embed software files directly into packages, as long as you are not bound by +distribution rights. --> +<!-- * If you are an organization making private packages, you probably have no issues here --> +<!-- * If you are releasing to the community feed, you need to consider distribution rights. --> +<!-- Do not remove this test for UTF-8: if “Ω” doesn’t appear as greek uppercase omega letter +enclosed in quotation marks, you should use an editor that supports UTF-8, not this one. --> +<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd"> + <metadata> + <!-- == PACKAGE SPECIFIC SECTION == --> + <!-- This section is about this package, although id and version have ties back to the software --> + <!-- id is lowercase and if you want a good separator for words, use '-', not '.'. Dots are only + acceptable as suffixes for certain types of packages, e.g. .install, .portable, .extension, + .template --> + <!-- If the software is cross-platform, attempt to use the same id as the debian/rpm package(s) + if possible. --> + <id>Markdowser</id> + <!-- version should MATCH as closely as possible with the underlying software --> + <!-- Is the version a prerelease of a version? + https://docs.nuget.org/create/versioning#creating-prerelease-packages --> + <!-- Note that unstable versions like 0.0.1 can be considered a released version, but it's + possible that one can release a 0.0.1-beta before you release a 0.0.1 version. If the version + number is final, that is considered a released version and not a prerelease. --> + <version>1.0.0.0</version> + <!-- <packageSourceUrl>Where is this Chocolatey package located (think GitHub)? packageSourceUrl + is highly recommended for the community feed</packageSourceUrl>--> + <!-- owners is a poor name for maintainers of the package. It sticks around by this name for + compatibility reasons. It basically means you. --> + <!--<owners>__REPLACE_YOUR_NAME__</owners>--> + <!-- ============================== --> + + <!-- == SOFTWARE SPECIFIC SECTION == --> + <!-- This section is about the software itself --> + <title>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.manifest true 0.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($"![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; + 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}">