5.アカウント追加フォームが未完成

CustomTkinter

・データを追加するときにカテゴリーをいちいち選ぶのがめんどうなので、各タブに追加フレームを1つずつ配置したい

・サービス名やユーザー名などで即検索できる機能をつけたい

import subprocess
import sys
import os
import json
import sqlite3
import re
import customtkinter as ct
from datetime import datetime
from tkinter import messagebox 

CONFIG_FILE = "usb_auth.json"
DB_NAME = "passwords.db"


# ==========
# 挿入されているすべてのUSB情報を取得
# ==========
def get_usb_list():
    usb_dict = {}
    try:
        # PCに認識されている倫理ディスク(logicaldisk)を取得する
        # logicaldiskにはドライブレター(C:,D:,E:等)とその説明(LocalFixedDisk,RemovalDisk等)が書かれている
        # ここで「RemovalDisk」となっているものがUSBメモリやSDカードを意味する
        drives = subprocess.check_output(
            "wmic logicaldisk get name,description",
            shell=True
        ).decode("utf-8", errors="ignore").splitlines()
        # driveからRemovableを含む行(line)を探し、line.split()[0](最初のトークン、通常はE:のようなもの)を抜き出す
        removable_drives = []
        for line in drives:
            line = line.strip()
            if "Removable" in line:
                m = re.search(r"([A-Z]:)", line)
                if m:
                    removable_drives.append(m.group(1))
        # 初期値として「不明なUSB」をセット
        for drive in removable_drives:
            usb_dict[drive] = ("不明なUSB", "UNKNOWN")
        

        # 実際にPCに接続されているハードウェア情報(diskdrive)を取得する
        # 物理ディスク(接続ディスク)の製品名(Caption)とシリアル番号を取得
        result = subprocess.check_output(
            "wmic diskdrive get Caption,DeviceID,SerialNumber",
            shell=True
        ).decode("utf-8", errors="ignore").splitlines()   
        # resultからUSBを含む行(line)を探し、line.split()して分解
        for line in result:
            if "USB" in line:
                parts = line.split()
                if len(parts) >= 3:
                    caption = " ".join(parts[:-2]) #製品名
                    device_id = parts[-2] #DeviceID
                    serial = parts[-1] #シリアル番号

                    for d in usb_dict:
                        if usb_dict[d][1] == "UNKNOWN":
                            usb_dict[d] = (caption, serial)
                            break
    
    except Exception as e:
        print("USB取得エラー:", e)
    
    return usb_dict


# ==========
# 指定したserial(USBのシリアル番号)とドライブレターをJSONファイル(CONFIGファイル)に上書き保存する
# ==========
def save_usb(serial, drive):
    with open(CONFIG_FILE, "w") as f:
        json.dump({"serial": serial, "drive": drive}, f)



# ==========
# CONFIGファイルをロードして登録したUSBのシリアル番号を取得
# ==========
def load_usb():
    if os.path.exists(CONFIG_FILE):
        with open(CONFIG_FILE, "r") as f:
            return json.load(f) # dictを返す(configファイルがなければFalseを返す)



# ==========
# 挿したUSBのシリアル番号が登録したものと合致するか確認する
# ==========
def check_usb():
    # configは{"serial": "xxx", "drive": "E:"}のようなdictになる
    config = load_usb()
    # CONFIGファイルが空ならFalse
    if not config:
        return False
    # dictからシリアル番号を取り出す
    saved_serial = config.get("serial")
    usb_list = get_usb_list()
    return saved_serial in [serial for _, serial in usb_list.values()]



# ==========
# USBを登録していない場合は、選択画面を表示する
# ==========
def select_usb():
    usb_list = get_usb_list()
    if not usb_list:
        messagebox.showerror("エラー", "USBが見つかりません")
        sys.exit()
    win = ct.CTk()
    win.title("USB選択")
    ct.CTkLabel(win, text="起動に使うUSBを選択してください").pack(pady=10)

    #ボタンを押すとメッセージボックスを表示
    def on_select(serial, drive):
        save_usb(serial, drive)
        messagebox.showinfo("登録完了", f"USB({serial})が登録されました。もう一度アプリを起動させてください。")
        win.destroy()
    
    #Removable - BUFFALO USB - 0F7315812030 のようなボタンが表示される
    for drive, (caption, serial) in usb_list.items():
        text = f"{drive} - {caption} - {serial}"
        ct.CTkButton(win, text=text, command=lambda s=serial, d=drive: on_select(s, d)).pack(pady=5)
    
    win.mainloop()



