見出し画像

PythonでChatGPTと一緒に住所録を作ろう:フィルター編

ほぼ機能がそろいましたね。

細かい所は、出来ていないところがあるのですが
新規、編集、削除、保存、読み込み(ボタンに登録されてない)
は、出来るようになりました。

あと、フィルターが出来れば大まかなところが完了です。

前回の記事(ここまでのソースはこちらをご覧ください)


1.実現方法

早速、ChatGPTにプロンプトを送ります。
先日、ChatGPT Plus では ChatGPT o3-mini-high
使えるようになったのですがコーディング能力が
かなり向上してますね。

こちらは、無料版で頑張ることにします。

改めてプロンプトを送ります。
(今までのコードを全て貼り付け) 実行ボタンを押したときにフィルターを掛けて一覧表に表示したい。どのような方針で実現できるだろうか?


ChatGPT#####
フィルター機能を実装するには、以下の方針が考えられます。

方針

  1. フィルター入力値を取得する
    → filter_button がクリックされたときに、filter_input のテキストを取得する。

  2. テーブルのデータを取得する
    → 現在の QTableWidget にある全データをリスト化する。

  3. フィルターを適用する
    → 各行のデータを filter_input のキーワードで検索し、一致する行のみを表示する。

  4. テーブルを更新する
    → フィルターに一致しない行を非表示(またはテーブルをクリアして一致する行のみ再表示)する。


実装方法

大きく分けて 「非表示にする」 方法と 「一致するデータのみを再描画」 する方法がある。

方法1: 一致しない行を非表示にする(簡単)

  • QTableWidget の setRowHidden() を使って、一致しない行を非表示にする。

  • 既存のデータはそのままで、フィルターを切るとすぐ元の状態に戻せる。

実装コード

def apply_filter(self):
    """フィルターを適用して一覧を絞り込む"""
    filter_text = self.filter_input.text().strip()
    
    for row in range(self.table.rowCount()):
        row_matches = False
        for col in range(self.table.columnCount()):
            item = self.table.item(row, col)
            if item and filter_text.lower() in item.text().lower():
                row_matches = True
                break
        
        # 一致しない行を非表示
        self.table.setRowHidden(row, not row_matches)

この方法のメリット

  • 元のデータは維持されるので、フィルターを解除すると元の表示に戻る。

  • setRowHidden() を使うだけなので処理が軽い。

デメリット

  • QTableWidget に大量のデータがある場合、動作が遅くなる可能性がある。


方法2: フィルターに一致するデータのみを再描画

  • 一度 QTableWidget をクリアして、一致するデータのみを再描画する。

  • self.load_data_from_json() からデータをリスト化し、一致するデータだけ QTableWidget に再描画する。

実装コード

def apply_filter(self):
    """フィルターを適用し、一致するデータのみを再表示"""
    filter_text = self.filter_input.text().strip().lower()

    # JSONからデータを読み込み
    try:
        with open(DATA_FILE, "r", encoding="utf-8") as file:
            all_data = json.load(file)
    except FileNotFoundError:
        print("ファイルが見つかりません")
        return
    except json.JSONDecodeError:
        print("JSONデータが壊れています")
        return

    # 一致するデータのみを抽出
    filtered_data = [
        entry for entry in all_data
        if any(filter_text in str(value).lower() for value in entry.values())
    ]

    # テーブルをクリアして再描画
    self.table.setRowCount(0)
    self.table.setRowCount(len(filtered_data))

    for row, entry in enumerate(filtered_data):
        for col, key in enumerate(["姓", "姓フリガナ", "名", "名フリガナ", "郵便番号", "住所", "電話番号", "備考"]):
            self.table.setItem(row, col, QTableWidgetItem(entry.get(key, "")))

    print(f"{len(filtered_data)} 件が一致しました")

この方法のメリット

  • QTableWidget のリソースを削減できる。

  • 大量のデータを扱っても setRowHidden() を使うより高速。

デメリット

  • 一度データをクリアするので、元に戻すには再度 load_data_from_json() を呼ぶ必要がある。


どちらの方法を採用すべきか?

  • データ量が少ない(100件未満)方法1(setRowHidden) でシンプルに実装

  • データ量が多い(100件以上)方法2(再描画) で高速化


ボタンにイベントを接続

どちらの方法を選んでも、filter_button.clicked.connect(self.apply_filter) を setup_ui() に追加すれば、ボタンを押したときにフィルターが適用される。

