中級者向け No.21

連絡先帳(検索機能付き)

名前・電話・メール・住所を管理できる連絡先帳。インクリメンタルサーチとvCard形式でのエクスポートを学びます。

🎯 難易度: ★★☆ 📦 ライブラリ: tkinter(標準ライブラリ) ⏱️ 制作時間: 30〜90分

1. アプリ概要

名前・電話・メール・住所を管理できる連絡先帳。インクリメンタルサーチとvCard形式でのエクスポートを学びます。

このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ) で、難易度は ★★☆ です。

このアプリは「データベース連携」カテゴリです。データベース連携 GUI は永続化と画面更新を結びつける典型例で、トランザクション・スキーマ設計・UI 反映の三点を一度に学べます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。

コードを読む前に実行することをおすすめします。動いている挙動を先に把握しておくと、解説で出てくる関数や処理がどこに対応するかが頭に入りやすくなります。

応用のヒントは、機能を 1 つ増やす・見た目を整える・例外を一つでも丁寧に扱う、のいずれかから始めるのがおすすめです。

連絡先帳(検索機能付き) 実行画面(Windows)
実行画面(Windows)
連絡先帳(検索機能付き) 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • ループで生成するボタン群(それぞれ _add_contact()_update_contact()_delete_contact() を実行)
  • 「📤 vCardエクスポート」ボタン(_export_vcard())で操作
  • イベント <<ComboboxSelected>> から _load_contacts() を実行
  • イベント <<TreeviewSelect>> から _on_select() を実行
  • ttk.Treeview による表形式の一覧表示
  • sqlite3.connect() による SQLite データベース接続と SQL 実行
  • filedialog.asksaveasfilename() によるファイル選択ダイアログ
  • messagebox.askyesno()messagebox.showinfo()messagebox.showwarning() によるダイアログ通知
  • ウィンドウはタイトル「連絡先帳」・サイズ 881x578 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は標準ライブラリの範囲で動作する想定ですが、未検証です。

  • Python 3.10 以上
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

追加インストール不要(標準ライブラリのみ使用)
app21.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import sqlite3
import os
from datetime import datetime