# ==========
# DB初期化
# ==========
def init_db(db_path):
    conn = sqlite3.connect(db_path)
    c = conn.cursor()
    # アカウントデータのテーブル
    c.execute("""CREATE TABLE IF NOT EXISTS accounts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        service TEXT,
        username TEXT,
        password TEXT,
        created_at TEXT,
        sort_order INTEGER,
        category TEXT
    )""")

    # カテゴリ専用テーブル
    c.execute("""CREATE TABLE IF NOT EXISTS categories (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT UNIQUE
    )""")
    conn.commit()
    return conn


# ==========
# タブ切り替え
# ==========
def show_tab(tab_name):
    global tab_frames, current_tab
    for name, frame in tab_frames.items():
        frame.pack_forget()
    tab_frames[tab_name].pack(fill="y", expand=True, pady=5)
    current_tab = tab_name


# ==========
# カテゴリーをDBから読み込む
# ==========
def load_categories(conn):
    c = conn.cursor()
    c.execute("SELECT name FROM categories ORDER BY id ASC")
    rows = c.fetchall()
    if rows:
        return [r[0] for r in rows]
    else:
        c.execute("INSERT INTO categories (name) VALUES (?)", ("タブ1",))
        conn.commit()
        return ["タブ1"]




# ==========
# GUI
# ==========
def run_app(conn):
    global service_entry, user_entry, pass_entry, category_menu
    global tab_frames, current_tab, list_frames, root, categories

    root = ct.CTk()
    root.title("パスワード管理アプリwithUSB")
    root.geometry("1000x600")

    # まず、GUIを左右2分割して左側フレームと右側フレームをつくる
    main_frame = ct.CTkFrame(root)
    main_frame.pack(fill="both", expand=True, padx=10, pady=0)
    left_frame = ct.CTkFrame(main_frame, width=200)
    left_frame.pack(side="left", fill="y", expand=True, padx=(0,10), pady=0)
    right_frame = ct.CTkFrame(main_frame)
    right_frame.pack(side="right", fill="both", expand=True)

    # タブボタンフレームを画面左側に配置
    tab_buttons_frame = ct.CTkFrame(left_frame)
    tab_buttons_frame.pack(fill="y", expand=True, pady=5)


    # 入力用フレームをright_frameの一番下に配置
    # pack()はDIVタグのようなものであり、この中に横並びさせたい要素をいれる
    input_frame = ct.CTkFrame(right_frame)
    input_frame.pack(side="bottom", pady=10, fill="x")
    
    # 入力欄とボタンを横並びに配置
    service_entry = ct.CTkEntry(input_frame, placeholder_text="サービス名", width=150)
    service_entry.grid(row=0, column=0, padx=5, pady=5)

    user_entry = ct.CTkEntry(input_frame, placeholder_text="ユーザー名", width=150)
    user_entry.grid(row=0, column=1, padx=5, pady=5)

    pass_entry = ct.CTkEntry(input_frame, placeholder_text="パスワード", width=150)
    pass_entry.grid(row=0, column=2, padx=5, pady=5)

    # カテゴリ選択
    categories = load_categories(conn)
    category_menu = ct.CTkOptionMenu(input_frame, values=categories)
    category_menu.set(categories[0])
    category_menu.grid(row=0, column=3, padx=5, pady=5)

    add_btn = ct.CTkButton(input_frame, text="追加", command=lambda: add_account(conn))
    add_btn.grid(row=0, column=4, padx=5, pady=5)



    # タブボタン部分(Tabviewだとタブ名の変更ができない)
    tab_frames = {}
    current_tab = None
    content_frame = ct.CTkFrame(right_frame)
    content_frame.pack(fill="both", expand=True)

    # タブの作成
    def create_tab(tab_name):
        frame = ct.CTkFrame(content_frame)
        tab_frames[tab_name] = frame

        # tab_top_frameにタブタイトルとタブ名変更ボタンを記載
        tab_top_frame = ct.CTkFrame(frame)
        tab_top_frame.pack(fill="x", pady=5)
        title_label = ct.CTkLabel(tab_top_frame, text=tab_name, font=ct.CTkFont(size=16, weight="bold"))
        title_label.pack(side="left", padx=5)

        # タブ名変更関数
        def rename_tab_local(old_name=tab_name, label=title_label):
            new_name = ct.CTkInputDialog(text="新しいタブ名を入力").get_input()
            if new_name:
                # categoriesリストの更新
                idx = categories.index(old_name)
                categories[idx] = new_name
                # 辞書も更新
                tab_frames[new_name] = tab_frames.pop(old_name)
                # ラベル更新
                label.configure(text=new_name)
                # DBのcategoriesテーブルを更新
                c= conn.cursor()
                c.execute("UPDATE categories SET name=? WHERE name=?", (new_name, old_name))
                # DBのaccountsテーブルのcategoryカラムも更新
                c.execute("UPDATE accounts SET category=? WHERE category=?", (new_name, old_name))
                conn.commit()
                # カテゴリーメニューを更新
                category_menu.configure(values=categories)
                # タブボタンを一旦削除して再生成
                for widget in tab_buttons_frame.winfo_children():
                    widget.destroy()
                for cat in categories:
                    btn = ct.CTkButton(tab_buttons_frame, text=cat, command=lambda t=cat: show_tab(t))
                    btn.pack(fill="x", pady=5)
                # タブ追加ボタン再描画
                add_tab_btn = ct.CTkButton(tab_buttons_frame, text="+ タブを追加", command=add_tab)
                add_tab_btn.pack(fill="x", pady=10)
        

        # タブ削除関数
        def delete_tab_local(tab_to_delete=tab_name):
            if messagebox.askyesno("確認", f"タブ「{tab_to_delete}」を削除しますか?\n※このタブにあるデータも削除されます"):
                # DBのaccountsテーブルから該当タブのアカウントを削除
                c = conn.cursor()
                c.execute("DELETE FROM accounts WHERE category=?", (tab_to_delete,))
                # DBのcategoriesテーブルから削除
                c.execute("DELETE FROM categories WHERE name=?", (tab_to_delete,))
                conn.commit()
                # categoriesリストから削除
                categories.remove(tab_to_delete)
                # 削除したタブのフレームを破棄して画面に残らないようにする(辞書から取り出して破壊)
                frame_to_delete = tab_frames.pop(tab_to_delete, None)
                if frame_to_delete:
                    frame_to_delete.destroy()
                # list_framesからも削除して破壊
                if tab_to_delete in list_frames:
                    list_frames[tab_to_delete].destroy()
                    list_frames.pop(tab_to_delete)
                # タブボタンを再描画
                for widget in tab_buttons_frame.winfo_children():
                    widget.destroy()
                for cat in categories:
                    btn = ct.CTkButton(tab_buttons_frame, text=cat, command=lambda t=cat: show_tab(t))
                    btn.pack(fill="x", pady=5)
                # タブ追加ボタン再描画
                add_tab_btn = ct.CTkButton(tab_buttons_frame, text="+ タブを追加", command=add_tab)
                add_tab_btn.pack(fill="x", pady=10)
                
                # 最初のタブを表示
                if categories:
                    show_tab(categories[0])

        
        # タブ名変更ボタン
        rename_btn = ct.CTkButton(tab_top_frame, text="名前変更", width=60, command=rename_tab_local)
        rename_btn.pack(side="left", padx=5)

        # タブ削除ボタン
        delete_btn = ct.CTkButton(tab_top_frame, text="削除", width=60, command=delete_tab_local)
        delete_btn.pack(side="left", padx=5)

        # スクロールフレーム
        list_frames[tab_name] = ct.CTkScrollableFrame(frame, width=750, height=400)
        list_frames[tab_name].pack(fill="both", expand=True)


    # タブごとに一覧表示(Treeviewだとセル単位で編集できない、デザインがダサいので使わない)
    list_frames = {}
    for cat in categories:
        create_tab(cat)

    # タブボタンフレームにボタンを縦方向に配置する
    for cat in categories:
        btn = ct.CTkButton(tab_buttons_frame, text=cat, command=lambda t=cat: show_tab(t))
        btn.pack(fill="x", pady=5)


    # タブ追加機能
    def add_tab():
        global categories
        new_name = ct.CTkInputDialog(text="追加したいタブ名を入力してください").get_input()
        if new_name and new_name not in categories:
            categories.append(new_name)
            # DBに保存(既にある場合はスキップPASS)
            c = conn.cursor()
            try:
                c.execute("INSERT INTO categories (name) VALUES (?)", (new_name,))
                conn.commit()
            except sqlite3.IntegrityError:
                pass
            create_tab(new_name)
            # 画面左のタブボタンを再描画
            for widget in tab_buttons_frame.winfo_children():
                widget.destroy()
            for cat in categories:
                btn = ct.CTkButton(tab_buttons_frame, text=cat, command=lambda t=cat: show_tab(t))
                btn.pack(fill="x", pady=5)
            # タブ追加ボタンを再描画
            add_tab_btn = ct.CTkButton(tab_buttons_frame, text="+ タブを追加", command=add_tab)
            add_tab_btn.pack(fill="x", pady=10)
            # カテゴリメニューを更新
            category_menu.configure(values=categories)
            # 新しく作ったタブを表示
            show_tab(new_name)


    # タブ追加ボタンをtab_buttons_frame内に配置
    add_tag_btn = ct.CTkButton(tab_buttons_frame, text="+ タブを追加", command=add_tab)
    add_tag_btn.pack(fill="x", pady=5)

    # 最初のタブを表示する
    show_tab(categories[0])        


    # USB変更ボタン
    ct.CTkButton(root, text="USBを更新する", command=change_usb).pack(pady=10)

    # 最初の表示
    show_account(conn)

    root.mainloop()


