Compare commits

...

98 Commits

Author SHA1 Message Date
Tyrrrz f22b048d32 Update version 2021-02-06 21:22:42 +02:00
Lucas LaBuff a8031ad3aa Fix crash when parsing position on DM channels (#496) 2021-02-06 17:00:29 +02:00
Frederic Portaria-Janicki e49cf997ea [CLI] Fix missing channel category bug when API returns empty string (#492) 2021-02-02 14:42:04 +02:00
Lucas LaBuff 8b9afe45b9 [CLI] Fix sorting by channel/category position (#490) 2021-01-31 17:23:46 +02:00
Lucas LaBuff 77b7977324 Formatting output paths (#472) 2021-01-28 22:01:13 +02:00
Lucas LaBuff 915f4c8d9f Fix crash on encountering invalid snowflake (#479) 2021-01-18 19:21:22 +02:00
Tyrrrz 0872d6d44b Update screenshots 2020-12-29 21:06:45 +02:00
Tyrrrz 3aa90bb27b Update version 2020-12-29 21:02:46 +02:00
Tyrrrz 51acffc92e [HTML] Fix up replies 2020-12-29 20:49:34 +02:00
Tyrrrz 39a33538fe Cleanup last PR 2020-12-28 22:10:46 +02:00
Sanqui 07ac805677 Add support for replies (#455) 2020-12-28 22:03:30 +02:00
Alexey Golub 097a75e326 Update readme 2020-12-28 18:38:22 +02:00
Tyrrrz 3d9ee3b339 Embrace Snowflake as first class citizen 2020-12-27 19:42:21 +02:00
multisignature 4ff7990967 Fix grammar in readme (#466) 2020-12-23 21:00:33 +02:00
Andrew Kolos 730fed075f [CLI] Allow passing after and before message ids instead of dates (#462) 2020-12-22 17:41:40 +02:00
Alexey Golub 600c354ab2 Use Discord API v8 2020-12-19 18:37:38 +02:00
Alexey Golub d8dc12b3f7 Update readme 2020-12-19 18:22:03 +02:00
Alexey Golub 33a6d49d76 Use .NET 5.0 SDK in Docker 2020-12-19 18:11:31 +02:00
Tyrrrz 3861dba60c Refactor last PR 2020-12-17 17:49:35 +02:00
Andrew Kolos 69acb5bf76 Set last write time on downloaded media to the value provided in the Last-Modified header (#459) 2020-12-17 17:13:47 +02:00
Tyrrrz 0e67dcbf2e Update readme 2020-12-15 17:47:00 +02:00
Tyrrrz 45d2be549d Install dotnet 5 on CI/CD so that source generators work 2020-12-15 16:17:37 +02:00
Tyrrrz 9394824943 Update MiniRazor to use build-time template compilation 2020-12-15 03:44:13 +02:00
Sanqui 43c50324d8 [JSON] Don't escape Unicode characters when possible (#453)
While UnsafeRelaxedJsonEscaping sounds scary, there is no risk
here since the JSON data is saved directly into a file.  See #450
2020-12-14 21:57:48 +02:00
Alexey Golub a6353de2a7 Use preview language version 2020-12-14 18:48:34 +02:00
Sanqui d020652f50 [CLI] Display channel name for errors during ExportMultipleAsync (#452) 2020-12-14 18:48:11 +02:00
Tyrrrz 63803f98aa Use C#9 features 2020-12-14 17:45:42 +02:00
Tyrrrz 9dda9cfc27 Use floating dotnet versions on CI/CD 2020-12-14 17:37:49 +02:00
Tyrrrz 61d0ddeced Update project structure 2020-12-10 16:52:05 +02:00
Tyrrrz 4c64977b71 Expect null result in WaitAndRetry logic in http retry handler
Fixes #447
2020-12-07 16:24:33 +02:00
Tyrrrz 8bc5b547ef Remove gitattributes and simplify gitignore 2020-12-07 16:23:53 +02:00
Lucas LaBuff 286981d9f8 [HTML] Prettier attachment preview for non-media files (#448) 2020-12-07 16:20:38 +02:00
Tyrrrz 5a62ce6af9 Update JsonExtensions to 1.0.1 2020-11-29 00:42:05 +02:00
Tyrrrz d72fe594b4 Use JsonExtensions package 2020-11-29 00:17:58 +02:00
Tyrrrz 9df98b0405 Update version 2020-11-23 17:04:08 +02:00
Tyrrrz 0d2b9e8bc0 Refactor last PR 2020-11-23 16:53:22 +02:00
Andrew Kolos 53cebd82eb [CLI] Print error and exit if reuse-media option is used without media option (#433) 2020-11-22 20:45:19 +02:00
Tyrrrz 37fcee2f21 Fix relative path extraction in ExportContext when base directory is empty
Fixes #437
Fixes #434
2020-11-20 16:56:30 +02:00
Tyrrrz 047dccef7f Refactor last PR 2020-11-18 19:04:53 +02:00
sas41 cb47902d10 [HTML] Rich rendering support for audio and video embeds (#432) 2020-11-16 15:49:57 +02:00
Alexey Golub dca8b8ceb2 Update .NET runtime links 2020-11-11 15:23:43 +02:00
Tyrrrz a8e7c85b99 Update docker image references 2020-11-11 01:56:53 +02:00
Tyrrrz edd51e0838 Revert last change, .net5 doesn't work 2020-11-11 00:02:56 +02:00
Tyrrrz 5e04accdff Update build infra 2020-11-10 23:58:05 +02:00
Andrew Kolos 547bf0a463 [JSON] Add core user mentions to output (#416) 2020-10-25 23:00:30 +02:00
Tyrrrz 948fab7c86 Update readme 2020-10-25 01:53:32 +03:00
Tyrrrz 5038b8541e Update version 2020-10-24 21:26:34 +03:00
Tyrrrz 1da80956dd Refactor 2020-10-24 21:15:58 +03:00
Tyrrrz 0763a99765 [JSON] Fix incorrect embed color output 2020-10-24 17:45:54 +03:00
Tyrrrz eda5b21684 Update github actions 2020-10-24 00:02:32 +03:00
Tyrrrz 2c7b054c24 Update nuget packages 2020-10-24 00:02:32 +03:00
Andrew Kolos 520e023aff Stop redundantly downloading media when re-exporting (#395) 2020-10-23 16:38:15 +03:00
Alexey Golub 949c9d3f1e Update readme 2020-10-17 20:25:21 +03:00
Andrew Kolos fa825fce47 [GUI] Fix content bg color on first screen to match the second screen (#411) 2020-10-16 14:20:58 +03:00
Andrew Kolos 09acfcff59 Add retry policy to MediaDownloader to accommodate short network outages during download (#404) 2020-10-12 23:03:14 +03:00
Andrew Kolos ddb4856516 Add "unix" and "unixms" options for date format (#405) 2020-10-12 13:41:49 +03:00
Andrew Kolos c4007d4a80 [JSON] Add embed color (#403) 2020-10-10 16:10:56 +03:00
Alexey Golub 2a432c0ed2 Treat nullability warnings as errors 2020-10-07 15:33:59 +03:00
Alexey Golub c5eeb5f464 Update nuget packages 2020-10-07 15:33:47 +03:00
Alexey Golub a6bf546e79 [GUI] Clean up the UI 2020-10-05 17:41:09 +03:00
Alexey Golub 239c47c06e [GUI] Clean up theme management 2020-10-05 17:10:00 +03:00
Andrew Kolos 19f678ca01 [GUI] Add dark theme (#388) 2020-10-05 16:31:37 +03:00
Pegasis 08442f21b7 Add AUR package to README (#379) 2020-09-20 15:52:44 +03:00
Alexey Golub 921f348769 Update version 2020-09-14 15:37:36 +03:00
Alexey Golub a25e809671 Extend try/catch in media downloading to OperationCanceledException
Closes #372
2020-09-14 15:09:57 +03:00
Alexey Golub fa80c82468 Cleanup 2020-09-05 17:10:15 +03:00
Alexey Golub 91f4f02a35 Refactor last commit 2020-09-05 17:05:18 +03:00
wyattscarpenter 6d2880ce26 [CLI] Add command to export all channels across all guilds (#373) 2020-09-05 00:23:06 +03:00
Ahmed Massoud 355b8cb8cf Add call time for call status messages (#365) 2020-08-22 22:14:13 +03:00
Alexey Golub 82945ac3cf [HTML] Use CDN for Whitney fonts
Related to #322
2020-08-21 20:36:59 +03:00
Alexey Golub 5009b90a3e Update screenshots 2020-08-15 13:47:47 +03:00
Alexey Golub 1495ed9b90 Update version 2020-08-12 19:21:24 +03:00
Alexey Golub be66c74c08 Update nuget packages 2020-08-12 19:11:18 +03:00
Alexey Golub 6f54c09d96 [GUI] Refactor sorting 2020-08-12 19:09:12 +03:00
Alexey Golub 45926b0990 Refactor last commit 2020-08-07 21:41:10 +03:00
CarJem Generations 25d29dc079 [GUI] Add controls to configure export time limits in addition to dates (#355) 2020-08-07 21:06:49 +03:00
Alexey Golub efde9931c9 Refactor & optimize changes in last commit 2020-08-07 21:00:43 +03:00
CarJem Generations 2c3b461d49 [GUI] Make categories collapsible in channel list (#354) 2020-08-07 19:42:59 +03:00
CarJem Generations b405052fd6 [HTML] Workaround for Tupperbox nicknames (#352) 2020-08-05 21:58:17 +03:00
Alexey Golub da6d903f36 Update version 2020-07-30 16:03:27 +03:00
Alexey Golub e1b1755fba Don't escape media URL on non-HTML formats 2020-07-30 16:02:00 +03:00
Alexey Golub 1ff026eba6 [GUI] Trim spaces in token input 2020-07-29 20:58:49 +03:00
Alexey Golub 1fe4ecb3af Truncate long file names in MediaDownloader
Fixes #344
2020-07-29 20:58:38 +03:00
Alexey Golub 47a1518cd9 Update nuget packages 2020-07-29 20:49:08 +03:00
Alexey Golub d78c10ebb7 Remove default cap on MakeUniqueFilePath 2020-07-29 20:47:58 +03:00
Alexey Golub 3db29b520c Switch from RazorLight to MiniRazor 2020-07-29 20:08:51 +03:00
Alexey Golub 752003abc3 Fix media downloading 2020-07-28 23:07:31 +03:00
Alexey Golub e26a0660d1 Use Razor instead of Scriban 2020-07-28 22:43:45 +03:00
Alexey Golub 7dfc3b2723 Add link to extra services in readme 2020-07-26 15:54:30 +03:00
Alexey Golub 06c33373de Sanitize file names when exporting media
Fixes #332
2020-07-25 21:17:05 +03:00
Alexey Golub 563f5cb67b Update version 2020-07-19 19:09:45 +03:00
Alexey Golub 1f20915b4d Use original file names for downloaded media
Closes #327
2020-07-19 14:19:40 +03:00
Alexey Golub e58c7aefff Ignore failures when downloading media 2020-07-18 22:08:06 +03:00
Alexey Golub ac5ccc3fa4 Update version 2020-07-18 16:32:02 +03:00
Alexey Golub 2fc0aa43e8 Change "discordapp.com" to "discord.com" where appropriate 2020-07-18 16:04:47 +03:00
Alexey Golub ba66b52fa4 Use ValueTask instead of Task where possible 2020-07-18 15:57:51 +03:00
Alexey Golub ac64d9943a Self-contained export (#321) 2020-07-18 15:45:09 +03:00
Alexey Golub 94a85cdb01 Update nuget packages 2020-05-11 21:50:38 +03:00
118 changed files with 2971 additions and 1888 deletions
-63
View File
@@ -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
View File
@@ -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
View File
@@ -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/
+7 -246
View File
@@ -1,261 +1,22 @@
## 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/
.vs/
# 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: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+69
View File
@@ -1,3 +1,72 @@
### v2.26.1 (06-Feb-2021)
- [CLI] Added support for file name templates, which allow you to dynamically generate output file names based on channel and guild metadata. (Thanks [@Lucas LaBuff](https://github.com/96-LB))
- Fixed an issue where the application sometimes crashed with `Invalid snowflake` message when exporting chat logs that contained invalid or outdated mentions. (Thanks [@Lucas LaBuff](https://github.com/96-LB))
### 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.
- [HTML] Changed underlying templating engine to provide higher performance and better error messages. New templating engine adds a small cold start, but any export after the first should be faster.
- Changed naming schema for downloaded media so that it follows `<original filename> (nn)` format rather than `<original filename>-salt`.
- [HTML] Fixed an issue where downloaded media was sometimes inaccessible due to reserved characters appearing in the URL.
- Fixed an issue where the application crashed when trying to download media with a file name that exceeds system's maximum allowed length.
- Fixed an issue where the application crashed when trying to download media with illegal characters in the file name.
### v2.21.1 (19-Jul-2020)
- Fixed an issue where the export crashed if an embedded image has been deleted. Such media files are now ignored.
- Changed the naming convention for downloaded media files so that the original file names are used when possible.
### v2.21 (18-Jul-2020)
- Added a new option that enables self-contained exports for all output formats. You can turn it on in the export setup dialog in GUI or using the `--media` option in CLI. When using this, the application will additionally download any media content directly referenced from the exported file instead of linking back to Discord CDN. The files which are downloaded include: guild icons, user avatars, attachments, embedded images, reaction emojis. Note that only files which are actually referenced by the export are downloaded, which means that, for example, user avatars will not be downloaded when using plain text export format. This option is not meant to enable complete offline viewing for HTML exports, but rather to make it easier to archive media content that may eventually get deleted from Discord servers. Also keep in mind that this option may make the export drastically slower and the total file size larger.
- Changed "discordapp.com" to "discord.com" where applicable as Discord is migrating to a new domain. CDN will remain on "cdn.discordapp.com" for the foreseeable future.
Note that all existing and current HTML exports will likely not render accurately because Discord enabled CORS for their font resources, which prevents them from loading locally. Please refer to [issue #322](https://github.com/Tyrrrz/DiscordChatExporter/issues/322) for discussion on this topic.
### v2.20 (27-Apr-2020)
- [CLI] Added environment variables as fallback for `--token` and `--bot` options. They are `DISCORD_TOKEN` and `DISCORD_TOKEN_BOT` respectively.
@@ -2,11 +2,12 @@
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<Version>2.20</Version>
<Version>2.26.1</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;
@@ -11,34 +12,51 @@ namespace DiscordChatExporter.Cli.Commands.Base
{
public abstract class ExportCommandBase : TokenCommandBase
{
[CommandOption("format", 'f', Description = "Output file format.")]
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
[CommandOption("output", 'o', Description = "Output file or directory path.")]
public string OutputPath { get; set; } = Directory.GetCurrentDirectory();
public string OutputPath { get; init; } = Directory.GetCurrentDirectory();
[CommandOption("after", Description = "Limit to messages sent after this date.")]
public DateTimeOffset? After { get; set; }
[CommandOption("format", 'f', Description = "Export format.")]
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
[CommandOption("before", Description = "Limit to messages sent before this date.")]
public DateTimeOffset? Before { get; set; }
[CommandOption("after", Description = "Only include messages sent after this date or message ID.")]
public Snowflake? After { get; init; }
[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("dateformat", Description = "Date format used in output.")]
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
[CommandOption("media", Description = "Download referenced media content.")]
public bool ShouldDownloadMedia { get; init; }
protected ChannelExporter GetChannelExporter() => new ChannelExporter(GetDiscordClient());
[CommandOption("reuse-media", Description = "Reuse already existing media content to skip redundant downloads.")]
public bool ShouldReuseMedia { get; init; }
[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)
{
console.Output.Write($"Exporting channel '{channel.Category} / {channel.Name}'... ");
var progress = console.CreateProgressTicker();
await GetChannelExporter().ExportAsync(guild, channel,
OutputPath, ExportFormat, DateFormat, PartitionLimit,
After, Before, progress);
var request = new ExportRequest(
guild,
channel,
OutputPath,
ExportFormat,
After,
Before,
PartitionLimit,
ShouldDownloadMedia,
ShouldReuseMedia,
DateFormat
);
await GetChannelExporter().ExportChannelAsync(request, progress);
console.Output.WriteLine();
console.Output.WriteLine("Done.");
@@ -50,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;
@@ -7,6 +8,7 @@ using CliFx.Attributes;
using CliFx.Utilities;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exceptions;
using DiscordChatExporter.Domain.Exporting;
using DiscordChatExporter.Domain.Utilities;
using Gress;
using Tyrrrz.Extensions;
@@ -15,22 +17,22 @@ namespace DiscordChatExporter.Cli.Commands.Base
{
public abstract class ExportMultipleCommandBase : ExportCommandBase
{
[CommandOption("parallel", Description = "Export this number of separate 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)
{
// This uses a separate route from ExportCommandBase because the progress ticker is not thread-safe
// Ugly code ahead. Will need to refactor.
// 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;
@@ -39,15 +41,26 @@ namespace DiscordChatExporter.Cli.Commands.Base
{
var guild = await GetDiscordClient().GetGuildAsync(channel.GuildId);
await GetChannelExporter().ExportAsync(guild, channel,
OutputPath, ExportFormat, DateFormat, PartitionLimit,
After, Before, operation);
var request = new ExportRequest(
guild,
channel,
OutputPath,
ExportFormat,
After,
Before,
PartitionLimit,
ShouldDownloadMedia,
ShouldReuseMedia,
DateFormat
);
await GetChannelExporter().ExportChannelAsync(request, operation);
Interlocked.Increment(ref successfulExportCount);
}
catch (DiscordChatExporterException ex) when (!ex.IsCritical)
{
errors.Add(ex.Message);
errors.Add((channel, ex.Message));
}
finally
{
@@ -57,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,23 @@ using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Discord.Models.Common;
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, PositionBasedComparer.Instance).ThenBy(c => c.Name))
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
}
}
@@ -4,6 +4,7 @@ using CliFx;
using CliFx.Attributes;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Cli.Commands
@@ -13,9 +14,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.Name))
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
}
}
@@ -10,24 +10,30 @@ 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");
console.Output.WriteLine(" 4. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
console.Output.WriteLine(" 4. Select \"Local Storage\" > \"https://discord.com\" on the left");
console.Output.WriteLine(" 5. Press Ctrl+R to reload");
console.Output.WriteLine(" 6. Find \"token\" at the bottom and copy the value");
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.1.0" />
<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
{
}
}
@@ -16,9 +16,11 @@ namespace DiscordChatExporter.Domain.Discord
Value = value;
}
public AuthenticationHeaderValue GetAuthorizationHeader() => Type == AuthTokenType.User
? new AuthenticationHeaderValue(Value)
: new AuthenticationHeaderValue("Bot", Value);
public AuthenticationHeaderValue GetAuthorizationHeader() => Type switch
{
AuthTokenType.Bot => new AuthenticationHeaderValue("Bot", Value),
_ => new AuthenticationHeaderValue(Value)
};
public override string ToString() => Value;
}
@@ -8,59 +8,38 @@ using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exceptions;
using DiscordChatExporter.Domain.Internal;
using Polly;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Http;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord
{
public partial class DiscordClient
public class DiscordClient
{
private readonly AuthToken _token;
private readonly HttpClient _httpClient;
private readonly IAsyncPolicy<HttpResponseMessage> _httpRequestPolicy;
private readonly AuthToken _token;
private readonly Uri _baseUri = new Uri("https://discordapp.com/api/v6/", UriKind.Absolute);
private readonly Uri _baseUri = new("https://discord.com/api/v8/", UriKind.Absolute);
public DiscordClient(AuthToken token, HttpClient httpClient)
public DiscordClient(HttpClient httpClient, AuthToken token)
{
_token = token;
_httpClient = httpClient;
// Discord seems to always respond with 429 on the first request with unreasonable wait time (10+ minutes).
// For that reason the policy will start respecting their retry-after header only after Nth failed response.
_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);
_token = token;
}
public DiscordClient(AuthToken token)
: this(token, LazyHttpClient.Value)
{
}
: this(Http.Client, token) {}
private async Task<HttpResponseMessage> GetResponseAsync(string url)
{
return await _httpRequestPolicy.ExecuteAsync(async () =>
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 Task<JsonElement> GetJsonResponseAsync(string url)
private async ValueTask<JsonElement> GetJsonResponseAsync(string url)
{
using var response = await GetResponseAsync(url);
@@ -78,7 +57,7 @@ namespace DiscordChatExporter.Domain.Discord
return await response.Content.ReadAsJsonAsync();
}
private async Task<JsonElement?> TryGetJsonResponseAsync(string url)
private async ValueTask<JsonElement?> TryGetJsonResponseAsync(string url)
{
using var response = await GetResponseAsync(url);
@@ -91,24 +70,24 @@ 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")
.SetQueryParameterIfNotNullOrWhiteSpace("after", afterId)
.SetQueryParameter("after", currentAfter.ToString())
.Build();
var response = await GetJsonResponseAsync(url);
var isEmpty = true;
foreach (var guildJson in response.EnumerateArray())
foreach (var guild in response.EnumerateArray().Select(Guild.Parse))
{
var guild = Guild.Parse(guildJson);
yield return guild;
afterId = guild.Id;
currentAfter = guild.Id;
isEmpty = false;
}
@@ -117,7 +96,7 @@ namespace DiscordChatExporter.Domain.Discord
}
}
public async Task<Guild> GetGuildAsync(string guildId)
public async ValueTask<Guild> GetGuildAsync(Snowflake guildId)
{
if (guildId == Guild.DirectMessages.Id)
return Guild.DirectMessages;
@@ -126,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)
{
@@ -138,32 +117,39 @@ namespace DiscordChatExporter.Domain.Discord
{
var response = await GetJsonResponseAsync($"guilds/{guildId}/channels");
var categories = response
var orderedResponse = response
.EnumerateArray()
.ToDictionary(
j => j.GetProperty("id").GetString(),
j => j.GetProperty("name").GetString()
);
.OrderBy(j => j.GetProperty("position").GetInt32())
.ThenBy(j => ulong.Parse(j.GetProperty("id").GetString()));
foreach (var channelJson in response.EnumerateArray())
var categories = orderedResponse
.Where(j => j.GetProperty("type").GetInt32() == (int)ChannelType.GuildCategory)
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
.ToDictionary(j => j.Id.ToString());
var position = 0;
foreach (var channelJson in orderedResponse)
{
var parentId = channelJson.GetPropertyOrNull("parent_id")?.GetString();
var category = !string.IsNullOrWhiteSpace(parentId)
? categories.GetValueOrDefault(parentId)
: null;
var channel = Channel.Parse(channelJson, category);
var channel = Channel.Parse(channelJson, category, position);
// Skip non-text channels
if (!channel.IsTextChannel)
continue;
position++;
yield return channel;
}
}
}
public async IAsyncEnumerable<Role> GetGuildRolesAsync(string guildId)
public async IAsyncEnumerable<Role> GetGuildRolesAsync(Snowflake guildId)
{
if (guildId == Guild.DirectMessages.Id)
yield break;
@@ -174,7 +160,7 @@ namespace DiscordChatExporter.Domain.Discord
yield return Role.Parse(roleJson);
}
public async Task<Member?> TryGetGuildMemberAsync(string guildId, User user)
public async ValueTask<Member?> TryGetGuildMemberAsync(Snowflake guildId, User user)
{
if (guildId == Guild.DirectMessages.Id)
return Member.CreateForUser(user);
@@ -183,30 +169,43 @@ namespace DiscordChatExporter.Domain.Discord
return response?.Pipe(Member.Parse);
}
private async Task<string> GetChannelCategoryAsync(string channelParentId)
public async ValueTask<ChannelCategory> GetChannelCategoryAsync(Snowflake channelId)
{
var response = await GetJsonResponseAsync($"channels/{channelParentId}");
return response.GetProperty("name").GetString();
try
{
var response = await GetJsonResponseAsync($"channels/{channelId}");
return ChannelCategory.Parse(response);
}
/***
* In some cases, the Discord API returns an empty body when requesting some channel category info.
* Instead, we use an empty channel category as a fallback.
*/
catch (DiscordChatExporterException)
{
return ChannelCategory.Empty;
}
}
public async Task<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 Task<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")
.SetQueryParameterIfNotNullOrWhiteSpace("before", before?.ToSnowflake())
.SetQueryParameter("before", before?.ToString())
.Build();
var response = await GetJsonResponseAsync(url);
@@ -214,25 +213,29 @@ 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
// Get the last message in the specified range.
// This snapshots the boundaries, which means that messages posted after the exported started
// 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);
@@ -262,24 +265,9 @@ namespace DiscordChatExporter.Domain.Discord
);
yield return message;
afterId = message.Id;
currentAfter = message.Id;
}
}
}
}
public partial class DiscordClient
{
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);
});
}
}
}
@@ -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;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#attachment-object
// 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,12 +1,14 @@
using System.Linq;
using System;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
// Order of enum fields needs to match the order in the docs.
public enum ChannelType
{
@@ -19,10 +21,10 @@ namespace DiscordChatExporter.Domain.Discord.Models
GuildStore
}
// https://discordapp.com/developers/docs/resources/channel#channel-object
public partial class Channel : IHasId
// https://discord.com/developers/docs/resources/channel#channel-object
public partial class Channel : IHasIdAndPosition
{
public string Id { get; }
public Snowflake Id { get; }
public ChannelType Type { get; }
@@ -33,51 +35,61 @@ namespace DiscordChatExporter.Domain.Discord.Models
Type == ChannelType.GuildNews ||
Type == ChannelType.GuildStore;
public string GuildId { get; }
public Snowflake GuildId { get; }
public string Category { get; }
public ChannelCategory Category { get; }
public string Name { get; }
public int? Position { get; }
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, ChannelCategory? category, string name, int? position, string? topic)
{
Id = id;
Type = type;
GuildId = guildId;
Category = category;
Category = category ?? GetDefaultCategory(type);
Name = name;
Position = position;
Topic = topic;
}
public override string ToString() => Name;
}
public partial class Channel
{
private static string GetDefaultCategory(ChannelType channelType) => channelType switch
{
ChannelType.GuildTextChat => "Text",
ChannelType.DirectTextChat => "Private",
ChannelType.DirectGroupTextChat => "Group",
ChannelType.GuildNews => "News",
ChannelType.GuildStore => "Store",
_ => "Default"
};
private static ChannelCategory GetDefaultCategory(ChannelType channelType) => new(
Snowflake.Zero,
channelType switch
{
ChannelType.GuildTextChat => "Text",
ChannelType.DirectTextChat => "Private",
ChannelType.DirectGroupTextChat => "Group",
ChannelType.GuildNews => "News",
ChannelType.GuildStore => "Store",
_ => "Default"
},
0
);
public static Channel Parse(JsonElement json, string? category = null)
public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? position = 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();
var type = (ChannelType)json.GetProperty("type").GetInt32();
var name =
json.GetPropertyOrNull("name")?.GetString() ??
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ??
id;
id.ToString();
position ??= json.GetPropertyOrNull("position")?.GetInt32();
return new Channel(
id,
@@ -85,7 +97,9 @@ namespace DiscordChatExporter.Domain.Discord.Models
guildId ?? Guild.DirectMessages.Id,
category ?? GetDefaultCategory(type),
name,
topic);
position,
topic
);
}
}
}
@@ -0,0 +1,50 @@
using System;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{
public partial class ChannelCategory : IHasIdAndPosition
{
public Snowflake Id { get; }
public string Name { get; }
public int? Position { get; }
public ChannelCategory(Snowflake id, string name, int? position)
{
Id = id;
Name = name;
Position = position;
}
public override string ToString() => Name;
}
public partial class ChannelCategory
{
public static ChannelCategory Parse(JsonElement json, int? position = null)
{
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
position ??= json.GetPropertyOrNull("position")?.GetInt32();
var name = json.GetPropertyOrNull("name")?.GetString() ??
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ??
id.ToString();
return new ChannelCategory(
id,
name,
position
);
}
public static ChannelCategory Empty { get; } = new(Snowflake.Zero, "Missing", 0);
}
}
@@ -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; }
}
}
@@ -0,0 +1,7 @@
namespace DiscordChatExporter.Domain.Discord.Models.Common
{
public interface IHasIdAndPosition : IHasId
{
int? Position { 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();
}
}
@@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Domain.Discord.Models.Common
{
public partial class PositionBasedComparer : IComparer<IHasIdAndPosition>
{
public int Compare(IHasIdAndPosition? x, IHasIdAndPosition? y)
{
int result = Comparer<int?>.Default.Compare(x?.Position, y?.Position);
if (result == 0)
{
result = Comparer<ulong?>.Default.Compare(x?.Id.Value, y?.Id.Value);
}
return result;
}
}
public partial class PositionBasedComparer
{
public static PositionBasedComparer Instance { get; } = new();
}
}
@@ -3,11 +3,13 @@ using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object
// https://discord.com/developers/docs/resources/channel#embed-object
public partial class Embed
{
public string? Title { get; }
@@ -1,9 +1,9 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure
// https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure
public partial class EmbedAuthor
{
public string? Name { get; }
@@ -1,9 +1,9 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-field-structure
// https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure
public partial class EmbedField
{
public string Name { get; }
@@ -1,9 +1,9 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-footer-structure
// https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure
public partial class EmbedFooter
{
public string Text { get; }
@@ -1,9 +1,9 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure
// https://discord.com/developers/docs/resources/channel#embed-object-embed-image-structure
public partial class EmbedImage
{
public string? Url { get; }
@@ -2,12 +2,12 @@
using System.Linq;
using System.Text;
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using JsonExtensions.Reading;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/emoji#emoji-object
// https://discord.com/developers/docs/resources/emoji#emoji-object
public partial class Emoji
{
public string? Id { get; }
@@ -1,23 +1,23 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/guild#guild-object
// 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? iconHash)
public Guild(Snowflake id, string name, string iconUrl)
{
Id = id;
Name = name;
IconUrl = GetIconUrl(id, iconHash);
IconUrl = iconUrl;
}
public override string ToString() => Name;
@@ -25,21 +25,25 @@ namespace DiscordChatExporter.Domain.Discord.Models
public partial class Guild
{
public static Guild DirectMessages { get; } =
new Guild("@me", "Direct Messages", null);
public static Guild DirectMessages { get; } = new(Snowflake.Zero, "Direct Messages", GetDefaultIconUrl());
private static string GetIconUrl(string id, string? iconHash) =>
!string.IsNullOrWhiteSpace(iconHash)
? $"https://cdn.discordapp.com/icons/{id}/{iconHash}.png"
: "https://cdn.discordapp.com/embed/avatars/0.png";
private static string GetDefaultIconUrl() =>
"https://cdn.discordapp.com/embed/avatars/0.png";
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();
return new Guild(id, name, iconHash);
var iconUrl = !string.IsNullOrWhiteSpace(iconHash)
? GetIconUrl(id, iconHash)
: GetDefaultIconUrl();
return new Guild(id, name, iconUrl);
}
}
}
@@ -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;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/guild#guild-member-object
// 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;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#message-object-message-types
// 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://discordapp.com/developers/docs/resources/channel#message-object
// 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,54 +49,65 @@ 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 ?? (Embeds.Any()
? "<embed>"
: "<no content>");
public override string ToString() => Content;
}
public partial class Message
{
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.",
@@ -122,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,9 +1,9 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#reaction-object
// https://discord.com/developers/docs/resources/channel#reaction-object
public partial class Reaction
{
public Emoji Emoji { get; }
@@ -1,13 +1,16 @@
using System.Drawing;
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/topics/permissions#role-object
public partial class Role
// https://discord.com/developers/docs/topics/permissions#role-object
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;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/user#user-object
// 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,14 +21,13 @@ namespace DiscordChatExporter.Domain.Discord.Models
public string AvatarUrl { get; }
public User(string id, bool isBot, int discriminator, string name, string? avatarHash)
public User(Snowflake id, bool isBot, int discriminator, string name, string avatarUrl)
{
Id = id;
IsBot = isBot;
Discriminator = discriminator;
Name = name;
AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash);
AvatarUrl = avatarUrl;
}
public override string ToString() => FullName;
@@ -35,32 +35,32 @@ namespace DiscordChatExporter.Domain.Discord.Models
public partial class User
{
private static string GetAvatarUrl(string id, int discriminator, string? avatarHash)
private static string GetDefaultAvatarUrl(int discriminator) =>
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
private static string GetAvatarUrl(Snowflake id, string avatarHash)
{
// Custom avatar
if (!string.IsNullOrWhiteSpace(avatarHash))
{
// Animated
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.gif";
// Animated
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.gif";
// Non-animated
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png";
}
// Default avatar
return $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
// Non-animated
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png";
}
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();
var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false;
return new User(id, isBot, discriminator, name, avatarHash);
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
? GetAvatarUrl(id, avatarHash)
: GetDefaultAvatarUrl(discriminator);
return new User(id, isBot, discriminator, name, avatarUrl);
}
}
}
@@ -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,18 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../DiscordChatExporter.props" />
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Polly" Version="7.2.0" />
<PackageReference Include="Scriban" Version="2.1.2" />
<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>
<ItemGroup>
<EmbeddedResource Include="Exporting\Resources\HtmlCore.css" />
<EmbeddedResource Include="Exporting\Resources\HtmlDark.css" />
<EmbeddedResource Include="Exporting\Resources\HtmlLayoutTemplate.html" />
<EmbeddedResource Include="Exporting\Resources\HtmlLight.css" />
<EmbeddedResource Include="Exporting\Resources\HtmlMessageGroupTemplate.html" />
<EmbeddedResource Include="Exporting\Writers\Html\Core.css" />
<EmbeddedResource Include="Exporting\Writers\Html\Dark.css" />
<EmbeddedResource Include="Exporting\Writers\Html\Light.css" />
<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>
</Project>
@@ -1,6 +1,5 @@
using System;
using System.Net.Http;
using DiscordChatExporter.Domain.Discord.Models;
namespace DiscordChatExporter.Domain.Exceptions
{
@@ -43,17 +42,14 @@ Failed to perform an HTTP request.
internal static DiscordChatExporterException NotFound()
{
const string message = "Not found.";
const string message = "Requested resource does not exist.";
return new DiscordChatExporterException(message);
}
internal static DiscordChatExporterException ChannelEmpty(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);
}
internal static DiscordChatExporterException ChannelEmpty(Channel channel) =>
ChannelEmpty(channel.Name);
}
}
@@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Discord.Models;
@@ -12,7 +10,7 @@ using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Domain.Exporting
{
public partial class ChannelExporter
public class ChannelExporter
{
private readonly DiscordClient _discord;
@@ -20,49 +18,38 @@ namespace DiscordChatExporter.Domain.Exporting
public ChannelExporter(AuthToken token) : this(new DiscordClient(token)) {}
public async Task ExportAsync(
Guild guild,
Channel channel,
string outputPath,
ExportFormat format,
string dateFormat,
int? partitionLimit,
DateTimeOffset? after = null,
DateTimeOffset? before = null,
IProgress<double>? progress = null)
public async ValueTask ExportChannelAsync(ExportRequest request, IProgress<double>? progress = null)
{
var baseFilePath = GetFilePathFromOutputPath(guild, channel, outputPath, format, after, before);
// Options
var options = new ExportOptions(baseFilePath, format, partitionLimit);
// Context
// Build context
var contextMembers = new HashSet<Member>(IdBasedEqualityComparer.Instance);
var contextChannels = await _discord.GetGuildChannelsAsync(guild.Id);
var contextRoles = await _discord.GetGuildRolesAsync(guild.Id);
var contextChannels = await _discord.GetGuildChannelsAsync(request.Guild.Id);
var contextRoles = await _discord.GetGuildRolesAsync(request.Guild.Id);
var context = new ExportContext(
guild, channel, after, before, dateFormat,
contextMembers, contextChannels, contextRoles
request,
contextMembers,
contextChannels,
contextRoles
);
await using var messageExporter = new MessageExporter(options, context);
// Export messages
await using var messageExporter = new MessageExporter(context);
var exportedAnything = false;
var encounteredUsers = new HashSet<User>(IdBasedEqualityComparer.Instance);
await foreach (var message in _discord.GetMessagesAsync(channel.Id, after, before, progress))
await foreach (var message in _discord.GetMessagesAsync(request.Channel.Id, request.After, request.Before, progress))
{
// Resolve members for referenced users
foreach (var referencedUser in message.MentionedUsers.Prepend(message.Author))
{
if (encounteredUsers.Add(referencedUser))
{
var member =
await _discord.TryGetGuildMemberAsync(guild.Id, referencedUser) ??
Member.CreateForUser(referencedUser);
if (!encounteredUsers.Add(referencedUser))
continue;
contextMembers.Add(member);
}
var member =
await _discord.TryGetGuildMemberAsync(request.Guild.Id, referencedUser) ??
Member.CreateForUser(referencedUser);
contextMembers.Add(member);
}
// Export message
@@ -72,75 +59,7 @@ namespace DiscordChatExporter.Domain.Exporting
// Throw if no messages were exported
if (!exportedAnything)
throw DiscordChatExporterException.ChannelEmpty(channel);
}
}
public partial class ChannelExporter
{
public static string GetDefaultExportFileName(
Guild guild,
Channel channel,
ExportFormat format,
DateTimeOffset? after = null,
DateTimeOffset? before = null)
{
var buffer = new StringBuilder();
// Guild and channel names
buffer.Append($"{guild.Name} - {channel.Category} - {channel.Name} [{channel.Id}]");
// Date range
if (after != null || before != null)
{
buffer.Append(" (");
// Both 'after' and 'before' are set
if (after != null && before != null)
{
buffer.Append($"{after:yyyy-MM-dd} to {before:yyyy-MM-dd}");
}
// Only 'after' is set
else if (after != null)
{
buffer.Append($"after {after:yyyy-MM-dd}");
}
// Only 'before' is set
else
{
buffer.Append($"before {before:yyyy-MM-dd}");
}
buffer.Append(")");
}
// File extension
buffer.Append($".{format.GetFileExtension()}");
// Replace invalid chars
foreach (var invalidChar in Path.GetInvalidFileNameChars())
buffer.Replace(invalidChar, '_');
return buffer.ToString();
}
private static string GetFilePathFromOutputPath(
Guild guild,
Channel channel,
string outputPath,
ExportFormat format,
DateTimeOffset? after = null,
DateTimeOffset? before = null)
{
// Output is a directory
if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath)))
{
var fileName = GetDefaultExportFileName(guild, channel, format, after, before);
return Path.Combine(outputPath, fileName);
}
// Output is a file
return outputPath;
throw DiscordChatExporterException.ChannelIsEmpty();
}
}
}
@@ -1,22 +1,22 @@
using System;
using System.Collections.Generic;
using System.Drawing;
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;
namespace DiscordChatExporter.Domain.Exporting
{
public class ExportContext
internal class ExportContext
{
public Guild Guild { get; }
private readonly MediaDownloader _mediaDownloader;
public Channel Channel { get; }
public DateTimeOffset? After { get; }
public DateTimeOffset? Before { get; }
public string DateFormat { get; }
public ExportRequest Request { get; }
public IReadOnlyCollection<Member> Members { get; }
@@ -25,46 +25,80 @@ namespace DiscordChatExporter.Domain.Exporting
public IReadOnlyCollection<Role> Roles { get; }
public ExportContext(
Guild guild,
Channel channel,
DateTimeOffset? after,
DateTimeOffset? before,
string dateFormat,
ExportRequest request,
IReadOnlyCollection<Member> members,
IReadOnlyCollection<Channel> channels,
IReadOnlyCollection<Role> roles)
{
Guild = guild;
Channel = channel;
After = after;
Before = before;
DateFormat = dateFormat;
Request = request;
Members = members;
Channels = channels;
Roles = roles;
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath, request.ShouldReuseMedia);
}
public Member? TryGetMentionedMember(string id) =>
Members.FirstOrDefault(m => m.Id == id);
public Channel? TryGetMentionedChannel(string id) =>
Channels.FirstOrDefault(c => c.Id == id);
public Role? TryGetMentionedRole(string id) =>
Roles.FirstOrDefault(r => r.Id == id);
public Member? TryGetUserMember(User user) => Members
.FirstOrDefault(m => m.Id == user.Id);
public Color? TryGetUserColor(User user)
public string FormatDate(DateTimeOffset date) => Request.DateFormat switch
{
var member = TryGetUserMember(user);
"unix" => date.ToUnixTimeSeconds().ToString(),
"unixms" => date.ToUnixTimeMilliseconds().ToString(),
var dateFormat => date.ToLocalString(dateFormat)
};
public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id);
public Channel? TryGetChannel(Snowflake id) => Channels.FirstOrDefault(c => c.Id == id);
public Role? TryGetRole(Snowflake id) => Roles.FirstOrDefault(r => r.Id == id);
public Color? TryGetUserColor(Snowflake id)
{
var member = TryGetMember(id);
var roles = member?.RoleIds.Join(Roles, i => i, r => r.Id, (_, role) => role);
return roles?
.Where(r => r.Color != null)
.OrderByDescending(r => r.Position)
.Select(r => r.Color)
.FirstOrDefault(c => c != null);
.FirstOrDefault();
}
public async ValueTask<string> ResolveMediaUrlAsync(string url)
{
if (!Request.ShouldDownloadMedia)
return url;
try
{
var filePath = await _mediaDownloader.DownloadAsync(url);
// 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;
// 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
return relativeFilePath
.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.Select(Uri.EscapeDataString)
.JoinToString(Path.AltDirectorySeparatorChar.ToString());
}
return relativeFilePath;
}
// 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,18 +0,0 @@
namespace DiscordChatExporter.Domain.Exporting
{
public class ExportOptions
{
public string BaseFilePath { get; }
public ExportFormat Format { get; }
public int? PartitionLimit { get; }
public ExportOptions(string baseFilePath, ExportFormat format, int? partitionLimit)
{
BaseFilePath = baseFilePath;
Format = format;
PartitionLimit = partitionLimit;
}
}
}
@@ -0,0 +1,162 @@
using System;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Exporting
{
public partial class ExportRequest
{
public Guild Guild { get; }
public Channel Channel { get; }
public string OutputPath { get; }
public string OutputBaseFilePath { get; }
public string OutputBaseDirPath { get; }
public string OutputMediaDirPath { get; }
public ExportFormat Format { get; }
public Snowflake? After { get; }
public Snowflake? Before { get; }
public int? PartitionLimit { get; }
public bool ShouldDownloadMedia { get; }
public bool ShouldReuseMedia { get; }
public string DateFormat { get; }
public ExportRequest(
Guild guild,
Channel channel,
string outputPath,
ExportFormat format,
Snowflake? after,
Snowflake? before,
int? partitionLimit,
bool shouldDownloadMedia,
bool shouldReuseMedia,
string dateFormat)
{
Guild = guild;
Channel = channel;
OutputPath = outputPath;
Format = format;
After = after;
Before = before;
PartitionLimit = partitionLimit;
ShouldDownloadMedia = shouldDownloadMedia;
ShouldReuseMedia = shouldReuseMedia;
DateFormat = dateFormat;
OutputBaseFilePath = GetOutputBaseFilePath(
guild,
channel,
outputPath,
format,
after,
before
);
OutputBaseDirPath = Path.GetDirectoryName(OutputBaseFilePath) ?? OutputPath;
OutputMediaDirPath = $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}";
}
}
public partial class ExportRequest
{
private static string GetOutputBaseFilePath(
Guild guild,
Channel channel,
string outputPath,
ExportFormat format,
Snowflake? after = null,
Snowflake? before = null)
{
// Formats path
outputPath = Regex.Replace(outputPath, "%.", m =>
PathEx.EscapePath(m.Value switch
{
"%g" => guild.Id.ToString(),
"%G" => guild.Name,
"%t" => channel.Category.Id.ToString(),
"%T" => channel.Category.Name,
"%c" => channel.Id.ToString(),
"%C" => channel.Name,
"%p" => channel.Position?.ToString() ?? "0",
"%P" => channel.Category.Position?.ToString() ?? "0",
"%a" => (after ?? Snowflake.Zero).ToDate().ToString("yyyy-MM-dd"),
"%b" => (before?.ToDate() ?? DateTime.Now).ToString("yyyy-MM-dd"),
"%%" => "%",
_ => m.Value
})
);
// Output is a directory
if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath)))
{
var fileName = GetDefaultOutputFileName(guild, channel, format, after, before);
return Path.Combine(outputPath, fileName);
}
// Output is a file
return outputPath;
}
public static string GetDefaultOutputFileName(
Guild guild,
Channel channel,
ExportFormat format,
Snowflake? after = null,
Snowflake? before = null)
{
var buffer = new StringBuilder();
// Guild and channel names
buffer.Append($"{guild.Name} - {channel.Category.Name} - {channel.Name} [{channel.Id}]");
// Date range
if (after != null || before != null)
{
buffer.Append(" (");
// Both 'after' and 'before' are set
if (after != null && before != null)
{
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?.ToDate():yyyy-MM-dd}");
}
// Only 'before' is set
else
{
buffer.Append($"before {before?.ToDate():yyyy-MM-dd}");
}
buffer.Append(")");
}
// File extension
buffer.Append($".{format.GetFileExtension()}");
// Replace invalid chars
PathEx.EscapePath(buffer);
return buffer.ToString();
}
}
}
@@ -0,0 +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;
private readonly string _workingDirPath;
private readonly bool _reuseMedia;
// URL -> Local file path
private readonly Dictionary<string, string> _pathCache = new(StringComparer.Ordinal);
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 (_pathCache.TryGetValue(url, out var cachedFilePath))
return cachedFilePath;
var fileName = GetFileNameFromUrl(url);
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);
// 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);
}
// 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 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 urlHash = GetUrlHash(url);
// Try to extract file name from URL
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
// 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);
}
}
}
@@ -8,26 +8,24 @@ namespace DiscordChatExporter.Domain.Exporting
{
internal partial class MessageExporter : IAsyncDisposable
{
private readonly ExportOptions _options;
private readonly ExportContext _context;
private long _renderedMessageCount;
private long _messageCount;
private int _partitionIndex;
private MessageWriter? _writer;
public MessageExporter(ExportOptions options, ExportContext context)
public MessageExporter(ExportContext context)
{
_options = options;
_context = context;
}
private bool IsPartitionLimitReached() =>
_renderedMessageCount > 0 &&
_options.PartitionLimit != null &&
_options.PartitionLimit != 0 &&
_renderedMessageCount % _options.PartitionLimit == 0;
_messageCount > 0 &&
_context.Request.PartitionLimit != null &&
_context.Request.PartitionLimit != 0 &&
_messageCount % _context.Request.PartitionLimit == 0;
private async Task ResetWriterAsync()
private async ValueTask ResetWriterAsync()
{
if (_writer != null)
{
@@ -37,7 +35,7 @@ namespace DiscordChatExporter.Domain.Exporting
}
}
private async Task<MessageWriter> GetWriterAsync()
private async ValueTask<MessageWriter> GetWriterAsync()
{
// Ensure partition limit has not been exceeded
if (IsPartitionLimitReached())
@@ -50,23 +48,23 @@ namespace DiscordChatExporter.Domain.Exporting
if (_writer != null)
return _writer;
var filePath = GetPartitionFilePath(_options.BaseFilePath, _partitionIndex);
var filePath = GetPartitionFilePath(_context.Request.OutputBaseFilePath, _partitionIndex);
var dirPath = Path.GetDirectoryName(_options.BaseFilePath);
var dirPath = Path.GetDirectoryName(_context.Request.OutputBaseFilePath);
if (!string.IsNullOrWhiteSpace(dirPath))
Directory.CreateDirectory(dirPath);
var writer = CreateMessageWriter(filePath, _options.Format, _context);
var writer = CreateMessageWriter(filePath, _context.Request.Format, _context);
await writer.WritePreambleAsync();
return _writer = writer;
}
public async Task ExportMessageAsync(Message message)
public async ValueTask ExportMessageAsync(Message message)
{
var writer = await GetWriterAsync();
await writer.WriteMessageAsync(message);
_renderedMessageCount++;
_messageCount++;
}
public async ValueTask DisposeAsync() => await ResetWriterAsync();
@@ -74,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)
@@ -84,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);
@@ -1,31 +0,0 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Domain.Discord.Models;
namespace DiscordChatExporter.Domain.Exporting
{
// Used for grouping contiguous messages in HTML export
internal partial class MessageGroup
{
public User Author { get; }
public DateTimeOffset Timestamp { get; }
public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTimeOffset timestamp, IReadOnlyList<Message> messages)
{
Author = author;
Timestamp = timestamp;
Messages = messages;
}
}
internal partial class MessageGroup
{
public static bool CanGroup(Message message1, Message message2) =>
string.Equals(message1.Author.Id, message2.Author.Id, StringComparison.Ordinal) &&
string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) &&
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7;
}
}
@@ -1,96 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{~ # Metadata ~}}
<title>{{ Context.Guild.Name | html.escape }} - {{ Context.Channel.Name | html.escape }}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
{{~ # Styles ~}}
<style>
{{ CoreStyleSheet }}
</style>
<style>
{{ ThemeStyleSheet }}
</style>
{{~ # Syntax highlighting ~}}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/{{HighlightJsStyleName}}.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.pre--multiline').forEach(block => hljs.highlightBlock(block));
});
</script>
{{~ # Local scripts ~}}
<script>
function scrollToMessage(event, id) {
var element = document.getElementById('message-' + id);
if (element) {
event.preventDefault();
element.classList.add('chatlog__message--highlighted');
window.scrollTo({
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
behavior: 'smooth'
});
window.setTimeout(function() {
element.classList.remove('chatlog__message--highlighted');
}, 2000);
}
}
function showSpoiler(event, element) {
if (element && element.classList.contains('spoiler--hidden')) {
event.preventDefault();
element.classList.remove('spoiler--hidden');
}
}
</script>
</head>
<body>
{{~ # Preamble ~}}
<div class="preamble">
<div class="preamble__guild-icon-container">
<img class="preamble__guild-icon" src="{{ Context.Guild.IconUrl }}" alt="Guild icon">
</div>
<div class="preamble__entries-container">
<div class="preamble__entry">{{ Context.Guild.Name | html.escape }}</div>
<div class="preamble__entry">{{ Context.Channel.Category | html.escape }} / {{ Context.Channel.Name | html.escape }}</div>
{{~ if Context.Channel.Topic ~}}
<div class="preamble__entry preamble__entry--small">{{ Context.Channel.Topic | html.escape }}</div>
{{~ end ~}}
{{~ if Context.After || Context.Before ~}}
<div class="preamble__entry preamble__entry--small">
{{~ if Context.After && Context.Before ~}}
Between {{ Context.After | FormatDate | html.escape }} and {{ Context.Before | FormatDate | html.escape }}
{{~ else if Context.After ~}}
After {{ Context.After | FormatDate | html.escape }}
{{~ else if Context.Before ~}}
Before {{ Context.Before | FormatDate | html.escape }}
{{~ end ~}}
</div>
{{~ end ~}}
</div>
</div>
{{~ # Log ~}}
<div class="chatlog">
{{~ %SPLIT% ~}}
</div>
{{~ # Postamble ~}}
<div class="postamble">
<div class="postamble__entry">Exported {{ MessageCount | object.format "N0" }} message(s)</div>
</div>
</body>
</html>
@@ -1,184 +0,0 @@
<div class="chatlog__message-group">
{{~ # Avatar ~}}
<div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="{{ MessageGroup.Author.AvatarUrl }}" alt="Avatar">
</div>
<div class="chatlog__messages">
{{~ # Author name and timestamp ~}}
{{~ userColor = TryGetUserColor MessageGroup.Author | FormatColorRgb ~}}
<span class="chatlog__author-name" title="{{ MessageGroup.Author.FullName | html.escape }}" data-user-id="{{ MessageGroup.Author.Id | html.escape }}" {{ if userColor }} style="color: {{ userColor }}" {{ end }}>{{ (TryGetUserNick MessageGroup.Author ?? MessageGroup.Author.Name) | html.escape }}</span>
{{~ # Bot tag ~}}
{{~ if MessageGroup.Author.IsBot ~}}
<span class="chatlog__bot-tag">BOT</span>
{{~ end ~}}
<span class="chatlog__timestamp">{{ MessageGroup.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages ~}}
{{~ for message in MessageGroup.Messages ~}}
<div class="chatlog__message {{ if message.IsPinned }}chatlog__message--pinned{{ end }}" data-message-id="{{ message.Id }}" id="message-{{ message.Id }}">
{{~ # Content ~}}
{{~ if message.Content ~}}
<div class="chatlog__content">
<div class="markdown">
{{- message.Content | FormatMarkdown -}}
{{- # Edited timestamp -}}
{{- if message.EditedTimestamp -}}
{{-}}<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>{{-}}
{{- end -}}
</div>
</div>
{{~ end ~}}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
<div class="chatlog__attachment">
{{ # Spoiler image }}
{{~ if attachment.IsSpoiler ~}}
<div class="spoiler spoiler--hidden" onclick="showSpoiler(event, this)">
<div class="spoiler-image">
<a href="{{ attachment.Url }}">
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" alt="Attachment">
</a>
</div>
</div>
{{~ else ~}}
<a href="{{ attachment.Url }}">
{{ # Non-spoiler image }}
{{~ if attachment.IsImage ~}}
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" alt="Attachment">
{{~ # Non-image ~}}
{{~ else ~}}
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize }})
{{~ end ~}}
</a>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Embeds ~}}
{{~ for embed in message.Embeds ~}}
<div class="chatlog__embed">
{{~ if embed.Color ~}}
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color.R }},{{ embed.Color.G }},{{ embed.Color.B }},{{ embed.Color.A }})"></div>
{{~ else ~}}
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
{{~ end ~}}
<div class="chatlog__embed-content-container">
<div class="chatlog__embed-content">
<div class="chatlog__embed-text">
{{~ # Author ~}}
{{~ if embed.Author ~}}
<div class="chatlog__embed-author">
{{~ if embed.Author.IconUrl ~}}
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl }}" alt="Author icon">
{{~ end ~}}
{{~ if embed.Author.Name ~}}
<span class="chatlog__embed-author-name">
{{~ if embed.Author.Url ~}}
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | html.escape }}</a>
{{~ else ~}}
{{ embed.Author.Name | html.escape }}
{{~ end ~}}
</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Title ~}}
{{~ if embed.Title ~}}
<div class="chatlog__embed-title">
{{~ if embed.Url ~}}
<a class="chatlog__embed-title-link" href="{{ embed.Url }}"><div class="markdown">{{ embed.Title | FormatEmbedMarkdown }}</div></a>
{{~ else ~}}
<div class="markdown">{{ embed.Title | FormatEmbedMarkdown }}</div>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Description ~}}
{{~ if embed.Description ~}}
<div class="chatlog__embed-description"><div class="markdown">{{ embed.Description | FormatEmbedMarkdown }}</div></div>
{{~ end ~}}
{{~ # Fields ~}}
{{~ if embed.Fields | array.size > 0 ~}}
<div class="chatlog__embed-fields">
{{~ for field in embed.Fields ~}}
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
{{~ if field.Name ~}}
<div class="chatlog__embed-field-name"><div class="markdown">{{ field.Name | FormatEmbedMarkdown }}</div></div>
{{~ end ~}}
{{~ if field.Value ~}}
<div class="chatlog__embed-field-value"><div class="markdown">{{ field.Value | FormatEmbedMarkdown }}</div></div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ # Thumbnail ~}}
{{~ if embed.Thumbnail ~}}
<div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url }}">
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url }}" alt="Thumbnail">
</a>
</div>
{{~ end ~}}
</div>
{{~ # Image ~}}
{{~ if embed.Image ~}}
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
<img class="chatlog__embed-image" src="{{ embed.Image.Url }}" alt="Image">
</a>
</div>
{{~ end ~}}
{{~ # Footer ~}}
{{~ if embed.Footer || embed.Timestamp ~}}
<div class="chatlog__embed-footer">
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text && embed.Footer.IconUrl ~}}
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" alt="Footer icon">
{{~ end ~}}
{{~ end ~}}
<span class="chatlog__embed-footer-text">
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text ~}}
{{ embed.Footer.Text | html.escape }}
{{ if embed.Timestamp }} • {{ end }}
{{~ end ~}}
{{~ end ~}}
{{~ if embed.Timestamp ~}}
{{ embed.Timestamp | FormatDate | html.escape }}
{{~ end ~}}
</span>
</div>
{{~ end ~}}
</div>
</div>
{{~ end ~}}
{{~ # Reactions ~}}
{{~ if message.Reactions | array.size > 0 ~}}
<div class="chatlog__reactions">
{{~ for reaction in message.Reactions ~}}
<div class="chatlog__reaction">
<img class="emoji emoji--small" alt="{{ reaction.Emoji.Name }}" title="{{ reaction.Emoji.Name }}" src="{{ reaction.Emoji.ImageUrl }}">
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
</div>
@@ -1,11 +1,10 @@
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
using DiscordChatExporter.Domain.Internal;
using Tyrrrz.Extensions;
using DiscordChatExporter.Domain.Internal.Extensions;
namespace DiscordChatExporter.Domain.Exporting.Writers
{
@@ -22,22 +21,68 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
private string FormatMarkdown(string? markdown) =>
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
public override async Task WritePreambleAsync() =>
public override async ValueTask WritePreambleAsync() =>
await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions");
public override async Task WriteMessageAsync(Message message)
private async ValueTask WriteAttachmentsAsync(IReadOnlyList<Attachment> attachments)
{
var buffer = new StringBuilder();
buffer
.Append(CsvEncode(message.Author.Id)).Append(',')
.Append(CsvEncode(message.Author.FullName)).Append(',')
.Append(CsvEncode(message.Timestamp.ToLocalString(Context.DateFormat))).Append(',')
.Append(CsvEncode(FormatMarkdown(message.Content))).Append(',')
.Append(CsvEncode(message.Attachments.Select(a => a.Url).JoinToString(","))).Append(',')
.Append(CsvEncode(message.Reactions.Select(r => $"{r.Emoji.Name} ({r.Count})").JoinToString(",")));
foreach (var attachment in attachments)
{
buffer
.AppendIfNotEmpty(',')
.Append(await Context.ResolveMediaUrlAsync(attachment.Url));
}
await _writer.WriteLineAsync(buffer.ToString());
await _writer.WriteAsync(CsvEncode(buffer.ToString()));
}
private async ValueTask WriteReactionsAsync(IReadOnlyList<Reaction> reactions)
{
var buffer = new StringBuilder();
foreach (var reaction in reactions)
{
buffer
.AppendIfNotEmpty(',')
.Append(reaction.Emoji.Name)
.Append(' ')
.Append('(')
.Append(reaction.Count)
.Append(')');
}
await _writer.WriteAsync(CsvEncode(buffer.ToString()));
}
public override async ValueTask WriteMessageAsync(Message message)
{
// Author ID
await _writer.WriteAsync(CsvEncode(message.Author.Id.ToString()));
await _writer.WriteAsync(',');
// Author name
await _writer.WriteAsync(CsvEncode(message.Author.FullName));
await _writer.WriteAsync(',');
// Message timestamp
await _writer.WriteAsync(CsvEncode(Context.FormatDate(message.Timestamp)));
await _writer.WriteAsync(',');
// Message content
await _writer.WriteAsync(CsvEncode(FormatMarkdown(message.Content)));
await _writer.WriteAsync(',');
// Attachments
await WriteAttachmentsAsync(message.Attachments);
await _writer.WriteAsync(',');
// Reactions
await WriteReactionsAsync(message.Reactions);
// Finish row
await _writer.WriteLineAsync();
}
public override async ValueTask DisposeAsync()
@@ -2,31 +2,31 @@
@font-face {
font-family: Whitney;
src: url(https://discordapp.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://discordapp.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://discordapp.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://discordapp.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://discordapp.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);
}
@@ -0,0 +1,18 @@
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
{
internal class LayoutTemplateContext
{
public ExportContext ExportContext { get; }
public string ThemeName { get; }
public long MessageCount { get; }
public LayoutTemplateContext(ExportContext exportContext, string themeName, long messageCount)
{
ExportContext = exportContext;
ThemeName = themeName;
MessageCount = messageCount;
}
}
}
@@ -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;
}
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;
using DiscordChatExporter.Domain.Discord.Models;
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
{
// Used for grouping contiguous messages in HTML export
internal partial class MessageGroup
{
public User Author { get; }
public DateTimeOffset Timestamp { get; }
public IReadOnlyList<Message> Messages { get; }
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;
}
}
internal partial class MessageGroup
{
public static bool CanJoin(Message message1, Message message2) =>
message1.Author.Id == message2.Author.Id &&
string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) &&
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7 &&
message2.Reference is null;
public static MessageGroup Join(IReadOnlyList<Message> messages)
{
var first = messages.First();
return new MessageGroup(
first.Author,
first.Timestamp,
first.Reference,
first.ReferencedMessage,
messages
);
}
}
}
@@ -0,0 +1,316 @@
@using System
@using System.Linq
@using System.Threading.Tasks
@namespace DiscordChatExporter.Domain.Exporting.Writers.Html
@inherits MiniRazor.TemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.MessageGroupTemplateContext>
@{
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
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 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>
<div class="chatlog__messages">
<span class="chatlog__author-name" title="@Model.MessageGroup.Author.FullName" data-user-id="@Model.MessageGroup.Author.Id" style="@userColorStyle">@userNick</span>
@if (Model.MessageGroup.Author.IsBot)
{
<span class="chatlog__bot-tag">BOT</span>
}
<span class="chatlog__timestamp">@FormatDate(Model.MessageGroup.Timestamp)</span>
@foreach (var message in Model.MessageGroup.Messages)
{
var isPinnedStyle = message.IsPinned ? "chatlog__message--pinned" : null;
<div class="chatlog__message @isPinnedStyle" data-message-id="@message.Id" id="message-@message.Id">
@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">
<div class="@(attachment.IsSpoiler ? "spoiler spoiler--hidden" : "")" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : "")">
<div class="@(attachment.IsSpoiler ? "spoiler-image" : "")">
@if (attachment.IsImage)
{
<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
{
<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>
}
</div>
</div>
</div>
}
@foreach (var embed in message.Embeds)
{
<div class="chatlog__embed">
@if (embed.Color != null)
{
var embedColorStyle = $"background-color: rgba({embed.Color?.R},{embed.Color?.G},{embed.Color?.B},{embed.Color?.A})";
<div class="chatlog__embed-color-pill" style="@embedColorStyle"></div>
}
else
{
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
}
<div class="chatlog__embed-content-container">
<div class="chatlog__embed-content">
<div class="chatlog__embed-text">
@if (embed.Author != null)
{
<div class="chatlog__embed-author">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
{
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconUrl)" alt="Author icon">
}
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
{
<span class="chatlog__embed-author-name">
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
{
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
}
else
{
@embed.Author.Name
}
</span>
}
</div>
}
@if (!string.IsNullOrWhiteSpace(embed.Title))
{
<div class="chatlog__embed-title">
@if (!string.IsNullOrWhiteSpace(embed.Url))
{
<a class="chatlog__embed-title-link" href="@embed.Url">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
</a>
}
else
{
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
}
</div>
}
@if (!string.IsNullOrWhiteSpace(embed.Description))
{
<div class="chatlog__embed-description">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
</div>
}
@if (embed.Fields.Any())
{
<div class="chatlog__embed-fields">
@foreach (var field in embed.Fields)
{
var isInlineStyle = field.IsInline ? "chatlog__embed-field--inline" : null;
<div class="chatlog__embed-field @isInlineStyle">
@if (!string.IsNullOrWhiteSpace(field.Name))
{
<div class="chatlog__embed-field-name">
<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 preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div>
</div>
}
</div>
}
</div>
}
</div>
@if (embed.Thumbnail != null)
{
<div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.Url)">
<img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.Url)" alt="Thumbnail">
</a>
</div>
}
</div>
@if (embed.Image != null)
{
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(embed.Image.Url)">
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(embed.Image.Url)" alt="Image">
</a>
</div>
}
@if (embed.Footer != null || embed.Timestamp != null)
{
<div class="chatlog__embed-footer">
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
{
<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
}
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp != null)
{
@(" • ")
}
@if (embed.Timestamp != null)
{
@FormatDate(embed.Timestamp.Value)
}
</span>
</div>
}
</div>
</div>
}
@if (message.Reactions.Any())
{
<div class="chatlog__reactions">
@foreach (var reaction in message.Reactions)
{
<div class="chatlog__reaction">
<img class="emoji emoji--small" alt="@reaction.Emoji.Name" title="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)">
<span class="chatlog__reaction-count">@reaction.Count</span>
</div>
}
</div>
}
</div>
}
</div>
</div>
@@ -0,0 +1,20 @@
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
{
internal class MessageGroupTemplateContext
{
public ExportContext ExportContext { get; }
public MessageGroup MessageGroup { get; }
public MessageGroupTemplateContext(ExportContext exportContext, MessageGroup messageGroup)
{
ExportContext = exportContext;
MessageGroup = messageGroup;
}
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
}
}
@@ -0,0 +1,12 @@
@namespace DiscordChatExporter.Domain.Exporting.Writers.Html
@inherits MiniRazor.TemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.LayoutTemplateContext>
</div>
<div class="postamble">
<div class="postamble__entry">Exported @Model.MessageCount.ToString("N0") message(s)</div>
</div>
</body>
</html>
@@ -0,0 +1,101 @@
@using System
@using System.Threading.Tasks
@using Tyrrrz.Extensions
@namespace DiscordChatExporter.Domain.Exporting.Writers.Html
@inherits MiniRazor.TemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.LayoutTemplateContext>
@{
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
string GetStyleSheet(string name) => Model.GetType().Assembly.GetManifestResourceString($"DiscordChatExporter.Domain.Exporting.Writers.Html.{name}.css");
}
<!DOCTYPE html>
<html lang="en">
<head>
<title>@Model.ExportContext.Request.Guild.Name - @Model.ExportContext.Request.Channel.Name</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<style>
@Raw(GetStyleSheet("Core"))
</style>
<style>
@Raw(GetStyleSheet(Model.ThemeName))
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-@(Model.ThemeName.ToLowerInvariant()).min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.pre--multiline').forEach(block => hljs.highlightBlock(block));
});
</script>
<script>
function scrollToMessage(event, id) {
var element = document.getElementById('message-' + id);
if (element) {
event.preventDefault();
element.classList.add('chatlog__message--highlighted');
window.scrollTo({
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
behavior: 'smooth'
});
window.setTimeout(function() {
element.classList.remove('chatlog__message--highlighted');
}, 2000);
}
}
function showSpoiler(event, element) {
if (element && element.classList.contains('spoiler--hidden')) {
event.preventDefault();
element.classList.remove('spoiler--hidden');
}
}
</script>
</head>
<body>
<div class="preamble">
<div class="preamble__guild-icon-container">
<img class="preamble__guild-icon" src="@await ResolveUrlAsync(Model.ExportContext.Request.Guild.IconUrl)" alt="Guild icon">
</div>
<div class="preamble__entries-container">
<div class="preamble__entry">@Model.ExportContext.Request.Guild.Name</div>
<div class="preamble__entry">@Model.ExportContext.Request.Channel.Category / @Model.ExportContext.Request.Channel.Name</div>
@if (!string.IsNullOrWhiteSpace(Model.ExportContext.Request.Channel.Topic))
{
<div class="preamble__entry preamble__entry--small">@Model.ExportContext.Request.Channel.Topic</div>
}
@if (Model.ExportContext.Request.After != null || Model.ExportContext.Request.Before != null)
{
<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.ToDate())} and {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
}
else if (Model.ExportContext.Request.After != null)
{
@($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}")
}
else if (Model.ExportContext.Request.Before != null)
{
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
}
</div>
}
</div>
</div>
<div class="chatlog">
@@ -1,28 +1,18 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
using DiscordChatExporter.Domain.Internal;
using Scriban;
using Scriban.Runtime;
using Tyrrrz.Extensions;
using DiscordChatExporter.Domain.Exporting.Writers.Html;
namespace DiscordChatExporter.Domain.Exporting.Writers
{
internal partial class HtmlMessageWriter : MessageWriter
internal class HtmlMessageWriter : MessageWriter
{
private readonly TextWriter _writer;
private readonly string _themeName;
private readonly List<Message> _messageGroupBuffer = new List<Message>();
private readonly Template _preambleTemplate;
private readonly Template _messageGroupTemplate;
private readonly Template _postambleTemplate;
private readonly List<Message> _messageGroupBuffer = new();
private long _messageCount;
@@ -31,103 +21,37 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
{
_writer = new StreamWriter(stream);
_themeName = themeName;
_preambleTemplate = Template.Parse(GetPreambleTemplateCode());
_messageGroupTemplate = Template.Parse(GetMessageGroupTemplateCode());
_postambleTemplate = Template.Parse(GetPostambleTemplateCode());
}
private MessageGroup GetCurrentMessageGroup()
public override async ValueTask WritePreambleAsync()
{
var firstMessage = _messageGroupBuffer.First();
return new MessageGroup(firstMessage.Author, firstMessage.Timestamp, _messageGroupBuffer);
var templateContext = new LayoutTemplateContext(Context, _themeName, _messageCount);
await _writer.WriteLineAsync(
await PreambleTemplate.RenderAsync(templateContext)
);
}
private TemplateContext CreateTemplateContext(IReadOnlyDictionary<string, object>? constants = null)
private async ValueTask WriteMessageGroupAsync(MessageGroup messageGroup)
{
// Template context
var templateContext = new TemplateContext
{
MemberRenamer = m => m.Name,
MemberFilter = m => true,
LoopLimit = int.MaxValue,
StrictVariables = true
};
var templateContext = new MessageGroupTemplateContext(Context, messageGroup);
// Model
var scriptObject = new ScriptObject();
// Constants
scriptObject.SetValue("Context", Context, true);
scriptObject.SetValue("CoreStyleSheet", GetCoreStyleSheetCode(), true);
scriptObject.SetValue("ThemeStyleSheet", GetThemeStyleSheetCode(_themeName), true);
scriptObject.SetValue("HighlightJsStyleName", $"solarized-{_themeName.ToLowerInvariant()}", true);
// Additional constants
if (constants != null)
{
foreach (var (member, value) in constants)
scriptObject.SetValue(member, value, true);
}
// Functions
scriptObject.Import("FormatDate",
new Func<DateTimeOffset, string>(d => d.ToLocalString(Context.DateFormat)));
scriptObject.Import("FormatColorRgb",
new Func<Color?, string?>(c => c != null ? $"rgb({c?.R}, {c?.G}, {c?.B})" : null));
scriptObject.Import("TryGetUserColor",
new Func<User, Color?>(Context.TryGetUserColor));
scriptObject.Import("TryGetUserNick",
new Func<User, string?>(u => Context.TryGetUserMember(u)?.Nick));
scriptObject.Import("FormatMarkdown",
new Func<string?, string>(m => FormatMarkdown(m)));
scriptObject.Import("FormatEmbedMarkdown",
new Func<string?, string>(m => FormatMarkdown(m, false)));
// Push model
templateContext.PushGlobal(scriptObject);
// Push output
templateContext.PushOutput(new TextWriterOutput(_writer));
return templateContext;
await _writer.WriteLineAsync(
await MessageGroupTemplate.RenderAsync(templateContext)
);
}
private string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.Format(Context, markdown ?? "", isJumboAllowed);
private async Task RenderCurrentMessageGroupAsync()
{
var templateContext = CreateTemplateContext(new Dictionary<string, object>
{
["MessageGroup"] = GetCurrentMessageGroup()
});
await templateContext.EvaluateAsync(_messageGroupTemplate.Page);
}
public override async Task WritePreambleAsync()
{
var templateContext = CreateTemplateContext();
await templateContext.EvaluateAsync(_preambleTemplate.Page);
}
public override async Task WriteMessageAsync(Message message)
public override async ValueTask WriteMessageAsync(Message message)
{
// If message group is empty or the given message can be grouped, buffer the given message
if (!_messageGroupBuffer.Any() || MessageGroup.CanGroup(_messageGroupBuffer.Last(), message))
if (!_messageGroupBuffer.Any() || MessageGroup.CanJoin(_messageGroupBuffer.Last(), message))
{
_messageGroupBuffer.Add(message);
}
// Otherwise, flush the group and render messages
else
{
await RenderCurrentMessageGroupAsync();
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer));
_messageGroupBuffer.Clear();
_messageGroupBuffer.Add(message);
@@ -137,18 +61,17 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
_messageCount++;
}
public override async Task WritePostambleAsync()
public override async ValueTask WritePostambleAsync()
{
// Flush current message group
if (_messageGroupBuffer.Any())
await RenderCurrentMessageGroupAsync();
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer));
var templateContext = CreateTemplateContext(new Dictionary<string, object>
{
["MessageCount"] = _messageCount
});
var templateContext = new LayoutTemplateContext(Context, _themeName, _messageCount);
await templateContext.EvaluateAsync(_postambleTemplate.Page);
await _writer.WriteLineAsync(
await PostambleTemplate.RenderAsync(templateContext)
);
}
public override async ValueTask DisposeAsync()
@@ -157,32 +80,4 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
await base.DisposeAsync();
}
}
internal partial class HtmlMessageWriter
{
private static readonly Assembly ResourcesAssembly = typeof(HtmlMessageWriter).Assembly;
private static readonly string ResourcesNamespace = $"{ResourcesAssembly.GetName().Name}.Exporting.Resources";
private static string GetCoreStyleSheetCode() =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.HtmlCore.css");
private static string GetThemeStyleSheetCode(string themeName) =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.Html{themeName}.css");
private static string GetPreambleTemplateCode() =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.HtmlLayoutTemplate.html")
.SubstringUntil("{{~ %SPLIT% ~}}");
private static string GetMessageGroupTemplateCode() =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.HtmlMessageGroupTemplate.html");
private static string GetPostambleTemplateCode() =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.HtmlLayoutTemplate.html")
.SubstringAfter("{{~ %SPLIT% ~}}");
}
}
@@ -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;
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
});
}
@@ -25,62 +28,75 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
private string FormatMarkdown(string? markdown) =>
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
private void WriteAttachment(Attachment attachment)
private async ValueTask WriteAttachmentAsync(Attachment attachment)
{
_writer.WriteStartObject();
_writer.WriteString("id", attachment.Id);
_writer.WriteString("url", attachment.Url);
_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);
_writer.WriteEndObject();
await _writer.FlushAsync();
}
private void WriteEmbedAuthor(EmbedAuthor embedAuthor)
private async ValueTask WriteEmbedAuthorAsync(EmbedAuthor embedAuthor)
{
_writer.WriteStartObject("author");
_writer.WriteString("name", embedAuthor.Name);
_writer.WriteString("url", embedAuthor.Url);
_writer.WriteString("iconUrl", embedAuthor.IconUrl);
if (!string.IsNullOrWhiteSpace(embedAuthor.IconUrl))
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedAuthor.IconUrl));
_writer.WriteEndObject();
await _writer.FlushAsync();
}
private void WriteEmbedThumbnail(EmbedImage embedThumbnail)
private async ValueTask WriteEmbedThumbnailAsync(EmbedImage embedThumbnail)
{
_writer.WriteStartObject("thumbnail");
_writer.WriteString("url", embedThumbnail.Url);
if (!string.IsNullOrWhiteSpace(embedThumbnail.Url))
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedThumbnail.Url));
_writer.WriteNumber("width", embedThumbnail.Width);
_writer.WriteNumber("height", embedThumbnail.Height);
_writer.WriteEndObject();
await _writer.FlushAsync();
}
private void WriteEmbedImage(EmbedImage embedImage)
private async ValueTask WriteEmbedImageAsync(EmbedImage embedImage)
{
_writer.WriteStartObject("image");
_writer.WriteString("url", embedImage.Url);
if (!string.IsNullOrWhiteSpace(embedImage.Url))
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedImage.Url));
_writer.WriteNumber("width", embedImage.Width);
_writer.WriteNumber("height", embedImage.Height);
_writer.WriteEndObject();
await _writer.FlushAsync();
}
private void WriteEmbedFooter(EmbedFooter embedFooter)
private async ValueTask WriteEmbedFooterAsync(EmbedFooter embedFooter)
{
_writer.WriteStartObject("footer");
_writer.WriteString("text", embedFooter.Text);
_writer.WriteString("iconUrl", embedFooter.IconUrl);
if (!string.IsNullOrWhiteSpace(embedFooter.IconUrl))
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedFooter.IconUrl));
_writer.WriteEndObject();
await _writer.FlushAsync();
}
private void WriteEmbedField(EmbedField embedField)
private async ValueTask WriteEmbedFieldAsync(EmbedField embedField)
{
_writer.WriteStartObject();
@@ -89,9 +105,10 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
_writer.WriteBoolean("isInline", embedField.IsInline);
_writer.WriteEndObject();
await _writer.FlushAsync();
}
private void WriteEmbed(Embed embed)
private async ValueTask WriteEmbedAsync(Embed embed)
{
_writer.WriteStartObject();
@@ -100,30 +117,34 @@ 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)
WriteEmbedAuthor(embed.Author);
await WriteEmbedAuthorAsync(embed.Author);
if (embed.Thumbnail != null)
WriteEmbedThumbnail(embed.Thumbnail);
await WriteEmbedThumbnailAsync(embed.Thumbnail);
if (embed.Image != null)
WriteEmbedImage(embed.Image);
await WriteEmbedImageAsync(embed.Image);
if (embed.Footer != null)
WriteEmbedFooter(embed.Footer);
await WriteEmbedFooterAsync(embed.Footer);
// Fields
_writer.WriteStartArray("fields");
foreach (var field in embed.Fields)
WriteEmbedField(field);
await WriteEmbedFieldAsync(field);
_writer.WriteEndArray();
_writer.WriteEndObject();
await _writer.FlushAsync();
}
private void WriteReaction(Reaction reaction)
private async ValueTask WriteReactionAsync(Reaction reaction)
{
_writer.WriteStartObject();
@@ -132,56 +153,71 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
_writer.WriteString("id", reaction.Emoji.Id);
_writer.WriteString("name", reaction.Emoji.Name);
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
_writer.WriteString("imageUrl", reaction.Emoji.ImageUrl);
_writer.WriteString("imageUrl", await Context.ResolveMediaUrlAsync(reaction.Emoji.ImageUrl));
_writer.WriteEndObject();
_writer.WriteNumber("count", reaction.Count);
_writer.WriteEndObject();
await _writer.FlushAsync();
}
public override async Task WritePreambleAsync()
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)
_writer.WriteStartObject();
// Guild
_writer.WriteStartObject("guild");
_writer.WriteString("id", Context.Guild.Id);
_writer.WriteString("name", Context.Guild.Name);
_writer.WriteString("iconUrl", Context.Guild.IconUrl);
_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.Channel.Id);
_writer.WriteString("type", Context.Channel.Type.ToString());
_writer.WriteString("category", Context.Channel.Category);
_writer.WriteString("name", Context.Channel.Name);
_writer.WriteString("topic", Context.Channel.Topic);
_writer.WriteString("id", Context.Request.Channel.Id.ToString());
_writer.WriteString("type", Context.Request.Channel.Type.ToString());
_writer.WriteString("category", Context.Request.Channel.Category.Name);
_writer.WriteString("name", Context.Request.Channel.Name);
_writer.WriteString("topic", Context.Request.Channel.Topic);
_writer.WriteEndObject();
// Date range
_writer.WriteStartObject("dateRange");
_writer.WriteString("after", Context.After);
_writer.WriteString("before", Context.Before);
_writer.WriteString("after", Context.Request.After?.ToDate());
_writer.WriteString("before", Context.Request.Before?.ToDate());
_writer.WriteEndObject();
// Message array (start)
_writer.WriteStartArray("messages");
await _writer.FlushAsync();
}
public override async Task WriteMessageAsync(Message message)
public override async ValueTask WriteMessageAsync(Message message)
{
_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
@@ -189,18 +225,18 @@ 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);
_writer.WriteString("avatarUrl", message.Author.AvatarUrl);
_writer.WriteString("avatarUrl", await Context.ResolveMediaUrlAsync(message.Author.AvatarUrl));
_writer.WriteEndObject();
// Attachments
_writer.WriteStartArray("attachments");
foreach (var attachment in message.Attachments)
WriteAttachment(attachment);
await WriteAttachmentAsync(attachment);
_writer.WriteEndArray();
@@ -208,7 +244,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
_writer.WriteStartArray("embeds");
foreach (var embed in message.Embeds)
WriteEmbed(embed);
await WriteEmbedAsync(embed);
_writer.WriteEndArray();
@@ -216,18 +252,35 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
_writer.WriteStartArray("reactions");
foreach (var reaction in message.Reactions)
WriteReaction(reaction);
await WriteReactionAsync(reaction);
_writer.WriteEndArray();
_writer.WriteEndObject();
// Mentions
_writer.WriteStartArray("mentions");
// Flush every 100 messages
if (_messageCount++ % 100 == 0)
await _writer.FlushAsync();
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();
_messageCount++;
}
public override async Task WritePostambleAsync()
public override async ValueTask WritePostambleAsync()
{
// Message array (end)
_writer.WriteEndArray();
@@ -236,7 +289,6 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
// Root object (end)
_writer.WriteEndObject();
await _writer.FlushAsync();
}
@@ -3,10 +3,11 @@ 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.Internal;
using DiscordChatExporter.Domain.Markdown;
using DiscordChatExporter.Domain.Markdown.Ast;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
{
@@ -23,13 +24,13 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
_isJumbo = isJumbo;
}
public override MarkdownNode VisitText(TextNode text)
protected override MarkdownNode VisitText(TextNode text)
{
_buffer.Append(HtmlEncode(text.Text));
return base.VisitText(text);
}
public override MarkdownNode VisitFormatted(FormattedNode formatted)
protected override MarkdownNode VisitFormatted(FormattedNode formatted)
{
var (tagOpen, tagClose) = formatted.Formatting switch
{
@@ -50,7 +51,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
return result;
}
public override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
{
_buffer
.Append("<span class=\"pre pre--inline\">")
@@ -60,7 +61,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
return base.VisitInlineCodeBlock(inlineCodeBlock);
}
public override MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock)
protected override MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock)
{
var highlightCssClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
? $"language-{multiLineCodeBlock.Language}"
@@ -74,8 +75,9 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
return base.VisitMultiLineCodeBlock(multiLineCodeBlock);
}
public override MarkdownNode VisitMention(MentionNode mention)
protected override MarkdownNode VisitMention(MentionNode mention)
{
var mentionId = Snowflake.TryParse(mention.Id);
if (mention.Type == MentionType.Meta)
{
_buffer
@@ -85,7 +87,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
}
else if (mention.Type == MentionType.User)
{
var member = _context.TryGetMentionedMember(mention.Id);
var member = mentionId?.Pipe(_context.TryGetMember);
var fullName = member?.User.FullName ?? "Unknown";
var nick = member?.Nick ?? "Unknown";
@@ -96,7 +98,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
}
else if (mention.Type == MentionType.Channel)
{
var channel = _context.TryGetMentionedChannel(mention.Id);
var channel = mentionId?.Pipe(_context.TryGetChannel);
var name = channel?.Name ?? "deleted-channel";
_buffer
@@ -106,7 +108,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
}
else if (mention.Type == MentionType.Role)
{
var role = _context.TryGetMentionedRole(mention.Id);
var role = mentionId?.Pipe(_context.TryGetRole);
var name = role?.Name ?? "deleted-role";
var color = role?.Color;
@@ -123,7 +125,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
return base.VisitMention(mention);
}
public override MarkdownNode VisitEmoji(EmojiNode emoji)
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
{
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = _isJumbo ? "emoji--large" : "";
@@ -134,10 +136,10 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
return base.VisitEmoji(emoji);
}
public override MarkdownNode VisitLink(LinkNode link)
protected override MarkdownNode VisitLink(LinkNode link)
{
// Extract message ID if the link points to a Discord message
var linkedMessageId = Regex.Match(link.Url, "^https?://discordapp.com/channels/.*?/(\\d+)/?$").Groups[1].Value;
var linkedMessageId = Regex.Match(link.Url, "^https?://(?:discord|discordapp).com/channels/.*?/(\\d+)/?$").Groups[1].Value;
if (!string.IsNullOrWhiteSpace(linkedMessageId))
{
@@ -1,6 +1,8 @@
using System.Text;
using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Markdown;
using DiscordChatExporter.Domain.Markdown.Ast;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
{
@@ -15,35 +17,36 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
_buffer = buffer;
}
public override MarkdownNode VisitText(TextNode text)
protected override MarkdownNode VisitText(TextNode text)
{
_buffer.Append(text.Text);
return base.VisitText(text);
}
public override MarkdownNode VisitMention(MentionNode mention)
protected override MarkdownNode VisitMention(MentionNode mention)
{
var mentionId = Snowflake.TryParse(mention.Id);
if (mention.Type == MentionType.Meta)
{
_buffer.Append($"@{mention.Id}");
}
else if (mention.Type == MentionType.User)
{
var member = _context.TryGetMentionedMember(mention.Id);
var member = mentionId?.Pipe(_context.TryGetMember);
var name = member?.User.Name ?? "Unknown";
_buffer.Append($"@{name}");
}
else if (mention.Type == MentionType.Channel)
{
var channel = _context.TryGetMentionedChannel(mention.Id);
var channel = mentionId?.Pipe(_context.TryGetChannel);
var name = channel?.Name ?? "deleted-channel";
_buffer.Append($"#{name}");
}
else if (mention.Type == MentionType.Role)
{
var role = _context.TryGetMentionedRole(mention.Id);
var role = mentionId?.Pipe(_context.TryGetRole);
var name = role?.Name ?? "deleted-role";
_buffer.Append($"@{name}");
@@ -52,7 +55,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
return base.VisitMention(mention);
}
public override MarkdownNode VisitEmoji(EmojiNode emoji)
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
{
_buffer.Append(
emoji.IsCustomEmoji
@@ -17,11 +17,11 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
Context = context;
}
public virtual Task WritePreambleAsync() => Task.CompletedTask;
public virtual ValueTask WritePreambleAsync() => default;
public abstract Task WriteMessageAsync(Message message);
public abstract ValueTask WriteMessageAsync(Message message);
public virtual Task WritePostambleAsync() => Task.CompletedTask;
public virtual ValueTask WritePostambleAsync() => default;
public virtual async ValueTask DisposeAsync() => await Stream.DisposeAsync();
}
@@ -1,12 +1,10 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
using DiscordChatExporter.Domain.Internal;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Exporting.Writers
{
@@ -25,151 +23,134 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
private string FormatMarkdown(string? markdown) =>
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
private string FormatMessageHeader(Message message)
private async ValueTask WriteMessageHeaderAsync(Message message)
{
var buffer = new StringBuilder();
// Timestamp & author
buffer
.Append($"[{message.Timestamp.ToLocalString(Context.DateFormat)}]")
.Append(' ')
.Append($"{message.Author.FullName}");
await _writer.WriteAsync($"[{Context.FormatDate(message.Timestamp)}]");
await _writer.WriteAsync($" {message.Author.FullName}");
// Whether the message is pinned
if (message.IsPinned)
buffer.Append(' ').Append("(pinned)");
await _writer.WriteAsync(" (pinned)");
return buffer.ToString();
await _writer.WriteLineAsync();
}
private string FormatAttachments(IReadOnlyList<Attachment> attachments)
private async ValueTask WriteAttachmentsAsync(IReadOnlyList<Attachment> attachments)
{
if (!attachments.Any())
return "";
return;
var buffer = new StringBuilder();
await _writer.WriteLineAsync("{Attachments}");
buffer
.AppendLine("{Attachments}")
.AppendJoin(Environment.NewLine, attachments.Select(a => a.Url))
.AppendLine();
foreach (var attachment in attachments)
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(attachment.Url));
return buffer.ToString();
await _writer.WriteLineAsync();
}
private string FormatEmbeds(IReadOnlyList<Embed> embeds)
private async ValueTask WriteEmbedsAsync(IReadOnlyList<Embed> embeds)
{
if (!embeds.Any())
return "";
var buffer = new StringBuilder();
foreach (var embed in embeds)
{
buffer
.AppendLine("{Embed}")
.AppendLineIfNotNullOrWhiteSpace(embed.Author?.Name)
.AppendLineIfNotNullOrWhiteSpace(embed.Url)
.AppendLineIfNotNullOrWhiteSpace(FormatMarkdown(embed.Title))
.AppendLineIfNotNullOrWhiteSpace(FormatMarkdown(embed.Description));
await _writer.WriteLineAsync("{Embed}");
if (!string.IsNullOrWhiteSpace(embed.Author?.Name))
await _writer.WriteLineAsync(embed.Author.Name);
if (!string.IsNullOrWhiteSpace(embed.Url))
await _writer.WriteLineAsync(embed.Url);
if (!string.IsNullOrWhiteSpace(embed.Title))
await _writer.WriteLineAsync(FormatMarkdown(embed.Title));
if (!string.IsNullOrWhiteSpace(embed.Description))
await _writer.WriteLineAsync(FormatMarkdown(embed.Description));
foreach (var field in embed.Fields)
{
buffer
.AppendLineIfNotNullOrWhiteSpace(FormatMarkdown(field.Name))
.AppendLineIfNotNullOrWhiteSpace(FormatMarkdown(field.Value));
if (!string.IsNullOrWhiteSpace(field.Name))
await _writer.WriteLineAsync(FormatMarkdown(field.Name));
if (!string.IsNullOrWhiteSpace(field.Value))
await _writer.WriteLineAsync(FormatMarkdown(field.Value));
}
buffer
.AppendLineIfNotNullOrWhiteSpace(embed.Thumbnail?.Url)
.AppendLineIfNotNullOrWhiteSpace(embed.Image?.Url)
.AppendLineIfNotNullOrWhiteSpace(embed.Footer?.Text)
.AppendLine();
}
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Thumbnail.Url));
return buffer.ToString();
if (!string.IsNullOrWhiteSpace(embed.Image?.Url))
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Image.Url));
if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
await _writer.WriteLineAsync(embed.Footer.Text);
await _writer.WriteLineAsync();
}
}
private string FormatReactions(IReadOnlyList<Reaction> reactions)
private async ValueTask WriteReactionsAsync(IReadOnlyList<Reaction> reactions)
{
if (!reactions.Any())
return "";
return;
var buffer = new StringBuilder();
buffer.AppendLine("{Reactions}");
await _writer.WriteLineAsync("{Reactions}");
foreach (var reaction in reactions)
{
buffer.Append(reaction.Emoji.Name);
await _writer.WriteAsync(reaction.Emoji.Name);
if (reaction.Count > 1)
buffer.Append($" ({reaction.Count})");
await _writer.WriteAsync($" ({reaction.Count})");
buffer.Append(" ");
await _writer.WriteAsync(' ');
}
buffer.AppendLine();
return buffer.ToString();
await _writer.WriteLineAsync();
}
private string FormatMessage(Message message)
public override async ValueTask WritePreambleAsync()
{
var buffer = new StringBuilder();
await _writer.WriteLineAsync('='.Repeat(62));
await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}");
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category.Name} / {Context.Request.Channel.Name}");
buffer
.AppendLine(FormatMessageHeader(message))
.AppendLineIfNotNullOrWhiteSpace(FormatMarkdown(message.Content))
.AppendLine()
.AppendLineIfNotNullOrWhiteSpace(FormatAttachments(message.Attachments))
.AppendLineIfNotNullOrWhiteSpace(FormatEmbeds(message.Embeds))
.AppendLineIfNotNullOrWhiteSpace(FormatReactions(message.Reactions));
if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}");
return buffer.Trim().ToString();
if (Context.Request.After != null)
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.ToDate())}");
await _writer.WriteLineAsync('='.Repeat(62));
await _writer.WriteLineAsync();
}
public override async Task WritePreambleAsync()
public override async ValueTask WriteMessageAsync(Message message)
{
var buffer = new StringBuilder();
await WriteMessageHeaderAsync(message);
buffer.Append('=', 62).AppendLine();
buffer.AppendLine($"Guild: {Context.Guild.Name}");
buffer.AppendLine($"Channel: {Context.Channel.Category} / {Context.Channel.Name}");
if (!string.IsNullOrWhiteSpace(message.Content))
await _writer.WriteLineAsync(FormatMarkdown(message.Content));
if (!string.IsNullOrWhiteSpace(Context.Channel.Topic))
buffer.AppendLine($"Topic: {Context.Channel.Topic}");
await _writer.WriteLineAsync();
if (Context.After != null)
buffer.AppendLine($"After: {Context.After.Value.ToLocalString(Context.DateFormat)}");
await WriteAttachmentsAsync(message.Attachments);
await WriteEmbedsAsync(message.Embeds);
await WriteReactionsAsync(message.Reactions);
if (Context.Before != null)
buffer.AppendLine($"Before: {Context.Before.Value.ToLocalString(Context.DateFormat)}");
buffer.Append('=', 62).AppendLine();
await _writer.WriteLineAsync(buffer.ToString());
}
public override async Task WriteMessageAsync(Message message)
{
await _writer.WriteLineAsync(FormatMessage(message));
await _writer.WriteLineAsync();
_messageCount++;
}
public override async Task WritePostambleAsync()
public override async ValueTask WritePostambleAsync()
{
var buffer = new StringBuilder();
buffer
.Append('=', 62).AppendLine()
.AppendLine($"Exported {_messageCount:N0} message(s)")
.Append('=', 62).AppendLine()
.AppendLine();
await _writer.WriteLineAsync(buffer.ToString());
await _writer.WriteLineAsync('='.Repeat(62));
await _writer.WriteLineAsync($"Exported {_messageCount:N0} message(s)");
await _writer.WriteLineAsync('='.Repeat(62));
}
public override async ValueTask DisposeAsync()
@@ -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();
}
}
}
@@ -1,6 +1,6 @@
using System.Drawing;
namespace DiscordChatExporter.Domain.Internal
namespace DiscordChatExporter.Domain.Internal.Extensions
{
internal static class ColorExtensions
{
@@ -9,5 +9,7 @@ namespace DiscordChatExporter.Domain.Internal
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}";
}
}
@@ -1,16 +1,10 @@
using System;
using System.Globalization;
namespace DiscordChatExporter.Domain.Internal
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);
}
@@ -1,11 +1,9 @@
using System;
namespace DiscordChatExporter.Domain.Internal
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
@@ -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;
}
}
@@ -0,0 +1,17 @@
using System.Text;
namespace DiscordChatExporter.Domain.Internal.Extensions
{
internal static class StringExtensions
{
public static string Truncate(this string str, int charCount) =>
str.Length > charCount
? str.Substring(0, charCount)
: str;
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
builder.Length > 0
? builder.Append(value)
: builder;
}
}
@@ -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));
}
}
@@ -1,17 +0,0 @@
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
namespace DiscordChatExporter.Domain.Internal
{
internal static class HttpClientExtensions
{
public static async Task<JsonElement> ReadAsJsonAsync(this HttpContent content)
{
await using var stream = await content.ReadAsStreamAsync();
using var doc = await JsonDocument.ParseAsync(stream);
return doc.RootElement.Clone();
}
}
}
@@ -1,12 +0,0 @@
using System.Text.Json;
namespace DiscordChatExporter.Domain.Internal
{
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;
}
}
@@ -0,0 +1,18 @@
using System.IO;
using System.Text;
namespace DiscordChatExporter.Domain.Internal
{
internal static class PathEx
{
public static StringBuilder EscapePath(StringBuilder pathBuffer)
{
foreach (var invalidChar in Path.GetInvalidFileNameChars())
pathBuffer.Replace(invalidChar, '_');
return pathBuffer;
}
public static string EscapePath(string path) => EscapePath(new StringBuilder(path)).ToString();
}
}
@@ -1,21 +0,0 @@
using System.Text;
namespace DiscordChatExporter.Domain.Internal
{
internal static class StringExtensions
{
public static StringBuilder AppendLineIfNotNullOrWhiteSpace(this StringBuilder builder, string? value) =>
!string.IsNullOrWhiteSpace(value) ? builder.AppendLine(value) : builder;
public static StringBuilder Trim(this StringBuilder builder)
{
while (builder.Length > 0 && char.IsWhiteSpace(builder[0]))
builder.Remove(0, 1);
while (builder.Length > 0 && char.IsWhiteSpace(builder[^1]))
builder.Remove(builder.Length - 1, 1);
return builder;
}
}
}
@@ -4,14 +4,13 @@ using System.Linq;
using System.Net;
using System.Text;
namespace DiscordChatExporter.Domain.Discord
namespace DiscordChatExporter.Domain.Internal
{
internal class UrlBuilder
{
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)
{
@@ -19,8 +18,11 @@ namespace DiscordChatExporter.Domain.Discord
return this;
}
public UrlBuilder SetQueryParameter(string key, string? value)
public UrlBuilder SetQueryParameter(string key, string? value, bool ignoreUnsetValue = true)
{
if (ignoreUnsetValue && string.IsNullOrWhiteSpace(value))
return this;
var keyEncoded = WebUtility.UrlEncode(key);
var valueEncoded = WebUtility.UrlEncode(value);
_queryParameters[keyEncoded] = valueEncoded;
@@ -28,11 +30,6 @@ namespace DiscordChatExporter.Domain.Discord
return this;
}
public UrlBuilder SetQueryParameterIfNotNullOrWhiteSpace(string key, string? value) =>
!string.IsNullOrWhiteSpace(value)
? SetQueryParameter(key, value)
: this;
public string Build()
{
var buffer = new StringBuilder();
@@ -1,28 +0,0 @@
using System;
using System.Text.Json;
namespace DiscordChatExporter.Domain.Internal
{
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();
}
}
}
@@ -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
@@ -6,23 +6,23 @@ namespace DiscordChatExporter.Domain.Markdown
{
internal abstract class MarkdownVisitor
{
public virtual MarkdownNode VisitText(TextNode text) => text;
protected virtual MarkdownNode VisitText(TextNode text) => text;
public virtual MarkdownNode VisitFormatted(FormattedNode formatted)
protected virtual MarkdownNode VisitFormatted(FormattedNode formatted)
{
Visit(formatted.Children);
return formatted;
}
public virtual MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) => inlineCodeBlock;
protected virtual MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) => inlineCodeBlock;
public virtual MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) => multiLineCodeBlock;
protected virtual MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) => multiLineCodeBlock;
public virtual MarkdownNode VisitLink(LinkNode link) => link;
protected virtual MarkdownNode VisitLink(LinkNode link) => link;
public virtual MarkdownNode VisitEmoji(EmojiNode emoji) => emoji;
protected virtual MarkdownNode VisitEmoji(EmojiNode emoji) => emoji;
public virtual MarkdownNode VisitMention(MentionNode mention) => mention;
protected virtual MarkdownNode VisitMention(MentionNode mention) => mention;
public MarkdownNode Visit(MarkdownNode node) => node switch
{
@@ -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 Task 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);
}
}
+97 -70
View File
@@ -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>
+37 -4
View File
@@ -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);
}
+9
View File
@@ -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.0" />
<PackageReference Include="Stylet" Version="1.3.1" />
<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>
+10
View File
@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More