Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions ChatGPTExport/ConsoleFeatures.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
namespace ChatGPTExport
{
internal static class ConsoleFeatures
{
public static void StartIndeterminate()
{
if (enableOscTabProgress)
{
Console.Write("\x1b]9;4;3\x07"); // https://learn.microsoft.com/en-us/windows/terminal/tutorials/progress-bar-sequences
}
}

public static void SetProgress(int percent)
{
if (enableOscTabProgress)
{
Console.Write($"\x1b]9;4;1;{percent}\x07");
}
}

public static void ClearState()
{
if (enableOscTabProgress)
{
Console.Write("\x1b]9;4;0;\x07");
}
}

private static bool enableOscTabProgress = IsInteractiveTty() && SupportsOscTabProgress();

private static bool IsInteractiveTty() =>
!Console.IsOutputRedirected; // avoid dumping control bytes into logs/pipes

private static bool SupportsOscTabProgress()
{
// Known-bad first
var termProgram = Environment.GetEnvironmentVariable("TERM_PROGRAM") ?? "";
if (termProgram.Contains("iTerm", StringComparison.OrdinalIgnoreCase)) return false; // iTerm2
if (Environment.GetEnvironmentVariable("KITTY_WINDOW_ID") is not null) return false; // kitty
if (termProgram.Contains("Apple_Terminal", StringComparison.OrdinalIgnoreCase)) return false; // macOS Terminal
if (termProgram.Equals("vscode", StringComparison.OrdinalIgnoreCase)) return false; // VS Code integrated

// tmux/screen often block OSC; be conservative
if (Environment.GetEnvironmentVariable("TMUX") is not null ||
(Environment.GetEnvironmentVariable("TERM") ?? "").StartsWith("screen")) return false;

// Known-good
if (Environment.GetEnvironmentVariable("WT_SESSION") is not null) return true; // Windows Terminal
if (Environment.GetEnvironmentVariable("VTE_VERSION") is not null) return true; // GNOME Terminal/VTE
if (Environment.GetEnvironmentVariable("WEZTERM_EXECUTABLE") is not null ||
termProgram.Contains("WezTerm", StringComparison.OrdinalIgnoreCase)) return true; // WezTerm
if (termProgram.Contains("Ghostty", StringComparison.OrdinalIgnoreCase)) return true; // Ghostty

return false; // default safe
}
}
}
113 changes: 69 additions & 44 deletions ChatGPTExport/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@

Console.OutputEncoding = System.Text.Encoding.UTF8;

Console.CancelKeyPress += (sender, args) =>
{
args.Cancel = true;
ConsoleFeatures.ClearState();
Environment.Exit(0);
};

const string searchPattern = "conversations.json";