self.filter_button.clicked.connect(self.apply_filter)

まとめ

フィルター機能を実装する方法は2つある:

  1. setRowHidden() を使って非表示にする(簡単・データ量が少ない場合向け)

  2. QTableWidget をクリアし、フィルターに一致するデータのみ再描画(データ量が多い場合向け)

どちらを採用するか、データ量に応じて選ぶとよい。
簡単に実装したい場合は方法1、高速化を考慮するなら方法2が適している。


ご#####
ChatGPTは、
1.一致しない行を非表示 : 少量のデータ、簡単
2.一致するデータを再表示 : 大量のデータ、高速
という事なので簡単なほうの1を採用することにします。

2.組み込み

ChatGPTが作った apply_filter() メソッド(1.非表示版)を
クラス AddressBookApp の最後尾に追加しました。
そして、他のボタンの動作を設定した部分の最後尾に

        self.filter_button.clicked.connect(self.apply_filter)

を追加します。

これで、実行してみましょう。
うまく動作しています。

コードを確認しましょう。

def apply_filter(self):
    """フィルターを適用して一覧を絞り込む"""
    filter_text = self.filter_input.text().strip()
    
    for row in range(self.table.rowCount()): # 行数分繰り返す
        row_matches = False
        for col in range(self.table.columnCount()): # 列数分繰り返す
            item = self.table.item(row, col) # セルを一つづつ取り出す
            if item and filter_text.lower() in item.text().lower():
                row_matches = True
                break
        
        # 一致しない行を非表示
        self.table.setRowHidden(row, not row_matches)

filter_text = self.filter_input.text().strip()

フィルターの入力されたテキストからスペースを取り除きます。

            if item and filter_text.lower() in item.text().lower():
                row_matches = True
                break

if ( item ) and ( filter_text.lower() in item.text().lower() ):

分かりやすく()を入れてみました。
item = True であり
filter_text.lower() が item.text().lower() に含まれているときに True
であった場合 判定フラグ row_matches = True となります。

lower() は、すべてを小文字化して比較するために使っています。

フィルター実行画面

3.もう一つの方法

ChatGPTが提案してくれたもう一つの案、

一致するデータを再表示」の方について解説します。

実は、こちらも簡単に試してみました。
結果、このやり方はいろいろと面倒という事になりました。

例えば、このコードだとフィルターの根拠は address_book.json に
なっています。
毎回、読み込んでいます。

これだとテーブルで削除などの変更があった場合を考えて
一度データを保存してから再度読み込まないとなりません。

また、フィルターを掛けた画面を表示している場合。
この画面で削除などの変更を掛けた場合 QTableWidget のデータを
直接使えないので普段の削除と違うロジックが必要になります。

address_book.json とデータを突き合わせるなどが必要になります。

このような理由で「一致するデータを再表示」する方法は複雑に
なるので採用を見送りました。

4.全コード

import sys
import json
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton,
    QTableWidget, QTableWidgetItem, QHeaderView, QWidget, QLabel, QDialog, QFormLayout
)

DATA_FILE = "address_book.json"

class EditDialog(QDialog):
    def __init__(self, parent=None, data=None):
        super().__init__(parent)
        self.setWindowTitle("編集画面")
        self.setGeometry(200, 200, 400, 300)

        layout = QFormLayout()

        self.last_name_input = QLineEdit(data["姓"] if data else "")
        self.last_name_kana_input = QLineEdit(data["姓フリガナ"] if data else "")
        self.first_name_input = QLineEdit(data["名"] if data else "")
        self.first_name_kana_input = QLineEdit(data["名フリガナ"] if data else "")
        self.postal_code_input = QLineEdit(data["郵便番号"] if data else "")
        self.address_input = QLineEdit(data["住所"] if data else "")
        self.phone_number_input = QLineEdit(data["電話番号"] if data else "")
        self.notes_input = QLineEdit(data["備考"] if data else "")

        layout.addRow("姓", self.last_name_input)
        layout.addRow("姓フリガナ", self.last_name_kana_input)
        layout.addRow("名", self.first_name_input)
        layout.addRow("名フリガナ", self.first_name_kana_input)
        layout.addRow("郵便番号", self.postal_code_input)
        layout.addRow("住所", self.address_input)
        layout.addRow("電話番号", self.phone_number_input)
        layout.addRow("備考", self.notes_input)

        self.save_button = QPushButton("保存")
        self.cancel_button = QPushButton("キャンセル")

        button_layout = QHBoxLayout()
        button_layout.addWidget(self.save_button)
        button_layout.addWidget(self.cancel_button)
        layout.addRow(button_layout)

        self.setLayout(layout)

        # ボタンのイベント
        self.cancel_button.clicked.connect(self.reject)
        self.save_button.clicked.connect(self.save_data)

    def get_data(self):
        """入力されたデータを辞書形式で返す"""
        return {
            "姓": self.last_name_input.text(),
            "姓フリガナ": self.last_name_kana_input.text(),
            "名": self.first_name_input.text(),
            "名フリガナ": self.first_name_kana_input.text(),
            "郵便番号": self.postal_code_input.text(),
            "住所": self.address_input.text(),
            "電話番号": self.phone_number_input.text(),
            "備考": self.notes_input.text(),
        }

    def save_data(self):
        """データを取得してダイアログを閉じる"""
        self.accept()  # ダイアログを閉じる


