Merge pull request #2 from Stone-Red-Code/develop

Develop
This commit is contained in:
Stone_Red
2024-06-04 18:08:43 +02:00
committed by GitHub
52 changed files with 1678 additions and 334 deletions
+140
View File
@@ -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
+5 -2
View File
@@ -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)
+133
View File
@@ -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 `<id />` field in the nuspec
* ChocolateyPackageTitle - The title of the package, equivalent to the `<title />` 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.
+119
View File
@@ -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 “Ω” doesnt 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</title>
<authors>Stone_Red</authors>
<!-- projectUrl is required for the community feed -->
<projectUrl>https://github.com/Stone-Red-Code/Markdowser</projectUrl>
<!-- There are a number of CDN Services that can be used for hosting the Icon for a package.
More information can be found here:
https://docs.chocolatey.org/en-us/create/create-packages#package-icon-guidelines -->
<!-- Here is an example using Githack -->
<!--<iconUrl>http://rawcdn.githack.com/__REPLACE_YOUR_REPO__/master/icons/Markdowser.png</iconUrl>-->
<!-- <copyright>Year Software Vendor</copyright> -->
<!-- If there is a license Url available, it is required for the community feed -->
<licenseUrl>https://github.com/Stone-Red-Code/Markdowser/blob/main/LICENSE</licenseUrl>
<requireLicenseAcceptance>true</requireLicenseAcceptance>
<projectSourceUrl>https://github.com/Stone-Red-Code/Markdowser</projectSourceUrl>
<!--<docsUrl>At
what url are the software docs located?</docsUrl>-->
<!--<mailingListUrl></mailingListUrl>-->
<!--<bugTrackerUrl></bugTrackerUrl>-->
<tags>Markdowser arp</tags>
<summary>A lightweight cross-platform IP scanner</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)
</description>
<releaseNotes>https://github.com/Stone-Red-Code/Markdowser/releases/latest</releaseNotes>
<!-- =============================== -->
<!-- Specifying dependencies and version ranges?
https://docs.nuget.org/create/versioning#specifying-version-ranges-in-.nuspec-files -->
<!--<dependencies>
<dependency id="" version="__MINIMUM_VERSION__" />
<dependency id="" version="[__EXACT_VERSION__]" />
<dependency id="" version="[_MIN_VERSION_INCLUSIVE, MAX_VERSION_INCLUSIVE]" />
<dependency id="" version="[_MIN_VERSION_INCLUSIVE, MAX_VERSION_EXCLUSIVE)" />
<dependency id="" />
<dependency id="chocolatey-core.extension" version="1.1.0" />
</dependencies>-->
<!-- chocolatey-core.extension -
https://community.chocolatey.org/packages/chocolatey-core.extension -->
<!--<provides>NOT
YET IMPLEMENTED</provides>-->
<!--<conflicts>NOT
YET IMPLEMENTED</conflicts>-->
<!--<replaces>NOT
YET IMPLEMENTED</replaces>-->
</metadata>
<files>
<!-- this section controls what actually gets packaged into the Chocolatey package -->
<file src="tools\**" target="tools" />
<file src="..\src\Markdowser\bin\Release\net8.0\**" target="tools" />
</files>
</package>
+23
View File
@@ -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.
+8
View File
@@ -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
+45
View File
@@ -0,0 +1,45 @@
name: markdowser # you probably want to 'snapcraft register <name>'
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
+1
View File
@@ -0,0 +1 @@
Markdowser
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RiderProjectSettingsUpdater">
<option name="vcsConfiguration" value="2" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
+80
View File
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoGeneratedRunConfigurationManager">
<projectFile>Markdowser/Markdowser.csproj</projectFile>
</component>
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="35ad98ab-fce0-4c9e-9180-ec9795d8ed8e" name="Changes" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="DpaMonitoringSettings">
<option name="autoShow" value="false" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$/.." />
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 6
}]]></component>
<component name="ProjectId" id="2fg8GifhQMdOrTygPnNJLDd0rxo" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
".NET Project.Markdowser.executor": "Run",
"git-widget-placeholder": "develop",
"node.js.selected.package.tslint": "(autodetect)"
},
"keyToStringList": {
"rider.external.source.directories": [
"C:\\Users\\Camer\\AppData\\Roaming\\JetBrains\\Rider2024.1\\resharper-host\\DecompilerCache",
"C:\\Users\\Camer\\AppData\\Roaming\\JetBrains\\Rider2024.1\\resharper-host\\SourcesCache",
"C:\\Users\\Camer\\AppData\\Local\\Symbols\\src"
]
}
}]]></component>
<component name="RunManager">
<configuration name="Markdowser" type="DotNetProject" factoryName=".NET Project" temporary="true">
<option name="EXE_PATH" value="$PROJECT_DIR$/Markdowser/bin/Debug/net8.0/Markdowser.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Markdowser/bin/Debug/net8.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Markdowser/Markdowser.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net8.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<recent_temporary>
<list>
<item itemvalue=".NET Project.Markdowser" />
</list>
</recent_temporary>
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="VcsManagerConfiguration">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
</component>
</project>
+21
View File
@@ -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)
@@ -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();
}
}
}
+1 -1
View File
@@ -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;
}
}
+21 -4
View File
@@ -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}");
}
}
}
+12
View File
@@ -7,6 +7,7 @@
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<Version>0.1.0.0</Version>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
<ItemGroup>
@@ -30,11 +31,22 @@
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.10" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.10" />
<PackageReference Include="CuteUtils" Version="1.0.0" />
<PackageReference Include="Markdown.Avalonia" Version="11.0.2" />
<PackageReference Include="Projektanker.Icons.Avalonia" Version="9.1.2" />
<PackageReference Include="Projektanker.Icons.Avalonia.FontAwesome" Version="9.1.2" />
<PackageReference Include="Projektanker.Icons.Avalonia.MaterialDesign" Version="9.1.2" />
<PackageReference Include="ReverseMarkdown" Version="4.3.0" />
<PackageReference Include="Semi.Avalonia" Version="11.0.7.1" />
<PackageReference Include="Syroot.Windows.IO.KnownFolders" Version="1.3.0" />
</ItemGroup>
<ItemGroup>
<Compile Update="Views\Content\CommonImageContentView.axaml.cs">
<DependentUpon>CommonImageContentView.axaml</DependentUpon>
</Compile>
<Compile Update="Views\Content\MarkdownContentView.axaml.cs">
<DependentUpon>MarkdownContentView.axaml</DependentUpon>
</Compile>
</ItemGroup>
</Project>
+14
View File
@@ -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))
@@ -26,9 +32,17 @@ public class Settings
private static Settings LoadSettings()
{
if (File.Exists(Configuration.SettingsFilePath))
{
try
{
return JsonSerializer.Deserialize<Settings>(File.ReadAllText(Configuration.SettingsFilePath)) ?? new Settings();
}
catch (Exception ex)
{
GlobalState.Logger.LogWarn(ex.Message);
return new Settings();
}
}
else
{
return new Settings();
+41
View File
@@ -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<string> BackHistory { get; } = new();
public Stack<string> 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);
}
}
@@ -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<IContentProcessor> processors = [];
public void RegisterProcessor(IContentProcessor processor)
{
processors.Add(processor);
}
public Task<ContentViewModelBase> ProcessContent(HttpResponseMessage httpResponseMessage, IProgress<ProcessingProgress> 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);
}
}
@@ -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<ContentViewModelBase> Process(HttpResponseMessage httpResponseMessage, IProgress<ProcessingProgress> progress);
}
@@ -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);
}
@@ -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<ContentViewModelBase> Process(HttpResponseMessage httpResponseMessage, IProgress<ProcessingProgress> progress)
{
return new CommonImageContentViewModel(httpResponseMessage.RequestMessage?.RequestUri?.Host?.ToString() ?? "Image", await httpResponseMessage.Content.ReadAsStreamAsync());
}
}
@@ -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<ContentViewModelBase> Process(HttpResponseMessage httpResponseMessage, IProgress<ProcessingProgress> 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
};
}
}
@@ -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<ContentViewModelBase> Process(HttpResponseMessage httpResponseMessage, IProgress<ProcessingProgress> 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();
}
+28
View File
@@ -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;
@@ -16,10 +22,32 @@ internal static class Program
// yet and stuff might break.
[STAThread]
public static void Main(string[] 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<Exception>(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.
public static AppBuilder BuildAvaloniaApp()
+50
View File
@@ -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;
}
}
+27
View File
@@ -0,0 +1,27 @@
using Markdowser.ViewModels;
using System.Collections.Generic;
namespace Markdowser.Utilities;
public class CacheService
{
private readonly Dictionary<string, ContentViewModelBase> 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;
}
}
+1 -1
View File
@@ -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;
+30 -3
View File
@@ -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");
}
+37 -33
View File
@@ -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 = "<Pending>")]
internal static class GlobalState
{
public static event EventHandler<string>? 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<string> BackHistory { get; } = new();
public static Stack<string> 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();
}
url = value;
UrlChanged?.Invoke(null, url);
LogTarget = LogTarget.DebugConsole
},
InfoConfig = new()
{
LogTarget = LogTarget.DebugConsole | LogTarget.File,
FilePath = Configuration.LogFilePath
},
WarnConfig = new()
{
LogTarget = LogTarget.DebugConsole | LogTarget.File,
FilePath = Configuration.LogFilePath
},
ErrorConfig = new()
{
LogTarget = LogTarget.DebugConsole | LogTarget.File,
FilePath = Configuration.LogFilePath
},
FatalConfig = new()
{
LogTarget = LogTarget.DebugConsole | LogTarget.File,
FilePath = Configuration.LogFilePath
},
FormatConfig = new()
{
DebugConsoleFormat = new LogFormatBuilder().DateTime().Text(" ").LogSeverity(padding: -6).FilePath().Text(":").MemberName().Text(":").LineNumber().Text(Environment.NewLine).Message(),
FileFormat = new LogFormatBuilder().DateTime().Text(" ").LogSeverity(padding: -6).FilePath().Text(":").MemberName().Text(":").LineNumber().Text(Environment.NewLine).Message(),
}
}
};
public static ObservableCollection<TabItem> 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);
}
}
+32 -5
View File
@@ -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);
}
}
+7 -5
View File
@@ -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;
}
}
@@ -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);
}
@@ -0,0 +1,6 @@
namespace Markdowser.ViewModels.Content;
public class MarkdownContentViewModel(string title, string markdown) : ContentViewModelBase(title)
{
public string Markdown { get; } = markdown;
}
@@ -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;
}
+278 -193
View File
@@ -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<TabItem> Tabs => GlobalState.Tabs;
public string Title => $"{nameof(Markdowser)} - {Assembly.GetExecutingAssembly().GetName().Version?.ToString()}";
private double progress;
private double progressMax;
private string progressText;
public ObservableCollection<TabItem> 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();
}
}
public StringBuilder Content
if (CurrentTabState.Content is null)
{
get => content ?? DefaultContent;
set => this.RaiseAndSetIfChanged(ref content, value);
FetchUrl(true);
}
else
{
Content = CurrentTabState.Content;
}
}
}
public TabState CurrentTabState
{
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,41 +177,224 @@ 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(Icon));
};
}
private static bool IsValidHttpUri(string uriString, [NotNullWhen(true)] out Uri? uri)
{
return Uri.TryCreate(uriString, UriKind.Absolute, out uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
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 = DefaultContent;
Logger.LogDebug("URL is empty.");
CurrentTab.Header = "New Tab";
return;
}
if (!IsValidHttpUri(Url, out _))
{
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));
}
}
}
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 () =>
{
Logger.LogInfo($"Fetching URL: {Url}");
try
{
ContentViewModelBase? cachedContent = cacheService.Get(Url);
if (cachedContent is not null && useCache)
{
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<ProcessingProgress>(UpdateProgress));
if (Content.Cacheable)
{
if (oldUrl != Url)
{
cacheService.Set(oldUrl, Content);
}
cacheService.Set(Url, Content);
}
}
Dispatcher.UIThread.Post(() => CurrentTab.Header = Content.Title);
Logger.LogInfo($"Fetched URL: {Url}");
}
catch (HttpRequestException e)
{
StringBuilder errorMessage = new();
if (e.StatusCode is not null)
{
_ = 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}");
}
_ = 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)
{
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);
}
finally
{
IsBusy = false;
}
});
}
private void UpdateProgress(ProcessingProgress progress)
{
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));
@@ -174,152 +406,5 @@ public partial class MainWindowViewModel : ViewModelBase
}
FetchUrl();
};
GlobalState.ContentReload += (sender, _) =>
{
this.RaisePropertyChanged(nameof(Content));
};
}
private static bool IsValidHttpUri(string uriString, [NotNullWhen(true)] out Uri? uri)
{
return Uri.TryCreate(uriString, UriKind.Absolute, out uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
[GeneratedRegex("!\\[(.*?)\\]\\((.*?)\\)")]
private static partial Regex MarkdownImage();
private void FetchUrl()
{
if (string.IsNullOrWhiteSpace(Url))
{
content = null;
_ = html.Clear();
ContentChanged();
Debug.WriteLine("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)}";
}
content ??= new StringBuilder();
_ = content.Clear();
_ = html.Clear();
IsBusy = true;
Progress = 0;
_ = Task.Run(async () =>
{
Debug.WriteLine("Fetching URL...");
try
{
HttpResponseMessage httpResponseMessage = await httpClient.GetAsync(Url);
Url = httpResponseMessage.RequestMessage?.RequestUri?.ToString() ?? Url;
if (!httpResponseMessage.IsSuccessStatusCode)
{
IsBusy = false;
_ = content.AppendLine($"# {(int)httpResponseMessage.StatusCode} {httpResponseMessage.StatusCode}");
_ = content.AppendLine($"Failed to fetch URL: {httpResponseMessage.ReasonPhrase}");
ContentChanged();
return;
}
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 = (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;
});
}
catch (HttpRequestException e)
{
IsBusy = false;
if (e.StatusCode is not null)
{
_ = content.AppendLine($"# {(int)e.StatusCode} {e.StatusCode}");
}
_ = content.AppendLine($"# {e.HttpRequestError}");
_ = content.AppendLine($"Failed to fetch URL: {e.Message}");
ContentChanged();
}
catch (Exception e)
{
IsBusy = false;
_ = content.AppendLine($"# {e.GetType().Name}");
_ = content.AppendLine($"Failed to fetch URL: {e.Message}");
ContentChanged();
}
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)
{
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);
}
ContentChanged();
IsBusy = false;
});
}
private void ContentChanged()
{
this.RaisePropertyChanged(nameof(Content));
this.RaisePropertyChanged(nameof(RawHtmlViewModel));
this.RaisePropertyChanged(nameof(RawMarkdownViewModel));
}
}
@@ -1,8 +0,0 @@
using System.Text;
namespace Markdowser.ViewModels;
public class RawHtmlViewModel(StringBuilder html) : ViewModelBase
{
public StringBuilder Html => html;
}
@@ -1,9 +0,0 @@
using System;
using System.Text;
namespace Markdowser.ViewModels;
public class RawMarkdownViewModel(Func<StringBuilder> markdown) : ViewModelBase
{
public StringBuilder Markdown => markdown();
}
@@ -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;
+47 -1
View File
@@ -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
};
}
}
@@ -0,0 +1,18 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Markdowser.ViewModels"
xmlns:cmd="using:Markdowser.Commands"
xmlns:utils="using:Markdowser.Utilities"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
xmlns:md="https://github.com/whistyun/Markdown.Avalonia"
xmlns:ctxt="clr-namespace:ColorTextBlock.Avalonia;assembly=ColorTextBlock.Avalonia"
xmlns:content="clr-namespace:Markdowser.ViewModels.Content"
x:DataType="content:CommonImageContentViewModel"
x:Class="Markdowser.Views.Content.CommonImageContentView">
<Grid>
<Image Source="{Binding Image}"/>
</Grid>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Markdowser.Views.Content;
public partial class CommonImageContentView : UserControl
{
public CommonImageContentView()
{
InitializeComponent();
}
}
@@ -0,0 +1,72 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Markdowser.ViewModels"
xmlns:cmd="using:Markdowser.Commands"
xmlns:utils="using:Markdowser.Utilities"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
xmlns:md="https://github.com/whistyun/Markdown.Avalonia"
xmlns:ctxt="clr-namespace:ColorTextBlock.Avalonia;assembly=ColorTextBlock.Avalonia"
xmlns:content="clr-namespace:Markdowser.ViewModels.Content"
x:DataType="content:MarkdownContentViewModel"
x:Class="Markdowser.Views.Content.MarkdownContentView">
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary>
<utils:HttpPathResolver x:Key="HttpPathResolver" />
<cmd:HyperlinkCommand x:Key="HyperlinkCommand" />
<cmd:ChangeThemeCommand x:Key="ChangeThemeCommand" />
<cmd:HomeCommand x:Key="HomeCommand" />
</ResourceDictionary>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</UserControl.Resources>
<Grid>
<md:MarkdownScrollViewer Margin="10 0 10 0" Markdown="{Binding Markdown}">
<md:MarkdownScrollViewer.Styles>
<Style Selector="ctxt|CTextBlock">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading1">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading2">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading3">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading4">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading5">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading6">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CHyperlink">
<Setter Property="Foreground" Value="{DynamicResource SemiColorLink}" />
</Style>
<Style Selector="ctxt|CHyperlink:pointerover">
<Setter Property="Foreground" Value="{DynamicResource SemiColorLinkPointerover}" />
</Style>
<Style Selector="ctxt|CHyperlink:pressed">
<Setter Property="Foreground" Value="{DynamicResource SemiColorLinkVisited}" />
</Style>
<Style Selector="ctxt|CImage">
<Setter Property="FittingWhenProtrude" Value="True" />
<Setter Property="LayoutWidth" Value="500" />
<Setter Property="SaveAspectRatio" Value="True" />
</Style>
</md:MarkdownScrollViewer.Styles>
<md:MarkdownScrollViewer.Plugins>
<md:MdAvPlugins PathResolver="{StaticResource HttpPathResolver}" HyperlinkCommand="{StaticResource HyperlinkCommand}" />
</md:MarkdownScrollViewer.Plugins>
</md:MarkdownScrollViewer>
</Grid>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Markdowser.Views.Content;
public partial class MarkdownContentView : UserControl
{
public MarkdownContentView()
{
InitializeComponent();
}
}
+50 -64
View File
@@ -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}">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
@@ -24,8 +23,6 @@
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary>
<utils:HttpPathResolver x:Key="HttpPathResolver" />
<cmd:HyperlinkCommand x:Key="HyperlinkCommand" />
<cmd:ChangeThemeCommand x:Key="ChangeThemeCommand" />
<cmd:HomeCommand x:Key="HomeCommand" />
</ResourceDictionary>
@@ -33,11 +30,31 @@
</ResourceDictionary>
</Window.Resources>
<Window.KeyBindings>
<KeyBinding Gesture="Ctrl+R" Command="{Binding Reload}" />
<KeyBinding Gesture="F5" Command="{Binding Reload}" />
<KeyBinding Gesture="Alt+Home" Command="{StaticResource HomeCommand}" />
<KeyBinding Gesture="Alt+H" Command="{StaticResource HomeCommand}" />
<KeyBinding Gesture="Alt+Left" Command="{Binding Back}" />
<KeyBinding Gesture="Alt+Right" Command="{Binding Forward}" />
<KeyBinding Gesture="Ctrl+T" Command="{Binding NewTab}" />
<KeyBinding Gesture="Ctrl+W" Command="{Binding CloseTab}" />
<KeyBinding Gesture="Alt+S" Command="{Binding ToggleSidePanel}" />
<KeyBinding Gesture="F11" Command="{Binding ToggleFullScreen}" />
</Window.KeyBindings>
<Grid RowDefinitions="Auto, *, Auto">
<Grid Grid.Row="0" ColumnDefinitions="*, Auto, Auto">
<TabControl Grid.Column="0" Theme="{DynamicResource ScrollTabControl}" SelectedItem="{Binding CurrentTab, Mode=TwoWay}" ItemsSource="{Binding Tabs}" />
<Button Grid.Column="1" Command="{Binding CloseTab}" IsEnabled="{Binding CloseTabEnabled}" HorizontalAlignment="Right" VerticalAlignment="Top" Height="32" i:Attached.Icon="fa-solid fa-xmark" />
<Button Grid.Column="2" Command="{Binding NewTab}" HorizontalAlignment="Right" VerticalAlignment="Top" Height="32" i:Attached.Icon="fa-solid fa-plus" />
<TabControl Grid.Column="0" SelectedItem="{Binding CurrentTab, Mode=TwoWay}" ItemsSource="{Binding Tabs}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="1" Command="{Binding CloseTab}" HorizontalAlignment="Right" VerticalAlignment="Stretch" i:Attached.Icon="fa-solid fa-xmark">
<Button.IsEnabled>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="CloseTabEnabled" />
<Binding Path="!IsBusy" />
</MultiBinding>
</Button.IsEnabled>
</Button>
<Button Grid.Column="2" Command="{Binding NewTab}" HorizontalAlignment="Right" VerticalAlignment="Stretch" i:Attached.Icon="fa-solid fa-plus" IsEnabled="{Binding !IsBusy}" />
</Grid>
<Grid Grid.Row="1">
@@ -47,48 +64,8 @@
<ColumnDefinition Width="*" utils:ColumnDefinition.IsVisible="{Binding ShowSidePanel}" />
</Grid.ColumnDefinitions>
<md:MarkdownScrollViewer Margin="10 0 10 10" IsEnabled="{Binding !IsBusy}" Markdown="{Binding Content}">
<md:MarkdownScrollViewer.Styles>
<Style Selector="ctxt|CTextBlock">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading1">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading2">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading3">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading4">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading5">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading6">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CHyperlink">
<Setter Property="Foreground" Value="{DynamicResource SemiColorLink}" />
</Style>
<Style Selector="ctxt|CHyperlink:pointerover">
<Setter Property="Foreground" Value="{DynamicResource SemiColorLinkPointerover}" />
</Style>
<Style Selector="ctxt|CHyperlink:pressed">
<Setter Property="Foreground" Value="{DynamicResource SemiColorLinkVisited}" />
</Style>
<Style Selector="ctxt|CImage">
<Setter Property="FittingWhenProtrude" Value="True" />
<Setter Property="LayoutWidth" Value="500" />
<Setter Property="SaveAspectRatio" Value="True" />
</Style>
</md:MarkdownScrollViewer.Styles>
<md:MarkdownScrollViewer.Plugins>
<md:MdAvPlugins PathResolver="{StaticResource HttpPathResolver}" HyperlinkCommand="{StaticResource HyperlinkCommand}" />
</md:MarkdownScrollViewer.Plugins>
</md:MarkdownScrollViewer>
<ContentControl IsEnabled="{Binding !IsBusy}" Content="{Binding Content}" />
<GridSplitter Grid.Column="1" IsVisible="{Binding ShowSidePanel}" Background="{DynamicResource SemiColorTertiary}" ResizeDirection="Columns" />
<TabControl Grid.Column="2" IsVisible="{Binding ShowSidePanel}" Margin="10">
<TabItem Header="Settings">
@@ -96,14 +73,9 @@
<ContentControl Margin="0 10 0 0" Content="{Binding SettingsViewModel}" />
</ScrollViewer>
</TabItem>
<TabItem Header="Markdown">
<TabItem Header="Raw Content">
<ScrollViewer>
<ContentControl Margin="0 10 0 0" Content="{Binding RawMarkdownViewModel}" />
</ScrollViewer>
</TabItem>
<TabItem Header="HTML">
<ScrollViewer>
<ContentControl Margin="0 10 0 0" Content="{Binding RawHtmlViewModel}" />
<SelectableTextBlock Margin="0 10 0 0" Text="{Binding RawContent}" TextWrapping="Wrap" />
</ScrollViewer>
</TabItem>
</TabControl>
@@ -111,13 +83,27 @@
<Border Grid.Row="2" BorderThickness="0 2 0 0" CornerRadius="0" BorderBrush="{DynamicResource SemiColorTertiary}">
<Grid ColumnDefinitions="Auto, Auto, Auto, Auto, *, Auto">
<Button Grid.Column="0" i:Attached.Icon="fa-solid fa-caret-left" IsEnabled="{Binding BackEnabled}" Command="{Binding Back}" />
<Button Grid.Column="1" i:Attached.Icon="fa-solid fa-caret-right" IsEnabled="{Binding ForwardEnabled}" Command="{Binding Forward}" />
<Button Grid.Column="2" i:Attached.Icon="fa-solid fa-rotate-right" Command="{Binding Browse}" />
<Button Grid.Column="3" i:Attached.Icon="fa-solid fa-house" Command="{StaticResource HomeCommand}" />
<Button Grid.Column="0" i:Attached.Icon="fa-solid fa-caret-left" Command="{Binding Back}">
<Button.IsEnabled>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="BackEnabled" />
<Binding Path="!IsBusy" />
</MultiBinding>
</Button.IsEnabled>
</Button>
<Button Grid.Column="1" i:Attached.Icon="fa-solid fa-caret-right" Command="{Binding Forward}">
<Button.IsEnabled>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="ForwardEnabled" />
<Binding Path="!IsBusy" />
</MultiBinding>
</Button.IsEnabled>
</Button>
<Button Grid.Column="2" i:Attached.Icon="fa-solid fa-rotate-right" Command="{Binding Reload}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="3" i:Attached.Icon="fa-solid fa-house" Command="{StaticResource HomeCommand}" IsEnabled="{Binding !IsBusy}" />
<Grid Grid.Column="4">
<TextBox x:Name="urlBar" IsVisible="{Binding !IsBusy}" Watermark="Search DuckDuckGo or type a URL" Text="{Binding Url}" BorderThickness="0">
<TextBox IsVisible="{Binding !IsBusy}" Watermark="Search or type a URL" Text="{Binding Url}" BorderThickness="0">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding Browse}" />
</TextBox.KeyBindings>
@@ -128,7 +114,7 @@
</TextBox.Styles>
</TextBox>
<ProgressBar IsVisible="{Binding IsBusy}" IsIndeterminate="{Binding ProgressIndeterminate}" ShowProgressText="{Binding !ProgressIndeterminate}" Maximum="100" Height="32" Grid.Row="2" Value="{Binding Progress}" />
<ProgressBar IsVisible="{Binding IsBusy}" IsIndeterminate="{Binding ProgressIndeterminate}" ShowProgressText="{Binding !ProgressIndeterminate}" ProgressTextFormat="{Binding ProgressText}" Height="32" Grid.Row="2" Value="{Binding Progress}" Maximum="{Binding ProgressMax}" />
</Grid>
<Button Grid.Column="5" Command="{Binding ToggleSidePanel}" HorizontalAlignment="Right" i:Attached.Icon="fa-solid fa-table-columns" />
+8
View File
@@ -8,4 +8,12 @@ public partial class MainWindow : Window
{
InitializeComponent();
}
private void Window_Loaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (DataContext is ViewModels.MainWindowViewModel vm)
{
vm.InitializeWindowNotificationManager(this);
}
}
}
-10
View File
@@ -1,10 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Markdowser.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:DataType="vm:RawHtmlViewModel"
x:Class="Markdowser.Views.RawHtmlView">
<SelectableTextBlock Text="{Binding Html}" TextWrapping="Wrap" />
</UserControl>
-11
View File
@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace Markdowser.Views;
public partial class RawHtmlView : UserControl
{
public RawHtmlView()
{
InitializeComponent();
}
}
@@ -1,10 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Markdowser.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:DataType="vm:RawMarkdownViewModel"
x:Class="Markdowser.Views.RawMarkdownView">
<SelectableTextBlock Text="{Binding Markdown}" TextWrapping="Wrap" />
</UserControl>
@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace Markdowser.Views;
public partial class RawMarkdownView : UserControl
{
public RawMarkdownView()
{
InitializeComponent();
}
}
+6
View File
@@ -23,7 +23,13 @@
<TextBlock Text="Dark Mode" />
<ToggleSwitch IsChecked="{Binding DarkMode}" Command="{StaticResource ChangeThemeCommand}" />
<TextBlock Text="Search Engine URL" />
<TextBox Text="{Binding SearchEngineUrl}" Watermark="URL with {0} as encoded search query" />
<TextBlock Text="Home URL" />
<TextBox Text="{Binding HomeUrl}" Watermark="URL, or leave empty to use default" />
<TextBlock Text="User Agent" />
<TextBox Text="{Binding UserAgent}" Watermark="User agent string" />
</StackPanel>
</UserControl>