mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 06:39:33 +02:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aa90bb27b | |||
| 51acffc92e | |||
| 39a33538fe | |||
| 07ac805677 | |||
| 097a75e326 | |||
| 3d9ee3b339 | |||
| 4ff7990967 | |||
| 730fed075f | |||
| 600c354ab2 | |||
| d8dc12b3f7 | |||
| 33a6d49d76 | |||
| 3861dba60c | |||
| 69acb5bf76 | |||
| 0e67dcbf2e | |||
| 45d2be549d | |||
| 9394824943 | |||
| 43c50324d8 | |||
| a6353de2a7 | |||
| d020652f50 | |||
| 63803f98aa | |||
| 9dda9cfc27 | |||
| 61d0ddeced | |||
| 4c64977b71 | |||
| 8bc5b547ef | |||
| 286981d9f8 | |||
| 5a62ce6af9 | |||
| d72fe594b4 | |||
| 9df98b0405 | |||
| 0d2b9e8bc0 | |||
| 53cebd82eb | |||
| 37fcee2f21 | |||
| 047dccef7f | |||
| cb47902d10 | |||
| dca8b8ceb2 | |||
| a8e7c85b99 | |||
| edd51e0838 | |||
| 5e04accdff | |||
| 547bf0a463 | |||
| 948fab7c86 | |||
| 5038b8541e | |||
| 1da80956dd | |||
| 0763a99765 | |||
| eda5b21684 | |||
| 2c7b054c24 | |||
| 520e023aff | |||
| 949c9d3f1e | |||
| fa825fce47 | |||
| 09acfcff59 | |||
| ddb4856516 | |||
| c4007d4a80 | |||
| 2a432c0ed2 | |||
| c5eeb5f464 | |||
| a6bf546e79 | |||
| 239c47c06e | |||
| 19f678ca01 | |||
| 08442f21b7 | |||
| 921f348769 | |||
| a25e809671 | |||
| fa80c82468 | |||
| 91f4f02a35 | |||
| 6d2880ce26 | |||
| 355b8cb8cf | |||
| 82945ac3cf | |||
| 5009b90a3e | |||
| 1495ed9b90 | |||
| be66c74c08 | |||
| 6f54c09d96 | |||
| 45926b0990 | |||
| 25d29dc079 | |||
| efde9931c9 | |||
| 2c3b461d49 | |||
| b405052fd6 |
@@ -1,63 +0,0 @@
|
||||
###############################################################################
|
||||
# Set default behavior to automatically normalize line endings.
|
||||
###############################################################################
|
||||
* text=auto
|
||||
|
||||
###############################################################################
|
||||
# Set default behavior for command prompt diff.
|
||||
#
|
||||
# This is need for earlier builds of msysgit that does not have it on by
|
||||
# default for csharp files.
|
||||
# Note: This is only used by command line
|
||||
###############################################################################
|
||||
#*.cs diff=csharp
|
||||
|
||||
###############################################################################
|
||||
# Set the merge driver for project and solution files
|
||||
#
|
||||
# Merging from the command prompt will add diff markers to the files if there
|
||||
# are conflicts (Merging from VS is not affected by the settings below, in VS
|
||||
# the diff markers are never inserted). Diff markers may cause the following
|
||||
# file extensions to fail to load in VS. An alternative would be to treat
|
||||
# these files as binary and thus will always conflict and require user
|
||||
# intervention with every merge. To do so, just uncomment the entries below
|
||||
###############################################################################
|
||||
#*.sln merge=binary
|
||||
#*.csproj merge=binary
|
||||
#*.vbproj merge=binary
|
||||
#*.vcxproj merge=binary
|
||||
#*.vcproj merge=binary
|
||||
#*.dbproj merge=binary
|
||||
#*.fsproj merge=binary
|
||||
#*.lsproj merge=binary
|
||||
#*.wixproj merge=binary
|
||||
#*.modelproj merge=binary
|
||||
#*.sqlproj merge=binary
|
||||
#*.wwaproj merge=binary
|
||||
|
||||
###############################################################################
|
||||
# behavior for image files
|
||||
#
|
||||
# image files are treated as binary by default.
|
||||
###############################################################################
|
||||
#*.jpg binary
|
||||
#*.png binary
|
||||
#*.gif binary
|
||||
|
||||
###############################################################################
|
||||
# diff behavior for common document formats
|
||||
#
|
||||
# Convert binary document formats to text before diffing them. This feature
|
||||
# is only available from the command line. Turn it on by uncommenting the
|
||||
# entries below.
|
||||
###############################################################################
|
||||
#*.doc diff=astextplain
|
||||
#*.DOC diff=astextplain
|
||||
#*.docx diff=astextplain
|
||||
#*.DOCX diff=astextplain
|
||||
#*.dot diff=astextplain
|
||||
#*.DOT diff=astextplain
|
||||
#*.pdf diff=astextplain
|
||||
#*.PDF diff=astextplain
|
||||
#*.rtf diff=astextplain
|
||||
#*.RTF diff=astextplain
|
||||
+52
-47
@@ -3,64 +3,69 @@ name: CD
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2.3.3
|
||||
|
||||
- name: Install .NET Core
|
||||
uses: actions/setup-dotnet@v1.4.0
|
||||
with:
|
||||
dotnet-version: 3.1.100
|
||||
- name: Install .NET (v3.1)
|
||||
uses: actions/setup-dotnet@v1.7.2
|
||||
with:
|
||||
dotnet-version: 3.1.x
|
||||
|
||||
- name: Build & publish (CLI)
|
||||
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
||||
- name: Install .NET (v5.0)
|
||||
uses: actions/setup-dotnet@v1.7.2
|
||||
with:
|
||||
dotnet-version: 5.0.x
|
||||
|
||||
- name: Build & publish (GUI)
|
||||
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
|
||||
- name: Build & publish (CLI)
|
||||
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
||||
|
||||
- name: Pack (CLI)
|
||||
run: Compress-Archive -Path DiscordChatExporter.Cli/bin/Publish/* -DestinationPath DiscordChatExporter.Cli/bin/Publish/Archive.zip -Force
|
||||
shell: pwsh
|
||||
- name: Build & publish (GUI)
|
||||
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
|
||||
|
||||
- name: Pack (GUI)
|
||||
run: Compress-Archive -Path DiscordChatExporter.Gui/bin/Publish/* -DestinationPath DiscordChatExporter.Gui/bin/Publish/Archive.zip -Force
|
||||
shell: pwsh
|
||||
- name: Pack (CLI)
|
||||
run: Compress-Archive -Path DiscordChatExporter.Cli/bin/Publish/* -DestinationPath DiscordChatExporter.Cli/bin/Publish/Archive.zip -Force
|
||||
shell: pwsh
|
||||
|
||||
- name: Create release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1.0.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref }}
|
||||
body: |
|
||||
[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)
|
||||
draft: false
|
||||
prerelease: false
|
||||
- name: Pack (GUI)
|
||||
run: Compress-Archive -Path DiscordChatExporter.Gui/bin/Publish/* -DestinationPath DiscordChatExporter.Gui/bin/Publish/Archive.zip -Force
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload release asset (CLI)
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: DiscordChatExporter.Cli/bin/Publish/Archive.zip
|
||||
asset_name: DiscordChatExporter.CLI.zip
|
||||
asset_content_type: application/zip
|
||||
- name: Create release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1.0.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref }}
|
||||
body: |
|
||||
[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: Upload release asset (GUI)
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: DiscordChatExporter.Gui/bin/Publish/Archive.zip
|
||||
asset_name: DiscordChatExporter.zip
|
||||
asset_content_type: application/zip
|
||||
- name: Upload release asset (CLI)
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: DiscordChatExporter.Cli/bin/Publish/Archive.zip
|
||||
asset_name: DiscordChatExporter.CLI.zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload release asset (GUI)
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: DiscordChatExporter.Gui/bin/Publish/Archive.zip
|
||||
asset_name: DiscordChatExporter.zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
+25
-20
@@ -7,28 +7,33 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2.3.3
|
||||
|
||||
- name: Install .NET Core
|
||||
uses: actions/setup-dotnet@v1.4.0
|
||||
with:
|
||||
dotnet-version: 3.1.100
|
||||
- name: Install .NET (v3.1)
|
||||
uses: actions/setup-dotnet@v1.7.2
|
||||
with:
|
||||
dotnet-version: 3.1.x
|
||||
|
||||
- name: Build & publish (CLI)
|
||||
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
||||
- name: Install .NET (v5.0)
|
||||
uses: actions/setup-dotnet@v1.7.2
|
||||
with:
|
||||
dotnet-version: 5.0.x
|
||||
|
||||
- name: Build & publish (GUI)
|
||||
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
|
||||
- name: Build & publish (CLI)
|
||||
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
||||
|
||||
- name: Upload build artifacts (CLI)
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
name: DiscordChatExporter.CLI
|
||||
path: DiscordChatExporter.Cli/bin/Publish/
|
||||
- name: Build & publish (GUI)
|
||||
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
|
||||
|
||||
- name: Upload build artifact (GUI)
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
name: DiscordChatExporter
|
||||
path: DiscordChatExporter.Gui/bin/Publish/
|
||||
- name: Upload build artifacts (CLI)
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
name: DiscordChatExporter.CLI
|
||||
path: DiscordChatExporter.Cli/bin/Publish/
|
||||
|
||||
- name: Upload build artifact (GUI)
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
name: DiscordChatExporter
|
||||
path: DiscordChatExporter.Gui/bin/Publish/
|
||||
|
||||
+6
-246
@@ -1,261 +1,21 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
|
||||
# User-specific files
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
.idea/
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Xx]64/
|
||||
[Xx]86/
|
||||
[Bb]uild/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
|
||||
# Visual Studio 2015 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUNIT
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# DNX
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_i.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# JustCode is a .NET coding add-in
|
||||
.JustCode
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# TODO: Comment the next line if you want to checkin your web deploy settings
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
#*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/packages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/packages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/packages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignoreable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
node_modules/
|
||||
orleans.codegen.cs
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# JetBrains Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# CodeRush
|
||||
.cr/
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
# Coverage
|
||||
*.opencover.xml
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 27 KiB |
@@ -1,3 +1,46 @@
|
||||
### v2.26 (29-Dec-2020)
|
||||
|
||||
- [HTML] Added support for replies. (Thanks [@Sanqui](https://github.com/Sanqui))
|
||||
- [HTML] Improved styling of non-media attachments (regular files, i.e. not video, audio, or image). (Thanks [@Lucas LaBuff](https://github.com/96-LB))
|
||||
- [HTML] Improved initial export performance.
|
||||
- [JSON] Relaxed escaping rules for unicode characters. (Thanks [@Sanqui](https://github.com/Sanqui))
|
||||
- [JSON] Added support for replies. (Thanks [@Sanqui](https://github.com/Sanqui))
|
||||
- [CLI] Added channel names to errors shown on failures when exporting multiple channels. (Thanks [@Sanqui](https://github.com/Sanqui))
|
||||
- [CLI] Added support for passing message IDs (snowflakes) to `--after` and `--before` options. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
- Files downloaded by the "download referenced media" option now have their dates set according to the `Last-Modified` header provided by Discord CDN. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
- Fixed an issue where the application sometimes crashed with `NullReferenceException` when retrying a failed HTTP request.
|
||||
- Migrated to Discord API v8.
|
||||
|
||||
### v2.25 (23-Nov-2020)
|
||||
|
||||
- [JSON] Added `mentions` property to the output. This contains users referenced inside the message text, but doesn't include mentions in embeds. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
- [HTML] Added better previews for attached video and audio. They are now rendered using a browser native player element. The actual file path can still be retrieved by right clicking and selecting "Copy file path". (Thanks [@sas41](https://github.com/sas41))
|
||||
- [CLI] Fixed an issue where using `--media` option without providing a rooted path via `--output` option triggered an error.
|
||||
- [CLI] Fixed an issue where using `--reuse-media` option without also providing `--media` option was accepted, but didn't actually do anything. Now this produces an error instead. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
|
||||
### v2.24 (24-Oct-2020)
|
||||
|
||||
- [CLI] Updated to [CliFx v1.5](https://github.com/Tyrrrz/CliFx), which brings some UX improvements.
|
||||
- [GUI] Added dark mode, which can be enabled in settings. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
- [GUI] Improved the UX in a few minor places.
|
||||
- [JSON] Added `embed.color` property to the output. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
- Added additional date formats: `unix` to write dates as a unix timestamp and `unixms` for the milliseconds version. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
- Added retry policy for downloading media content. This is mostly a workaround for transient failures that sometimes may occur when downloading assets referenced from embeds. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
- Added an option to reuse already downloaded media to avoid redundant network requests. It can be enabled in settings in GUI, or by passing `--reuse-media` in CLI. To facilitate this feature, the file name strategy has been changed as well -- now instead of relying on the original file name and adding `(n)` to the end in case of duplicates, a URL hash is injected in every file name. This also means that when the "reuse media" option is disabled, existing files inside the media directory may be overwritten. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
|
||||
### v2.23 (14-Sep-2020)
|
||||
|
||||
- [CLI] Added a command to export all channels across all servers. Use `exportall` to do it. (Thanks [@wyattscarpenter](https://github.com/wyattscarpenter))
|
||||
- [HTML] Fixed an issue where Whitney fonts were not being loaded properly, causing the browser to fall back to Helvetica.
|
||||
- Fixed an issue where self-contained export crashed occasionally. This usually happened when the server hosting the file did not serve the stream properly. Such files are now ignored after the first failed attempt.
|
||||
|
||||
### v2.22 (12-Aug-2020)
|
||||
|
||||
- [GUI] Improved the channel list by adding collapsible category groups. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
||||
- [GUI] Improved exporting options by adding a set of controls that can be used to limit the date range of the export down to minutes. Previously it was only possible to configure the date range without the time component. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
||||
- [HTML] Fixed an issue where the export didn't reflect changes in nicknames between messages sent by bots. This affected chat logs that contained interactions with the Tupperbox bot. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
||||
- [CLI] Fixed an issue where the application crashed if there were two environment variables defined that had the same name but in different case.
|
||||
|
||||
### v2.21.2 (30-Jul-2020)
|
||||
|
||||
- [GUI] When copy-pasting token, any surrounding spaces are now discarded, in addition to double quotes.
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||
<Version>2.21.2</Version>
|
||||
<Version>2.26</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<WarningsAsErrors>nullable</WarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,9 +1,10 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Utilities;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exporting;
|
||||
|
||||
@@ -12,27 +13,30 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
||||
public abstract class ExportCommandBase : TokenCommandBase
|
||||
{
|
||||
[CommandOption("output", 'o', Description = "Output file or directory path.")]
|
||||
public string OutputPath { get; set; } = Directory.GetCurrentDirectory();
|
||||
public string OutputPath { get; init; } = Directory.GetCurrentDirectory();
|
||||
|
||||
[CommandOption("format", 'f', Description = "Output file format.")]
|
||||
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||
[CommandOption("format", 'f', Description = "Export format.")]
|
||||
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
||||
|
||||
[CommandOption("after", Description = "Limit to messages sent after this date.")]
|
||||
public DateTimeOffset? After { get; set; }
|
||||
[CommandOption("after", Description = "Only include messages sent after this date or message ID.")]
|
||||
public Snowflake? After { get; init; }
|
||||
|
||||
[CommandOption("before", Description = "Limit to messages sent before this date.")]
|
||||
public DateTimeOffset? Before { get; set; }
|
||||
[CommandOption("before", Description = "Only include messages sent before this date or message ID.")]
|
||||
public Snowflake? Before { get; init; }
|
||||
|
||||
[CommandOption("partition", 'p', Description = "Split output into partitions limited to this number of messages.")]
|
||||
public int? PartitionLimit { get; set; }
|
||||
public int? PartitionLimit { get; init; }
|
||||
|
||||
[CommandOption("media", Description = "Download referenced media content.")]
|
||||
public bool ShouldDownloadMedia { get; set; }
|
||||
public bool ShouldDownloadMedia { get; init; }
|
||||
|
||||
[CommandOption("dateformat", Description = "Date format used in output.")]
|
||||
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||
[CommandOption("reuse-media", Description = "Reuse already existing media content to skip redundant downloads.")]
|
||||
public bool ShouldReuseMedia { get; init; }
|
||||
|
||||
protected ChannelExporter GetChannelExporter() => new ChannelExporter(GetDiscordClient());
|
||||
[CommandOption("dateformat", Description = "Format used when writing dates.")]
|
||||
public string DateFormat { get; init; } = "dd-MMM-yy hh:mm tt";
|
||||
|
||||
protected ChannelExporter GetChannelExporter() => new(GetDiscordClient());
|
||||
|
||||
protected async ValueTask ExportAsync(IConsole console, Guild guild, Channel channel)
|
||||
{
|
||||
@@ -48,6 +52,7 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
||||
Before,
|
||||
PartitionLimit,
|
||||
ShouldDownloadMedia,
|
||||
ShouldReuseMedia,
|
||||
DateFormat
|
||||
);
|
||||
|
||||
@@ -63,10 +68,20 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
||||
await ExportAsync(console, guild, channel);
|
||||
}
|
||||
|
||||
protected async ValueTask ExportAsync(IConsole console, string channelId)
|
||||
protected async ValueTask ExportAsync(IConsole console, Snowflake channelId)
|
||||
{
|
||||
var channel = await GetDiscordClient().GetChannelAsync(channelId);
|
||||
await ExportAsync(console, channel);
|
||||
}
|
||||
|
||||
public override ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
if (ShouldReuseMedia && !ShouldDownloadMedia)
|
||||
{
|
||||
throw new CommandException("The --reuse-media option cannot be used without the --media option.");
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -16,21 +17,22 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
||||
{
|
||||
public abstract class ExportMultipleCommandBase : ExportCommandBase
|
||||
{
|
||||
[CommandOption("parallel", Description = "Export this number of channels in parallel.")]
|
||||
public int ParallelLimit { get; set; } = 1;
|
||||
[CommandOption("parallel", Description = "Limits how many channels can be exported in parallel.")]
|
||||
public int ParallelLimit { get; init; } = 1;
|
||||
|
||||
protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||
{
|
||||
// HACK: this uses a separate route from ExportCommandBase because the progress ticker is not thread-safe
|
||||
// This uses a different route from ExportCommandBase.ExportAsync() because it runs
|
||||
// in parallel and needs another way to report progress to console.
|
||||
|
||||
console.Output.Write($"Exporting {channels.Count} channels... ");
|
||||
var progress = console.CreateProgressTicker();
|
||||
|
||||
var operations = progress.Wrap().CreateOperations(channels.Count);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
var successfulExportCount = 0;
|
||||
var errors = new ConcurrentBag<(Channel, string)>();
|
||||
|
||||
await channels.Zip(operations).ParallelForEachAsync(async tuple =>
|
||||
{
|
||||
var (channel, operation) = tuple;
|
||||
@@ -48,6 +50,7 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
||||
Before,
|
||||
PartitionLimit,
|
||||
ShouldDownloadMedia,
|
||||
ShouldReuseMedia,
|
||||
DateFormat
|
||||
);
|
||||
|
||||
@@ -57,7 +60,7 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
||||
}
|
||||
catch (DiscordChatExporterException ex) when (!ex.IsCritical)
|
||||
{
|
||||
errors.Add(ex.Message);
|
||||
errors.Add((channel, ex.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -67,8 +70,8 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
||||
|
||||
console.Output.WriteLine();
|
||||
|
||||
foreach (var error in errors)
|
||||
console.Error.WriteLine(error);
|
||||
foreach (var (channel, error) in errors)
|
||||
console.Error.WriteLine($"Channel '{channel}': {error}");
|
||||
|
||||
console.Output.WriteLine($"Successfully exported {successfulExportCount} channel(s).");
|
||||
}
|
||||
|
||||
@@ -7,17 +7,20 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
||||
{
|
||||
public abstract class TokenCommandBase : ICommand
|
||||
{
|
||||
[CommandOption("token", 't', IsRequired = true, EnvironmentVariableName = "DISCORD_TOKEN",
|
||||
Description = "Authorization token.")]
|
||||
public string TokenValue { get; set; } = "";
|
||||
[CommandOption("token", 't', IsRequired = true, EnvironmentVariableName = "DISCORD_TOKEN", Description = "Authorization token.")]
|
||||
public string TokenValue { get; init; } = "";
|
||||
|
||||
[CommandOption("bot", 'b', EnvironmentVariableName = "DISCORD_TOKEN_BOT",
|
||||
Description = "Whether this authorization token belongs to a bot.")]
|
||||
public bool IsBotToken { get; set; }
|
||||
[CommandOption("bot", 'b', EnvironmentVariableName = "DISCORD_TOKEN_BOT", Description = "Authorize as a bot.")]
|
||||
public bool IsBotToken { get; init; }
|
||||
|
||||
protected AuthToken GetAuthToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue);
|
||||
protected AuthToken GetAuthToken() => new(
|
||||
IsBotToken
|
||||
? AuthTokenType.Bot
|
||||
: AuthTokenType.User,
|
||||
TokenValue
|
||||
);
|
||||
|
||||
protected DiscordClient GetDiscordClient() => new DiscordClient(GetAuthToken());
|
||||
protected DiscordClient GetDiscordClient() => new(GetAuthToken());
|
||||
|
||||
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("exportall", Description = "Export all accessible channels.")]
|
||||
public class ExportAllCommand : ExportMultipleCommandBase
|
||||
{
|
||||
[CommandOption("include-dm", Description = "Include direct message channels.")]
|
||||
public bool IncludeDirectMessages { get; init; } = true;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var channels = new List<Channel>();
|
||||
|
||||
// Aggregate channels from all guilds
|
||||
await foreach (var guild in GetDiscordClient().GetUserGuildsAsync())
|
||||
{
|
||||
// Skip DMs if instructed to
|
||||
if (!IncludeDirectMessages && guild.Id == Guild.DirectMessages.Id)
|
||||
continue;
|
||||
|
||||
await foreach (var channel in GetDiscordClient().GetGuildChannelsAsync(guild.Id))
|
||||
{
|
||||
channels.Add(channel);
|
||||
}
|
||||
}
|
||||
|
||||
await ExportMultipleAsync(console, channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
@@ -9,8 +10,12 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
public class ExportChannelCommand : ExportCommandBase
|
||||
{
|
||||
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID.")]
|
||||
public string ChannelId { get; set; } = "";
|
||||
public Snowflake ChannelId { get; init; }
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console) => await ExportAsync(console, ChannelId);
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
await base.ExecuteAsync(console);
|
||||
await ExportAsync(console, ChannelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var dmChannels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
||||
await ExportMultipleAsync(console, dmChannels);
|
||||
var channels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
||||
await ExportMultipleAsync(console, channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
@@ -10,12 +11,12 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
public class ExportGuildCommand : ExportMultipleCommandBase
|
||||
{
|
||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
||||
public string GuildId { get; set; } = "";
|
||||
public Snowflake GuildId { get; init; }
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
||||
await ExportMultipleAsync(console, guildChannels);
|
||||
var channels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
||||
await ExportMultipleAsync(console, channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,22 @@ using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("channels", Description = "Get the list of channels in specified guild.")]
|
||||
[Command("channels", Description = "Get the list of channels in a guild.")]
|
||||
public class GetChannelsCommand : TokenCommandBase
|
||||
{
|
||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
||||
public string GuildId { get; set; } = "";
|
||||
public Snowflake GuildId { get; init; }
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
||||
var channels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
||||
|
||||
foreach (var channel in guildChannels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
||||
foreach (var channel in channels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
||||
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var dmChannels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
||||
var channels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
||||
|
||||
foreach (var channel in dmChannels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
||||
foreach (var channel in channels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
||||
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
public ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get user token:"));
|
||||
console.WithForegroundColor(ConsoleColor.White, () =>
|
||||
console.Output.WriteLine("To get user token:")
|
||||
);
|
||||
console.Output.WriteLine(" 1. Open Discord");
|
||||
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
||||
console.Output.WriteLine(" 3. Navigate to the Application tab");
|
||||
@@ -20,14 +22,18 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
console.Output.WriteLine(" * Automating user accounts is technically against TOS, use at your own risk.");
|
||||
console.Output.WriteLine();
|
||||
|
||||
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get bot token:"));
|
||||
console.WithForegroundColor(ConsoleColor.White, () =>
|
||||
console.Output.WriteLine("To get bot token:")
|
||||
);
|
||||
console.Output.WriteLine(" 1. Go to Discord developer portal");
|
||||
console.Output.WriteLine(" 2. Open your application's settings");
|
||||
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
||||
console.Output.WriteLine(" 4. Under Token click Copy");
|
||||
console.Output.WriteLine();
|
||||
|
||||
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get guild ID or guild channel ID:"));
|
||||
console.WithForegroundColor(ConsoleColor.White, () =>
|
||||
console.Output.WriteLine("To get guild ID or guild channel ID:")
|
||||
);
|
||||
console.Output.WriteLine(" 1. Open Discord");
|
||||
console.Output.WriteLine(" 2. Open Settings");
|
||||
console.Output.WriteLine(" 3. Go to Appearance section");
|
||||
@@ -35,7 +41,9 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
|
||||
console.Output.WriteLine();
|
||||
|
||||
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get direct message channel ID:"));
|
||||
console.WithForegroundColor(ConsoleColor.White, () =>
|
||||
console.Output.WriteLine("To get direct message channel ID:")
|
||||
);
|
||||
console.Output.WriteLine(" 1. Open Discord");
|
||||
console.Output.WriteLine(" 2. Open the desired direct message channel");
|
||||
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
||||
@@ -44,8 +52,12 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
console.Output.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
|
||||
console.Output.WriteLine();
|
||||
|
||||
console.Output.WriteLine("For more information, check out the wiki:");
|
||||
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
|
||||
console.WithForegroundColor(ConsoleColor.White,
|
||||
() => console.Output.WriteLine("For more information, check out the wiki:")
|
||||
);
|
||||
console.WithForegroundColor(ConsoleColor.Blue,
|
||||
() => console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki")
|
||||
);
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="../DiscordChatExporter.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -7,7 +6,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="1.3.1" />
|
||||
<PackageReference Include="CliFx" Version="1.6.0" />
|
||||
<PackageReference Include="Gress" Version="1.2.0" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// ReSharper disable CheckNamespace
|
||||
// TODO: remove after moving to .NET 5
|
||||
|
||||
namespace System.Runtime.CompilerServices
|
||||
{
|
||||
internal static class IsExternalInit
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -8,51 +8,36 @@ using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exceptions;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using Polly;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using JsonExtensions.Http;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord
|
||||
{
|
||||
public class DiscordClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly AuthToken _token;
|
||||
private readonly HttpClient _httpClient = Singleton.HttpClient;
|
||||
private readonly IAsyncPolicy<HttpResponseMessage> _httpRequestPolicy;
|
||||
|
||||
private readonly Uri _baseUri = new Uri("https://discord.com/api/v6/", UriKind.Absolute);
|
||||
private readonly Uri _baseUri = new("https://discord.com/api/v8/", UriKind.Absolute);
|
||||
|
||||
public DiscordClient(AuthToken token)
|
||||
public DiscordClient(HttpClient httpClient, AuthToken token)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_token = token;
|
||||
|
||||
// Discord seems to always respond with 429 on the first request with unreasonable wait time (10+ minutes).
|
||||
// For that reason the policy will ignore such errors at first, then wait a constant amount of time, and
|
||||
// finally wait the specified amount of time, based on how many requests have failed in a row.
|
||||
_httpRequestPolicy = Policy
|
||||
.HandleResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
|
||||
.WaitAndRetryAsync(6,
|
||||
(i, result, ctx) =>
|
||||
{
|
||||
if (i <= 3)
|
||||
return TimeSpan.FromSeconds(2 * i);
|
||||
|
||||
if (i <= 5)
|
||||
return TimeSpan.FromSeconds(5 * i);
|
||||
|
||||
return result.Result.Headers.RetryAfter.Delta ?? TimeSpan.FromSeconds(10 * i);
|
||||
},
|
||||
(response, timespan, retryCount, context) => Task.CompletedTask
|
||||
);
|
||||
}
|
||||
|
||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) => await _httpRequestPolicy.ExecuteAsync(async () =>
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||
request.Headers.Authorization = _token.GetAuthorizationHeader();
|
||||
public DiscordClient(AuthToken token)
|
||||
: this(Http.Client, token) {}
|
||||
|
||||
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
});
|
||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) =>
|
||||
await Http.ResponsePolicy.ExecuteAsync(async () =>
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||
request.Headers.Authorization = _token.GetAuthorizationHeader();
|
||||
|
||||
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
});
|
||||
|
||||
private async ValueTask<JsonElement> GetJsonResponseAsync(string url)
|
||||
{
|
||||
@@ -85,13 +70,14 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
{
|
||||
yield return Guild.DirectMessages;
|
||||
|
||||
var afterId = "";
|
||||
var currentAfter = Snowflake.Zero;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath("users/@me/guilds")
|
||||
.SetQueryParameter("limit", "100")
|
||||
.SetQueryParameter("after", afterId)
|
||||
.SetQueryParameter("after", currentAfter.ToString())
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url);
|
||||
@@ -101,7 +87,7 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
{
|
||||
yield return guild;
|
||||
|
||||
afterId = guild.Id;
|
||||
currentAfter = guild.Id;
|
||||
isEmpty = false;
|
||||
}
|
||||
|
||||
@@ -110,7 +96,7 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Guild> GetGuildAsync(string guildId)
|
||||
public async ValueTask<Guild> GetGuildAsync(Snowflake guildId)
|
||||
{
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
return Guild.DirectMessages;
|
||||
@@ -119,7 +105,7 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
return Guild.Parse(response);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(string guildId)
|
||||
public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(Snowflake guildId)
|
||||
{
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
{
|
||||
@@ -156,7 +142,7 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Role> GetGuildRolesAsync(string guildId)
|
||||
public async IAsyncEnumerable<Role> GetGuildRolesAsync(Snowflake guildId)
|
||||
{
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
yield break;
|
||||
@@ -167,7 +153,7 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
yield return Role.Parse(roleJson);
|
||||
}
|
||||
|
||||
public async ValueTask<Member?> TryGetGuildMemberAsync(string guildId, User user)
|
||||
public async ValueTask<Member?> TryGetGuildMemberAsync(Snowflake guildId, User user)
|
||||
{
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
return Member.CreateForUser(user);
|
||||
@@ -176,30 +162,31 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
return response?.Pipe(Member.Parse);
|
||||
}
|
||||
|
||||
private async ValueTask<string> GetChannelCategoryAsync(string channelParentId)
|
||||
private async ValueTask<string> GetChannelCategoryAsync(Snowflake channelParentId)
|
||||
{
|
||||
var response = await GetJsonResponseAsync($"channels/{channelParentId}");
|
||||
return response.GetProperty("name").GetString();
|
||||
}
|
||||
|
||||
public async ValueTask<Channel> GetChannelAsync(string channelId)
|
||||
public async ValueTask<Channel> GetChannelAsync(Snowflake channelId)
|
||||
{
|
||||
var response = await GetJsonResponseAsync($"channels/{channelId}");
|
||||
|
||||
var parentId = response.GetPropertyOrNull("parent_id")?.GetString();
|
||||
var category = !string.IsNullOrWhiteSpace(parentId)
|
||||
? await GetChannelCategoryAsync(parentId)
|
||||
var parentId = response.GetPropertyOrNull("parent_id")?.GetString().Pipe(Snowflake.Parse);
|
||||
|
||||
var category = parentId != null
|
||||
? await GetChannelCategoryAsync(parentId.Value)
|
||||
: null;
|
||||
|
||||
return Channel.Parse(response, category);
|
||||
}
|
||||
|
||||
private async ValueTask<Message?> TryGetLastMessageAsync(string channelId, DateTimeOffset? before = null)
|
||||
private async ValueTask<Message?> TryGetLastMessageAsync(Snowflake channelId, Snowflake? before = null)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channelId}/messages")
|
||||
.SetQueryParameter("limit", "1")
|
||||
.SetQueryParameter("before", before?.ToSnowflake())
|
||||
.SetQueryParameter("before", before?.ToString())
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url);
|
||||
@@ -207,9 +194,9 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Message> GetMessagesAsync(
|
||||
string channelId,
|
||||
DateTimeOffset? after = null,
|
||||
DateTimeOffset? before = null,
|
||||
Snowflake channelId,
|
||||
Snowflake? after = null,
|
||||
Snowflake? before = null,
|
||||
IProgress<double>? progress = null)
|
||||
{
|
||||
// Get the last message in the specified range.
|
||||
@@ -217,19 +204,19 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
// will not appear in the output.
|
||||
// Additionally, it provides the date of the last message, which is used to calculate progress.
|
||||
var lastMessage = await TryGetLastMessageAsync(channelId, before);
|
||||
if (lastMessage == null || lastMessage.Timestamp < after)
|
||||
if (lastMessage == null || lastMessage.Timestamp < after?.ToDate())
|
||||
yield break;
|
||||
|
||||
// Keep track of first message in range in order to calculate progress
|
||||
var firstMessage = default(Message);
|
||||
var afterId = after?.ToSnowflake() ?? "0";
|
||||
var currentAfter = after ?? Snowflake.Zero;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channelId}/messages")
|
||||
.SetQueryParameter("limit", "100")
|
||||
.SetQueryParameter("after", afterId)
|
||||
.SetQueryParameter("after", currentAfter.ToString())
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url);
|
||||
@@ -259,9 +246,9 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
);
|
||||
|
||||
yield return message;
|
||||
afterId = message.Id;
|
||||
currentAfter = message.Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#attachment-object
|
||||
public partial class Attachment : IHasId
|
||||
{
|
||||
public string Id { get; }
|
||||
public Snowflake Id { get; }
|
||||
|
||||
public string Url { get; }
|
||||
|
||||
@@ -20,15 +21,18 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public int? Height { get; }
|
||||
|
||||
public bool IsImage =>
|
||||
ImageFileExtensions.Contains(Path.GetExtension(FileName), StringComparer.OrdinalIgnoreCase);
|
||||
public bool IsImage => ImageFileExtensions.Contains(Path.GetExtension(FileName));
|
||||
|
||||
public bool IsVideo => VideoFileExtensions.Contains(Path.GetExtension(FileName));
|
||||
|
||||
public bool IsAudio => AudioFileExtensions.Contains(Path.GetExtension(FileName));
|
||||
|
||||
public bool IsSpoiler =>
|
||||
IsImage && FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
||||
(IsImage || IsVideo || IsAudio) && FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
||||
|
||||
public FileSize FileSize { get; }
|
||||
|
||||
public Attachment(string id, string url, string fileName, int? width, int? height, FileSize fileSize)
|
||||
public Attachment(Snowflake id, string url, string fileName, int? width, int? height, FileSize fileSize)
|
||||
{
|
||||
Id = id;
|
||||
Url = url;
|
||||
@@ -43,11 +47,18 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public partial class Attachment
|
||||
{
|
||||
private static readonly string[] ImageFileExtensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp"};
|
||||
private static readonly HashSet<string> ImageFileExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"};
|
||||
|
||||
private static readonly HashSet<string> VideoFileExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{".mp4", ".webm"};
|
||||
|
||||
private static readonly HashSet<string> AudioFileExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{".mp3", ".wav", ".ogg", ".flac", ".m4a"};
|
||||
|
||||
public static Attachment Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString();
|
||||
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
|
||||
var url = json.GetProperty("url").GetString();
|
||||
var width = json.GetPropertyOrNull("width")?.GetInt32();
|
||||
var height = json.GetPropertyOrNull("height")?.GetInt32();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using JsonExtensions.Reading;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
@@ -22,7 +23,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
// https://discord.com/developers/docs/resources/channel#channel-object
|
||||
public partial class Channel : IHasId
|
||||
{
|
||||
public string Id { get; }
|
||||
public Snowflake Id { get; }
|
||||
|
||||
public ChannelType Type { get; }
|
||||
|
||||
@@ -33,7 +34,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
Type == ChannelType.GuildNews ||
|
||||
Type == ChannelType.GuildStore;
|
||||
|
||||
public string GuildId { get; }
|
||||
public Snowflake GuildId { get; }
|
||||
|
||||
public string Category { get; }
|
||||
|
||||
@@ -41,7 +42,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public string? Topic { get; }
|
||||
|
||||
public Channel(string id, ChannelType type, string guildId, string category, string name, string? topic)
|
||||
public Channel(Snowflake id, ChannelType type, Snowflake guildId, string category, string name, string? topic)
|
||||
{
|
||||
Id = id;
|
||||
Type = type;
|
||||
@@ -68,8 +69,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public static Channel Parse(JsonElement json, string? category = null)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString();
|
||||
var guildId = json.GetPropertyOrNull("guild_id")?.GetString();
|
||||
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
|
||||
var guildId = json.GetPropertyOrNull("guild_id")?.GetString().Pipe(Snowflake.Parse);
|
||||
var topic = json.GetPropertyOrNull("topic")?.GetString();
|
||||
|
||||
var type = (ChannelType) json.GetProperty("type").GetInt32();
|
||||
@@ -77,7 +78,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
var name =
|
||||
json.GetPropertyOrNull("name")?.GetString() ??
|
||||
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ??
|
||||
id;
|
||||
id.ToString();
|
||||
|
||||
return new Channel(
|
||||
id,
|
||||
|
||||
@@ -52,7 +52,7 @@ namespace DiscordChatExporter.Domain.Discord.Models.Common
|
||||
if (Math.Abs(TotalKiloBytes) >= 1)
|
||||
return "KB";
|
||||
|
||||
return "B";
|
||||
return "bytes";
|
||||
}
|
||||
|
||||
public override string ToString() => $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}";
|
||||
@@ -60,6 +60,6 @@ namespace DiscordChatExporter.Domain.Discord.Models.Common
|
||||
|
||||
public partial struct FileSize
|
||||
{
|
||||
public static FileSize FromBytes(long bytes) => new FileSize(bytes);
|
||||
public static FileSize FromBytes(long bytes) => new(bytes);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,6 @@
|
||||
{
|
||||
public interface IHasId
|
||||
{
|
||||
string Id { get; }
|
||||
Snowflake Id { get; }
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,13 @@ namespace DiscordChatExporter.Domain.Discord.Models.Common
|
||||
{
|
||||
public partial class IdBasedEqualityComparer : IEqualityComparer<IHasId>
|
||||
{
|
||||
public bool Equals(IHasId? x, IHasId? y) => StringComparer.Ordinal.Equals(x?.Id, y?.Id);
|
||||
public bool Equals(IHasId? x, IHasId? y) => x?.Id == y?.Id;
|
||||
|
||||
public int GetHashCode(IHasId obj) => StringComparer.Ordinal.GetHashCode(obj.Id);
|
||||
public int GetHashCode(IHasId obj) => obj.Id.GetHashCode();
|
||||
}
|
||||
|
||||
public partial class IdBasedEqualityComparer
|
||||
{
|
||||
public static IdBasedEqualityComparer Instance { get; } = new IdBasedEqualityComparer();
|
||||
public static IdBasedEqualityComparer Instance { get; } = new();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/guild#guild-object
|
||||
public partial class Guild : IHasId
|
||||
{
|
||||
public string Id { get; }
|
||||
public Snowflake Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string IconUrl { get; }
|
||||
|
||||
public Guild(string id, string name, string iconUrl)
|
||||
public Guild(Snowflake id, string name, string iconUrl)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
@@ -24,18 +25,17 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public partial class Guild
|
||||
{
|
||||
public static Guild DirectMessages { get; } =
|
||||
new Guild("@me", "Direct Messages", GetDefaultIconUrl());
|
||||
public static Guild DirectMessages { get; } = new(Snowflake.Zero, "Direct Messages", GetDefaultIconUrl());
|
||||
|
||||
private static string GetDefaultIconUrl() =>
|
||||
"https://cdn.discordapp.com/embed/avatars/0.png";
|
||||
|
||||
private static string GetIconUrl(string id, string iconHash) =>
|
||||
private static string GetIconUrl(Snowflake id, string iconHash) =>
|
||||
$"https://cdn.discordapp.com/icons/{id}/{iconHash}.png";
|
||||
|
||||
public static Guild Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString();
|
||||
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
|
||||
var name = json.GetProperty("name").GetString();
|
||||
var iconHash = json.GetProperty("icon").GetString();
|
||||
|
||||
|
||||
@@ -3,22 +3,23 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/guild#guild-member-object
|
||||
public partial class Member : IHasId
|
||||
{
|
||||
public string Id => User.Id;
|
||||
public Snowflake Id => User.Id;
|
||||
|
||||
public User User { get; }
|
||||
|
||||
public string Nick { get; }
|
||||
|
||||
public IReadOnlyList<string> RoleIds { get; }
|
||||
public IReadOnlyList<Snowflake> RoleIds { get; }
|
||||
|
||||
public Member(User user, string? nick, IReadOnlyList<string> roleIds)
|
||||
public Member(User user, string? nick, IReadOnlyList<Snowflake> roleIds)
|
||||
{
|
||||
User = user;
|
||||
Nick = nick ?? user.Name;
|
||||
@@ -30,8 +31,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public partial class Member
|
||||
{
|
||||
public static Member CreateForUser(User user) =>
|
||||
new Member(user, null, Array.Empty<string>());
|
||||
public static Member CreateForUser(User user) => new(user, null, Array.Empty<Snowflake>());
|
||||
|
||||
public static Member Parse(JsonElement json)
|
||||
{
|
||||
@@ -39,8 +39,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
var nick = json.GetPropertyOrNull("nick")?.GetString();
|
||||
|
||||
var roleIds =
|
||||
json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString()).ToArray() ??
|
||||
Array.Empty<string>();
|
||||
json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString().Pipe(Snowflake.Parse)).ToArray() ??
|
||||
Array.Empty<Snowflake>();
|
||||
|
||||
return new Member(
|
||||
user,
|
||||
|
||||
@@ -3,27 +3,29 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||
public enum MessageType
|
||||
{
|
||||
Default,
|
||||
RecipientAdd,
|
||||
RecipientRemove,
|
||||
Call,
|
||||
ChannelNameChange,
|
||||
ChannelIconChange,
|
||||
ChannelPinnedMessage,
|
||||
GuildMemberJoin
|
||||
Default = 0,
|
||||
RecipientAdd = 1,
|
||||
RecipientRemove = 2,
|
||||
Call = 3,
|
||||
ChannelNameChange = 4,
|
||||
ChannelIconChange = 5,
|
||||
ChannelPinnedMessage = 6,
|
||||
GuildMemberJoin = 7,
|
||||
Reply = 19
|
||||
}
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#message-object
|
||||
public partial class Message : IHasId
|
||||
{
|
||||
public string Id { get; }
|
||||
public Snowflake Id { get; }
|
||||
|
||||
public MessageType Type { get; }
|
||||
|
||||
@@ -33,6 +35,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public DateTimeOffset? EditedTimestamp { get; }
|
||||
|
||||
public DateTimeOffset? CallEndedTimestamp { get; }
|
||||
|
||||
public bool IsPinned { get; }
|
||||
|
||||
public string Content { get; }
|
||||
@@ -45,30 +49,40 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public IReadOnlyList<User> MentionedUsers { get; }
|
||||
|
||||
public MessageReference? Reference { get; }
|
||||
|
||||
public Message? ReferencedMessage { get; }
|
||||
|
||||
public Message(
|
||||
string id,
|
||||
Snowflake id,
|
||||
MessageType type,
|
||||
User author,
|
||||
DateTimeOffset timestamp,
|
||||
DateTimeOffset? editedTimestamp,
|
||||
DateTimeOffset? callEndedTimestamp,
|
||||
bool isPinned,
|
||||
string content,
|
||||
IReadOnlyList<Attachment> attachments,
|
||||
IReadOnlyList<Embed> embeds,
|
||||
IReadOnlyList<Reaction> reactions,
|
||||
IReadOnlyList<User> mentionedUsers)
|
||||
IReadOnlyList<User> mentionedUsers,
|
||||
MessageReference? messageReference,
|
||||
Message? referencedMessage)
|
||||
{
|
||||
Id = id;
|
||||
Type = type;
|
||||
Author = author;
|
||||
Timestamp = timestamp;
|
||||
EditedTimestamp = editedTimestamp;
|
||||
CallEndedTimestamp = callEndedTimestamp;
|
||||
IsPinned = isPinned;
|
||||
Content = content;
|
||||
Attachments = attachments;
|
||||
Embeds = embeds;
|
||||
Reactions = reactions;
|
||||
MentionedUsers = mentionedUsers;
|
||||
Reference = messageReference;
|
||||
ReferencedMessage = referencedMessage;
|
||||
}
|
||||
|
||||
public override string ToString() => Content;
|
||||
@@ -78,18 +92,22 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
public static Message Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString();
|
||||
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
|
||||
var author = json.GetProperty("author").Pipe(User.Parse);
|
||||
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
||||
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
|
||||
var callEndedTimestamp = json.GetPropertyOrNull("call")?.GetPropertyOrNull("ended_timestamp")?.GetDateTimeOffset();
|
||||
var type = (MessageType) json.GetProperty("type").GetInt32();
|
||||
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
|
||||
var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse);
|
||||
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Message.Parse);
|
||||
|
||||
var content = type switch
|
||||
{
|
||||
MessageType.RecipientAdd => "Added a recipient.",
|
||||
MessageType.RecipientRemove => "Removed a recipient.",
|
||||
MessageType.Call => "Started a call.",
|
||||
MessageType.Call =>
|
||||
$"Started a call that lasted {callEndedTimestamp?.Pipe(t => t - timestamp).Pipe(t => (int) t.TotalMinutes) ?? 0} minutes.",
|
||||
MessageType.ChannelNameChange => "Changed the channel name.",
|
||||
MessageType.ChannelIconChange => "Changed the channel icon.",
|
||||
MessageType.ChannelPinnedMessage => "Pinned a message.",
|
||||
@@ -119,12 +137,15 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
author,
|
||||
timestamp,
|
||||
editedTimestamp,
|
||||
callEndedTimestamp,
|
||||
isPinned,
|
||||
content,
|
||||
attachments,
|
||||
embeds,
|
||||
reactions,
|
||||
mentionedUsers
|
||||
mentionedUsers,
|
||||
messageReference,
|
||||
referencedMessage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
|
||||
public partial class MessageReference
|
||||
{
|
||||
public Snowflake? MessageId { get; }
|
||||
|
||||
public Snowflake? ChannelId { get; }
|
||||
|
||||
public Snowflake? GuildId { get; }
|
||||
|
||||
public MessageReference(Snowflake? messageId, Snowflake? channelId, Snowflake? guildId)
|
||||
{
|
||||
MessageId = messageId;
|
||||
ChannelId = channelId;
|
||||
GuildId = guildId;
|
||||
}
|
||||
|
||||
public override string ToString() => MessageId?.ToString() ?? "<unknown reference>";
|
||||
}
|
||||
|
||||
public partial class MessageReference
|
||||
{
|
||||
public static MessageReference Parse(JsonElement json)
|
||||
{
|
||||
var messageId = json.GetPropertyOrNull("message_id")?.GetString().Pipe(Snowflake.Parse);
|
||||
var channelId = json.GetPropertyOrNull("channel_id")?.GetString().Pipe(Snowflake.Parse);
|
||||
var guildId = json.GetPropertyOrNull("guild_id")?.GetString().Pipe(Snowflake.Parse);
|
||||
|
||||
return new MessageReference(messageId, channelId, guildId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using System.Drawing;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/topics/permissions#role-object
|
||||
public partial class Role
|
||||
public partial class Role : IHasId
|
||||
{
|
||||
public string Id { get; }
|
||||
public Snowflake Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
@@ -15,7 +18,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public Color? Color { get; }
|
||||
|
||||
public Role(string id, string name, int position, Color? color)
|
||||
public Role(Snowflake id, string name, int position, Color? color)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
@@ -30,7 +33,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
public static Role Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString();
|
||||
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
|
||||
var name = json.GetProperty("name").GetString();
|
||||
var position = json.GetProperty("position").GetInt32();
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/user#user-object
|
||||
public partial class User : IHasId
|
||||
{
|
||||
public string Id { get; }
|
||||
public Snowflake Id { get; }
|
||||
|
||||
public bool IsBot { get; }
|
||||
|
||||
@@ -20,7 +21,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public string AvatarUrl { get; }
|
||||
|
||||
public User(string id, bool isBot, int discriminator, string name, string avatarUrl)
|
||||
public User(Snowflake id, bool isBot, int discriminator, string name, string avatarUrl)
|
||||
{
|
||||
Id = id;
|
||||
IsBot = isBot;
|
||||
@@ -37,7 +38,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
private static string GetDefaultAvatarUrl(int discriminator) =>
|
||||
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
|
||||
|
||||
private static string GetAvatarUrl(string id, string avatarHash)
|
||||
private static string GetAvatarUrl(Snowflake id, string avatarHash)
|
||||
{
|
||||
// Animated
|
||||
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
|
||||
@@ -49,7 +50,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public static User Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString();
|
||||
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
|
||||
var discriminator = json.GetProperty("discriminator").GetString().Pipe(int.Parse);
|
||||
var name = json.GetProperty("username").GetString();
|
||||
var avatarHash = json.GetProperty("avatar").GetString();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord
|
||||
{
|
||||
public readonly partial struct Snowflake
|
||||
{
|
||||
public ulong Value { get; }
|
||||
|
||||
public Snowflake(ulong value) => Value = value;
|
||||
|
||||
public DateTimeOffset ToDate() =>
|
||||
DateTimeOffset.FromUnixTimeMilliseconds((long) ((Value >> 22) + 1420070400000UL)).ToLocalTime();
|
||||
|
||||
public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public partial struct Snowflake
|
||||
{
|
||||
public static Snowflake Zero { get; } = new(0);
|
||||
|
||||
public static Snowflake FromDate(DateTimeOffset date)
|
||||
{
|
||||
var value = ((ulong) date.ToUnixTimeMilliseconds() - 1420070400000UL) << 22;
|
||||
return new Snowflake(value);
|
||||
}
|
||||
|
||||
public static Snowflake? TryParse(string? str, IFormatProvider? formatProvider = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(str))
|
||||
return null;
|
||||
|
||||
// As number
|
||||
if (Regex.IsMatch(str, @"^\d{15,}$") &&
|
||||
ulong.TryParse(str, NumberStyles.Number, formatProvider, out var value))
|
||||
{
|
||||
return new Snowflake(value);
|
||||
}
|
||||
|
||||
// As date
|
||||
if (DateTimeOffset.TryParse(str, formatProvider, DateTimeStyles.None, out var date))
|
||||
{
|
||||
return FromDate(date);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Snowflake Parse(string str, IFormatProvider? formatProvider) =>
|
||||
TryParse(str, formatProvider) ?? throw new FormatException($"Invalid snowflake: {str}.");
|
||||
|
||||
public static Snowflake Parse(string str) => Parse(str, null);
|
||||
}
|
||||
|
||||
public partial struct Snowflake : IEquatable<Snowflake>
|
||||
{
|
||||
public bool Equals(Snowflake other) => Value == other.Value;
|
||||
|
||||
public override bool Equals(object? obj) => obj is Snowflake other && Equals(other);
|
||||
|
||||
public override int GetHashCode() => Value.GetHashCode();
|
||||
|
||||
public static bool operator ==(Snowflake left, Snowflake right) => left.Equals(right);
|
||||
|
||||
public static bool operator !=(Snowflake left, Snowflake right) => !(left == right);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="../DiscordChatExporter.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MiniRazor" Version="1.0.1" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.0.1" />
|
||||
<PackageReference Include="MiniRazor" Version="2.0.3" />
|
||||
<PackageReference Include="Polly" Version="7.2.1" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||
</ItemGroup>
|
||||
@@ -11,9 +15,9 @@
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\Core.css" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\Dark.css" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\Light.css" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\PreambleTemplate.cshtml" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\PostambleTemplate.cshtml" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\MessageGroupTemplate.cshtml" />
|
||||
<AdditionalFiles Include="Exporting\Writers\Html\PreambleTemplate.cshtml" IsRazorTemplate="true" />
|
||||
<AdditionalFiles Include="Exporting\Writers\Html\PostambleTemplate.cshtml" IsRazorTemplate="true" />
|
||||
<AdditionalFiles Include="Exporting\Writers\Html\MessageGroupTemplate.cshtml" IsRazorTemplate="true" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -46,9 +46,9 @@ Failed to perform an HTTP request.
|
||||
return new DiscordChatExporterException(message);
|
||||
}
|
||||
|
||||
internal static DiscordChatExporterException ChannelIsEmpty(string channel)
|
||||
internal static DiscordChatExporterException ChannelIsEmpty()
|
||||
{
|
||||
var message = $"Channel '{channel}' contains no messages for the specified period.";
|
||||
var message = $"No messages for the specified period.";
|
||||
return new DiscordChatExporterException(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
|
||||
// Throw if no messages were exported
|
||||
if (!exportedAnything)
|
||||
throw DiscordChatExporterException.ChannelIsEmpty(request.Channel.Name);
|
||||
throw DiscordChatExporterException.ChannelIsEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using Tyrrrz.Extensions;
|
||||
@@ -34,21 +35,23 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
Channels = channels;
|
||||
Roles = roles;
|
||||
|
||||
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath);
|
||||
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath, request.ShouldReuseMedia);
|
||||
}
|
||||
|
||||
public string FormatDate(DateTimeOffset date) => date.ToLocalString(Request.DateFormat);
|
||||
public string FormatDate(DateTimeOffset date) => Request.DateFormat switch
|
||||
{
|
||||
"unix" => date.ToUnixTimeSeconds().ToString(),
|
||||
"unixms" => date.ToUnixTimeMilliseconds().ToString(),
|
||||
var dateFormat => date.ToLocalString(dateFormat)
|
||||
};
|
||||
|
||||
public Member? TryGetMember(string id) =>
|
||||
Members.FirstOrDefault(m => m.Id == id);
|
||||
public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id);
|
||||
|
||||
public Channel? TryGetChannel(string id) =>
|
||||
Channels.FirstOrDefault(c => c.Id == id);
|
||||
public Channel? TryGetChannel(Snowflake id) => Channels.FirstOrDefault(c => c.Id == id);
|
||||
|
||||
public Role? TryGetRole(string id) =>
|
||||
Roles.FirstOrDefault(r => r.Id == id);
|
||||
public Role? TryGetRole(Snowflake id) => Roles.FirstOrDefault(r => r.Id == id);
|
||||
|
||||
public Color? TryGetUserColor(string id)
|
||||
public Color? TryGetUserColor(Snowflake id)
|
||||
{
|
||||
var member = TryGetMember(id);
|
||||
var roles = member?.RoleIds.Join(Roles, i => i, r => r.Id, (_, role) => role);
|
||||
@@ -69,10 +72,13 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
{
|
||||
var filePath = await _mediaDownloader.DownloadAsync(url);
|
||||
|
||||
// We want relative path so that the output files can be copied around without breaking
|
||||
var relativeFilePath = Path.GetRelativePath(Request.OutputBaseDirPath, filePath);
|
||||
// We want relative path so that the output files can be copied around without breaking.
|
||||
// Base directory path may be null if the file is stored at the root or relative to working directory.
|
||||
var relativeFilePath = !string.IsNullOrWhiteSpace(Request.OutputBaseDirPath)
|
||||
? Path.GetRelativePath(Request.OutputBaseDirPath, filePath)
|
||||
: filePath;
|
||||
|
||||
// For HTML, we need to format the URL properly
|
||||
// HACK: for HTML, we need to format the URL properly
|
||||
if (Request.Format == ExportFormat.HtmlDark || Request.Format == ExportFormat.HtmlLight)
|
||||
{
|
||||
// Need to escape each path segment while keeping the directory separators intact
|
||||
@@ -84,8 +90,12 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
|
||||
return relativeFilePath;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
// Try to catch only exceptions related to failed HTTP requests
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/332
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372
|
||||
catch (Exception ex) when (ex is HttpRequestException || ex is OperationCanceledException)
|
||||
{
|
||||
// TODO: add logging so we can be more liberal with catching exceptions
|
||||
// We don't want this to crash the exporting process in case of failure
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
|
||||
@@ -22,14 +22,16 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
|
||||
public ExportFormat Format { get; }
|
||||
|
||||
public DateTimeOffset? After { get; }
|
||||
public Snowflake? After { get; }
|
||||
|
||||
public DateTimeOffset? Before { get; }
|
||||
public Snowflake? Before { get; }
|
||||
|
||||
public int? PartitionLimit { get; }
|
||||
|
||||
public bool ShouldDownloadMedia { get; }
|
||||
|
||||
public bool ShouldReuseMedia { get; }
|
||||
|
||||
public string DateFormat { get; }
|
||||
|
||||
public ExportRequest(
|
||||
@@ -37,10 +39,11 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
Channel channel,
|
||||
string outputPath,
|
||||
ExportFormat format,
|
||||
DateTimeOffset? after,
|
||||
DateTimeOffset? before,
|
||||
Snowflake? after,
|
||||
Snowflake? before,
|
||||
int? partitionLimit,
|
||||
bool shouldDownloadMedia,
|
||||
bool shouldReuseMedia,
|
||||
string dateFormat)
|
||||
{
|
||||
Guild = guild;
|
||||
@@ -51,6 +54,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
Before = before;
|
||||
PartitionLimit = partitionLimit;
|
||||
ShouldDownloadMedia = shouldDownloadMedia;
|
||||
ShouldReuseMedia = shouldReuseMedia;
|
||||
DateFormat = dateFormat;
|
||||
|
||||
OutputBaseFilePath = GetOutputBaseFilePath(
|
||||
@@ -74,8 +78,8 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
Channel channel,
|
||||
string outputPath,
|
||||
ExportFormat format,
|
||||
DateTimeOffset? after = null,
|
||||
DateTimeOffset? before = null)
|
||||
Snowflake? after = null,
|
||||
Snowflake? before = null)
|
||||
{
|
||||
// Output is a directory
|
||||
if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath)))
|
||||
@@ -92,8 +96,8 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
Guild guild,
|
||||
Channel channel,
|
||||
ExportFormat format,
|
||||
DateTimeOffset? after = null,
|
||||
DateTimeOffset? before = null)
|
||||
Snowflake? after = null,
|
||||
Snowflake? before = null)
|
||||
{
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
@@ -108,17 +112,17 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
// Both 'after' and 'before' are set
|
||||
if (after != null && before != null)
|
||||
{
|
||||
buffer.Append($"{after:yyyy-MM-dd} to {before:yyyy-MM-dd}");
|
||||
buffer.Append($"{after?.ToDate():yyyy-MM-dd} to {before?.ToDate():yyyy-MM-dd}");
|
||||
}
|
||||
// Only 'after' is set
|
||||
else if (after != null)
|
||||
{
|
||||
buffer.Append($"after {after:yyyy-MM-dd}");
|
||||
buffer.Append($"after {after?.ToDate():yyyy-MM-dd}");
|
||||
}
|
||||
// Only 'before' is set
|
||||
else
|
||||
{
|
||||
buffer.Append($"before {before:yyyy-MM-dd}");
|
||||
buffer.Append($"before {before?.ToDate():yyyy-MM-dd}");
|
||||
}
|
||||
|
||||
buffer.Append(")");
|
||||
|
||||
@@ -1,55 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting
|
||||
{
|
||||
internal partial class MediaDownloader
|
||||
{
|
||||
private readonly HttpClient _httpClient = Singleton.HttpClient;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _workingDirPath;
|
||||
private readonly bool _reuseMedia;
|
||||
|
||||
private readonly Dictionary<string, string> _pathMap = new Dictionary<string, string>();
|
||||
// URL -> Local file path
|
||||
private readonly Dictionary<string, string> _pathCache = new(StringComparer.Ordinal);
|
||||
|
||||
public MediaDownloader(string workingDirPath)
|
||||
public MediaDownloader(HttpClient httpClient, string workingDirPath, bool reuseMedia)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_workingDirPath = workingDirPath;
|
||||
_reuseMedia = reuseMedia;
|
||||
}
|
||||
|
||||
public MediaDownloader(string workingDirPath, bool reuseMedia)
|
||||
: this(Http.Client, workingDirPath, reuseMedia) {}
|
||||
|
||||
public async ValueTask<string> DownloadAsync(string url)
|
||||
{
|
||||
if (_pathMap.TryGetValue(url, out var cachedFilePath))
|
||||
if (_pathCache.TryGetValue(url, out var cachedFilePath))
|
||||
return cachedFilePath;
|
||||
|
||||
var fileName = GetFileNameFromUrl(url);
|
||||
var filePath = PathEx.MakeUniqueFilePath(Path.Combine(_workingDirPath, fileName));
|
||||
var filePath = Path.Combine(_workingDirPath, fileName);
|
||||
|
||||
// Reuse existing files if we're allowed to
|
||||
if (_reuseMedia && File.Exists(filePath))
|
||||
return _pathCache[url] = filePath;
|
||||
|
||||
Directory.CreateDirectory(_workingDirPath);
|
||||
|
||||
await _httpClient.DownloadAsync(url, filePath);
|
||||
// This catches IOExceptions which is dangerous as we're working also with files
|
||||
await Http.ExceptionPolicy.ExecuteAsync(async () =>
|
||||
{
|
||||
// Download the file
|
||||
using var response = await _httpClient.GetAsync(url);
|
||||
await using (var output = File.Create(filePath))
|
||||
{
|
||||
await response.Content.CopyToAsync(output);
|
||||
}
|
||||
|
||||
return _pathMap[url] = filePath;
|
||||
// Try to set the file date according to the last-modified header
|
||||
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
|
||||
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
|
||||
? date
|
||||
: (DateTimeOffset?) null
|
||||
);
|
||||
|
||||
if (lastModified != null)
|
||||
{
|
||||
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
}
|
||||
});
|
||||
|
||||
return _pathCache[url] = filePath;
|
||||
}
|
||||
}
|
||||
|
||||
internal partial class MediaDownloader
|
||||
{
|
||||
private static string GetRandomFileName() => Guid.NewGuid().ToString().Replace("-", "").Substring(0, 16);
|
||||
private static string GetUrlHash(string url)
|
||||
{
|
||||
using var hash = SHA256.Create();
|
||||
|
||||
var data = hash.ComputeHash(Encoding.UTF8.GetBytes(url));
|
||||
return data.ToHex().Truncate(5); // 5 chars ought to be enough for anybody
|
||||
}
|
||||
|
||||
private static string GetFileNameFromUrl(string url)
|
||||
{
|
||||
var originalFileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||
var urlHash = GetUrlHash(url);
|
||||
|
||||
var fileName = !string.IsNullOrWhiteSpace(originalFileName)
|
||||
? $"{Path.GetFileNameWithoutExtension(originalFileName).Truncate(50)}{Path.GetExtension(originalFileName)}"
|
||||
: GetRandomFileName();
|
||||
// Try to extract file name from URL
|
||||
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||
|
||||
return PathEx.EscapePath(fileName);
|
||||
// If it's not there, just use the URL hash as the file name
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
return urlHash;
|
||||
|
||||
// Otherwise, use the original file name but inject the hash in the middle
|
||||
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
|
||||
var fileExtension = Path.GetExtension(fileName);
|
||||
|
||||
return PathEx.EscapePath(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,9 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
|
||||
internal partial class MessageExporter
|
||||
{
|
||||
private static string GetPartitionFilePath(string baseFilePath, int partitionIndex)
|
||||
private static string GetPartitionFilePath(
|
||||
string baseFilePath,
|
||||
int partitionIndex)
|
||||
{
|
||||
// First partition - don't change file name
|
||||
if (partitionIndex <= 0)
|
||||
@@ -82,16 +84,17 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath);
|
||||
var fileExt = Path.GetExtension(baseFilePath);
|
||||
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
|
||||
|
||||
// Generate new path
|
||||
var dirPath = Path.GetDirectoryName(baseFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(dirPath))
|
||||
return Path.Combine(dirPath, fileName);
|
||||
|
||||
return fileName;
|
||||
return !string.IsNullOrWhiteSpace(dirPath)
|
||||
? Path.Combine(dirPath, fileName)
|
||||
: fileName;
|
||||
}
|
||||
|
||||
private static MessageWriter CreateMessageWriter(string filePath, ExportFormat format, ExportContext context)
|
||||
private static MessageWriter CreateMessageWriter(
|
||||
string filePath,
|
||||
ExportFormat format,
|
||||
ExportContext context)
|
||||
{
|
||||
// Stream will be disposed by the underlying writer
|
||||
var stream = File.Create(filePath);
|
||||
|
||||
@@ -59,7 +59,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
public override async ValueTask WriteMessageAsync(Message message)
|
||||
{
|
||||
// Author ID
|
||||
await _writer.WriteAsync(CsvEncode(message.Author.Id));
|
||||
await _writer.WriteAsync(CsvEncode(message.Author.Id.ToString()));
|
||||
await _writer.WriteAsync(',');
|
||||
|
||||
// Author name
|
||||
|
||||
@@ -2,31 +2,31 @@
|
||||
|
||||
@font-face {
|
||||
font-family: Whitney;
|
||||
src: url(https://discord.com/assets/6c6374bad0b0b6d204d8d6dc4a18d820.woff);
|
||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-300.woff);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Whitney;
|
||||
src: url(https://discord.com/assets/e8acd7d9bf6207f99350ca9f9e23b168.woff);
|
||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-400.woff);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Whitney;
|
||||
src: url(https://discord.com/assets/3bdef1251a424500c1b3a78dea9b7e57.woff);
|
||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-500.woff);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Whitney;
|
||||
src: url(https://discord.com/assets/be0060dafb7a0e31d2a1ca17c0708636.woff);
|
||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-600.woff);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Whitney;
|
||||
src: url(https://discord.com/assets/8e12fb4f14d9c4592eb8ec9f22337b04.woff);
|
||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-700.woff);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -49,13 +49,18 @@ img {
|
||||
|
||||
.markdown {
|
||||
max-width: 100%;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.preserve-whitespace {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.spoiler {
|
||||
width: fit-content;
|
||||
/* width: fit-content; */
|
||||
display: inline-block;
|
||||
/* This is more consistent across browsers, the old attribute worked well under Chrome but not FireFox. */
|
||||
}
|
||||
|
||||
.spoiler--hidden {
|
||||
@@ -84,7 +89,7 @@ img {
|
||||
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.spoiler--hidden .spoiler-image img {
|
||||
.spoiler--hidden .spoiler-image * {
|
||||
filter: blur(44px);
|
||||
}
|
||||
|
||||
@@ -97,7 +102,7 @@ img {
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-weight: 600;
|
||||
padding: 0.5em 0.7em;
|
||||
padding: 100%;
|
||||
border-radius: 20px;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.9em;
|
||||
@@ -202,6 +207,58 @@ img {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.chatlog__reference-symbol {
|
||||
grid-column: 1;
|
||||
border-style: solid;
|
||||
border-width: 2px 0 0 2px;
|
||||
border-radius: 8px 0 0 0;
|
||||
margin-left: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chatlog__reference {
|
||||
display: flex;
|
||||
grid-column: 2;
|
||||
margin-left: 1.2em;
|
||||
margin-bottom: 0.25em;
|
||||
font-size: 0.875em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chatlog__reference-avatar {
|
||||
border-radius: 50%;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
|
||||
.chatlog__reference-name {
|
||||
margin-right: 0.25em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chatlog__reference-link {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chatlog__reference-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chatlog__reference-content > * {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.chatlog__reference-edited-timestamp {
|
||||
margin-left: 0.25em;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.chatlog__author-avatar-container {
|
||||
grid-column: 1;
|
||||
width: 40px;
|
||||
@@ -257,6 +314,53 @@ img {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chatlog__attachment-container {
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
padding: 10px;
|
||||
border: 1px solid;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chatlog__attachment-icon {
|
||||
float: left;
|
||||
height: 100%;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.chatlog__attachment-icon > .a {
|
||||
fill: #f4f5fb;
|
||||
d: path("M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z");
|
||||
}
|
||||
|
||||
.chatlog__attachment-icon > .b {
|
||||
fill: #7789c4;
|
||||
d: path("M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z");
|
||||
}
|
||||
|
||||
.chatlog__attachment-icon > .c {
|
||||
fill: #f4f5fb;
|
||||
d: path("M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z");
|
||||
}
|
||||
|
||||
.chatlog__attachment-icon > .d {
|
||||
fill: #7789c4;
|
||||
d: path("M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z");
|
||||
}
|
||||
|
||||
.chatlog__attachment-filesize {
|
||||
color: #72767d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chatlog__attachment-filename {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chatlog__embed {
|
||||
display: flex;
|
||||
margin-top: 0.3em;
|
||||
|
||||
@@ -46,6 +46,26 @@ a {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.chatlog__reference-symbol {
|
||||
border-color: #4f545c;
|
||||
}
|
||||
|
||||
.chatlog__reference {
|
||||
color: #b5b6b8;
|
||||
}
|
||||
|
||||
.chatlog__reference-link {
|
||||
color: #b5b6b8;
|
||||
}
|
||||
|
||||
.chatlog__reference-link:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.chatlog__reference-edited-timestamp {
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.chatlog__author-name {
|
||||
color: #ffffff;
|
||||
}
|
||||
@@ -62,6 +82,11 @@ a {
|
||||
background-color: rgba(249, 168, 37, 0.05);
|
||||
}
|
||||
|
||||
.chatlog__attachment-container {
|
||||
background-color: #2f3136;
|
||||
border-color: #292b2f;
|
||||
}
|
||||
|
||||
.chatlog__edited-timestamp {
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,26 @@ a {
|
||||
border-color: #eceeef;
|
||||
}
|
||||
|
||||
.chatlog__reference-symbol {
|
||||
border-color: #c7ccd1;
|
||||
}
|
||||
|
||||
.chatlog__reference {
|
||||
color: #5f5f60;
|
||||
}
|
||||
|
||||
.chatlog__reference-link {
|
||||
color: #5f5f60;
|
||||
}
|
||||
|
||||
.chatlog__reference-link:hover {
|
||||
color: #2f3136;
|
||||
}
|
||||
|
||||
.chatlog__reference-edited-timestamp {
|
||||
color: #747f8d;
|
||||
}
|
||||
|
||||
.chatlog__author-name {
|
||||
font-weight: 600;
|
||||
color: #2f3136;
|
||||
@@ -64,6 +84,11 @@ a {
|
||||
background-color: rgba(249, 168, 37, 0.05);
|
||||
}
|
||||
|
||||
.chatlog__attachment-container {
|
||||
background-color: #f2f3f5;
|
||||
border-color: #ebedef;
|
||||
}
|
||||
|
||||
.chatlog__edited-timestamp {
|
||||
color: #747f8d;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,21 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
|
||||
public IReadOnlyList<Message> Messages { get; }
|
||||
|
||||
public MessageGroup(User author, DateTimeOffset timestamp, IReadOnlyList<Message> messages)
|
||||
public MessageReference? Reference { get; }
|
||||
|
||||
public Message? ReferencedMessage {get; }
|
||||
|
||||
public MessageGroup(
|
||||
User author,
|
||||
DateTimeOffset timestamp,
|
||||
MessageReference? reference,
|
||||
Message? referencedMessage,
|
||||
IReadOnlyList<Message> messages)
|
||||
{
|
||||
Author = author;
|
||||
Timestamp = timestamp;
|
||||
Reference = reference;
|
||||
ReferencedMessage = referencedMessage;
|
||||
Messages = messages;
|
||||
}
|
||||
}
|
||||
@@ -25,9 +36,10 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
internal partial class MessageGroup
|
||||
{
|
||||
public static bool CanJoin(Message message1, Message message2) =>
|
||||
string.Equals(message1.Author.Id, message2.Author.Id, StringComparison.Ordinal) &&
|
||||
message1.Author.Id == message2.Author.Id &&
|
||||
string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) &&
|
||||
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7;
|
||||
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7 &&
|
||||
message2.Reference is null;
|
||||
|
||||
public static MessageGroup Join(IReadOnlyList<Message> messages)
|
||||
{
|
||||
@@ -36,6 +48,8 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
return new MessageGroup(
|
||||
first.Author,
|
||||
first.Timestamp,
|
||||
first.Reference,
|
||||
first.ReferencedMessage,
|
||||
messages
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
@using System
|
||||
@using System.Linq
|
||||
@using System.Threading.Tasks
|
||||
@inherits MiniRazor.MiniRazorTemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.MessageGroupTemplateContext>
|
||||
@namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
@inherits MiniRazor.TemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.MessageGroupTemplateContext>
|
||||
|
||||
@{
|
||||
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
||||
@@ -13,15 +14,72 @@
|
||||
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
|
||||
|
||||
var userMember = Model.ExportContext.TryGetMember(Model.MessageGroup.Author.Id);
|
||||
|
||||
var userColor = Model.ExportContext.TryGetUserColor(Model.MessageGroup.Author.Id);
|
||||
var userNick = userMember?.Nick ?? Model.MessageGroup.Author.Name;
|
||||
|
||||
var userColorStyle = userColor != null
|
||||
? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})"
|
||||
: null;
|
||||
|
||||
var userNick = Model.MessageGroup.Author.IsBot
|
||||
? Model.MessageGroup.Author.Name
|
||||
: userMember?.Nick ?? Model.MessageGroup.Author.Name;
|
||||
|
||||
var referencedUserMember = Model.MessageGroup.ReferencedMessage != null
|
||||
? Model.ExportContext.TryGetMember(Model.MessageGroup.ReferencedMessage.Author.Id)
|
||||
: null;
|
||||
|
||||
var referencedUserColor = Model.MessageGroup.ReferencedMessage != null
|
||||
? Model.ExportContext.TryGetUserColor(Model.MessageGroup.ReferencedMessage.Author.Id)
|
||||
: null;
|
||||
|
||||
var referencedUserColorStyle = referencedUserColor != null
|
||||
? $"color: rgb({referencedUserColor?.R},{referencedUserColor?.G},{referencedUserColor?.B})"
|
||||
: null;
|
||||
|
||||
var referencedUserNick = Model.MessageGroup.ReferencedMessage != null
|
||||
? Model.MessageGroup.ReferencedMessage.Author.IsBot
|
||||
? Model.MessageGroup.ReferencedMessage.Author.Name
|
||||
: referencedUserMember?.Nick ?? Model.MessageGroup.ReferencedMessage.Author.Name
|
||||
: null;
|
||||
}
|
||||
|
||||
<div class="chatlog__message-group">
|
||||
@if (Model.MessageGroup.Reference != null)
|
||||
{
|
||||
<div class="chatlog__reference-symbol">
|
||||
</div>
|
||||
<div class="chatlog__reference">
|
||||
@if (Model.MessageGroup.ReferencedMessage != null)
|
||||
{
|
||||
<img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.ReferencedMessage.Author.AvatarUrl)" alt="Avatar">
|
||||
<span class="chatlog__reference-name" title="@Model.MessageGroup.ReferencedMessage.Author.FullName" style="@referencedUserColorStyle">@referencedUserNick</span>
|
||||
<a class="chatlog__reference-link" href="#" onclick="scrollToMessage(event, '@Model.MessageGroup.ReferencedMessage.Id')">
|
||||
<span class="chatlog__reference-content">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.MessageGroup.ReferencedMessage.Content))
|
||||
{
|
||||
@Raw(FormatMarkdown(Model.MessageGroup.ReferencedMessage.Content))
|
||||
}
|
||||
else
|
||||
{
|
||||
<em>Click to see original message</em>
|
||||
}
|
||||
</span>
|
||||
|
||||
@if (Model.MessageGroup.ReferencedMessage.EditedTimestamp != null)
|
||||
{
|
||||
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(Model.MessageGroup.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="chatlog__reference-unknown">
|
||||
Original message was deleted.
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="chatlog__author-avatar-container">
|
||||
<img class="chatlog__author-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.Author.AvatarUrl)" alt="Avatar">
|
||||
</div>
|
||||
@@ -40,36 +98,64 @@
|
||||
var isPinnedStyle = message.IsPinned ? "chatlog__message--pinned" : null;
|
||||
|
||||
<div class="chatlog__message @isPinnedStyle" data-message-id="@message.Id" id="message-@message.Id">
|
||||
<div class="chatlog__content">
|
||||
<div class="markdown">@Raw(FormatMarkdown(message.Content)) @if (message.EditedTimestamp != null) {<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>}</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp != null)
|
||||
{
|
||||
<div class="chatlog__content">
|
||||
<div class="markdown">
|
||||
<span class="preserve-whitespace">@Raw(FormatMarkdown(message.Content))</span>
|
||||
|
||||
@if (message.EditedTimestamp != null)
|
||||
{
|
||||
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var attachment in message.Attachments)
|
||||
{
|
||||
<div class="chatlog__attachment">
|
||||
@if (attachment.IsSpoiler)
|
||||
{
|
||||
<div class="spoiler spoiler--hidden" onclick="showSpoiler(event, this)">
|
||||
<div class="spoiler-image">
|
||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Attachment">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||
<div class="@(attachment.IsSpoiler ? "spoiler spoiler--hidden" : "")" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : "")">
|
||||
<div class="@(attachment.IsSpoiler ? "spoiler-image" : "")">
|
||||
@if (attachment.IsImage)
|
||||
{
|
||||
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Attachment">
|
||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Image attachment" title="@($"Image: {attachment.FileName} ({attachment.FileSize})")">
|
||||
</a>
|
||||
}
|
||||
else if (attachment.IsVideo)
|
||||
{
|
||||
<video controls class="chatlog__attachment-thumbnail">
|
||||
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Video attachment" title="@($"Video: {attachment.FileName} ({attachment.FileSize})")">
|
||||
</video>
|
||||
}
|
||||
else if (attachment.IsAudio)
|
||||
{
|
||||
<audio controls class="chatlog__attachment-thumbnail">
|
||||
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Audio attachment" title="@($"Audio: {attachment.FileName} ({attachment.FileSize})")">
|
||||
</audio>
|
||||
}
|
||||
else
|
||||
{
|
||||
@($"Attachment: {attachment.FileName} ({attachment.FileSize})")
|
||||
<div class="chatlog__attachment-container">
|
||||
<svg class="chatlog__attachment-icon" viewBox="0 0 720 960">
|
||||
<path class="a"/>
|
||||
<path class="b"/>
|
||||
<path class="c"/>
|
||||
<path class="d"/>
|
||||
</svg>
|
||||
<div class="chatlog__attachment-filename">
|
||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||
@attachment.FileName
|
||||
</a>
|
||||
</div>
|
||||
<div class="chatlog__attachment-filesize">
|
||||
@attachment.FileSize
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -119,12 +205,12 @@
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||
{
|
||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -132,7 +218,7 @@
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||
{
|
||||
<div class="chatlog__embed-description">
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -147,14 +233,14 @@
|
||||
@if (!string.IsNullOrWhiteSpace(field.Name))
|
||||
{
|
||||
<div class="chatlog__embed-field-name">
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(field.Name))</div>
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Name))</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(field.Value))
|
||||
{
|
||||
<div class="chatlog__embed-field-value">
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(field.Value))</div>
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -190,22 +276,22 @@
|
||||
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconUrl)" alt="Footer icon">
|
||||
}
|
||||
|
||||
<span class="chatlog__embed-footer-text">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||
{
|
||||
@embed.Footer.Text
|
||||
}
|
||||
<span class="chatlog__embed-footer-text">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||
{
|
||||
@embed.Footer.Text
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp != null)
|
||||
{
|
||||
@(" • ")
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp != null)
|
||||
{
|
||||
@(" • ")
|
||||
}
|
||||
|
||||
@if (embed.Timestamp != null)
|
||||
{
|
||||
@FormatDate(embed.Timestamp.Value)
|
||||
}
|
||||
</span>
|
||||
@if (embed.Timestamp != null)
|
||||
{
|
||||
@FormatDate(embed.Timestamp.Value)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@inherits MiniRazor.MiniRazorTemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.LayoutTemplateContext>
|
||||
@namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
@inherits MiniRazor.TemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.LayoutTemplateContext>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -7,4 +8,5 @@
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,7 +1,8 @@
|
||||
@using System
|
||||
@using System.Threading.Tasks
|
||||
@using Tyrrrz.Extensions
|
||||
@inherits MiniRazor.MiniRazorTemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.LayoutTemplateContext>
|
||||
@namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
@inherits MiniRazor.TemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.LayoutTemplateContext>
|
||||
|
||||
@{
|
||||
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
||||
@@ -82,15 +83,15 @@
|
||||
<div class="preamble__entry preamble__entry--small">
|
||||
@if (Model.ExportContext.Request.After != null && Model.ExportContext.Request.Before != null)
|
||||
{
|
||||
@($"Between {FormatDate(Model.ExportContext.Request.After.Value)} and {FormatDate(Model.ExportContext.Request.Before.Value)}")
|
||||
@($"Between {FormatDate(Model.ExportContext.Request.After.Value.ToDate())} and {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
|
||||
}
|
||||
else if (Model.ExportContext.Request.After != null)
|
||||
{
|
||||
@($"After {FormatDate(Model.ExportContext.Request.After.Value)}")
|
||||
@($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}")
|
||||
}
|
||||
else if (Model.ExportContext.Request.Before != null)
|
||||
{
|
||||
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value)}")
|
||||
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Tasks;
|
||||
using MiniRazor;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
[assembly: InternalsVisibleTo("RazorAssembly")]
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
{
|
||||
internal partial class TemplateBundle
|
||||
{
|
||||
public MiniRazorTemplateDescriptor PreambleTemplate { get; }
|
||||
|
||||
public MiniRazorTemplateDescriptor MessageGroupTemplate { get; }
|
||||
|
||||
public MiniRazorTemplateDescriptor PostambleTemplate { get; }
|
||||
|
||||
public TemplateBundle(
|
||||
MiniRazorTemplateDescriptor preambleTemplate,
|
||||
MiniRazorTemplateDescriptor messageGroupTemplate,
|
||||
MiniRazorTemplateDescriptor postambleTemplate)
|
||||
{
|
||||
PreambleTemplate = preambleTemplate;
|
||||
MessageGroupTemplate = messageGroupTemplate;
|
||||
PostambleTemplate = postambleTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
internal partial class TemplateBundle
|
||||
{
|
||||
private static TemplateBundle? _lastBundle;
|
||||
|
||||
// This is very CPU-heavy
|
||||
private static async ValueTask<TemplateBundle> CompileAsync() => await Task.Run(() =>
|
||||
{
|
||||
var ns = typeof(TemplateBundle).Namespace!;
|
||||
|
||||
var preambleTemplateSource = typeof(HtmlMessageWriter).Assembly
|
||||
.GetManifestResourceString($"{ns}.PreambleTemplate.cshtml");
|
||||
|
||||
var messageGroupTemplateSource = typeof(HtmlMessageWriter).Assembly
|
||||
.GetManifestResourceString($"{ns}.MessageGroupTemplate.cshtml");
|
||||
|
||||
var postambleTemplateSource = typeof(HtmlMessageWriter).Assembly
|
||||
.GetManifestResourceString($"{ns}.PostambleTemplate.cshtml");
|
||||
|
||||
var engine = new MiniRazorTemplateEngine("RazorAssembly", ns);
|
||||
|
||||
var preambleTemplate = engine.Compile(preambleTemplateSource);
|
||||
var messageGroupTemplate = engine.Compile(messageGroupTemplateSource);
|
||||
var postambleTemplate = engine.Compile(postambleTemplateSource);
|
||||
|
||||
return new TemplateBundle(preambleTemplate, messageGroupTemplate, postambleTemplate);
|
||||
});
|
||||
|
||||
public static async ValueTask<TemplateBundle> ResolveAsync() =>
|
||||
_lastBundle ??= await CompileAsync();
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
private readonly TextWriter _writer;
|
||||
private readonly string _themeName;
|
||||
|
||||
private readonly List<Message> _messageGroupBuffer = new List<Message>();
|
||||
private readonly List<Message> _messageGroupBuffer = new();
|
||||
|
||||
private long _messageCount;
|
||||
|
||||
@@ -26,20 +26,18 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
public override async ValueTask WritePreambleAsync()
|
||||
{
|
||||
var templateContext = new LayoutTemplateContext(Context, _themeName, _messageCount);
|
||||
var templateBundle = await TemplateBundle.ResolveAsync();
|
||||
|
||||
await _writer.WriteLineAsync(
|
||||
await templateBundle.PreambleTemplate.RenderAsync(templateContext)
|
||||
await PreambleTemplate.RenderAsync(templateContext)
|
||||
);
|
||||
}
|
||||
|
||||
private async ValueTask WriteMessageGroupAsync(MessageGroup messageGroup)
|
||||
{
|
||||
var templateContext = new MessageGroupTemplateContext(Context, messageGroup);
|
||||
var templateBundle = await TemplateBundle.ResolveAsync();
|
||||
|
||||
await _writer.WriteLineAsync(
|
||||
await templateBundle.MessageGroupTemplate.RenderAsync(templateContext)
|
||||
await MessageGroupTemplate.RenderAsync(templateContext)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,10 +68,9 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer));
|
||||
|
||||
var templateContext = new LayoutTemplateContext(Context, _themeName, _messageCount);
|
||||
var templateBundle = await TemplateBundle.ResolveAsync();
|
||||
|
||||
await _writer.WriteLineAsync(
|
||||
await templateBundle.PostambleTemplate.RenderAsync(templateContext)
|
||||
await PostambleTemplate.RenderAsync(templateContext)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text.Encodings.Web;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using JsonExtensions.Writing;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
{
|
||||
@@ -18,6 +20,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
{
|
||||
_writer = new Utf8JsonWriter(stream, new JsonWriterOptions
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = true
|
||||
});
|
||||
}
|
||||
@@ -29,7 +32,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("id", attachment.Id);
|
||||
_writer.WriteString("id", attachment.Id.ToString());
|
||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(attachment.Url));
|
||||
_writer.WriteString("fileName", attachment.FileName);
|
||||
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
||||
@@ -114,6 +117,9 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
_writer.WriteString("timestamp", embed.Timestamp);
|
||||
_writer.WriteString("description", FormatMarkdown(embed.Description));
|
||||
|
||||
if (embed.Color != null)
|
||||
_writer.WriteString("color", embed.Color.Value.ToHex());
|
||||
|
||||
if (embed.Author != null)
|
||||
await WriteEmbedAuthorAsync(embed.Author);
|
||||
|
||||
@@ -156,6 +162,20 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
await _writer.FlushAsync();
|
||||
}
|
||||
|
||||
private async ValueTask WriteMentionAsync(User mentionedUser)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("id", mentionedUser.Id.ToString());
|
||||
_writer.WriteString("name", mentionedUser.Name);
|
||||
_writer.WriteNumber("discriminator", mentionedUser.Discriminator);
|
||||
_writer.WriteString("nickname", Context.TryGetMember(mentionedUser.Id)?.Nick ?? mentionedUser.Name);
|
||||
_writer.WriteBoolean("isBot", mentionedUser.IsBot);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
await _writer.FlushAsync();
|
||||
}
|
||||
|
||||
public override async ValueTask WritePreambleAsync()
|
||||
{
|
||||
// Root object (start)
|
||||
@@ -163,14 +183,14 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
// Guild
|
||||
_writer.WriteStartObject("guild");
|
||||
_writer.WriteString("id", Context.Request.Guild.Id);
|
||||
_writer.WriteString("id", Context.Request.Guild.Id.ToString());
|
||||
_writer.WriteString("name", Context.Request.Guild.Name);
|
||||
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(Context.Request.Guild.IconUrl));
|
||||
_writer.WriteEndObject();
|
||||
|
||||
// Channel
|
||||
_writer.WriteStartObject("channel");
|
||||
_writer.WriteString("id", Context.Request.Channel.Id);
|
||||
_writer.WriteString("id", Context.Request.Channel.Id.ToString());
|
||||
_writer.WriteString("type", Context.Request.Channel.Type.ToString());
|
||||
_writer.WriteString("category", Context.Request.Channel.Category);
|
||||
_writer.WriteString("name", Context.Request.Channel.Name);
|
||||
@@ -179,8 +199,8 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
// Date range
|
||||
_writer.WriteStartObject("dateRange");
|
||||
_writer.WriteString("after", Context.Request.After);
|
||||
_writer.WriteString("before", Context.Request.Before);
|
||||
_writer.WriteString("after", Context.Request.After?.ToDate());
|
||||
_writer.WriteString("before", Context.Request.Before?.ToDate());
|
||||
_writer.WriteEndObject();
|
||||
|
||||
// Message array (start)
|
||||
@@ -193,10 +213,11 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
_writer.WriteStartObject();
|
||||
|
||||
// Metadata
|
||||
_writer.WriteString("id", message.Id);
|
||||
_writer.WriteString("id", message.Id.ToString());
|
||||
_writer.WriteString("type", message.Type.ToString());
|
||||
_writer.WriteString("timestamp", message.Timestamp);
|
||||
_writer.WriteString("timestampEdited", message.EditedTimestamp);
|
||||
_writer.WriteString("callEndedTimestamp", message.CallEndedTimestamp);
|
||||
_writer.WriteBoolean("isPinned", message.IsPinned);
|
||||
|
||||
// Content
|
||||
@@ -204,7 +225,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
// Author
|
||||
_writer.WriteStartObject("author");
|
||||
_writer.WriteString("id", message.Author.Id);
|
||||
_writer.WriteString("id", message.Author.Id.ToString());
|
||||
_writer.WriteString("name", message.Author.Name);
|
||||
_writer.WriteString("discriminator", $"{message.Author.Discriminator:0000}");
|
||||
_writer.WriteBoolean("isBot", message.Author.IsBot);
|
||||
@@ -235,6 +256,24 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
// Mentions
|
||||
_writer.WriteStartArray("mentions");
|
||||
|
||||
foreach (var mention in message.MentionedUsers)
|
||||
await WriteMentionAsync(mention);
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
// Message reference
|
||||
if (message.Reference != null)
|
||||
{
|
||||
_writer.WriteStartObject("reference");
|
||||
_writer.WriteString("messageId", message.Reference.MessageId?.ToString());
|
||||
_writer.WriteString("channelId", message.Reference.ChannelId?.ToString());
|
||||
_writer.WriteString("guildId", message.Reference.GuildId?.ToString());
|
||||
_writer.WriteEndObject();
|
||||
}
|
||||
|
||||
_writer.WriteEndObject();
|
||||
await _writer.FlushAsync();
|
||||
|
||||
|
||||
+4
-3
@@ -3,6 +3,7 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Markdown;
|
||||
using DiscordChatExporter.Domain.Markdown.Ast;
|
||||
@@ -84,7 +85,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
}
|
||||
else if (mention.Type == MentionType.User)
|
||||
{
|
||||
var member = _context.TryGetMember(mention.Id);
|
||||
var member = _context.TryGetMember(Snowflake.Parse(mention.Id));
|
||||
var fullName = member?.User.FullName ?? "Unknown";
|
||||
var nick = member?.Nick ?? "Unknown";
|
||||
|
||||
@@ -95,7 +96,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
}
|
||||
else if (mention.Type == MentionType.Channel)
|
||||
{
|
||||
var channel = _context.TryGetChannel(mention.Id);
|
||||
var channel = _context.TryGetChannel(Snowflake.Parse(mention.Id));
|
||||
var name = channel?.Name ?? "deleted-channel";
|
||||
|
||||
_buffer
|
||||
@@ -105,7 +106,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
}
|
||||
else if (mention.Type == MentionType.Role)
|
||||
{
|
||||
var role = _context.TryGetRole(mention.Id);
|
||||
var role = _context.TryGetRole(Snowflake.Parse(mention.Id));
|
||||
var name = role?.Name ?? "deleted-role";
|
||||
var color = role?.Color;
|
||||
|
||||
|
||||
+4
-3
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Markdown;
|
||||
using DiscordChatExporter.Domain.Markdown.Ast;
|
||||
|
||||
@@ -29,21 +30,21 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
}
|
||||
else if (mention.Type == MentionType.User)
|
||||
{
|
||||
var member = _context.TryGetMember(mention.Id);
|
||||
var member = _context.TryGetMember(Snowflake.Parse(mention.Id));
|
||||
var name = member?.User.Name ?? "Unknown";
|
||||
|
||||
_buffer.Append($"@{name}");
|
||||
}
|
||||
else if (mention.Type == MentionType.Channel)
|
||||
{
|
||||
var channel = _context.TryGetChannel(mention.Id);
|
||||
var channel = _context.TryGetChannel(Snowflake.Parse(mention.Id));
|
||||
var name = channel?.Name ?? "deleted-channel";
|
||||
|
||||
_buffer.Append($"#{name}");
|
||||
}
|
||||
else if (mention.Type == MentionType.Role)
|
||||
{
|
||||
var role = _context.TryGetRole(mention.Id);
|
||||
var role = _context.TryGetRole(Snowflake.Parse(mention.Id));
|
||||
var name = role?.Name ?? "deleted-role";
|
||||
|
||||
_buffer.Append($"@{name}");
|
||||
|
||||
@@ -119,10 +119,10 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}");
|
||||
|
||||
if (Context.Request.After != null)
|
||||
await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value)}");
|
||||
await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value.ToDate())}");
|
||||
|
||||
if (Context.Request.Before != null)
|
||||
await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value)}");
|
||||
await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value.ToDate())}");
|
||||
|
||||
await _writer.WriteLineAsync('='.Repeat(62));
|
||||
await _writer.WriteLineAsync();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
{
|
||||
internal static class BinaryExtensions
|
||||
{
|
||||
public static string ToHex(this byte[] data)
|
||||
{
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
foreach (var t in data)
|
||||
{
|
||||
buffer.Append(t.ToString("X2"));
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,7 @@ namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
public static Color ResetAlpha(this Color color) => color.WithAlpha(255);
|
||||
|
||||
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff;
|
||||
|
||||
public static string ToHex(this Color color) => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,6 @@ namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
{
|
||||
internal static class DateExtensions
|
||||
{
|
||||
public static string ToSnowflake(this DateTimeOffset dateTime)
|
||||
{
|
||||
var value = ((ulong) dateTime.ToUnixTimeMilliseconds() - 1420070400000UL) << 22;
|
||||
return value.ToString();
|
||||
}
|
||||
|
||||
public static string ToLocalString(this DateTimeOffset dateTime, string format) =>
|
||||
dateTime.ToLocalTime().ToString(format, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
{
|
||||
internal static class GenericExtensions
|
||||
{
|
||||
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) => transform(input);
|
||||
|
||||
public static T? NullIf<T>(this T value, Func<T, bool> predicate) where T : struct =>
|
||||
!predicate(value)
|
||||
? value
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
{
|
||||
internal static class HttpClientExtensions
|
||||
{
|
||||
public static async ValueTask DownloadAsync(this HttpClient httpClient, string uri, string outputFilePath)
|
||||
{
|
||||
await using var input = await httpClient.GetStreamAsync(uri);
|
||||
var output = File.Create(outputFilePath);
|
||||
|
||||
await input.CopyToAsync(output);
|
||||
await output.DisposeAsync();
|
||||
}
|
||||
|
||||
public static async ValueTask<JsonElement> ReadAsJsonAsync(this HttpContent content)
|
||||
{
|
||||
await using var stream = await content.ReadAsStreamAsync();
|
||||
using var doc = await JsonDocument.ParseAsync(stream);
|
||||
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
{
|
||||
internal static class HttpExtensions
|
||||
{
|
||||
public static string? TryGetValue(this HttpContentHeaders headers, string name) =>
|
||||
headers.TryGetValues(name, out var values)
|
||||
? string.Concat(values)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
{
|
||||
internal static class JsonElementExtensions
|
||||
{
|
||||
public static JsonElement? GetPropertyOrNull(this JsonElement element, string propertyName) =>
|
||||
element.TryGetProperty(propertyName, out var result) && result.ValueKind != JsonValueKind.Null
|
||||
? result
|
||||
: (JsonElement?) null;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
{
|
||||
internal static class Utf8JsonWriterExtensions
|
||||
{
|
||||
public static void WriteString(this Utf8JsonWriter writer, string propertyName, DateTimeOffset? value)
|
||||
{
|
||||
writer.WritePropertyName(propertyName);
|
||||
|
||||
if (value != null)
|
||||
writer.WriteStringValue(value.Value);
|
||||
else
|
||||
writer.WriteNullValue();
|
||||
}
|
||||
|
||||
public static void WriteNumber(this Utf8JsonWriter writer, string propertyName, int? value)
|
||||
{
|
||||
writer.WritePropertyName(propertyName);
|
||||
|
||||
if (value != null)
|
||||
writer.WriteNumberValue(value.Value);
|
||||
else
|
||||
writer.WriteNullValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Polly;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal
|
||||
{
|
||||
internal static class Http
|
||||
{
|
||||
public static HttpClient Client { get; } = new();
|
||||
|
||||
public static IAsyncPolicy<HttpResponseMessage> ResponsePolicy { get; } =
|
||||
Policy
|
||||
.Handle<IOException>()
|
||||
.Or<HttpRequestException>()
|
||||
.OrResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
.OrResult(m => m.StatusCode == HttpStatusCode.RequestTimeout)
|
||||
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
|
||||
.WaitAndRetryAsync(8,
|
||||
(i, result, _) =>
|
||||
{
|
||||
// If rate-limited, use retry-after as a guide
|
||||
if (result.Result?.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
// Only start respecting retry-after after a few attempts.
|
||||
// The reason is that Discord often sends unreasonable (20+ minutes) retry-after
|
||||
// on the very first request.
|
||||
if (i > 3)
|
||||
{
|
||||
var retryAfterDelay = result.Result.Headers.RetryAfter.Delta;
|
||||
if (retryAfterDelay != null)
|
||||
return retryAfterDelay.Value + TimeSpan.FromSeconds(1); // margin just in case
|
||||
}
|
||||
}
|
||||
|
||||
return TimeSpan.FromSeconds(Math.Pow(2, i) + 1);
|
||||
},
|
||||
(_, _, _, _) => Task.CompletedTask);
|
||||
|
||||
private static HttpStatusCode? TryGetStatusCodeFromException(HttpRequestException ex)
|
||||
{
|
||||
// This is extremely frail, but there's no other way
|
||||
var statusCodeRaw = Regex.Match(ex.Message, @": (\d+) \(").Groups[1].Value;
|
||||
return !string.IsNullOrWhiteSpace(statusCodeRaw)
|
||||
? (HttpStatusCode) int.Parse(statusCodeRaw, CultureInfo.InvariantCulture)
|
||||
: (HttpStatusCode?) null;
|
||||
}
|
||||
|
||||
public static IAsyncPolicy ExceptionPolicy { get; } =
|
||||
Policy
|
||||
.Handle<IOException>() // dangerous
|
||||
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) == HttpStatusCode.TooManyRequests)
|
||||
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) == HttpStatusCode.RequestTimeout)
|
||||
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) >= HttpStatusCode.InternalServerError)
|
||||
.WaitAndRetryAsync(4, i => TimeSpan.FromSeconds(Math.Pow(2, i) + 1));
|
||||
}
|
||||
}
|
||||
@@ -14,27 +14,5 @@ namespace DiscordChatExporter.Domain.Internal
|
||||
}
|
||||
|
||||
public static string EscapePath(string path) => EscapePath(new StringBuilder(path)).ToString();
|
||||
|
||||
public static string MakeUniqueFilePath(string baseFilePath, int maxAttempts = int.MaxValue)
|
||||
{
|
||||
if (!File.Exists(baseFilePath))
|
||||
return baseFilePath;
|
||||
|
||||
var baseDirPath = Path.GetDirectoryName(baseFilePath);
|
||||
var baseFileNameWithoutExtension = Path.GetFileNameWithoutExtension(baseFilePath);
|
||||
var baseFileExtension = Path.GetExtension(baseFilePath);
|
||||
|
||||
for (var i = 1; i <= maxAttempts; i++)
|
||||
{
|
||||
var filePath = $"{baseFileNameWithoutExtension} ({i}){baseFileExtension}";
|
||||
if (!string.IsNullOrWhiteSpace(baseDirPath))
|
||||
filePath = Path.Combine(baseDirPath, filePath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return baseFilePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal
|
||||
{
|
||||
internal static class Singleton
|
||||
{
|
||||
private static readonly Lazy<HttpClient> LazyHttpClient = new Lazy<HttpClient>(() =>
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
|
||||
if (handler.SupportsAutomaticDecompression)
|
||||
handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||
|
||||
handler.UseCookies = false;
|
||||
|
||||
return new HttpClient(handler, true);
|
||||
});
|
||||
|
||||
public static HttpClient HttpClient { get; } = LazyHttpClient.Value;
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,7 @@ namespace DiscordChatExporter.Domain.Internal
|
||||
{
|
||||
private string _path = "";
|
||||
|
||||
private readonly Dictionary<string, string?> _queryParameters =
|
||||
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string?> _queryParameters = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public UrlBuilder SetPath(string path)
|
||||
{
|
||||
|
||||
@@ -9,74 +9,92 @@ namespace DiscordChatExporter.Domain.Markdown
|
||||
// The following parsing logic is meant to replicate Discord's markdown grammar as close as possible
|
||||
internal static partial class MarkdownParser
|
||||
{
|
||||
private const RegexOptions DefaultRegexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Multiline;
|
||||
private const RegexOptions DefaultRegexOptions =
|
||||
RegexOptions.Compiled |
|
||||
RegexOptions.CultureInvariant |
|
||||
RegexOptions.Multiline;
|
||||
|
||||
/* Formatting */
|
||||
|
||||
// Capture any character until the earliest double asterisk not followed by an asterisk
|
||||
private static readonly IMatcher<MarkdownNode> BoldFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Bold, Parse(p.Slice(m.Groups[1]))));
|
||||
(p, m) => new FormattedNode(TextFormatting.Bold, Parse(p.Slice(m.Groups[1])))
|
||||
);
|
||||
|
||||
// Capture any character until the earliest single asterisk not preceded or followed by an asterisk
|
||||
// Opening asterisk must not be followed by whitespace
|
||||
// Closing asterisk must not be preceded by whitespace
|
||||
private static readonly IMatcher<MarkdownNode> ItalicFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1]))));
|
||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1])))
|
||||
);
|
||||
|
||||
// Capture any character until the earliest triple asterisk not followed by an asterisk
|
||||
private static readonly IMatcher<MarkdownNode> ItalicBoldFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("\\*(\\*\\*.+?\\*\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1]), BoldFormattedNodeMatcher)));
|
||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1]), BoldFormattedNodeMatcher))
|
||||
);
|
||||
|
||||
// Capture any character except underscore until an underscore
|
||||
// Closing underscore must not be followed by a word character
|
||||
private static readonly IMatcher<MarkdownNode> ItalicAltFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("_([^_]+)_(?!\\w)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1]))));
|
||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1])))
|
||||
);
|
||||
|
||||
// Capture any character until the earliest double underscore not followed by an underscore
|
||||
private static readonly IMatcher<MarkdownNode> UnderlineFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Underline, Parse(p.Slice(m.Groups[1]))));
|
||||
(p, m) => new FormattedNode(TextFormatting.Underline, Parse(p.Slice(m.Groups[1])))
|
||||
);
|
||||
|
||||
// Capture any character until the earliest triple underscore not followed by an underscore
|
||||
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1]), UnderlineFormattedNodeMatcher)));
|
||||
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattedNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
new Regex("_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Italic,
|
||||
Parse(p.Slice(m.Groups[1]), UnderlineFormattedNodeMatcher))
|
||||
);
|
||||
|
||||
// Capture any character until the earliest double tilde
|
||||
private static readonly IMatcher<MarkdownNode> StrikethroughFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Strikethrough, Parse(p.Slice(m.Groups[1]))));
|
||||
private static readonly IMatcher<MarkdownNode> StrikethroughFormattedNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Strikethrough, Parse(p.Slice(m.Groups[1])))
|
||||
);
|
||||
|
||||
// Capture any character until the earliest double pipe
|
||||
private static readonly IMatcher<MarkdownNode> SpoilerFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("\\|\\|(.+?)\\|\\|", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Spoiler, Parse(p.Slice(m.Groups[1]))));
|
||||
(p, m) => new FormattedNode(TextFormatting.Spoiler, Parse(p.Slice(m.Groups[1])))
|
||||
);
|
||||
|
||||
// Capture any character until the end of the line
|
||||
// Opening 'greater than' character must be followed by whitespace
|
||||
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("^>\\s(.+\n?)", DefaultRegexOptions),
|
||||
(p, m) => new FormattedNode(TextFormatting.Quote, Parse(p.Slice(m.Groups[1]))));
|
||||
(p, m) => new FormattedNode(TextFormatting.Quote, Parse(p.Slice(m.Groups[1])))
|
||||
);
|
||||
|
||||
// Repeatedly capture any character until the end of the line
|
||||
// This one is tricky as it ends up producing multiple separate captures which need to be joined
|
||||
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("(?:^>\\s(.+\n?)){2,}", DefaultRegexOptions),
|
||||
(p, m) =>
|
||||
{
|
||||
var content = string.Concat(m.Groups[1].Captures.Select(c => c.Value));
|
||||
return new FormattedNode(TextFormatting.Quote, Parse(content));
|
||||
});
|
||||
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
new Regex("(?:^>\\s(.+\n?)){2,}", DefaultRegexOptions),
|
||||
(_, m) =>
|
||||
{
|
||||
var content = string.Concat(m.Groups[1].Captures.Select(c => c.Value));
|
||||
return new FormattedNode(TextFormatting.Quote, Parse(content));
|
||||
}
|
||||
);
|
||||
|
||||
// Capture any character until the end of the input
|
||||
// Opening 'greater than' characters must be followed by whitespace
|
||||
private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("^>>>\\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Quote, Parse(p.Slice(m.Groups[1]))));
|
||||
(p, m) => new FormattedNode(TextFormatting.Quote, Parse(p.Slice(m.Groups[1])))
|
||||
);
|
||||
|
||||
/* Code blocks */
|
||||
|
||||
@@ -85,41 +103,48 @@ namespace DiscordChatExporter.Domain.Markdown
|
||||
// There can be either one or two backticks, but equal number on both sides
|
||||
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("(`{1,2})([^`]+)\\1", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
m => new InlineCodeBlockNode(m.Groups[2].Value.Trim('\r', '\n')));
|
||||
m => new InlineCodeBlockNode(m.Groups[2].Value.Trim('\r', '\n'))
|
||||
);
|
||||
|
||||
// Capture language identifier and then any character until the earliest triple backtick
|
||||
// Language identifier is one word immediately after opening backticks, followed immediately by newline
|
||||
// Blank lines at the beginning and end of content are trimmed
|
||||
private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("```(?:(\\w*)\\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
m => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n')));
|
||||
m => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
|
||||
);
|
||||
|
||||
/* Mentions */
|
||||
|
||||
// Capture @everyone
|
||||
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher = new StringMatcher<MarkdownNode>(
|
||||
"@everyone",
|
||||
p => new MentionNode("everyone", MentionType.Meta));
|
||||
_ => new MentionNode("everyone", MentionType.Meta)
|
||||
);
|
||||
|
||||
// Capture @here
|
||||
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher = new StringMatcher<MarkdownNode>(
|
||||
"@here",
|
||||
p => new MentionNode("here", MentionType.Meta));
|
||||
_ => new MentionNode("here", MentionType.Meta)
|
||||
);
|
||||
|
||||
// Capture <@123456> or <@!123456>
|
||||
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("<@!?(\\d+)>", DefaultRegexOptions),
|
||||
m => new MentionNode(m.Groups[1].Value, MentionType.User));
|
||||
m => new MentionNode(m.Groups[1].Value, MentionType.User)
|
||||
);
|
||||
|
||||
// Capture <#123456>
|
||||
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("<#(\\d+)>", DefaultRegexOptions),
|
||||
m => new MentionNode(m.Groups[1].Value, MentionType.Channel));
|
||||
m => new MentionNode(m.Groups[1].Value, MentionType.Channel)
|
||||
);
|
||||
|
||||
// Capture <@&123456>
|
||||
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("<@&(\\d+)>", DefaultRegexOptions),
|
||||
m => new MentionNode(m.Groups[1].Value, MentionType.Role));
|
||||
m => new MentionNode(m.Groups[1].Value, MentionType.Role)
|
||||
);
|
||||
|
||||
/* Emojis */
|
||||
|
||||
@@ -129,30 +154,36 @@ namespace DiscordChatExporter.Domain.Markdown
|
||||
// ... or digit followed by enclosing mark
|
||||
// (this does not match all emojis in Discord but it's reasonably accurate enough)
|
||||
private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("((?:[\\uD83C][\\uDDE6-\\uDDFF]){2}|[\\u2600-\\u26FF]|\\p{Cs}{2}|\\d\\p{Me})", DefaultRegexOptions),
|
||||
m => new EmojiNode(m.Groups[1].Value));
|
||||
new Regex("((?:[\\uD83C][\\uDDE6-\\uDDFF]){2}|[\\u2600-\\u26FF]|\\p{Cs}{2}|\\d\\p{Me})",
|
||||
DefaultRegexOptions),
|
||||
m => new EmojiNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
// Capture <:lul:123456> or <a:lul:123456>
|
||||
private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions),
|
||||
m => new EmojiNode(m.Groups[3].Value, m.Groups[2].Value, !string.IsNullOrWhiteSpace(m.Groups[1].Value)));
|
||||
m => new EmojiNode(m.Groups[3].Value, m.Groups[2].Value, !string.IsNullOrWhiteSpace(m.Groups[1].Value))
|
||||
);
|
||||
|
||||
/* Links */
|
||||
|
||||
// Capture [title](link)
|
||||
private static readonly IMatcher<MarkdownNode> TitledLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions),
|
||||
m => new LinkNode(m.Groups[2].Value, m.Groups[1].Value));
|
||||
m => new LinkNode(m.Groups[2].Value, m.Groups[1].Value)
|
||||
);
|
||||
|
||||
// Capture any non-whitespace character after http:// or https:// until the last punctuation character or whitespace
|
||||
private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("(https?://\\S*[^\\.,:;\"\'\\s])", DefaultRegexOptions),
|
||||
m => new LinkNode(m.Groups[1].Value));
|
||||
m => new LinkNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
// Same as auto link but also surrounded by angular brackets
|
||||
private static readonly IMatcher<MarkdownNode> HiddenLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("<(https?://\\S*[^\\.,:;\"\'\\s])>", DefaultRegexOptions),
|
||||
m => new LinkNode(m.Groups[1].Value));
|
||||
m => new LinkNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
/* Text */
|
||||
|
||||
@@ -160,25 +191,29 @@ namespace DiscordChatExporter.Domain.Markdown
|
||||
// This escapes it from matching for formatting
|
||||
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher = new StringMatcher<MarkdownNode>(
|
||||
@"¯\_(ツ)_/¯",
|
||||
p => new TextNode(p.ToString()));
|
||||
p => new TextNode(p.ToString())
|
||||
);
|
||||
|
||||
// Capture some specific emojis that don't get rendered
|
||||
// This escapes it from matching for emoji
|
||||
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("(\\u26A7|\\u2640|\\u2642|\\u2695|\\u267E|\\u00A9|\\u00AE|\\u2122)", DefaultRegexOptions),
|
||||
m => new TextNode(m.Groups[1].Value));
|
||||
m => new TextNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
// Capture any "symbol/other" character or surrogate pair preceded by a backslash
|
||||
// This escapes it from matching for emoji
|
||||
private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("\\\\(\\p{So}|\\p{Cs}{2})", DefaultRegexOptions),
|
||||
m => new TextNode(m.Groups[1].Value));
|
||||
m => new TextNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash
|
||||
// This escapes it from matching for formatting or other tokens
|
||||
private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("\\\\([^a-zA-Z0-9\\s])", DefaultRegexOptions),
|
||||
m => new TextNode(m.Groups[1].Value));
|
||||
m => new TextNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
// Combine all matchers into one
|
||||
// Matchers that have similar patterns are ordered from most specific to least specific
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
{
|
||||
}
|
||||
|
||||
public StringPart Slice(int newStartIndex, int newLength) => new StringPart(Target, newStartIndex, newLength);
|
||||
public StringPart Slice(int newStartIndex, int newLength) => new(Target, newStartIndex, newLength);
|
||||
|
||||
public StringPart Slice(int newStartIndex) => Slice(newStartIndex, EndIndex - newStartIndex);
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ namespace DiscordChatExporter.Domain.Utilities
|
||||
{
|
||||
public static class AsyncExtensions
|
||||
{
|
||||
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(this IAsyncEnumerable<T> asyncEnumerable)
|
||||
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable)
|
||||
{
|
||||
var list = new List<T>();
|
||||
|
||||
@@ -19,10 +20,14 @@ namespace DiscordChatExporter.Domain.Utilities
|
||||
return list;
|
||||
}
|
||||
|
||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(this IAsyncEnumerable<T> asyncEnumerable) =>
|
||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable) =>
|
||||
asyncEnumerable.AggregateAsync().GetAwaiter();
|
||||
|
||||
public static async ValueTask ParallelForEachAsync<T>(this IEnumerable<T> source, Func<T, Task> handleAsync, int degreeOfParallelism)
|
||||
public static async ValueTask ParallelForEachAsync<T>(
|
||||
this IEnumerable<T> source,
|
||||
Func<T, ValueTask> handleAsync,
|
||||
int degreeOfParallelism)
|
||||
{
|
||||
using var semaphore = new SemaphoreSlim(degreeOfParallelism);
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Utilities
|
||||
{
|
||||
public static class GeneralExtensions
|
||||
{
|
||||
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) => transform(input);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
|
||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||
xmlns:s="https://github.com/canton7/Stylet">
|
||||
<Application.Resources>
|
||||
<s:ApplicationLoader>
|
||||
@@ -13,69 +14,15 @@
|
||||
|
||||
<!-- Merged dictionaries -->
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" />
|
||||
<!-- Colors are irrelevant because they are overriden in runtime -->
|
||||
<materialDesign:BundledTheme
|
||||
BaseTheme="Light"
|
||||
PrimaryColor="Blue"
|
||||
SecondaryColor="Blue" />
|
||||
|
||||
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
<!-- Colors -->
|
||||
<Color x:Key="PrimaryColor">#343838</Color>
|
||||
<Color x:Key="PrimaryLightColor">#5E6262</Color>
|
||||
<Color x:Key="PrimaryDarkColor">#0D1212</Color>
|
||||
<Color x:Key="AccentColor">#F9A825</Color>
|
||||
<Color x:Key="AccentDarkColor">#C17900</Color>
|
||||
<Color x:Key="TextColor">#000000</Color>
|
||||
<Color x:Key="InverseTextColor">#FFFFFF</Color>
|
||||
|
||||
<SolidColorBrush x:Key="PrimaryHueLightBrush" Color="{DynamicResource PrimaryLightColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueLightForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueMidBrush" Color="{DynamicResource PrimaryColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueMidForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueDarkBrush" Color="{DynamicResource PrimaryDarkColor}" />
|
||||
<SolidColorBrush x:Key="PrimaryHueDarkForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush x:Key="SecondaryAccentBrush" Color="{DynamicResource AccentColor}" />
|
||||
<SolidColorBrush x:Key="SecondaryAccentForegroundBrush" Color="{DynamicResource TextColor}" />
|
||||
|
||||
<SolidColorBrush
|
||||
x:Key="PrimaryTextBrush"
|
||||
Opacity="0.87"
|
||||
Color="{DynamicResource TextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="SecondaryTextBrush"
|
||||
Opacity="0.64"
|
||||
Color="{DynamicResource TextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="DimTextBrush"
|
||||
Opacity="0.45"
|
||||
Color="{DynamicResource TextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="PrimaryInverseTextBrush"
|
||||
Opacity="1"
|
||||
Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="SecondaryInverseTextBrush"
|
||||
Opacity="0.7"
|
||||
Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="DimInverseTextBrush"
|
||||
Opacity="0.52"
|
||||
Color="{DynamicResource InverseTextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="AccentTextBrush"
|
||||
Opacity="1"
|
||||
Color="{DynamicResource AccentColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="AccentDarkTextBrush"
|
||||
Opacity="1"
|
||||
Color="{DynamicResource AccentDarkColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="DividerBrush"
|
||||
Opacity="0.12"
|
||||
Color="{DynamicResource TextColor}" />
|
||||
<SolidColorBrush
|
||||
x:Key="InverseDividerBrush"
|
||||
Opacity="0.12"
|
||||
Color="{DynamicResource InverseTextColor}" />
|
||||
|
||||
<!-- Styles -->
|
||||
<Style x:Key="MaterialDesignRoot" TargetType="{x:Type Control}">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource MaterialDesignFont}" />
|
||||
@@ -83,7 +30,7 @@
|
||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||
<Setter Property="TextElement.FontSize" Value="13" />
|
||||
<Setter Property="TextElement.FontWeight" Value="Regular" />
|
||||
<Setter Property="TextElement.Foreground" Value="{DynamicResource SecondaryTextBrush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{DynamicResource MaterialDesignBody}" />
|
||||
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
|
||||
<Setter Property="TextOptions.TextRenderingMode" Value="Auto" />
|
||||
<Setter Property="UseLayoutRounding" Value="True" />
|
||||
@@ -93,23 +40,20 @@
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" />
|
||||
<Setter Property="Height" Value="2" />
|
||||
<Setter Property="Maximum" Value="1" />
|
||||
<Setter Property="Minimum" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
||||
</Style>
|
||||
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" />
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
||||
</Style>
|
||||
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
||||
</Style>
|
||||
<Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}" />
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}" />
|
||||
|
||||
<Style
|
||||
x:Key="MaterialDesignFlatDarkButton"
|
||||
@@ -136,6 +80,89 @@
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) -->
|
||||
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Expander">
|
||||
<Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}">
|
||||
<DockPanel Background="{TemplateBinding Background}">
|
||||
<ToggleButton
|
||||
Name="HeaderSite"
|
||||
BorderThickness="0"
|
||||
Content="{TemplateBinding Header}"
|
||||
ContentStringFormat="{TemplateBinding HeaderStringFormat}"
|
||||
ContentTemplate="{TemplateBinding HeaderTemplate}"
|
||||
ContentTemplateSelector="{TemplateBinding HeaderTemplateSelector}"
|
||||
Cursor="Hand"
|
||||
DockPanel.Dock="Top"
|
||||
Focusable="False"
|
||||
Foreground="{TemplateBinding Foreground}"
|
||||
IsChecked="{Binding Path=IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
IsTabStop="False"
|
||||
Opacity=".87"
|
||||
Style="{StaticResource MaterialDesignExpanderDownHeaderStyle}"
|
||||
TextElement.FontSize="15" />
|
||||
<Border
|
||||
Name="ContentSite"
|
||||
DockPanel.Dock="Bottom"
|
||||
Visibility="Collapsed">
|
||||
<StackPanel
|
||||
Name="ContentPanel"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
|
||||
<ContentPresenter
|
||||
Name="PART_Content"
|
||||
ContentStringFormat="{TemplateBinding ContentStringFormat}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
ContentTemplateSelector="{TemplateBinding ContentTemplateSelector}"
|
||||
Focusable="False" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsExpanded" Value="true">
|
||||
<Setter TargetName="ContentSite" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="ExpandDirection" Value="Right">
|
||||
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Left" />
|
||||
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Right" />
|
||||
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
|
||||
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
|
||||
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderRightHeaderStyle}" />
|
||||
</Trigger>
|
||||
|
||||
<Trigger Property="ExpandDirection" Value="Left">
|
||||
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Right" />
|
||||
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Left" />
|
||||
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
|
||||
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
|
||||
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderLeftHeaderStyle}" />
|
||||
</Trigger>
|
||||
|
||||
<Trigger Property="ExpandDirection" Value="Up">
|
||||
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Bottom" />
|
||||
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Top" />
|
||||
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
|
||||
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
|
||||
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderUpHeaderStyle}" />
|
||||
</Trigger>
|
||||
|
||||
<Trigger Property="ExpandDirection" Value="Down">
|
||||
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Top" />
|
||||
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Bottom" />
|
||||
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
|
||||
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
|
||||
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderDownHeaderStyle}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</s:ApplicationLoader>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -1,16 +1,49 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using DiscordChatExporter.Gui.Internal;
|
||||
using MaterialDesignThemes.Wpf;
|
||||
|
||||
namespace DiscordChatExporter.Gui
|
||||
{
|
||||
public partial class App
|
||||
{
|
||||
private static readonly Assembly Assembly = typeof(App).Assembly;
|
||||
private static Assembly Assembly { get; } = typeof(App).Assembly;
|
||||
|
||||
public static string Name => Assembly.GetName().Name!;
|
||||
public static string Name { get; } = Assembly.GetName().Name!;
|
||||
|
||||
public static Version Version => Assembly.GetName().Version!;
|
||||
public static Version Version { get; } = Assembly.GetName().Version!;
|
||||
|
||||
public static string VersionString => Version.ToString(3);
|
||||
public static string VersionString { get; } = Version.ToString(3);
|
||||
|
||||
public static string GitHubProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter";
|
||||
|
||||
public static string GitHubProjectWikiUrl { get; } = GitHubProjectUrl + "/wiki";
|
||||
}
|
||||
|
||||
public partial class App
|
||||
{
|
||||
private static Theme LightTheme { get; } = Theme.Create(
|
||||
new MaterialDesignLightTheme(),
|
||||
MediaColor.FromHex("#343838"),
|
||||
MediaColor.FromHex("#F9A825")
|
||||
);
|
||||
|
||||
private static Theme DarkTheme { get; } = Theme.Create(
|
||||
new MaterialDesignDarkTheme(),
|
||||
MediaColor.FromHex("#E8E8E8"),
|
||||
MediaColor.FromHex("#F9A825")
|
||||
);
|
||||
|
||||
public static void SetLightTheme()
|
||||
{
|
||||
var paletteHelper = new PaletteHelper();
|
||||
paletteHelper.SetTheme(LightTheme);
|
||||
}
|
||||
|
||||
public static void SetDarkTheme()
|
||||
{
|
||||
var paletteHelper = new PaletteHelper();
|
||||
paletteHelper.SetTheme(DarkTheme);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,9 +31,9 @@ namespace DiscordChatExporter.Gui.Behaviors
|
||||
private bool _viewHandled;
|
||||
private bool _modelHandled;
|
||||
|
||||
public IList SelectedItems
|
||||
public IList? SelectedItems
|
||||
{
|
||||
get => (IList) GetValue(SelectedItemsProperty);
|
||||
get => (IList?) GetValue(SelectedItemsProperty);
|
||||
set => SetValue(SelectedItemsProperty, value);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,15 @@ namespace DiscordChatExporter.Gui
|
||||
{
|
||||
public class Bootstrapper : Bootstrapper<RootViewModel>
|
||||
{
|
||||
protected override void OnStart()
|
||||
{
|
||||
base.OnStart();
|
||||
|
||||
// Set default theme
|
||||
// (preferred theme will be chosen later, once the settings are loaded)
|
||||
App.SetLightTheme();
|
||||
}
|
||||
|
||||
protected override void ConfigureIoC(IStyletIoCBuilder builder)
|
||||
{
|
||||
base.ConfigureIoC(builder);
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace DiscordChatExporter.Gui.Converters
|
||||
[ValueConversion(typeof(DateTimeOffset?), typeof(DateTime?))]
|
||||
public class DateTimeOffsetToDateTimeConverter : IValueConverter
|
||||
{
|
||||
public static DateTimeOffsetToDateTimeConverter Instance { get; } = new DateTimeOffsetToDateTimeConverter();
|
||||
public static DateTimeOffsetToDateTimeConverter Instance { get; } = new();
|
||||
|
||||
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace DiscordChatExporter.Gui.Converters
|
||||
[ValueConversion(typeof(ExportFormat), typeof(string))]
|
||||
public class ExportFormatToStringConverter : IValueConverter
|
||||
{
|
||||
public static ExportFormatToStringConverter Instance { get; } = new ExportFormatToStringConverter();
|
||||
public static ExportFormatToStringConverter Instance { get; } = new();
|
||||
|
||||
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
@@ -18,9 +18,7 @@ namespace DiscordChatExporter.Gui.Converters
|
||||
return default(string);
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace DiscordChatExporter.Gui.Converters
|
||||
[ValueConversion(typeof(bool), typeof(bool))]
|
||||
public class InverseBoolConverter : IValueConverter
|
||||
{
|
||||
public static InverseBoolConverter Instance { get; } = new InverseBoolConverter();
|
||||
public static InverseBoolConverter Instance { get; } = new();
|
||||
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Converters
|
||||
{
|
||||
[ValueConversion(typeof(TimeSpan?), typeof(DateTime?))]
|
||||
public class TimeSpanToDateTimeConverter : IValueConverter
|
||||
{
|
||||
public static TimeSpanToDateTimeConverter Instance { get; } = new();
|
||||
|
||||
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is TimeSpan timeSpanValue)
|
||||
return DateTime.Today.Add(timeSpanValue);
|
||||
|
||||
return default(DateTime?);
|
||||
}
|
||||
|
||||
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is DateTime dateTimeValue)
|
||||
return dateTimeValue.TimeOfDay;
|
||||
|
||||
return default(TimeSpan?);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
|
||||
<Import Project="../DiscordChatExporter.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
@@ -17,12 +16,12 @@
|
||||
<PackageReference Include="MaterialDesignColors" Version="1.2.2" />
|
||||
<PackageReference Include="MaterialDesignThemes" Version="3.0.1" />
|
||||
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.19" />
|
||||
<PackageReference Include="Ookii.Dialogs.Wpf" Version="1.1.0" />
|
||||
<PackageReference Include="Onova" Version="2.6.1" />
|
||||
<PackageReference Include="Stylet" Version="1.3.4" />
|
||||
<PackageReference Include="Ookii.Dialogs.Wpf" Version="3.1.0" />
|
||||
<PackageReference Include="Onova" Version="2.6.2" />
|
||||
<PackageReference Include="Stylet" Version="1.3.5" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
||||
<PackageReference Include="PropertyChanged.Fody" Version="3.2.8" PrivateAssets="all" />
|
||||
<PackageReference Include="PropertyChanged.Fody" Version="3.3.1" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -11,6 +11,16 @@
|
||||
<xs:documentation>Used to control if the On_PropertyName_Changed feature is enabled.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="TriggerDependentProperties" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to control if the Dependent properties feature is enabled.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="EnableIsChangedProperty" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to control if the IsChanged property feature is enabled.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="EventInvokerNames" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form.</xs:documentation>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Internal
|
||||
{
|
||||
internal static class MediaColor
|
||||
{
|
||||
public static Color FromHex(string hex) => (Color) ColorConverter.ConvertFromString(hex);
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,7 @@ namespace DiscordChatExporter.Gui.Internal
|
||||
UseShellExecute = true
|
||||
};
|
||||
|
||||
using (Process.Start(startInfo))
|
||||
{ }
|
||||
using (Process.Start(startInfo)) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,18 @@ namespace DiscordChatExporter.Gui.Services
|
||||
{
|
||||
public class SettingsService : SettingsManager
|
||||
{
|
||||
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||
|
||||
public bool IsAutoUpdateEnabled { get; set; } = true;
|
||||
|
||||
public bool IsDarkModeEnabled { get; set; }
|
||||
|
||||
public bool IsTokenPersisted { get; set; } = true;
|
||||
|
||||
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||
|
||||
public int ParallelLimit { get; set; } = 1;
|
||||
|
||||
public bool ShouldReuseMedia { get; set; }
|
||||
|
||||
public AuthToken? LastToken { get; set; }
|
||||
|
||||
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||
|
||||
@@ -10,7 +10,8 @@ namespace DiscordChatExporter.Gui.Services
|
||||
{
|
||||
private readonly IUpdateManager _updateManager = new UpdateManager(
|
||||
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
|
||||
new ZipPackageExtractor());
|
||||
new ZipPackageExtractor()
|
||||
);
|
||||
|
||||
private readonly SettingsService _settingsService;
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exporting;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Gui.Services;
|
||||
using DiscordChatExporter.Gui.ViewModels.Framework;
|
||||
|
||||
@@ -26,9 +28,23 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
||||
|
||||
public ExportFormat SelectedFormat { get; set; }
|
||||
|
||||
public DateTimeOffset? After { get; set; }
|
||||
// This date/time abomination is required because we use separate controls to set these
|
||||
|
||||
public DateTimeOffset? Before { get; set; }
|
||||
public DateTimeOffset? AfterDate { get; set; }
|
||||
|
||||
public bool IsAfterDateSet => AfterDate != null;
|
||||
|
||||
public TimeSpan? AfterTime { get; set; }
|
||||
|
||||
public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero);
|
||||
|
||||
public DateTimeOffset? BeforeDate { get; set; }
|
||||
|
||||
public bool IsBeforeDateSet => BeforeDate != null;
|
||||
|
||||
public TimeSpan? BeforeTime { get; set; }
|
||||
|
||||
public DateTimeOffset? Before => BeforeDate?.Add(BeforeTime ?? TimeSpan.Zero);
|
||||
|
||||
public int? PartitionLimit { get; set; }
|
||||
|
||||
@@ -60,17 +76,17 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
||||
_settingsService.LastPartitionLimit = PartitionLimit;
|
||||
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
|
||||
|
||||
// Clamp 'after' and 'before' values
|
||||
if (After > Before)
|
||||
After = Before;
|
||||
if (Before < After)
|
||||
Before = After;
|
||||
|
||||
// If single channel - prompt file path
|
||||
if (IsSingleChannel)
|
||||
if (Channels != null && IsSingleChannel)
|
||||
{
|
||||
var channel = Channels.Single();
|
||||
var defaultFileName = ExportRequest.GetDefaultOutputFileName(Guild!, channel, SelectedFormat, After, Before);
|
||||
var defaultFileName = ExportRequest.GetDefaultOutputFileName(
|
||||
Guild!,
|
||||
channel,
|
||||
SelectedFormat,
|
||||
After?.Pipe(Snowflake.FromDate),
|
||||
Before?.Pipe(Snowflake.FromDate)
|
||||
);
|
||||
|
||||
// Filter
|
||||
var ext = SelectedFormat.GetFileExtension();
|
||||
@@ -84,11 +100,24 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
||||
OutputPath = _dialogManager.PromptDirectoryPath();
|
||||
}
|
||||
|
||||
// If canceled - return
|
||||
if (string.IsNullOrWhiteSpace(OutputPath))
|
||||
return;
|
||||
|
||||
Close(true);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ExportSetupViewModelExtensions
|
||||
{
|
||||
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
|
||||
Guild guild, IReadOnlyList<Channel> channels)
|
||||
{
|
||||
var viewModel = factory.CreateExportSetupViewModel();
|
||||
|
||||
viewModel.Guild = guild;
|
||||
viewModel.Channels = channels;
|
||||
|
||||
return viewModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,29 +8,41 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
||||
{
|
||||
private readonly SettingsService _settingsService;
|
||||
|
||||
public string DateFormat
|
||||
{
|
||||
get => _settingsService.DateFormat;
|
||||
set => _settingsService.DateFormat = value;
|
||||
}
|
||||
|
||||
public bool IsAutoUpdateEnabled
|
||||
{
|
||||
get => _settingsService.IsAutoUpdateEnabled;
|
||||
set => _settingsService.IsAutoUpdateEnabled = value;
|
||||
}
|
||||
|
||||
public bool IsDarkModeEnabled
|
||||
{
|
||||
get => _settingsService.IsDarkModeEnabled;
|
||||
set => _settingsService.IsDarkModeEnabled = value;
|
||||
}
|
||||
|
||||
public bool IsTokenPersisted
|
||||
{
|
||||
get => _settingsService.IsTokenPersisted;
|
||||
set => _settingsService.IsTokenPersisted = value;
|
||||
}
|
||||
|
||||
public string DateFormat
|
||||
{
|
||||
get => _settingsService.DateFormat;
|
||||
set => _settingsService.DateFormat = value;
|
||||
}
|
||||
|
||||
public int ParallelLimit
|
||||
{
|
||||
get => _settingsService.ParallelLimit;
|
||||
set => _settingsService.ParallelLimit = value.Clamp(1, 10);
|
||||
}
|
||||
|
||||
public bool ShouldReuseMedia
|
||||
{
|
||||
get => _settingsService.ShouldReuseMedia;
|
||||
set => _settingsService.ShouldReuseMedia = value;
|
||||
}
|
||||
|
||||
public SettingsViewModel(SettingsService settingsService)
|
||||
{
|
||||
|
||||
@@ -19,13 +19,10 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
|
||||
|
||||
public async ValueTask<T> ShowDialogAsync<T>(DialogScreen<T> dialogScreen)
|
||||
{
|
||||
// Get the view that renders this viewmodel
|
||||
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
|
||||
|
||||
// Set up event routing that will close the view when called from viewmodel
|
||||
void OnDialogOpened(object? sender, DialogOpenedEventArgs openArgs)
|
||||
{
|
||||
// Delegate to close the dialog and unregister event handler
|
||||
void OnScreenClosed(object? o, EventArgs closeArgs)
|
||||
{
|
||||
openArgs.Session.Close();
|
||||
@@ -35,37 +32,31 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
|
||||
dialogScreen.Closed += OnScreenClosed;
|
||||
}
|
||||
|
||||
// Show view
|
||||
await DialogHost.Show(view, OnDialogOpened);
|
||||
|
||||
// Return the result
|
||||
return dialogScreen.DialogResult;
|
||||
}
|
||||
|
||||
public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
|
||||
{
|
||||
// Create dialog
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Filter = filter,
|
||||
AddExtension = true,
|
||||
FileName = defaultFilePath,
|
||||
DefaultExt = Path.GetExtension(defaultFilePath) ?? ""
|
||||
DefaultExt = Path.GetExtension(defaultFilePath)
|
||||
};
|
||||
|
||||
// Show dialog and return result
|
||||
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
||||
}
|
||||
|
||||
public string? PromptDirectoryPath(string defaultDirPath = "")
|
||||
{
|
||||
// Create dialog
|
||||
var dialog = new VistaFolderBrowserDialog
|
||||
{
|
||||
SelectedPath = defaultDirPath
|
||||
};
|
||||
|
||||
// Show dialog and return result
|
||||
return dialog.ShowDialog() == true ? dialog.SelectedPath : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
|
||||
|
||||
public void Close(T dialogResult = default)
|
||||
{
|
||||
DialogResult = dialogResult;
|
||||
DialogResult = dialogResult!;
|
||||
Closed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||
|
||||
namespace DiscordChatExporter.Gui.ViewModels.Framework
|
||||
{
|
||||
public static class Extensions
|
||||
{
|
||||
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
|
||||
Guild guild, IReadOnlyList<Channel> channels)
|
||||
{
|
||||
var viewModel = factory.CreateExportSetupViewModel();
|
||||
viewModel.Guild = guild;
|
||||
viewModel.Channels = channels;
|
||||
|
||||
return viewModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exceptions;
|
||||
using DiscordChatExporter.Domain.Exporting;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Gui.Internal;
|
||||
using DiscordChatExporter.Gui.Services;
|
||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||
using DiscordChatExporter.Gui.ViewModels.Framework;
|
||||
using Gress;
|
||||
using MaterialDesignThemes.Wpf;
|
||||
@@ -63,14 +65,21 @@ namespace DiscordChatExporter.Gui.ViewModels
|
||||
|
||||
// Update busy state when progress manager changes
|
||||
ProgressManager.Bind(o => o.IsActive,
|
||||
(sender, args) => IsBusy = ProgressManager.IsActive);
|
||||
(sender, args) => IsBusy = ProgressManager.IsActive
|
||||
);
|
||||
|
||||
ProgressManager.Bind(o => o.IsActive,
|
||||
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1));
|
||||
(sender, args) => IsProgressIndeterminate =
|
||||
ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)
|
||||
);
|
||||
|
||||
ProgressManager.Bind(o => o.Progress,
|
||||
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1));
|
||||
(sender, args) => IsProgressIndeterminate =
|
||||
ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)
|
||||
);
|
||||
}
|
||||
|
||||
private async ValueTask HandleAutoUpdateAsync()
|
||||
private async ValueTask CheckForUpdatesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -108,7 +117,16 @@ namespace DiscordChatExporter.Gui.ViewModels
|
||||
TokenValue = _settingsService.LastToken.Value;
|
||||
}
|
||||
|
||||
await HandleAutoUpdateAsync();
|
||||
if (_settingsService.IsDarkModeEnabled)
|
||||
{
|
||||
App.SetDarkTheme();
|
||||
}
|
||||
else
|
||||
{
|
||||
App.SetLightTheme();
|
||||
}
|
||||
|
||||
await CheckForUpdatesAsync();
|
||||
}
|
||||
|
||||
protected override void OnClose()
|
||||
@@ -125,6 +143,8 @@ namespace DiscordChatExporter.Gui.ViewModels
|
||||
await _dialogManager.ShowDialogAsync(dialog);
|
||||
}
|
||||
|
||||
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
|
||||
|
||||
public bool CanPopulateGuildsAndChannels =>
|
||||
!IsBusy && !string.IsNullOrWhiteSpace(TokenValue);
|
||||
|
||||
@@ -150,11 +170,7 @@ namespace DiscordChatExporter.Gui.ViewModels
|
||||
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
|
||||
await foreach (var guild in discord.GetUserGuildsAsync())
|
||||
{
|
||||
var channels = await discord.GetGuildChannelsAsync(guild.Id);
|
||||
guildChannelMap[guild] = channels
|
||||
.OrderBy(c => c.Category)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToArray();
|
||||
guildChannelMap[guild] = await discord.GetGuildChannelsAsync(guild.Id);
|
||||
}
|
||||
|
||||
GuildChannelMap = guildChannelMap;
|
||||
@@ -182,8 +198,8 @@ namespace DiscordChatExporter.Gui.ViewModels
|
||||
var exporter = new ChannelExporter(token);
|
||||
|
||||
var operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
|
||||
|
||||
var successfulExportCount = 0;
|
||||
|
||||
await dialog.Channels.Zip(operations).ParallelForEachAsync(async tuple =>
|
||||
{
|
||||
var (channel, operation) = tuple;
|
||||
@@ -195,10 +211,11 @@ namespace DiscordChatExporter.Gui.ViewModels
|
||||
channel!,
|
||||
dialog.OutputPath!,
|
||||
dialog.SelectedFormat,
|
||||
dialog.After,
|
||||
dialog.Before,
|
||||
dialog.After?.Pipe(Snowflake.FromDate),
|
||||
dialog.Before?.Pipe(Snowflake.FromDate),
|
||||
dialog.PartitionLimit,
|
||||
dialog.ShouldDownloadMedia,
|
||||
_settingsService.ShouldReuseMedia,
|
||||
_settingsService.DateFormat
|
||||
);
|
||||
|
||||
|
||||
@@ -48,12 +48,13 @@
|
||||
Margin="8,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="19"
|
||||
FontWeight="Light"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
||||
<Run Text="{Binding Channels[0].Category, Mode=OneWay}" ToolTip="{Binding Channels[0].Category, Mode=OneWay}" />
|
||||
<Run Text="/" />
|
||||
<Run
|
||||
Foreground="{DynamicResource PrimaryTextBrush}"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding Channels[0].Name, Mode=OneWay}"
|
||||
ToolTip="{Binding Channels[0].Name, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
@@ -82,36 +83,58 @@
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<DatePicker
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Margin="16,8"
|
||||
materialDesign:HintAssist.Hint="From (optional)"
|
||||
Margin="16,8,16,4"
|
||||
materialDesign:HintAssist.Hint="After (date)"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
DisplayDateEnd="{Binding Before, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||
SelectedDate="{Binding After, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||
ToolTip="If this is set, only messages sent after this date will be exported" />
|
||||
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||
SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||
ToolTip="Only include messages sent after this date" />
|
||||
<DatePicker
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Margin="16,8"
|
||||
materialDesign:HintAssist.Hint="To (optional)"
|
||||
Margin="16,8,16,4"
|
||||
materialDesign:HintAssist.Hint="Before (date)"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
DisplayDateStart="{Binding After, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||
SelectedDate="{Binding Before, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||
ToolTip="If this is set, only messages sent before this date will be exported" />
|
||||
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||
ToolTip="Only include messages sent before this date" />
|
||||
<materialDesign:TimePicker
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Margin="16,4,16,8"
|
||||
materialDesign:HintAssist.Hint="After (time)"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
IsEnabled="{Binding IsAfterDateSet}"
|
||||
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
||||
ToolTip="Only include messages sent after this time" />
|
||||
<materialDesign:TimePicker
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Margin="16,4,16,8"
|
||||
materialDesign:HintAssist.Hint="Before (time)"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
IsEnabled="{Binding IsBeforeDateSet}"
|
||||
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
||||
ToolTip="Only include messages sent before this time" />
|
||||
</Grid>
|
||||
|
||||
<!-- Partitioning -->
|
||||
<TextBox
|
||||
Margin="16,8"
|
||||
materialDesign:HintAssist.Hint="Messages per partition (optional)"
|
||||
materialDesign:HintAssist.Hint="Messages per partition"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
Text="{Binding PartitionLimit, TargetNullValue=''}"
|
||||
ToolTip="If this is set, the exported file will be split into multiple partitions, each containing no more than specified number of messages" />
|
||||
ToolTip="Split output into partitions limited to this number of messages" />
|
||||
|
||||
<!-- Download media -->
|
||||
<Grid Margin="16,16" ToolTip="If this is set, the export will include additional files such as user avatars, attached files, embedded images, etc">
|
||||
<Grid Margin="16,16" ToolTip="Download referenced media content (user avatars, attached files, embedded images, etc)">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
@@ -120,7 +143,7 @@
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
Text="Download referenced media content" />
|
||||
Text="Download media" />
|
||||
<ToggleButton
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
@@ -141,18 +164,13 @@
|
||||
<ToggleButton
|
||||
x:Name="AdvancedSectionToggleButton"
|
||||
Grid.Column="0"
|
||||
Margin="8"
|
||||
Width="24"
|
||||
Height="24"
|
||||
Margin="12"
|
||||
Cursor="Hand"
|
||||
Loaded="AdvancedSectionToggleButton_OnLoaded"
|
||||
Style="{DynamicResource MaterialDesignFlatToggleButton}"
|
||||
ToolTip="Show advanced options">
|
||||
<ToggleButton.Content>
|
||||
<materialDesign:PackIcon
|
||||
Width="24"
|
||||
Height="24"
|
||||
Kind="Menu" />
|
||||
</ToggleButton.Content>
|
||||
</ToggleButton>
|
||||
IsChecked="{Binding IsAdvancedSectionDisplayedByDefault, Mode=OneTime}"
|
||||
Style="{DynamicResource MaterialDesignHamburgerToggleButton}"
|
||||
ToolTip="Toggle advanced options" />
|
||||
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
using System.Windows;
|
||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Views.Dialogs
|
||||
namespace DiscordChatExporter.Gui.Views.Dialogs
|
||||
{
|
||||
public partial class ExportSetupView
|
||||
{
|
||||
@@ -9,11 +6,5 @@ namespace DiscordChatExporter.Gui.Views.Dialogs
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void AdvancedSectionToggleButton_OnLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ExportSetupViewModel vm)
|
||||
AdvancedSectionToggleButton.IsChecked = vm.IsAdvancedSectionDisplayedByDefault;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:s="https://github.com/canton7/Stylet"
|
||||
Width="300"
|
||||
Width="310"
|
||||
d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}"
|
||||
Style="{DynamicResource MaterialDesignRoot}"
|
||||
mc:Ignorable="d">
|
||||
@@ -15,17 +15,8 @@
|
||||
<TextBlock
|
||||
Margin="16"
|
||||
FontSize="17"
|
||||
Foreground="{DynamicResource PrimaryTextBrush}"
|
||||
Text="Settings" />
|
||||
|
||||
<!-- Date format -->
|
||||
<TextBox
|
||||
Margin="16,8"
|
||||
materialDesign:HintAssist.Hint="Date format"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
Text="{Binding DateFormat}"
|
||||
ToolTip="Format used when rendering dates (refer to .NET date format)" />
|
||||
|
||||
<!-- Auto-updates -->
|
||||
<DockPanel
|
||||
Background="Transparent"
|
||||
@@ -41,6 +32,24 @@
|
||||
IsChecked="{Binding IsAutoUpdateEnabled}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Dark mode -->
|
||||
<DockPanel
|
||||
Background="Transparent"
|
||||
LastChildFill="False"
|
||||
ToolTip="Use darker colors in the UI">
|
||||
<TextBlock
|
||||
Margin="16,8"
|
||||
DockPanel.Dock="Left"
|
||||
Text="Dark mode" />
|
||||
<ToggleButton
|
||||
x:Name="DarkModeToggleButton"
|
||||
Margin="16,8"
|
||||
Checked="DarkModeToggleButton_Checked"
|
||||
DockPanel.Dock="Right"
|
||||
IsChecked="{Binding IsDarkModeEnabled}"
|
||||
Unchecked="DarkModeToggleButton_Unchecked" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Persist token -->
|
||||
<DockPanel
|
||||
Background="Transparent"
|
||||
@@ -56,14 +65,37 @@
|
||||
IsChecked="{Binding IsTokenPersisted}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Reuse media -->
|
||||
<DockPanel
|
||||
Background="Transparent"
|
||||
LastChildFill="False"
|
||||
ToolTip="Reuse already existing media content to skip redundant downloads">
|
||||
<TextBlock
|
||||
Margin="16,8"
|
||||
DockPanel.Dock="Left"
|
||||
Text="Reuse downloaded media" />
|
||||
<ToggleButton
|
||||
Margin="16,8"
|
||||
DockPanel.Dock="Right"
|
||||
IsChecked="{Binding ShouldReuseMedia}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Date format -->
|
||||
<TextBox
|
||||
Margin="16,8"
|
||||
materialDesign:HintAssist.Hint="Date format"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
Text="{Binding DateFormat}"
|
||||
ToolTip="Format used when writing dates (uses .NET date formatting rules)" />
|
||||
|
||||
<!-- Parallel limit -->
|
||||
<StackPanel Background="Transparent" ToolTip="How many channels can be exported at the same time">
|
||||
<TextBlock Margin="16,8">
|
||||
<TextBlock Margin="16,8,16,4">
|
||||
<Run Text="Parallel limit:" />
|
||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="{Binding ParallelLimit, Mode=OneWay}" />
|
||||
<Run Text="{Binding ParallelLimit, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
<Slider
|
||||
Margin="16,8"
|
||||
Margin="16,4,16,8"
|
||||
IsSnapToTickEnabled="True"
|
||||
Maximum="10"
|
||||
Minimum="1"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user