# ==========
# アカウントをデータベースに追加
# ==========
def add_account(conn):
    service = service_entry.get()
    user = user_entry.get()
    password = pass_entry.get()
    category = category_menu.get()
    now = datetime.now().strftime("%Y/%m/%d %H:%M:%S")

    if not service or not user or not password:
        messagebox.showerror("エラー", "すべて入力してください")
        return
    
    # sort_orderの最大値を探して+1する
    c = conn.cursor()
    c.execute("SELECT MAX(sort_order) FROM accounts WHERE category=?", (category,))
    max_order = c.fetchone()[0]
    if max_order is None:
        max_order = 0
    sort_order = max_order + 1

    c.execute("INSERT INTO accounts (service, username, password, created_at, sort_order, category) VALUES (?, ?, ?, ?, ?, ?)",
              (service, user, password, now, sort_order, category))
    conn.commit()
    messagebox.showinfo("保存", f"{category}にデータを保存しました")
    show_account(conn)



# ==========
# アカウントの表示(カテゴリごと)
# ==========
def show_account(conn):

    # 既存のフレームを削除
    for frame in list_frames.values():
        for widget in frame.winfo_children():
            widget.destroy()
    
    c = conn.cursor()
    c.execute("SELECT id, service, username, password, created_at, sort_order, category FROM accounts ORDER BY sort_order ASC")
    rows = c.fetchall()

    # ヘッダー(サービス名、ユーザー名、パスワード等)
    for category, frame in list_frames.items():
        headers = ["サービス名", "ユーザー名", "パスワード", "更新日時", "更新", "削除", "上", "下"]
        for col, text in enumerate(headers):
            label = ct.CTkLabel(frame, text=text, font=ct.CTkFont(weight="bold"))
            label.grid(row=0, column=col, padx=5, pady=2)

    # 
    for row in rows:
        account_id, service, username, password, created_at, sort_order, category = row
        frame = list_frames.get(category)
        if not frame:
            continue
        # row=0はヘッダーなので1から開始する(1行の要素数8に応じて計算)
        idx = 1 + len(frame.winfo_children()) // 8 

        service_entry = ct.CTkEntry(frame, width=150)
        service_entry.insert(0, service)
        service_entry.grid(row=idx, column=0, padx=5, pady=2)

        user_entry = ct.CTkEntry(frame, width=150)
        user_entry.insert(0, username)
        user_entry.grid(row=idx, column=1, padx=5, pady=2)

        pass_entry = ct.CTkEntry(frame, width=50)
        pass_entry.insert(0, password)
        pass_entry.grid(row=idx, column=2, padx=5, pady=2)

        created_entry = ct.CTkEntry(frame, width=160)
        created_entry.insert(0, created_at or "")
        created_entry.grid(row=idx, column=3, padx=5, pady=2)

        # 更新ボタン
        update_btn = ct.CTkButton(
            frame, text = "更新",
            command=lambda i=account_id, s=service_entry, u=user_entry, p=pass_entry: update_account(conn, i, s, u, p),
            width=60
        )
        update_btn.grid(row=idx, column=4, padx=5)

        # 削除ボタン
        delete_btn = ct.CTkButton(
            frame, text = "削除",
            command=lambda i=account_id: delete_account(conn, i),
            width=60
        )
        delete_btn.grid(row=idx, column=5, padx=5)

        # 行入れ替えボタン
        upper_btn = ct.CTkButton(frame, text="↑", command=lambda i=account_id: move_account(conn, i, -1), width=30)
        upper_btn.grid(row=idx, column=6, padx=2)
        lower_btn = ct.CTkButton(frame, text="↓", command=lambda i=account_id: move_account(conn, i, +1), width=30)
        lower_btn.grid(row=idx, column=7, padx=2)



