Skip to content
1 change: 1 addition & 0 deletions ChatGPTExport/ChatGPTExport.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Markdig" Version="0.41.3" />
<PackageReference Include="System.IO.Abstractions" Version="22.0.15" />
<PackageReference Include="System.Text.Json" Version="9.0.8" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta6.25358.103" />
Expand Down
77 changes: 42 additions & 35 deletions ChatGPTExport/Exporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,54 +9,61 @@ public class Exporter(IFileSystem fileSystem, IEnumerable<IExporter> exporters)
{
public void Process(IEnumerable<(IAssetLocator AssetLocator, Conversation conversation)> conversations, IDirectoryInfo destination)
{
if (conversations.Select(p => p.conversation.conversation_id).Distinct().Count() > 1)
try
{
throw new ApplicationException("Unable to export multiple conversations at once");
}
if (conversations.Select(p => p.conversation.conversation_id).Distinct().Count() > 1)
{
throw new ApplicationException("Unable to export multiple conversations at once");
}

var fileContentsMap = new Dictionary<string, IEnumerable<string>>();
var fileContentsMap = new Dictionary<string, IEnumerable<string>>();

var titles = string.Join(Environment.NewLine, conversations.Select(p => p.conversation.title).Distinct().ToArray());
Console.WriteLine(titles);
var titles = string.Join(Environment.NewLine, conversations.Select(p => p.conversation.title).Distinct().ToArray());
Console.WriteLine(titles);

foreach (var (assetLocator, conversation) in conversations)
{
Console.WriteLine($"\tMessages: {conversation.mapping.Count}\tLeaves: {conversation.mapping.Count(p => p.Value.IsLeaf())}");
foreach (var exporter in exporters)
foreach (var (assetLocator, conversation) in conversations)
{
Console.Write($"\t\t{exporter.GetType().Name}");

if (conversation.HasMultipleBranches())
Console.WriteLine($"\tMessages: {conversation.mapping.Count}\tLeaves: {conversation.mapping.Count(p => p.Value.IsLeaf())}");
foreach (var exporter in exporters)
{
var completeFilename = GetFilename(conversation, "complete", exporter.GetExtension());
fileContentsMap[completeFilename] = exporter.Export(assetLocator, conversation);
}
Console.Write($"\t\t{exporter.GetType().Name}");

var latest = conversation.GetLastestConversation();
var filename = GetFilename(latest, "", exporter.GetExtension());
fileContentsMap[filename] = exporter.Export(assetLocator, latest);
if (conversation.HasMultipleBranches())
{
var completeFilename = GetFilename(conversation, "complete", exporter.GetExtension());
fileContentsMap[completeFilename] = exporter.Export(assetLocator, conversation);
}

Console.WriteLine($"...Done");
}
}
var latest = conversation.GetLastestConversation();
var filename = GetFilename(latest, "", exporter.GetExtension());
fileContentsMap[filename] = exporter.Export(assetLocator, latest);

foreach (var kv in fileContentsMap)
{
var destinationFilename = fileSystem.Path.Join(destination.FullName, kv.Key);
var contents = string.Join(Environment.NewLine, kv.Value);
var destinationExists = fileSystem.File.Exists(destinationFilename);
if (destinationExists == false || destinationExists && fileSystem.File.ReadAllText(destinationFilename) != contents)
{
fileSystem.File.WriteAllText(destinationFilename, contents);
fileSystem.File.SetCreationTimeUtcIfPossible(destinationFilename, conversations.Last().conversation.GetCreateTime().DateTime);
fileSystem.File.SetLastWriteTimeUtc(destinationFilename, conversations.Last().conversation.GetUpdateTime().DateTime);
Console.WriteLine($"{kv.Key}...Saved");
Console.WriteLine($"...Done");
}
}
else

foreach (var kv in fileContentsMap)
{
Console.WriteLine($"{kv.Key}...No change");
var destinationFilename = fileSystem.Path.Join(destination.FullName, kv.Key);
var contents = string.Join(Environment.NewLine, kv.Value);
var destinationExists = fileSystem.File.Exists(destinationFilename);
if (destinationExists == false || destinationExists && fileSystem.File.ReadAllText(destinationFilename) != contents)
{
fileSystem.File.WriteAllText(destinationFilename, contents);
fileSystem.File.SetCreationTimeUtcIfPossible(destinationFilename, conversations.Last().conversation.GetCreateTime().DateTime);
fileSystem.File.SetLastWriteTimeUtc(destinationFilename, conversations.Last().conversation.GetUpdateTime().DateTime);
Console.WriteLine($"\t{kv.Key}...Saved");
}
else
{
Console.WriteLine($"\t{kv.Key}...No change");
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.ToString());
}
}

private string GetFilename(Conversation conversation, string modifier, string extension)
Expand Down
15 changes: 15 additions & 0 deletions ChatGPTExport/Exporters/ExtensionMethods.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using ChatGPTExport.Models;

namespace ChatGPTExport.Exporters
{
internal static class ExtensionMethods
{
public static IEnumerable<Message> GetMessagesWithContent(this Conversation conversation)
{
return conversation.mapping.Select(p => p.Value).
Where(p => p.message != null).
Select(p => p.message).
Where(p => p.content != null);
}
}
}
74 changes: 74 additions & 0 deletions ChatGPTExport/Exporters/HtmlExporter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System.Net;
using ChatGPTExport.Assets;
using ChatGPTExport.Models;
using Markdig;

namespace ChatGPTExport.Exporters
{
internal class HtmlExporter(IHtmlFormatter formatter) : IExporter
{
private readonly string LineBreak = Environment.NewLine;

public IEnumerable<string> Export(IAssetLocator assetLocator, Conversation conversation)
{
var messages = conversation.GetMessagesWithContent();

var strings = new List<(Author Author, string Content)>();

var visitor = new MarkdownContentVisitor(assetLocator);

foreach (var message in messages)
{
try
{
var (messageContent, suffix) = message.Accept(visitor);

if (messageContent.Any())
{
strings.Add((message.author, string.Join(LineBreak, messageContent)));
}
}
catch (Exception ex)
{
Console.Error.WriteLine(ex.Message);
}
}

var markdownPipeline = GetPipeline();
var bodyHtml = strings.Select(p => GetHtmlChunks(p.Author, p.Content, markdownPipeline));

var titleString = WebUtility.HtmlEncode(conversation.title);
string html = formatter.FormatHtmlPage(titleString, bodyHtml);

return [html];
}

private string GetHtmlChunks(Author author, string content, MarkdownPipeline markdownPipeline)
{
var html = Markdown.ToHtml(content, markdownPipeline);

if(author.role == "user")
{
return formatter.FormatUserInput(html);
}
else
{
return html;
}
}

private MarkdownPipeline GetPipeline()
{
var pipelineBuilder = new MarkdownPipelineBuilder()
.UseAdvancedExtensions() // tables, footnotes, lists, etc.
.UsePipeTables();

formatter.ApplyMarkdownPipelineBuilder(pipelineBuilder);

var pipeline = pipelineBuilder.Build();
return pipeline;
}

public string GetExtension() => ".html";
}
}
8 changes: 8 additions & 0 deletions ChatGPTExport/Exporters/HtmlFormat.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace ChatGPTExport.Exporters
{
public enum HtmlFormat
{
Tailwind = 0,
Bootstrap = 1,
}
}
48 changes: 48 additions & 0 deletions ChatGPTExport/Exporters/HtmlTemplate/BootstrapHtmlFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Markdig;

namespace ChatGPTExport.Exporters.HtmlTemplate
{
internal class BootstrapHtmlFormatter : IHtmlFormatter
{
public void ApplyMarkdownPipelineBuilder(MarkdownPipelineBuilder markdownPipelineBuilder)
{
markdownPipelineBuilder.UseBootstrap();
}

public string FormatHtmlPage(string titleString, IEnumerable<string> bodyHtml)
{
return $$"""
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{titleString}}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.7/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
<script>hljs.highlightAll();</script>

</head>
<body class="container">
<div class="my-4">
<h1>{{titleString}}</h1>
</div>
{{string.Join("", bodyHtml)}}
</body>
</html>
""";
}

public string FormatUserInput(string html)
{
return $"""
<div class="d-flex justify-content-end mb-2">
<div class="bg-body-secondary rounded-3 px-3 pt-3 ms-auto col-12 col-sm-10 col-md-8 col-lg-6 text-break">
{html}
</div>
</div>
""";
}
}
}
60 changes: 60 additions & 0 deletions ChatGPTExport/Exporters/HtmlTemplate/TailwindHtmlFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using Markdig;

namespace ChatGPTExport.Exporters.HtmlTemplate
{
internal class TailwindHtmlFormatter : IHtmlFormatter
{
public void ApplyMarkdownPipelineBuilder(MarkdownPipelineBuilder markdownPipelineBuilder)
{
}

public string FormatHtmlPage(string titleString, IEnumerable<string> bodyHtml)
{
return $$"""
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{titleString}}</title>

<!-- Tailwind (with Typography plugin) -->
<script>
window.tailwind = { config: { darkMode: 'class' } };
</script>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>

<!-- highlight.js (dark theme) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
</head>
<body class="bg-neutral-900 text-neutral-100 antialiased">
<div class="container mx-auto max-w-4xl px-4 py-6">
<h1 class="text-3xl font-semibold mb-6">{{titleString}}</h1>
<div class="prose prose-invert leading-relaxed max-w-[80ch] md:max-w-[90ch]
prose-p:my-2 prose-li:my-1
prose-ul:list-disc prose-ol:list-decimal
prose-pre:overflow-x-auto
prose-hr:my-4
marker:text-neutral-400">
{{string.Join("", bodyHtml)}}
</div>
</div>
</body>
</html>
""";
}

public string FormatUserInput(string html)
{
return $"""
<div class="flex justify-end mb-2">
<div class="ml-auto w-full sm:w-5/6 md:w-2/3 lg:w-2/3 rounded-xl bg-neutral-800 px-3 break-words">
{html}
</div>
</div>
""";
}
}
}
12 changes: 12 additions & 0 deletions ChatGPTExport/Exporters/IHtmlFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@

using Markdig;

namespace ChatGPTExport.Exporters
{
internal interface IHtmlFormatter
{
void ApplyMarkdownPipelineBuilder(MarkdownPipelineBuilder markdownPipelineBuilder);
string FormatHtmlPage(string titleString, IEnumerable<string> bodyHtml);
string FormatUserInput(string html);
}
}
13 changes: 6 additions & 7 deletions ChatGPTExport/Exporters/MarkdownExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ internal class MarkdownExporter : IExporter

public IEnumerable<string> Export(IAssetLocator assetLocator, Conversation conversation)
{
var messages = conversation.mapping.Select(p => p.Value).
Where(p => p.message != null).
Select(p => p.message).
Where(p => p.content != null);
var messages = conversation.GetMessagesWithContent();

var strings = new List<string>();

Expand All @@ -22,13 +19,13 @@ public IEnumerable<string> Export(IAssetLocator assetLocator, Conversation conve
{
try
{
var (itemContent, suffix) = message.Accept(visitor);
var (messageContent, suffix) = message.Accept(visitor);

if (itemContent.Any())
if (messageContent.Any())
{
var authorname = string.IsNullOrWhiteSpace(message.author.name) ? "" : $" ({message.author.name})";
strings.Add($"**{message.author.role}{authorname}{suffix}**: "); // double space for line break
strings.AddRange(itemContent);
strings.Add(string.Join(LineBreak, messageContent));
strings.Add(LineBreak);
}
}
Expand All @@ -41,6 +38,8 @@ public IEnumerable<string> Export(IAssetLocator assetLocator, Conversation conve
return strings;
}



public string GetExtension() => ".md";
}
}
Loading
Loading