Size: 12.7 KB Modified: 30/06/2026 9:00 PM
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);
Offline