# ==========
# アカウント更新
# ==========
def update_account(conn, account_id, service_entry, user_entry, pass_entry):
    service = service_entry.get()
    user = user_entry.get()
    password = pass_entry.get()
    now = datetime.now().strftime("%Y/%m/%d %H:%M:%S")

    c = conn.cursor()
    c.execute("UPDATE accounts SET service=?, username=?, password=?, created_at=? WHERE id=?",
              (service, user, password, now, account_id))
    conn.commit()
    messagebox.showinfo("更新", "データを更新しました")

    # 表示更新する
    show_account(conn)



# ==========
# アカウント削除
# ==========
def delete_account(conn, account_id):
    if messagebox.askyesno("確認", "本当に削除しますか?"):
        c = conn.cursor()
        c.execute("DELETE FROM accounts WHERE id=?", (account_id,))
        conn.commit()
        show_account(conn)


# ==========
# アカウントの行入れ替え
# ==========
def move_account(conn, account_id, direction):
    # 現在のsort_orderの数値を取得
    c = conn.cursor()
    c.execute("SELECT sort_order FROM accounts WHERE id=?", (account_id,))
    current_order = c.fetchone()[0]

    # 隣接している行を取得
    if direction == -1:
        c.execute("SELECT id, sort_order FROM accounts WHERE sort_order < ? ORDER BY sort_order DESC LIMIT 1", (current_order,))
    else:
        c.execute("SELECT id, sort_order FROM accounts WHERE sort_order > ? ORDER BY sort_order ASC LIMIT 1", (current_order,))
    neighbor = c.fetchone()
    
    # 隣接行と入れ替える
    if neighbor:
        neighbor_id, neighbor_order = neighbor
        c.execute("UPDATE accounts SET sort_order=? WHERE id=?", (neighbor_order, account_id))
        c.execute("UPDATE accounts SET sort_order=? WHERE id=?", (current_order, neighbor_id))
        conn.commit()
        show_account(conn)