class AddressBookApp(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("住所録")
        self.setGeometry(100, 100, 800, 600)

        # GUIを構築
        self.setup_ui()

        # データを読み込む
        self.load_data_from_json(DATA_FILE)

    def setup_ui(self):
        """GUIを構築するメソッド"""
        # メインウィジェットとレイアウト
        main_widget = QWidget()
        self.setCentralWidget(main_widget)
        main_layout = QVBoxLayout()
        main_widget.setLayout(main_layout)

        # フィルター入力と実行ボタン
        filter_layout = QHBoxLayout()
        self.filter_input = QLineEdit()
        self.filter_input.setPlaceholderText("フィルターを入力")
        self.filter_button = QPushButton("実行")
        filter_layout.addWidget(self.filter_input)
        filter_layout.addWidget(self.filter_button)
        main_layout.addLayout(filter_layout)

        # 一覧表
        self.table = QTableWidget()
        self.table.setColumnCount(8)
        self.table.setHorizontalHeaderLabels([
            "姓", "姓フリガナ", "名", "名フリガナ", "郵便番号", "住所", "電話番号", "備考"
        ])
        self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
        main_layout.addWidget(self.table)

        # ボタン群
        button_layout = QHBoxLayout()
        self.new_button = QPushButton("新規作成")
        self.edit_button = QPushButton("編集")
        self.delete_button = QPushButton("削除")
        self.save_button = QPushButton("保存")
        self.load_button = QPushButton("読み込み")
        
        button_layout.addWidget(self.new_button)
        button_layout.addWidget(self.edit_button)
        button_layout.addWidget(self.delete_button)
        button_layout.addWidget(self.save_button)
        button_layout.addWidget(self.load_button)
        main_layout.addLayout(button_layout)

        # ボタンの動作
        self.new_button.clicked.connect(self.open_new_dialog)
        self.edit_button.clicked.connect(self.open_edit_dialog)
        self.save_button.clicked.connect(self.save_data_to_json)
        self.delete_button.clicked.connect(self.delete_selected_row)
        self.filter_button.clicked.connect(self.apply_filter)

    def load_data_from_json(self, file_path):
        try:
            # JSONデータの読み込み
            with open(file_path, "r", encoding="utf-8") as file:
                address_book = json.load(file)

            # データをテーブルに追加
            self.table.setRowCount(len(address_book))  # 行数を設定
            for row, entry in enumerate(address_book):
                for col, item in enumerate(["姓", "姓フリガナ", "名", "名フリガナ",
                                            "郵便番号","住所", "電話番号", "備考"]):
                    cell = QTableWidgetItem(entry.get(item, ""))
                    self.table.setItem(row, col, cell)
        except FileNotFoundError:
            print("ファイルが見つかりません")
        except json.JSONDecodeError:
            print("JSONデータが壊れています")
    
    def save_data_to_json(self):
        data = []
        for row in range(self.table.rowCount()):
            entry = {
                "姓": self.table.item(row, 0).text() if self.table.item(row, 0) else "",
                "姓フリガナ": self.table.item(row, 1).text() if self.table.item(row, 1) else "",
                "名": self.table.item(row, 2).text() if self.table.item(row, 2) else "",
                "名フリガナ": self.table.item(row, 3).text() if self.table.item(row, 3) else "",
                "郵便番号": self.table.item(row, 4).text() if self.table.item(row, 4) else "",
                "住所": self.table.item(row, 5).text() if self.table.item(row, 5) else "",
                "電話番号": self.table.item(row, 6).text() if self.table.item(row, 6) else "",
                "備考": self.table.item(row, 7).text() if self.table.item(row, 7) else ""
            }
            data.append(entry)
        
        with open(DATA_FILE, "w", encoding="utf-8") as file:
            json.dump(data, file, ensure_ascii=False, indent=4)
        print("データを保存しました")
        
    def open_new_dialog(self):
        """編集画面を開き、データを取得してテーブルに追加"""
        dialog = EditDialog(self)
        if dialog.exec():  # OK(保存)された場合のみ処理
            new_entry = dialog.get_data()
            row_position = self.table.rowCount()
            self.table.insertRow(row_position)

            for col, key in enumerate(["姓", "姓フリガナ", "名", "名フリガナ", 
                                    "郵便番号", "住所", "電話番号", "備考"]):
                self.table.setItem(row_position, col, QTableWidgetItem(new_entry[key]))
            
    def open_edit_dialog(self):
        """選択した行を編集する"""
        selected_items = self.table.selectedItems()
        if not selected_items:
            print("編集する行を選択してください")
            return

        # 選択された行のうち、一番小さい行番号を取得
        selected_rows = sorted(set(item.row() for item in selected_items))
        row = selected_rows[0]  # 最小の行番号を選択

        # 既存データを取得
        current_data = {
            "姓": self.table.item(row, 0).text() if self.table.item(row, 0) else "",
            "姓フリガナ": self.table.item(row, 1).text() if self.table.item(row, 1) else "",
            "名": self.table.item(row, 2).text() if self.table.item(row, 2) else "",
            "名フリガナ": self.table.item(row, 3).text() if self.table.item(row, 3) else "",
            "郵便番号": self.table.item(row, 4).text() if self.table.item(row, 4) else "",
            "住所": self.table.item(row, 5).text() if self.table.item(row, 5) else "",
            "電話番号": self.table.item(row, 6).text() if self.table.item(row, 6) else "",
            "備考": self.table.item(row, 7).text() if self.table.item(row, 7) else ""
        }

        # 編集画面を開き、データを渡す
        dialog = EditDialog(self, data=current_data)
        if dialog.exec():  # OK(保存)された場合のみ処理
            updated_data = dialog.get_data()

            # データをテーブルに反映
            for col, key in enumerate(["姓", "姓フリガナ", "名", "名フリガナ", 
                                    "郵便番号", "住所", "電話番号", "備考"]):
                self.table.setItem(row, col, QTableWidgetItem(updated_data[key]))

                
    def delete_selected_row(self):
        """選択された行を削除する"""
        selected_items = self.table.selectedItems()
        if not selected_items:
            print("削除する行を選択してください")
            return

        # 選択された行のインデックスを取得(重複を防ぐためsetでユニークにする)
        selected_rows = sorted(set(item.row() for item in selected_items), reverse=True)

        # 後ろから削除することで、インデックスのずれを防ぐ
        for row in selected_rows:
            self.table.removeRow(row)

        print("選択した行を削除しました")
        
    def apply_filter(self):
        """フィルターを適用して一覧を絞り込む"""
        filter_text = self.filter_input.text().strip()
        
        for row in range(self.table.rowCount()):
            row_matches = False
            for col in range(self.table.columnCount()):
                item = self.table.item(row, col)
                if item and filter_text.lower() in item.text().lower():
                    row_matches = True
                    break
            
            # 一致しない行を非表示
            self.table.setRowHidden(row, not row_matches)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = AddressBookApp()
    window.show()
    sys.exit(app.exec()

5.まとめ

今回は、住所録にフィルター機能を追加しました。

ChatGPTにフィルターをどのように実現したらよいかを尋ねたところ
2つの方法を提示されました。

1.一致しない行を非表示 : 少量のデータ、簡単
2.一致するデータを再表示 : 大量のデータ、高速

2の方法は、複雑になるので
QTableWidget で 一致しない行を非表示 にすることで フィルターを実現することにしました。

以上、ここまでで大まかな機能は全て実現できました。

次回は、エラー処理とダイアログの表示などの処理を加えて完成させます。

お楽しみに!



いいなと思ったら応援しよう!