← Back to Files
Program.cs
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
var conversationsPath = args.Length > 0 ? args[0]
: @"e:\Google Drive\Misc\The Chat Files\data-99139e0b-b4c1-4c3a-9163-b4b84a7ee1bc-1781156915-c252847e-batch-0000\conversations.json";
var workspaceDir = Directory.GetParent(Path.GetDirectoryName(Path.GetFullPath(conversationsPath))!)?.FullName
?? Environment.CurrentDirectory;
var outputDir = args.Length > 1 ? args[1]
: Path.Combine(workspaceDir, "Extracted The Chat Project");
Directory.CreateDirectory(outputDir);
Console.WriteLine($"Reading conversations from: {conversationsPath}");
using var doc = JsonDocument.Parse(File.ReadAllText(conversationsPath));
var root = doc.RootElement;
Console.WriteLine($"Total conversations: {root.GetArrayLength():N0}");
var conversations = new List<ExtractedConversation>();
for (var i = 0; i < root.GetArrayLength(); i++)
{
var conv = root[i];
var name = conv.GetProperty("name").GetString() ?? "";
var sortKey = GetTheChatSortKey(name);
if (sortKey is null)
continue;
conversations.Add(new ExtractedConversation(
JsonIndex: i,
SortKey: sortKey.Value,
Name: name,
Uuid: conv.GetProperty("uuid").GetString() ?? "",
CreatedAt: conv.GetProperty("created_at").GetString() ?? "",
UpdatedAt: conv.GetProperty("updated_at").GetString() ?? "",
AssistantName: AssistantDisplayName(name),
MessageCount: conv.GetProperty("chat_messages").GetArrayLength(),
Element: conv));
}
conversations = conversations.OrderBy(c => c.SortKey).ThenBy(c => c.CreatedAt, StringComparer.Ordinal).ToList();
var written = new List<WrittenConversation>();
foreach (var conv in conversations)
{
var fileName = BuildFileName(conv.Name);
var filePath = Path.Combine(outputDir, fileName);
File.WriteAllText(filePath, RenderConversation(conv), Encoding.UTF8);
written.Add(new WrittenConversation(conv, fileName));
Console.WriteLine($" [{conv.JsonIndex:D4}] {conv.Name} -> {fileName}");
}
File.WriteAllText(Path.Combine(outputDir, "_index.md"), RenderIndex(written, conversationsPath), Encoding.UTF8);
Console.WriteLine();
Console.WriteLine($"Extracted: {written.Count} conversations");
Console.WriteLine($"Output: {outputDir}");
Console.WriteLine($"Index: {Path.Combine(outputDir, "_index.md")}");
static int? GetTheChatSortKey(string name)
{
var part = Regex.Match(name, @"^The Chat Part\s+(\d+)$", RegexOptions.IgnoreCase);
if (part.Success && int.TryParse(part.Groups[1].Value, out var partNumber))
return partNumber;
var test = Regex.Match(name, @"^Test Chat\s+(\d+)$", RegexOptions.IgnoreCase);
if (test.Success && int.TryParse(test.Groups[1].Value, out var testNumber))
return 10_000 + testNumber;
return null;
}
static string BuildFileName(string name)
{
var part = Regex.Match(name, @"^The Chat Part\s+(\d+)$", RegexOptions.IgnoreCase);
if (part.Success && int.TryParse(part.Groups[1].Value, out var partNumber))
return $"{partNumber:D3}_{SanitizeFilename(name)}.md";
var test = Regex.Match(name, @"^Test Chat\s+(\d+)$", RegexOptions.IgnoreCase);
if (test.Success && int.TryParse(test.Groups[1].Value, out var testNumber))
return $"T{testNumber:D2}_{SanitizeFilename(name)}.md";
return $"{SanitizeFilename(name)}.md";
}
static string AssistantDisplayName(string name)
{
var part = Regex.Match(name, @"^The Chat Part\s+(\d+)$", RegexOptions.IgnoreCase);
if (part.Success && int.TryParse(part.Groups[1].Value, out var partNumber))
return partNumber >= 11 ? "Pyrite" : "Claude";
if (Regex.IsMatch(name, @"^Test Chat\s+\d+$", RegexOptions.IgnoreCase))
return "Pyrite";
return "Claude";
}
static string RenderConversation(ExtractedConversation conv)
{
var sb = new StringBuilder();
sb.AppendLine($"# {conv.Name}");
sb.AppendLine();
sb.AppendLine("| Field | Value |");
sb.AppendLine("| --- | --- |");
sb.AppendLine($"| UUID | `{conv.Uuid}` |");
sb.AppendLine($"| JSON index | `{conv.JsonIndex:D4}` |");
sb.AppendLine($"| Created | {conv.CreatedAt} |");
sb.AppendLine($"| Updated | {conv.UpdatedAt} |");
sb.AppendLine($"| Assistant label | {conv.AssistantName} |");
sb.AppendLine($"| Messages | {conv.MessageCount.ToString(CultureInfo.InvariantCulture)} |");
sb.AppendLine();
sb.AppendLine("## Transcript");
sb.AppendLine();
foreach (var msg in conv.Element.GetProperty("chat_messages").EnumerateArray())
{
var sender = msg.GetProperty("sender").GetString() ?? "unknown";
var displaySender = sender switch
{
"human" => "Raymond",
"assistant" => conv.AssistantName,
_ => CultureInfo.InvariantCulture.TextInfo.ToTitleCase(sender)
};
var createdAt = msg.TryGetProperty("created_at", out var created) ? created.GetString() ?? "" : "";
var body = VisibleMessageText(msg, sender);
var attachments = RenderAttachments(msg, body);
var files = RenderFileList(msg);
if (string.IsNullOrWhiteSpace(body) && string.IsNullOrWhiteSpace(attachments) && string.IsNullOrWhiteSpace(files))
continue;
sb.AppendLine($"### {displaySender} - {createdAt}");
sb.AppendLine();
if (!string.IsNullOrWhiteSpace(body))
{
sb.AppendLine(body.Trim());
sb.AppendLine();
}
if (!string.IsNullOrWhiteSpace(attachments))
{
sb.Append(attachments);
sb.AppendLine();
}
if (!string.IsNullOrWhiteSpace(files))
{
sb.Append(files);
sb.AppendLine();
}
}
return sb.ToString().TrimEnd() + Environment.NewLine;
}
static string VisibleMessageText(JsonElement msg, string sender)
{
var pieces = new List<string>();
if (msg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array)
{
foreach (var item in content.EnumerateArray())
{
var type = item.TryGetProperty("type", out var typeElement) ? typeElement.GetString() : "text";
if (!string.Equals(type, "text", StringComparison.OrdinalIgnoreCase))
continue;
if (item.TryGetProperty("text", out var textElement))
AddPiece(pieces, textElement.GetString());
}
}
if (pieces.Count == 0 && msg.TryGetProperty("text", out var fallback))
{
var fallbackText = fallback.GetString();
if (sender == "assistant")
fallbackText = StripAssistantNoise(fallbackText ?? "");
AddPiece(pieces, fallbackText);
}
return CleanMessageText(string.Join("\n\n", Deduplicate(pieces)));
}
static string RenderAttachments(JsonElement msg, string body)
{
if (!msg.TryGetProperty("attachments", out var attachments) || attachments.ValueKind != JsonValueKind.Array)
return "";
var sb = new StringBuilder();
foreach (var attachment in attachments.EnumerateArray())
{
var fileName = attachment.TryGetProperty("file_name", out var fileNameElement)
? fileNameElement.GetString() ?? "attachment"
: "attachment";
var fileType = attachment.TryGetProperty("file_type", out var fileTypeElement)
? fileTypeElement.GetString() ?? ""
: "";
var fileSize = attachment.TryGetProperty("file_size", out var fileSizeElement) && fileSizeElement.ValueKind == JsonValueKind.Number
? fileSizeElement.GetInt64().ToString("N0", CultureInfo.InvariantCulture)
: "";
var extracted = attachment.TryGetProperty("extracted_content", out var extractedElement)
? NormalizeLineEndings(extractedElement.GetString() ?? "").Trim()
: "";
sb.AppendLine($"**Attachment:** `{fileName}`{FormatAttachmentMeta(fileType, fileSize)}");
sb.AppendLine();
if (!string.IsNullOrWhiteSpace(extracted) && !body.Contains(extracted, StringComparison.Ordinal))
{
AppendFencedBlock(sb, extracted, "text");
sb.AppendLine();
}
}
return sb.ToString();
}
static string RenderFileList(JsonElement msg)
{
if (!msg.TryGetProperty("files", out var files) || files.ValueKind != JsonValueKind.Array || files.GetArrayLength() == 0)
return "";
var sb = new StringBuilder();
sb.AppendLine("**Files:**");
foreach (var file in files.EnumerateArray())
{
var fileName = file.TryGetProperty("file_name", out var fileNameElement)
? fileNameElement.GetString() ?? "file"
: "file";
var fileUuid = file.TryGetProperty("file_uuid", out var fileUuidElement)
? fileUuidElement.GetString() ?? ""
: "";
sb.Append("- `").Append(fileName).Append('`');
if (!string.IsNullOrWhiteSpace(fileUuid))
sb.Append(" (").Append(fileUuid).Append(')');
sb.AppendLine();
}
return sb.ToString();
}
static string RenderIndex(IReadOnlyCollection<WrittenConversation> written, string sourcePath)
{
var sb = new StringBuilder();
sb.AppendLine("# Extracted The Chat Project");
sb.AppendLine();
sb.AppendLine($"Source: `{sourcePath}`");
sb.AppendLine();
sb.AppendLine("Note: this Claude export does not include conversation-to-project UUID links. The extractor therefore uses the exact The Chat series names available in the export: `The Chat Part N` and `Test Chat N`. Parts 1-10 are labelled `Claude`; Part 11 onward is labelled `Pyrite`; Test Chats are included as auxiliary Pyrite test material because they may still contain useful later context.");
sb.AppendLine();
sb.AppendLine($"Count: {written.Count.ToString(CultureInfo.InvariantCulture)}");
sb.AppendLine();
sb.AppendLine("| JSON index | Conversation | Created | Updated | Messages | File |");
sb.AppendLine("| ---: | --- | --- | --- | ---: | --- |");
foreach (var item in written)
{
var c = item.Conversation;
sb.AppendLine($"| {c.JsonIndex:D4} | {EscapeTable(c.Name)} | {c.CreatedAt} | {c.UpdatedAt} | {c.MessageCount.ToString(CultureInfo.InvariantCulture)} | [{EscapeTable(item.FileName)}]({Uri.EscapeDataString(item.FileName).Replace("%2F", "/")}) |");
}
return sb.ToString();
}
static string FormatAttachmentMeta(string fileType, string fileSize)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(fileType))
parts.Add(fileType);
if (!string.IsNullOrWhiteSpace(fileSize))
parts.Add($"{fileSize} bytes");
return parts.Count == 0 ? "" : $" ({string.Join(", ", parts)})";
}
static void AppendFencedBlock(StringBuilder sb, string text, string language)
{
var fence = "```";
while (text.Contains(fence, StringComparison.Ordinal))
fence += "`";
sb.Append(fence).AppendLine(language);
sb.AppendLine(text);
sb.AppendLine(fence);
}
static void AddPiece(ICollection<string> pieces, string? value)
{
value = NormalizeLineEndings(value ?? "").Trim();
if (!string.IsNullOrWhiteSpace(value))
pieces.Add(value);
}
static IEnumerable<string> Deduplicate(IEnumerable<string> pieces)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var piece in pieces)
{
if (seen.Add(piece))
yield return piece;
}
}
static string CleanMessageText(string text)
{
text = NormalizeLineEndings(text);
text = Regex.Replace(text, @"\[\^\d+\]:\s*<output>\s*", "", RegexOptions.Multiline);
text = text.Replace("```\nThis block is not supported on your current device yet.\n```", "", StringComparison.Ordinal);
text = Regex.Replace(text, @"\n{3,}", "\n\n");
return text.Trim();
}
static string StripAssistantNoise(string text)
{
text = Regex.Replace(text, @"<antml:thinking>.*?</antml:thinking>", "", RegexOptions.Singleline);
text = Regex.Replace(text, @"```[\s\S]*?This block is not supported on your current device yet\.[\s\S]*?```", "", RegexOptions.Multiline);
return text;
}
static string NormalizeLineEndings(string value)
{
return value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace("\r", "\n", StringComparison.Ordinal);
}
static string SanitizeFilename(string name)
{
var invalid = Path.GetInvalidFileNameChars();
var sanitized = new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray()).Trim();
return sanitized.Length > 120 ? sanitized[..120] : sanitized;
}
static string EscapeTable(string value)
{
return value.Replace("|", "\\|", StringComparison.Ordinal);
}
internal sealed record ExtractedConversation(
int JsonIndex,
int SortKey,
string Name,
string Uuid,
string CreatedAt,
string UpdatedAt,
string AssistantName,
int MessageCount,
JsonElement Element);
internal sealed record WrittenConversation(ExtractedConversation Conversation, string FileName);