TabToJson

<Window x:Class="TabToJson.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d"
        Title="タブ区切り → JSON 変換" Height="600" Width="1000"
        MinHeight="400" MinWidth="700">
    <Grid Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <!-- ヘッダー -->
        <TextBlock Grid.Row="0" Text="タブ区切りテキスト → JSON 変換ツール"
                   FontSize="16" FontWeight="Bold" Margin="0,0,0,10"/>

        <!-- メインパネル -->
        <Grid Grid.Row="1">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition Width="*"/>
            </Grid.ColumnDefinitions>

            <!-- 入力パネル -->
            <Grid Grid.Column="0">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="*"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>
                <TextBlock Grid.Row="0" Text="入力(タブ区切りテキスト)" FontWeight="Bold" Margin="0,0,0,4"/>
                <TextBox Grid.Row="1" x:Name="InputTextBox"
                         AcceptsReturn="True" AcceptsTab="True"
                         VerticalScrollBarVisibility="Auto"
                         HorizontalScrollBarVisibility="Auto"
                         FontFamily="Consolas" FontSize="12"
                         TextWrapping="NoWrap"
                         Padding="4"
                         TextChanged="InputTextBox_TextChanged"
                         AllowDrop="True"
                         Drop="InputTextBox_Drop"
                         PreviewDragOver="InputTextBox_PreviewDragOver"/>
                <StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,4,0,0">
                    <TextBlock x:Name="InputStatusText" FontSize="11" Foreground="Gray" VerticalAlignment="Center"/>
                    <TextBlock Text="文字コード:" Margin="8,0,4,0" VerticalAlignment="Center" FontSize="11"/>
                    <ComboBox x:Name="EncodingComboBox" Width="130" VerticalAlignment="Center" FontSize="11"
                              SelectionChanged="EncodingComboBox_SelectionChanged"/>
                    <Button Content="ファイルを開く..." Margin="8,0,0,0" Padding="8,2" Click="OpenFile_Click"/>
                    <Button Content="貼り付け" Margin="4,0,0,0" Padding="8,2" Click="PasteInput_Click"/>
                    <Button Content="クリア" Margin="4,0,0,0" Padding="8,2" Click="ClearInput_Click"/>
                </StackPanel>
            </Grid>

            <!-- 中央ボタン -->
            <StackPanel Grid.Column="1" VerticalAlignment="Center" Margin="10,0">
                <Button Content="→ 変換" Padding="10,8" Click="Convert_Click"
                        FontWeight="Bold" FontSize="13"/>
            </StackPanel>

            <!-- 出力パネル -->
            <Grid Grid.Column="2">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="*"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>
                <StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,4">
                    <TextBlock Text="出力(JSON)" FontWeight="Bold" VerticalAlignment="Center"/>
                    <CheckBox x:Name="PrettyPrintCheck" Content="整形出力" IsChecked="True"
                              Margin="12,0,0,0" VerticalAlignment="Center"/>
                    <CheckBox x:Name="ArrayWrapCheck" Content="配列形式" IsChecked="True"
                              Margin="8,0,0,0" VerticalAlignment="Center"/>
                </StackPanel>
                <TextBox Grid.Row="1" x:Name="OutputTextBox"
                         AcceptsReturn="True"
                         VerticalScrollBarVisibility="Auto"
                         HorizontalScrollBarVisibility="Auto"
                         FontFamily="Consolas" FontSize="12"
                         TextWrapping="NoWrap"
                         IsReadOnly="True"
                         Padding="4"
                         Background="#F9F9F9"/>
                <StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,4,0,0">
                    <TextBlock x:Name="OutputStatusText" FontSize="11" Foreground="Gray" VerticalAlignment="Center"/>
                    <Button Content="コピー" Margin="8,0,0,0" Padding="8,2" Click="CopyOutput_Click"/>
                    <Button Content="保存..." Margin="4,0,0,0" Padding="8,2" Click="SaveOutput_Click"/>
                </StackPanel>
            </Grid>
        </Grid>

        <!-- エラーメッセージ -->
        <TextBlock Grid.Row="2" x:Name="ErrorText" Foreground="Red"
                   FontSize="11" Margin="0,6,0,0" TextWrapping="Wrap"
                   Visibility="Collapsed"/>

        <!-- フッター -->
        <StackPanel Grid.Row="3" Orientation="Horizontal" Margin="0,6,0,0">
            <TextBlock x:Name="FilePathText" FontSize="10" Foreground="Gray" VerticalAlignment="Center"/>
            <TextBlock FontSize="10" Foreground="Gray" VerticalAlignment="Center"
                       Text="1行目をフィールド名として使用します。空セルは null として出力されます。 ファイルのドラッグ&ドロップにも対応しています。"/>
        </StackPanel>
    </Grid>
</Window>
using System.IO;
using System.Text;
using System.Text.Json;
using System.Windows;
using Microsoft.Win32;