class App21:
    """連絡先帳(検索機能付き)"""

    DB_PATH = os.path.join(os.path.dirname(__file__), "contacts.db")

    def __init__(self, root):
        self.root = root
        self.root.title("連絡先帳")
        self.root.geometry("881x578")
        self.root.minsize(881, 578)
        self.root.configure(bg="#f8f9fc")
        self._init_db()
        self._build_ui()
        self._load_contacts()

    def _init_db(self):
        self.conn = sqlite3.connect(self.DB_PATH)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS contacts (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL,
                phone TEXT,
                email TEXT,
                address TEXT,
                birthday TEXT,
                company TEXT,
                notes TEXT,
                group_name TEXT,
                created_at TEXT
            )
        """)
        self.conn.commit()
        if not self.conn.execute("SELECT 1 FROM contacts").fetchone():
            samples = [
                ("山田太郎", "090-1234-5678", "taro@example.com",
                 "東京都新宿区", "1990-05-15", "ABC株式会社", "", "仕事"),
                ("田中花子", "080-9876-5432", "hanako@example.com",
                 "大阪府大阪市", "1988-12-03", "", "", "友人"),
                ("佐藤次郎", "070-1111-2222", "jiro@example.com",
                 "神奈川県横浜市", "", "XYZ商事", "", "仕事"),
            ]
            for s in samples:
                self.conn.execute(
                    "INSERT INTO contacts (name,phone,email,address,birthday,"
                    "company,notes,group_name,created_at) VALUES (?,?,?,?,?,?,?,?,?)",
                    (*s, datetime.now().isoformat()))
            self.conn.commit()

    def _build_ui(self):
        title_frame = tk.Frame(self.root, bg="#1976d2", pady=10)
        title_frame.pack(fill=tk.X)
        tk.Label(title_frame, text="📇 連絡先帳",
                 font=("Noto Sans JP", 15, "bold"),
                 bg="#1976d2", fg="white").pack(side=tk.LEFT, padx=12)
        ttk.Button(title_frame, text="📤 vCardエクスポート",
                   command=self._export_vcard).pack(side=tk.RIGHT, padx=8)

        # 検索バー
        search_frame = tk.Frame(self.root, bg="#e8eef5", pady=6)
        search_frame.pack(fill=tk.X)
        tk.Label(search_frame, text="🔍 インクリメンタル検索:",
                 bg="#e8eef5").pack(side=tk.LEFT, padx=8)
        self.search_var = tk.StringVar()
        search_entry = ttk.Entry(search_frame, textvariable=self.search_var,
                                  width=28, font=("Arial", 11))
        search_entry.pack(side=tk.LEFT, padx=4)
        self.search_var.trace_add("write", lambda *a: self._load_contacts())
        search_entry.focus_set()
        tk.Label(search_frame, text="グループ:",
                 bg="#e8eef5").pack(side=tk.LEFT, padx=(12, 4))
        self.group_var = tk.StringVar(value="すべて")
        self.group_cb = ttk.Combobox(search_frame, textvariable=self.group_var,
                                      state="readonly", width=12)
        self.group_cb.pack(side=tk.LEFT)
        self.group_cb.bind("<<ComboboxSelected>>", lambda e: self._load_contacts())

        # メインエリア
        main = tk.Frame(self.root, bg="#f8f9fc")
        main.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左: 連絡先リスト
        list_frame = ttk.LabelFrame(main, text="連絡先一覧", padding=4)
        list_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        cols = ("name", "phone", "email", "company", "group_name")
        self.tree = ttk.Treeview(list_frame, columns=cols,
                                  show="headings", selectmode="browse")
        for c, h, w in [("name", "名前", 120), ("phone", "電話", 110),
                         ("email", "メール", 160), ("company", "会社", 110),
                         ("group_name", "グループ", 70)]:
            self.tree.heading(c, text=h,
                              command=lambda c=c: self._sort(c))
            self.tree.column(c, width=w, minwidth=40)
        sb = ttk.Scrollbar(list_frame, command=self.tree.yview)
        self.tree.configure(yscrollcommand=sb.set)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.tree.pack(fill=tk.BOTH, expand=True)
        self.tree.bind("<<TreeviewSelect>>", self._on_select)

        # 右: 詳細・編集
        right = ttk.LabelFrame(main, text="詳細・編集", padding=10)
        right.pack(side=tk.RIGHT, fill=tk.Y, padx=(8, 0))
        right.configure(width=260)

        fields = [("名前", "name"), ("電話", "phone"), ("メール", "email"),
                  ("住所", "address"), ("誕生日", "birthday"),
                  ("会社", "company"), ("グループ", "group_edit")]
        for lbl, attr in fields:
            tk.Label(right, text=f"{lbl}:").pack(anchor="w")
            var = tk.StringVar()
            ttk.Entry(right, textvariable=var, width=26).pack(
                anchor="w", pady=1)
            setattr(self, f"{attr}_var", var)

        tk.Label(right, text="メモ:").pack(anchor="w")
        self.notes_text = tk.Text(right, height=3, width=26, font=("Arial", 10))
        self.notes_text.pack(anchor="w", pady=1)

        btn_f = tk.Frame(right, bg=ttk.Style().lookup("TLabelframe", "background"))
        btn_f.pack(fill=tk.X, pady=6)
        for text, cmd in [("➕ 追加", self._add_contact),
                           ("✏️ 更新", self._update_contact),
                           ("🗑️ 削除", self._delete_contact)]:
            ttk.Button(btn_f, text=text, command=cmd).pack(side=tk.LEFT, padx=2)

        self.status_var = tk.StringVar(value="")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#dde", font=("Arial", 9), anchor="w", padx=8
                 ).pack(fill=tk.X, side=tk.BOTTOM)
        self._selected_id = None

    def _load_contacts(self):
        q = self.search_var.get().lower()
        group = self.group_var.get()
        sql = ("SELECT id,name,phone,email,company,group_name "
               "FROM contacts WHERE 1=1")
        params = []
        if q:
            sql += (" AND (LOWER(name) LIKE ? OR LOWER(phone) LIKE ? "
                    "OR LOWER(email) LIKE ? OR LOWER(company) LIKE ?)")
            params += [f"%{q}%"] * 4
        if group != "すべて":
            sql += " AND group_name=?"
            params.append(group)
        sql += " ORDER BY name COLLATE NOCASE"
        rows = self.conn.execute(sql, params).fetchall()
        self.tree.delete(*self.tree.get_children())
        for row in rows:
            cid, *vals = row
            self.tree.insert("", "end", iid=str(cid), values=vals)
        self.status_var.set(f"{len(rows)} 件")
        # グループ一覧更新
        groups = self.conn.execute(
            "SELECT DISTINCT group_name FROM contacts "
            "WHERE group_name != '' ORDER BY group_name").fetchall()
        self.group_cb.configure(
            values=["すべて"] + [g[0] for g in groups])

    def _on_select(self, event):
        sel = self.tree.selection()
        if not sel:
            return
        cid = int(sel[0])
        self._selected_id = cid
        row = self.conn.execute(
            "SELECT name,phone,email,address,birthday,company,group_name,notes "
            "FROM contacts WHERE id=?", (cid,)).fetchone()
        if row:
            (name, phone, email, addr, bday,
             company, group, notes) = row
            self.name_var.set(name)
            self.phone_var.set(phone or "")
            self.email_var.set(email or "")
            self.address_var.set(addr or "")
            self.birthday_var.set(bday or "")
            self.company_var.set(company or "")
            self.group_edit_var.set(group or "")
            self.notes_text.delete("1.0", tk.END)
            self.notes_text.insert("1.0", notes or "")

    def _add_contact(self):
        name = self.name_var.get().strip()
        if not name:
            messagebox.showwarning("警告", "名前を入力してください")
            return
        self.conn.execute(
            "INSERT INTO contacts (name,phone,email,address,birthday,"
            "company,group_name,notes,created_at) VALUES (?,?,?,?,?,?,?,?,?)",
            (name, self.phone_var.get(), self.email_var.get(),
             self.address_var.get(), self.birthday_var.get(),
             self.company_var.get(), self.group_edit_var.get(),
             self.notes_text.get("1.0", tk.END).strip(),
             datetime.now().isoformat()))
        self.conn.commit()
        self._load_contacts()

    def _update_contact(self):
        if not self._selected_id:
            return
        self.conn.execute(
            "UPDATE contacts SET name=?,phone=?,email=?,address=?,birthday=?,"
            "company=?,group_name=?,notes=? WHERE id=?",
            (self.name_var.get(), self.phone_var.get(), self.email_var.get(),
             self.address_var.get(), self.birthday_var.get(),
             self.company_var.get(), self.group_edit_var.get(),
             self.notes_text.get("1.0", tk.END).strip(),
             self._selected_id))
        self.conn.commit()
        self._load_contacts()

    def _delete_contact(self):
        if not self._selected_id:
            return
        if messagebox.askyesno("確認", "この連絡先を削除しますか?"):
            self.conn.execute("DELETE FROM contacts WHERE id=?",
                              (self._selected_id,))
            self.conn.commit()
            self._selected_id = None
            self._load_contacts()

    def _export_vcard(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".vcf",
            filetypes=[("vCardファイル", "*.vcf"), ("すべて", "*.*")])
        if not path:
            return
        rows = self.conn.execute(
            "SELECT name,phone,email,address,company FROM contacts"
        ).fetchall()
        with open(path, "w", encoding="utf-8") as f:
            for name, phone, email, addr, company in rows:
                f.write("BEGIN:VCARD\nVERSION:3.0\n")
                f.write(f"FN:{name}\n")
                if phone:
                    f.write(f"TEL:{phone}\n")
                if email:
                    f.write(f"EMAIL:{email}\n")
                if addr:
                    f.write(f"ADR:;;{addr};;;;\n")
                if company:
                    f.write(f"ORG:{company}\n")
                f.write("END:VCARD\n\n")
        messagebox.showinfo("完了", f"vCardエクスポート: {len(rows)} 件\n{path}")

    def _sort(self, col):
        data = [(self.tree.set(k, col), k) for k in self.tree.get_children()]
        data.sort()
        for idx, (_, k) in enumerate(data):
            self.tree.move(k, "", idx)


if __name__ == "__main__":
    root = tk.Tk()
    app = App21(root)
    root.mainloop()

5. コード解説

連絡先帳(検索機能付き)のコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App21 クラスにアプリの全機能をまとめています(メソッド10個・全261行)。__init__ ではタイトル「連絡先帳」とウィンドウサイズ 881x578 を設定します。そのあと _init_db()_build_ui()_load_contacts() の順に呼び出して初期状態を作ります。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×4・tk.Label×5(ほかにループでも生成)・ttk.Button(ループで生成)・ttk.Entry(ループで生成)・ttk.Comboboxttk.LabelFrame×2・ttk.Treeviewttk.Scrollbartk.Textttk.Style で構成しています。見出し付きの枠(LabelFrame)は「連絡先一覧」「詳細・編集」のラベルでエリアを区切っています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「📤 vCardエクスポート」ボタン(_export_vcard())を command= で接続しています。ボタンはラベルと処理をペアで並べたデータをループで回して生成し、それぞれ _add_contact()_update_contact()_delete_contact()command= に渡しています。ボタンを増やしたいときはこのペアを1行足すだけで済みます。また bind("<<ComboboxSelected>>", ...) から _load_contacts()bind("<<TreeviewSelect>>", ...) から _on_select() を呼べるようにイベントも登録しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

SQLite データベースの接続と実行

sqlite3.connect() でデータベースファイルに接続し、SQL の実行は execute()、確定は commit() で行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

クラス定数によるデータ定義

クラスの先頭でデータを定数として定義しています:DB_PATH = os.path.join(os.path.dirname(__file__), 'contacts.db')。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app21.py と保存します。使うのは datetimeossqlite3tkinter だけなので、追加インストールは不要です。

  2. 2
    クラスの骨格を作る

    App21 クラスを定義し、__init__ と、末尾の root = tk.Tk()mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("連絡先帳")geometry("881x578")configure(bg="#f8f9fc")minsize(881, 578) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×4・tk.Label×5(ほかにループでも生成)・ttk.Button(ループで生成)・ttk.Entry(ループで生成)・ttk.Comboboxttk.LabelFrame×2・ttk.Treeviewttk.Scrollbartk.Textttk.Style を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_export_vcard())につなぎます。ループ生成のボタンは、ラベルと処理(_add_contact()_update_contact()_delete_contact())をペアで並べたデータをループで回し、command= に渡して接続します。bind("<<ComboboxSelected>>", ...)bind("<<TreeviewSelect>>", ...) の登録も忘れずに。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _load_contacts()(26行)・_export_vcard()(23行)・_on_select()(21行)など を実装します。SQL の実行結果は execute() の戻り値から取り出します。

  7. 7
    動作確認する

    python app21.py で起動し、各ボタンが反応すること・データベースファイルを開いて内容が表示されることを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 クラス定数を書き換えて動きを変える

データはクラス定数(DB_PATH)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。

💡 配色をまとめて管理する

背景色 #f8f9fc をはじめ、色コードが各所に直書きされています。色を1つの辞書にまとめると、テーマの切り替えが1か所の変更で済むようになります。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ 「database is locked」というエラーが出る

原因:同じデータベースファイルを別のツール(DB Browser など)で開いたまま書き込むと、SQLite がファイルをロックしてこのエラーになります。

解決法:他のツールでの接続を閉じてから実行してください。書き込みは commit() で確定するまで反映されない点にも注意しましょう。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("881x578") の固定サイズで起動するためです。

解決法:geometry()minsize() の両方の数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。ただし minsize(881, 578) を指定しているため、起動時の大きさより小さくは縮められません(縮めると画面部品が表示されなくなるのを防ぐためです)。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。練習に実在の連絡先は入れないでください。最初から入っているサンプル3件(taro@example.com などの例示用アドレス)だけで、どちらの課題も確かめられます。データはコードと同じフォルダの contacts.db に入り、書き出した .vcf には名前と電話とメールがそのまま並びます。追加のライブラリは要りません。

  1. 課題1:画面で絞り込んだ連絡先だけを書き出す

    「📤 vCardエクスポート」は一覧の絞り込みを見ずに、いつでも全件を書き出します。検索して1件だけ表示していても、ファイルには全員が入ります。表示中の連絡先だけを書き出してください。

    期待結果:検索欄に 田中 と入れると一覧は 1 件 になる。この状態で「📤 vCardエクスポート」を押して保存すると、完了ダイアログは vCardエクスポート: 3 件 と出て、ファイルには BEGIN:VCARD が3つ入る。直したあとは 1 件 になり、ファイルに残るのは FN:田中花子 の1件だけ。

    合格条件

    • 検索欄が空でグループが「すべて」のときは、これまでどおり全件を書き出す
    • 書き出す形式(BEGIN:VCARD から END:VCARD まで)は変えない
    • 完了ダイアログの件数と、ファイルの中の件数が一致する

    ヒント①(どこを触るか): 触るのは _export_vcard() の1か所です。rows = self.conn.execute(...) で全件を読んでいるところです。
    ヒント②(使うもの): 一覧に出ている行の id は self.tree.get_children() で取れます(_load_contacts()iid=str(cid) で入れています)。WHERE id IN (?,?,?) の形にするには、",".join("?" * len(ids)) でプレースホルダの数を合わせます。
    つまずきやすい点: id を文字列で直接つなぐと、_load_contacts() が守っている書き方(値は ? で渡す)を崩すことになります。0件のときは IN () になりますが、SQLite はこれをエラーにせず0件を返すため、空のファイルが黙ってできます。書き出す順番は登録順のままです(IN に一覧の並びで id を渡しても、SQLite は id の昇順で返します)。一覧と同じ並びにしたいなら ORDER BY name COLLATE NOCASE を付け足します。

  2. 課題2:追加した直後の「✏️ 更新」で別の人が書き換わる

    一覧から誰かを選び、入力欄を書き換えて「➕ 追加」を押すと、新しい連絡先が増えます。ところが選んでいた相手の id は残ったままです。そのまま「✏️ 更新」を押すと、前に選んでいた人の行が新しい入力で上書きされます。

    期待結果:一覧で「山田太郎」を選び、名前を テスト太郎 に書き換えて「➕ 追加」を押す。一覧は 4 件 になり、山田太郎とテスト太郎が並ぶ。続けて「✏️ 更新」を押すと、山田太郎の行が テスト太郎 に変わり、一覧にはテスト太郎が2行残る。直したあとは、追加した直後の「✏️ 更新」では何も書き換わらない。

    合格条件

    • 一覧から行を選び直せば、これまでどおり「✏️ 更新」で編集できる
    • 「➕ 追加」で連絡先が増える動きは変わらない
    • 「🗑️ 削除」も、選び直すまでは何も消さない

    ヒント①(どこを触るか): 触るのは _add_contact() の末尾1か所です。self.conn.commit()self._load_contacts() の間に足します。
    ヒント②(使うもの): self._selected_id = None に戻します。入力欄も空にするなら self.name_var.set("") のように各 StringVar を空にし、メモ欄は self.notes_text.delete("1.0", tk.END) で空にできます。
    つまずきやすい点: 入力欄の変数は _build_ui()setattr(self, f"{attr}_var", var) でまとめて作られ、グループ欄だけ group_edit_var という名前です。検索バーの group_var は絞り込み用なので、こちらを空にすると一覧が0件になります。_update_contact()_delete_contact() は先頭で if not self._selected_id: を見ているため、None に戻せば警告を出さずに空振りします。

  3. 課題3:新しいボタンを追加する

    既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と command= の接続がセットで身に付きます。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリにも挑戦してみましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。