6. 一応完成

CustomTkinter

好きな行の後ろに空行を追加できるようにした

import subprocess
import sys
import os
import json
import sqlite3
import re
import customtkinter as ct
from datetime import datetime
from tkinter import messagebox
from PIL import Image #from tkinter import PhotoImage  PhotoImageだと拡大縮小ができない
from customtkinter import CTkImage

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("1100x700")

    # ==========
    # GUIを左右2分割
    # ==========
    main_frame = ct.CTkFrame(root)
    main_frame.pack(fill="both", expand=True, padx=10, pady=0)
    bg_color = main_frame._apply_appearance_mode(main_frame._fg_color)
    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, fg_color=bg_color)
    right_frame.pack(side="right", fill="both", expand=True)

    # ==========
    # タブボタンフレーム(画面左側)
    # ==========
    bg_color = left_frame._apply_appearance_mode(left_frame._fg_color)
    tab_buttons_frame = ct.CTkFrame(left_frame, fg_color=bg_color)
    tab_buttons_frame.pack(fill="y", expand=True, pady=5)

    # ==========
    # 検索ボタン(画面右側一番上)
    # ==========
    bg_color = right_frame._apply_appearance_mode(right_frame._fg_color)
    search_frame = ct.CTkFrame(right_frame, fg_color=bg_color)
    search_frame.pack(padx=10, pady=5, fill="x")
    pil_image = Image.open("icon-search.png")
    search_icon = CTkImage(light_image=pil_image, dark_image=pil_image, size=(24,24))
    search_btn = ct.CTkButton(search_frame, image=search_icon, text="", width=16, height=16)
    search_btn.pack(side="right", padx=5)
    search_var = ct.StringVar()
    search_entry = ct.CTkEntry(search_frame, textvariable=search_var, width=250)
    search_entry.pack(side="right", padx=10, pady=5, fill="x")
    search_var.trace_add("write", lambda *args: search_account_list())
    

    # ==========
    # 検索結果用フレーム(最初は隠しておく)
    # ==========
    search_result_frame = ct.CTkFrame(right_frame)
    search_result_frame.pack(fill="both", expand=True)
    search_result_frame.pack_forget()

    # ==========
    # 検索機能
    # ==========
    def search_account_list():
        # フレームを一旦削除
        for widget in search_result_frame.winfo_children():
            widget.destroy()
        
        # 検索キーワード取得
        keyword = search_var.get().lower().strip()

        # キーワードが空ならリセットする
        if keyword == "":
            reset_account_list()
            return

        # サービス名orユーザー名にキーワードが入っているアカウントを取得
        c = conn.cursor()
        c.execute("SELECT id, service, username, password FROM accounts")
        accounts = c.fetchall()
        if keyword:
            accounts = [
                acc for acc in accounts
                if keyword in acc[1].lower() or keyword in acc[2].lower()
            ]
        
        # 該当アカウント一覧を表示
        if accounts:
            for acc in accounts:
                service, username, password = acc[1], acc[2], acc[3]
                label = ct.CTkLabel(search_result_frame, text=f"{service} | {username} | {password}")
                label.pack(anchor="w", padx=10, pady=2)
        else:
            ct.CTkLabel(search_result_frame, text="該当なし").pack(anchor="w", padx=10, pady=2)
        
        # 検索時はlist_framesとcontent_frameを隠してsearch_result_frameを表示
        for frame in list_frames.values():
            frame.pack_forget()
        content_frame.pack_forget()
        search_result_frame.pack(fill="both", expand=True)
    
    # ==========
    # 検索解除機能
    # ==========
    def reset_account_list():
        search_result_frame.pack_forget()
        for frame in list_frames.values():
            frame.pack(fill="both", expand=True)
        content_frame.pack(fill="both", expand=True)


    # カテゴリ選択
    categories = load_categories(conn)


    # ==========
    # タブフレーム(画面右側)
    # ===========
    tab_frames = {}
    current_tab = None
    content_frame = ct.CTkFrame(right_frame, fg_color=bg_color)
    content_frame.pack(fill="both", expand=True)


    # ==========
    # タブボタンフレーム(画面左側)再描画関数
    # ==========
    def redraw_tab_buttons():
        for widget in tab_buttons_frame.winfo_children():
            widget.destroy()
        for cat in categories:
            tab_item_frame = ct.CTkFrame(tab_buttons_frame, fg_color="transparent")
            tab_item_frame.pack(fill="x")
            btn = ct.CTkButton(
                tab_item_frame,
                text=cat,
                corner_radius=0,
                fg_color="transparent",
                hover_color="#444444",
                command=lambda t=cat: show_tab(t)
            )
            btn.pack(fill="x", pady=5)
            line = ct.CTkFrame(tab_item_frame, height=2, fg_color="#444444")
            line.pack(fill="x", side="bottom")
        # タブ追加ボタン
        add_tab_btn = ct.CTkButton(tab_buttons_frame, text="+ タブを追加", command=add_tab)
        add_tab_btn.pack(padx=10, pady=10)


    # ==========
    # タブ作成関数(Tabviewだとタブ名の変更ができない)
    # ==========
    def create_tab(tab_name):
        frame = ct.CTkFrame(content_frame, fg_color=bg_color)
        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):
            old_name = label.cget("text")
            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()
                # タブボタンフレーム(画面左側)を再描画
                redraw_tab_buttons()
        

        # ==========
        # タブ削除関数
        # ==========
        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)
                # タブボタンフレーム(画面左側)を再描画
                redraw_tab_buttons()

        
        # タブ名変更ボタン
        rename_btn = ct.CTkButton(tab_top_frame, text="名前変更", width=60, command=lambda l=title_label: rename_tab_local(l))
        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=900, height=400)
        list_frames[tab_name].pack(fill="both", expand=True)

        # ==========
        # 入力用フレーム(画面右側一番下)
        # ==========
        input_frame = ct.CTkFrame(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)
        add_btn = ct.CTkButton(
            input_frame, text="追加",
            command=lambda s=service_entry, u=user_entry, p=pass_entry, c=tab_name: add_account(conn, s, u, p, c))
        add_btn.grid(row=0, column=3, padx=5, pady=5)


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

    # ==========
    # タブボタンフレーム(画面左側)にタブボタンを縦方向に配置
    # ==========
    for cat in categories:
        tab_item_frame = ct.CTkFrame(tab_buttons_frame, fg_color="transparent")
        tab_item_frame.pack(fill="x")

        btn = ct.CTkButton(
            tab_item_frame,
            text=cat,
            corner_radius=0,
            fg_color="transparent",
            hover_color="#444444",
            command=lambda t=cat: show_tab(t)
        )
        btn.pack(fill="x", pady=5)

        line = ct.CTkFrame(tab_item_frame, height=2, fg_color="#444444") # height=1だと見えない
        line.pack(fill="x", side="bottom")


    # ==========
    # タブ追加機能
    # ==========
    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)
            # 画面左のタブボタンを再描画
            redraw_tab_buttons()
            # 新しく作ったタブを表示
            show_tab(new_name)


    # タブ追加ボタンをtab_buttons_frame内に配置
    add_tab_btn = ct.CTkButton(tab_buttons_frame, text="+ タブを追加", command=add_tab)
    add_tab_btn.pack(padx=10, pady=10)

    # 最初のタブを表示する
    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_entry, user_entry, pass_entry, category):
    service = service_entry.get()
    user = user_entry.get()
    password = pass_entry.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 insert_empty_account(conn, account_id, category):
    now = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
    c = conn.cursor()
    c.execute("SELECT sort_order FROM accounts WHERE id=?", (account_id,))
    current_order = c.fetchone()[0]
    # 後ろの行のsort_orderを+1ずつずらしてスペースをつくる
    c.execute("UPDATE accounts SET sort_order = sort_order + 1 WHERE category=? AND sort_order > ?", (category, current_order))
    # 空行を挿入
    c.execute("INSERT INTO accounts (service, username, password, created_at, sort_order, category) VALUES (?, ?, ?, ?, ?, ?)",
              ("", "", "", now, current_order + 1, category))
    conn.commit()
    # 表示更新
    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()) // 9

        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=100)
        pass_entry.insert(0, password)
        pass_entry.grid(row=idx, column=2, padx=5, pady=2)

        created_entry = ct.CTkEntry(frame, width=150)
        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)

        # 好きな行の後ろに空行を追加するボタン
        insert_btn = ct.CTkButton(frame, text="+", width=30, command=lambda i=account_id, c=category, conn=conn: insert_empty_account(conn, i, c))
        insert_btn.grid(row=idx, column=8, 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