GoogleスプレッドシートがGoogle Colabノート内で使えるようになったので、Jupyter用にCSVエディタを作ってみる

## 概要

いつの間にかGoogle Colabのノート内でGoogle SpreadSheetが開ける、`InteractiveSheet`なるAPIが誕生していたので、JupyterやVSCodeのノート内でCSVを編集できるアプリをサクッと作ってみた(?)

## InteractiveSheetについて

とりあえず試せるようにしてみました。

実行するとGoogleへのログインと権限の承認が求められるのでログインして承認してください。するとセルの出力部分にGoogle SpreadSheetが表示され編集もできるのでいろいろ試してみてください。

https://colab.research.google.com/github/uniras/MiscNotebook/blob/main/interactive.ipynb?hl=ja

内容は概要に書いたとおりなので詳しくは以下を見てください

https://medium.com/google-colab/colab-tools-for-google-sheets-078cefd9d112

動作確認用のcolabノートブックにあるコードはこのサイトのものを使いました

https://note.com/unco3/n/n0936743b0afd


## JupyterやVSCodeでもやりたい

もしかするともっといい方法があるのかもしれませんが、なんとなくJupyterやVSCodeでもInteractiveSheetと同じとまではいかなくてもある程度CSVを編集できるといいなぁと思ったので車輪の再発明上等で作ってみた。

完成品

https://colab.research.google.com/github/uniras/MiscNotebook/blob/main/csveditor.ipynb

コード