namespace TabToJson;

public partial class MainWindow : Window
{
    private record EncodingItem(string Label, int CodePage);

    // 最後に読み込んだファイルのパス(エンコード変更時に再読み込みするため)
    private string? _lastLoadedPath;

    public MainWindow()
    {
        InitializeComponent();
        Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
        InitEncodingComboBox();
    }

    private void InitEncodingComboBox()
    {
        var items = new[]
        {
            new EncodingItem("Shift-JIS (CP932)",  932),
            new EncodingItem("UTF-8",              65001),
            new EncodingItem("UTF-8 BOM付き",      -1),   // 特殊扱い
            new EncodingItem("UTF-16 LE",          1200),
            new EncodingItem("UTF-16 BE",          1201),
            new EncodingItem("EUC-JP",             51932),
            new EncodingItem("自動判別",            0),
        };
        EncodingComboBox.ItemsSource = items;
        EncodingComboBox.DisplayMemberPath = nameof(EncodingItem.Label);
        EncodingComboBox.SelectedIndex = 0; // デフォルト: Shift-JIS
    }

    private Encoding GetSelectedEncoding()
    {
        if (EncodingComboBox.SelectedItem is not EncodingItem item)
            return Encoding.GetEncoding(932);

        return item.CodePage switch
        {
            0    => Encoding.GetEncoding(932), // 自動判別はLoadFile内で処理
            -1   => new UTF8Encoding(encoderShouldEmitUTF8Identifier: true),
            _    => Encoding.GetEncoding(item.CodePage)
        };
    }

    private bool IsAutoDetect =>
        EncodingComboBox.SelectedItem is EncodingItem { CodePage: 0 };

    private void EncodingComboBox_SelectionChanged(object sender,
        System.Windows.Controls.SelectionChangedEventArgs e)
    {
        // ファイルが読み込まれていれば選択変更時に再読み込み
        if (_lastLoadedPath is not null)
            LoadFile(_lastLoadedPath);
    }

    private void InputTextBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
    {
        UpdateInputStatus();
        ErrorText.Visibility = Visibility.Collapsed;
    }

    private void UpdateInputStatus()
    {
        var lines = InputTextBox.Text.Split('\n', StringSplitOptions.None);
        var nonEmpty = lines.Count(l => l.Trim().Length > 0);
        int dataRows = nonEmpty > 1 ? nonEmpty - 1 : 0;
        InputStatusText.Text = $"{nonEmpty} 行 (データ: {dataRows} 件)";
    }

    private void Convert_Click(object sender, RoutedEventArgs e)
    {
        ErrorText.Visibility = Visibility.Collapsed;
        OutputTextBox.Text = string.Empty;

        var input = InputTextBox.Text;
        if (string.IsNullOrWhiteSpace(input))
        {
            ShowError("入力が空です。タブ区切りテキストを入力してください。");
            return;
        }

        try
        {
            var json = ConvertToJson(input,
                prettyPrint: PrettyPrintCheck.IsChecked == true,
                asArray: ArrayWrapCheck.IsChecked == true);
            OutputTextBox.Text = json;
            OutputStatusText.Text = $"{json.Length} 文字";
        }
        catch (Exception ex)
        {
            ShowError($"変換エラー: {ex.Message}");
        }
    }

    private static string ConvertToJson(string input, bool prettyPrint, bool asArray)
    {
        // 行分割(CRLF / LF 両対応)
        var lines = input.Split('\n')
                         .Select(l => l.TrimEnd('\r'))
                         .ToArray();

        // 空行を除去
        var nonEmptyLines = lines
            .Select((line, i) => (line, i))
            .Where(x => x.line.Length > 0)
            .ToList();

        if (nonEmptyLines.Count == 0)
            throw new InvalidOperationException("有効な行がありません。");

        // 1行目をヘッダーとして取得
        var headers = nonEmptyLines[0].line.Split('\t');
        if (headers.Length == 0)
            throw new InvalidOperationException("ヘッダー行にタブ区切りデータが見つかりません。");

        var options = new JsonWriterOptions
        {
            Indented = prettyPrint,
            Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
        };

        using var stream = new MemoryStream();
        using var writer = new Utf8JsonWriter(stream, options);

        if (asArray)
            writer.WriteStartArray();

        foreach (var (line, _) in nonEmptyLines.Skip(1))
        {
            var values = line.Split('\t');
            writer.WriteStartObject();

            for (int i = 0; i < headers.Length; i++)
            {
                var key = headers[i].Trim();
                if (string.IsNullOrEmpty(key))
                    key = $"field{i + 1}";

                var value = i < values.Length ? values[i] : null;

                writer.WritePropertyName(key);

                if (string.IsNullOrEmpty(value))
                    writer.WriteNullValue();
                else if (long.TryParse(value, out var longVal))
                    writer.WriteNumberValue(longVal);
                else if (double.TryParse(value, System.Globalization.NumberStyles.Any,
                         System.Globalization.CultureInfo.InvariantCulture, out var doubleVal))
                    writer.WriteNumberValue(doubleVal);
                else if (bool.TryParse(value, out var boolVal))
                    writer.WriteBooleanValue(boolVal);
                else
                    writer.WriteStringValue(value);
            }

            writer.WriteEndObject();
        }

        if (asArray)
            writer.WriteEndArray();

        writer.Flush();
        return Encoding.UTF8.GetString(stream.ToArray());
    }