var sourceDirectoryOption = new Option<DirectoryInfo[]>("--source", "-s")
Expand All @@ -25,7 +32,7 @@
try
{
var directoryInfos = result.GetValue(sourceDirectoryOption);
foreach (var directoryInfo in directoryInfos)

Check warning on line 35 in ChatGPTExport/Program.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
{
if (directoryInfo.GetFiles(searchPattern, SearchOption.AllDirectories).Length == 0)
{
Expand Down Expand Up @@ -66,64 +73,82 @@

rootCommand.SetAction(parseResult =>
{
foreach (ParseError parseError in parseResult.Errors)
try
{
Console.Error.WriteLine(parseError.Message);
}
foreach (ParseError parseError in parseResult.Errors)
{
Console.Error.WriteLine(parseError.Message);
}

var sourceDirectoryInfos = parseResult.GetRequiredValue(sourceDirectoryOption);
var fileSystem = new FileSystem();
var destination = fileSystem.DirectoryInfo.Wrap(parseResult.GetRequiredValue(destinationDirectoryOption));
var sources = sourceDirectoryInfos.Select(p => fileSystem.DirectoryInfo.Wrap(p));
ConsoleFeatures.StartIndeterminate();

var conversationFiles = sources.Select(p => p.GetFiles(searchPattern, SearchOption.AllDirectories)).SelectMany(s => s).ToList();
var sourceDirectoryInfos = parseResult.GetRequiredValue(sourceDirectoryOption);
var fileSystem = new FileSystem();
var destination = fileSystem.DirectoryInfo.Wrap(parseResult.GetRequiredValue(destinationDirectoryOption));
var sources = sourceDirectoryInfos.Select(p => fileSystem.DirectoryInfo.Wrap(p));

var conversationsFactory = new ConversationsParser(fileSystem);
var exporters = new List<IExporter>();
if (parseResult.GetRequiredValue(jsonOption))
{
exporters.Add(new JsonExporter(fileSystem, destination));
}
if (parseResult.GetRequiredValue(markdownOption))
{
exporters.Add(new MarkdownExporter(fileSystem, destination));
}
var exporter = new Exporter(fileSystem, exporters);
var conversationFiles = sources.Select(p => p.GetFiles(searchPattern, SearchOption.AllDirectories)).SelectMany(s => s).ToList();

Conversations GetConversations(IFileInfo p)
{
try
var conversationsFactory = new ConversationsParser(fileSystem);
var exporters = new List<IExporter>();
if (parseResult.GetRequiredValue(jsonOption))
{
Console.WriteLine($"Loading conversation " + p.FullName);
return conversationsFactory.GetConversations(p);
exporters.Add(new JsonExporter(fileSystem, destination));
}
catch (Exception ex)
if (parseResult.GetRequiredValue(markdownOption))
{
Console.Error.WriteLine($"Error parsing file: {p.FullName} {ex.Message}");
return null;
exporters.Add(new MarkdownExporter(fileSystem, destination));
}
}
var exporter = new Exporter(fileSystem, exporters);

var directoryConversationsMap = conversationFiles
.Select(file => new
Conversations GetConversations(IFileInfo p)
{
file.Directory,
Conversations = GetConversations(file)
})
.Where(x => x.Conversations != null)
.OrderBy(x => x.Conversations.GetUpdateTime())
.ToList();

var groupedByConversationId = directoryConversationsMap
.SelectMany(entry => entry.Conversations, (entry, Conversation) => (entry.Directory, Conversation))
.GroupBy(x => x.Conversation.conversation_id)
.OrderBy(p => p.Key).ToList();

foreach (var group in groupedByConversationId)
try
{
Console.WriteLine($"Loading conversation " + p.FullName);
return conversationsFactory.GetConversations(p);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Error parsing file: {p.FullName} {ex.Message}");
return null;

Check warning on line 114 in ChatGPTExport/Program.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference return.
}
}

var directoryConversationsMap = conversationFiles
.Select(file => new
{
file.Directory,
Conversations = GetConversations(file)
})
.Where(x => x.Conversations != null)
.OrderBy(x => x.Conversations.GetUpdateTime())
.ToList();

var groupedByConversationId = directoryConversationsMap
.SelectMany(entry => entry.Conversations, (entry, Conversation) => (entry.Directory, Conversation))
.GroupBy(x => x.Conversation.conversation_id)
.OrderBy(p => p.Key).ToList();

var count = groupedByConversationId.Count;
var position = 0;
foreach (var group in groupedByConversationId)
{
var percent = (int)(position++ * 100.0 / count);
ConsoleFeatures.SetProgress(percent);
exporter.Process(group, destination);

Check warning on line 139 in ChatGPTExport/Program.cs

View workflow job for this annotation

GitHub Actions / build

Argument of type 'IGrouping<string, (IDirectoryInfo? Directory, Conversation Conversation)>' cannot be used for parameter 'conversations' of type 'IEnumerable<(IDirectoryInfo sourceDirectory, Conversation conversation)>' in 'void Exporter.Process(IEnumerable<(IDirectoryInfo sourceDirectory, Conversation conversation)> conversations, IDirectoryInfo destination)' due to differences in the nullability of reference types.
}
}
catch (Exception ex)
{
exporter.Process(group, destination);
Console.Error.WriteLine(ex.Message);
}
finally
{
ConsoleFeatures.ClearState();
}
return 0;

});

var parseResult = rootCommand.Parse(args);
Expand Down
Loading