```python
import dash
from dash import dash_table, html, dcc
from dash.dependencies import Input, Output, State
import pandas as pd
import os


def create_editable_dataframe_app(csv_file: str, width: int, height: int,
                                  row_editable: bool = True, column_editable: bool = True,
                                  backup: bool = True, backup_folder: str = '_backup'):
    """
    編集可能なデータフレームを表示し、列と行の編集、フィルタリング、ソート機能を提供するDashアプリケーションを作成します。

    Parameters:
    - csv_file (str): 編集対象のCSVファイル名。
    - width (int): データテーブルの幅。
    - height (int): データテーブルの高さ。
    - row_editable (bool): 行の追加・削除を許可するかどうか。
    - column_editable (bool): 列の追加・削除を許可するかどうか。
    - backup (bool): 保存時にバックアップを作成するかどうか。
    - backup_folder (str): バックアップを保存するフォルダ。デフォルトは'_backup'。空文字列の場合は元のCSVファイルと同じフォルダに保存される。
    """

    if os.path.exists(csv_file):
        df = pd.read_csv(csv_file)
        if df.empty:
            # 空のデータフレームの場合、デフォルト列を設定
            df = pd.DataFrame({'列1': [''], '列2': ['']})
    else:
        # ファイルがない場合、デフォルトのデータフレームを作成
        df = pd.DataFrame({'列1': [''], '列2': ['']})
        df.to_csv(csv_file, index=False)

    app = dash.Dash(__name__)

    app.layout = html.Div([
        html.Div(id='save-output', style={"color": "white", 'height': '30px',
                 'display': 'flex', 'alignItems': 'center', 'marginBottom': '10px'}, children=''),

        html.Div([
            dcc.Input(id='new-column-name', type='text', placeholder='新しい列名',
                      style={'display': 'block' if column_editable else 'none', 'height': '30px'}),
            html.Button('列を追加', id='add-column-btn', n_clicks=0,
                        style={'display': 'block' if column_editable else 'none', 'height': '30px'}),
            dcc.Dropdown(id='column-dropdown', options=[{'label': col, 'value': col} for col in df.columns],
                         placeholder='削除する列を選択',
                         style={'display': 'block' if column_editable else 'none', 'width': '200px', 'height': '30px'}),
            html.Button('選択した列を削除', id='delete-column-btn', n_clicks=0,
                        style={'display': 'block' if column_editable else 'none', 'height': '30px'}),
            html.Button('行を追加', id='add-row-btn', n_clicks=0,
                        style={'display': 'block' if row_editable else 'none', 'height': '30px'}),
            html.Button('最後の行を削除', id='delete-row-btn', n_clicks=0,
                        style={'display': 'block' if row_editable else 'none', 'height': '30px'}),
            html.Button('フィルタを切り替え', id='filter-toggle-btn', n_clicks=0,
                        style={'height': '30px'}),
            html.Button('データを保存', id='save-btn', n_clicks=0,
                        style={'height': '30px'}),
        ], style={'display': 'flex', 'gap': '10px', 'flexWrap': 'wrap', 'marginBottom': '20px', 'alignItems': 'center', 'height': '30px'}),

        dash_table.DataTable(
            id='editable-table',
            columns=[{"name": i, "id": i} for i in df.columns],
            data=df.to_dict('records'),
            editable=True,
            row_deletable=row_editable,
            sort_action='native',
            filter_action='none',
            style_table={'height': f'{height}px',
                         'width': f'{width}px', 'overflowY': 'auto'},
            style_cell={'minWidth': '150px', 'width': '150px',
                        'maxWidth': '150px', 'whiteSpace': 'normal'},
            style_header={'backgroundColor': '#333',
                          'color': 'white', 'border': '1px solid #444'},
            style_data={'backgroundColor': '#222',
                        'color': 'white', 'border': '1px solid #444'},
            style_data_conditional=[
                {'if': {'state': 'active'},
                    'backgroundColor': '#1E90FF', 'color': 'white'},
                {'if': {'state': 'selected'},
                    'backgroundColor': '#FFD700', 'color': 'black'}
            ],
        )
    ])

    @app.callback(
        Output('editable-table', 'columns'),
        Output('editable-table', 'data'),
        Output('column-dropdown', 'options'),
        Output('save-output', 'children'),
        Output('save-output', 'style'),
        Output('editable-table', 'filter_action'),
        Input('editable-table', 'data'),
        State('new-column-name', 'value'),
        State('column-dropdown', 'value'),
        State('editable-table', 'data'),
        State('editable-table', 'columns'),
        Input('add-column-btn', 'n_clicks'),
        Input('delete-column-btn', 'n_clicks'),
        Input('add-row-btn', 'n_clicks'),
        Input('delete-row-btn', 'n_clicks'),
        Input('save-btn', 'n_clicks'),
        Input('filter-toggle-btn', 'n_clicks'),
        State('editable-table', 'filter_action'),
        State('save-output', 'children')
    )
    def update_table(data, new_column_name, column_to_delete, rows, columns, add_column_clicks, delete_column_clicks,
                     add_row_clicks, delete_row_clicks, save_clicks, filter_clicks, current_filter_action, current_message):

        ctx = dash.callback_context
        triggered = ctx.triggered[0]['prop_id'].split('.')[0]

        # フィルタリング機能のON/OFF切り替え
        if triggered == 'filter-toggle-btn':
            if current_filter_action == 'native':
                filter_action = 'none'
            else:
                filter_action = 'native'
            return dash.no_update, dash.no_update, dash.no_update, dash.no_update, dash.no_update, filter_action

        # 初回ロード時のメッセージを表示
        if current_message == '':
            load_message = f"{csv_file} をロードしました"
            load_style = {"color": "white", 'backgroundColor': '#444'}
            return dash.no_update, dash.no_update, dash.no_update, load_message, load_style, dash.no_update

        # ユーザー操作があった場合に「まだセーブされていません」メッセージを表示
        message = f"{csv_file} はまだセーブされていません"
        style = {"color": "white", 'backgroundColor': 'red'}

        # 列の追加処理
        if triggered == 'add-column-btn' and new_column_name:
            if new_column_name not in [col['id'] for col in columns]:
                columns.append(
                    {"name": new_column_name, "id": new_column_name})
                for row in rows:
                    row[new_column_name] = ''
            options = [{'label': col['id'], 'value': col['id']}
                       for col in columns]
            return columns, rows, options, message, style, dash.no_update

        # 列の削除処理
        if triggered == 'delete-column-btn' and column_to_delete:
            columns = [col for col in columns if col['id'] != column_to_delete]
            for row in rows:
                if column_to_delete in row:
                    del row[column_to_delete]
            options = [{'label': col['id'], 'value': col['id']}
                       for col in columns]
            return columns, rows, options, message, style, dash.no_update

        # 行の追加処理
        if triggered == 'add-row-btn':
            rows.append({col['id']: '' for col in columns})
            return columns, rows, [{'label': col['id'], 'value': col['id']} for col in columns], message, style, dash.no_update

        # 行の削除処理
        if triggered == 'delete-row-btn' and rows:
            rows = rows[:-1]
            return columns, rows, [{'label': col['id'], 'value': col['id']} for col in columns], message, style, dash.no_update

        # データの保存処理
        if triggered == 'save-btn' and save_clicks > 0:
            df_current = pd.DataFrame(rows)
            if backup and os.path.exists(csv_file):
                base, ext = os.path.splitext(os.path.basename(csv_file))

                # バックアップフォルダの決定
                directory = os.path.dirname(csv_file)
                if directory == "":  # dirnameが空の場合、現在の作業ディレクトリを使用
                    directory = os.getcwd()

                if backup_folder == "":  # 空文字列の場合は同じフォルダに保存
                    backup_dir = directory
                else:
                    backup_dir = os.path.join(directory, backup_folder)

                # バックアップフォルダが存在しない場合は作成
                if not os.path.exists(backup_dir):
                    os.makedirs(backup_dir)

                # バックアップファイルを保存
                backup_number = 1
                while True:
                    backup_file = os.path.join(
                        backup_dir, f"{base}_{backup_number}{ext}")
                    if not os.path.exists(backup_file):
                        df_backup = pd.read_csv(csv_file)
                        df_backup.to_csv(backup_file, index=False)
                        break
                    backup_number += 1

            # CSVファイルを保存
            df_current.to_csv(csv_file, index=False)
            saved_message = f"{csv_file} をセーブしました"
            saved_style = {"color": "white", 'backgroundColor': '#444'}
            return columns, rows, [{'label': col['id'], 'value': col['id']} for col in columns], saved_message, saved_style, dash.no_update

        # デフォルトでは、メッセージとスタイルを更新しない
        return dash.no_update, dash.no_update, dash.no_update, message, style, dash.no_update

    return app


# アプリケーションを作成して実行
app = create_editable_dataframe_app(
    csv_file='data.csv',
    width=800,
    height=400,
    row_editable=True,
    column_editable=True,
    backup=True,
    backup_folder='_backup'
)

if __name__ == '__main__':
    app.run_server(debug=False)