    private void ClearInput_Click(object sender, RoutedEventArgs e)
    {
        InputTextBox.Text = string.Empty;
        OutputTextBox.Text = string.Empty;
        OutputStatusText.Text = string.Empty;
        InputStatusText.Text = string.Empty;
        FilePathText.Text = string.Empty;
        _lastLoadedPath = null;
        ErrorText.Visibility = Visibility.Collapsed;
    }

    private void OpenFile_Click(object sender, RoutedEventArgs e)
    {
        var dialog = new OpenFileDialog
        {
            Title = "タブ区切りファイルを開く",
            Filter = "テキストファイル (*.txt;*.tsv;*.tab;*.csv)|*.txt;*.tsv;*.tab;*.csv|すべてのファイル (*.*)|*.*"
        };

        if (dialog.ShowDialog() == true)
            LoadFile(dialog.FileName);
    }

    private void InputTextBox_PreviewDragOver(object sender, DragEventArgs e)
    {
        e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop)
            ? DragDropEffects.Copy
            : DragDropEffects.None;
        e.Handled = true;
    }

    private void InputTextBox_Drop(object sender, DragEventArgs e)
    {
        if (e.Data.GetData(DataFormats.FileDrop) is string[] files && files.Length > 0)
            LoadFile(files[0]);
    }

    private void LoadFile(string path)
    {
        try
        {
            var encoding = IsAutoDetect ? DetectEncoding(path) : GetSelectedEncoding();
            InputTextBox.Text = File.ReadAllText(path, encoding);
            _lastLoadedPath = path;
            FilePathText.Text = $"{Path.GetFileName(path)} [{encoding.WebName}] ";
            UpdateInputStatus();
            ErrorText.Visibility = Visibility.Collapsed;
        }
        catch (Exception ex)
        {
            ShowError($"ファイル読み込みエラー: {ex.Message}");
        }
    }

    private static Encoding DetectEncoding(string path)
    {
        // BOMチェック
        using var fs = File.OpenRead(path);
        var bom = new byte[4];
        _ = fs.Read(bom, 0, 4);

        if (bom[0] == 0xEF && bom[1] == 0xBB && bom[2] == 0xBF) return Encoding.UTF8;
        if (bom[0] == 0xFF && bom[1] == 0xFE) return Encoding.Unicode;
        if (bom[0] == 0xFE && bom[1] == 0xFF) return Encoding.BigEndianUnicode;

        // BOMなし → UTF-8 として試みる(失敗時は Shift-JIS フォールバック)
        try
        {
            var bytes = File.ReadAllBytes(path);
            var utf8 = new UTF8Encoding(false, throwOnInvalidBytes: true);
            utf8.GetString(bytes);
            return utf8;
        }
        catch
        {
            return Encoding.GetEncoding(932);
        }
    }

    private void PasteInput_Click(object sender, RoutedEventArgs e)
    {
        if (Clipboard.ContainsText())
        {
            InputTextBox.Text = Clipboard.GetText();
            FilePathText.Text = string.Empty;
            UpdateInputStatus();
        }
    }

    private void CopyOutput_Click(object sender, RoutedEventArgs e)
    {
        if (!string.IsNullOrEmpty(OutputTextBox.Text))
        {
            Clipboard.SetText(OutputTextBox.Text);
            OutputStatusText.Text = "コピーしました";
        }
    }

    private void SaveOutput_Click(object sender, RoutedEventArgs e)
    {
        if (string.IsNullOrEmpty(OutputTextBox.Text))
        {
            ShowError("保存するJSONがありません。先に変換を実行してください。");
            return;
        }

        var dialog = new SaveFileDialog
        {
            Title = "JSONファイルを保存",
            Filter = "JSON ファイル (*.json)|*.json|すべてのファイル (*.*)|*.*",
            DefaultExt = "json",
            FileName = "output.json"
        };

        if (dialog.ShowDialog() == true)
        {
            File.WriteAllText(dialog.FileName, OutputTextBox.Text, Encoding.UTF8);
            OutputStatusText.Text = $"保存しました: {Path.GetFileName(dialog.FileName)}";
        }
    }

    private void ShowError(string message)
    {
        ErrorText.Text = message;
        ErrorText.Visibility = Visibility.Visible;
    }
}