# ==========
# タブ名変更
# ==========




# ==========
# USBの変更
# ==========
def change_usb():
    select_usb()
    messagebox.showinfo("USB変更", "USBが変更されました。次回起動から有効です。")




# ==========
# アプリを起動したときの処理
# ==========
# configファイルからdictを取得
config = load_usb()

if not config:
    # CONFIGファイルがない初回起動ではUSB選択画面を表示する
    select_usb()
    # 一度システム終了する
    sys.exit()

# 登録USBを挿入していなければエラーを表示する
if not check_usb():
    messagebox.showerror("エラー", "認証USBが見つかりません。アプリを終了します。")
    sys.exit()

# CONFIGファイルがある場合は、dictからドライブレターを取得
drive = config.get("drive")

# 念の為、PATHが存在するか確認
#usb_path = drive + os.sep
#if not os.path.exists(usb_path):
#    messagebox.showerror("エラー", f"USBドライブ{drive}が見つかりません")
#    sys.exit()

# DBファイルの絶対パスを作成(os.sepは"/"と同じ意味)
db_path = os.path.join(drive + os.sep, DB_NAME)

# USB内のDBを初期化する
conn = init_db(db_path)

#画面表示
ct.set_appearance_mode("dark")
ct.set_default_color_theme("blue")
run_app(conn)

BACK