【python】よく使うリンクやファイルをボタンで登録するlauncher

よく使うリンクやファイル、フォルダを登録しボタンで飛べるlauncherを作成したので備忘録として残しておきます。

2023/1/10 追記
webサイトはドラッグ&ドロップでurlが取得できないため、
ラベルスペースで右クリック⇒サブウィンドウを表示し、リンクを登録できるよう修正しました。。

```python:launcher.py
import tkinter as tk
import tkinter.ttk as ttk
from tkinterdnd2 import *
import subprocess
import webbrowser
import os
import urllib.request
import ssl
import tkinter.simpledialog as simpledialog
import tkinter.messagebox as messagebox

class Launcher:

    # 拡張子.txtのファイル名を取得
    FILE_NAME_LIST=os.listdir("./")
    TEXT_FILE_NAME_LIST=[]
    for i in range(len(FILE_NAME_LIST)):
        if ".txt"==os.path.splitext(FILE_NAME_LIST[i])[1]:
            TEXT_FILE_NAME_LIST.append(FILE_NAME_LIST[i])

    ssl._create_default_https_context = ssl._create_unverified_context

    def main(self):
        global nb
        global root
        # ウィンドウ設定
        root = TkinterDnD.Tk()
        root.title("Launcher")
        root.geometry("325x400")
        root.resizable(width=False, height=False)

        # タブ作成
        nb = ttk.Notebook(root)
        self.create_tab(Launcher.TEXT_FILE_NAME_LIST)

        root.mainloop()

    @classmethod
    def create_tab(cls, file_name_list):
        # タブ作成
        global nb
        global tab_list

        # タブのインスタンスを作成
        tab_dict={}
        tab_list = []
        for file_name in file_name_list:
            tab = tk.Frame(nb)
            tab_dict[tab]=file_name

            # タブに表示する文字列の作成
            nb.add(tab, text=file_name.replace(".txt",""), padding=2)

            tab_list.append(tab)

        nb.pack(expand=True, fill=tk.BOTH)

        # タブの中身を作成
        for tab, file_path in tab_dict.items():
            # ラベルを作成
            label = cls.create_label(tab)
            label.pack(expand=True, fill=tk.BOTH)

            # ファイル読み込み
            link_dict, not_link_dict = cls.read_file(file_path)

            # ラベルの中にボタン作成
            cls.create_button(label, link_dict, not_link_dict)

    @classmethod
    def create_label(cls, tab):
        # ラベル作成
        global nb

        # labelにスクロールが付けれないため、スクロールを紐づけられるcavasを作成
        canvas = tk.Canvas(tab, width=200, relief=tk.RIDGE, cursor="hand2")
        # スクロールバーの作成
        scrollbar = tk.Scrollbar(canvas, orient=tk.VERTICAL, command=canvas.yview)
        # Canvasのスクロール範囲を設定
        canvas.configure(scrollregion=(0, 0, 300, 1000))
        canvas.configure(yscrollcommand=scrollbar.set)
        # 表示
        scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
        canvas.pack(expand=True, fill=tk.BOTH)

        frame=tk.Frame(canvas)
        # Canvas上の座標(0, 0)に対してFrameの左上（nw=north-west）をあてがうように、Frameを埋め込む
        canvas.create_window((0, 0), window=frame, anchor="nw", width=300, height=1000)

        label = ttk.Label(frame, text=nb.tab(tab, "text"), width=200, relief=tk.RIDGE, cursor="hand2")
        label.drop_target_register(DND_FILES)
        # drop_pathメソッドとラベルを紐づけ
        label.dnd_bind("<<Drop>>", cls.drop_path)
        # ラベルとメニューを紐づけ
        cls.link_label_and_menu(label)

        return label

    @classmethod
    def create_button(cls, label, link_dict, not_link_dict):
        # link
        row = 0
        col = 0
        for name, url in link_dict.items():
            # ボタン作成
            cls.create_link_button(label, name, url, row, col)
            if col == 1:
                row += 1
                col = 0
                continue
            col += 1

        # link以外
        for name, path in not_link_dict.items():
            # ボタン作成
            cls.create_not_link_button(label, name, path, row, col)
            if col == 1:
                row += 1
                col = 0
                continue
            col += 1

    @classmethod
    def create_link_button(cls, label, button_name, url, row, col):
        # ボタン作成
        button = tk.Button(label, text=button_name,
                        command=lambda: cls.jump_to_link(url), width=20, relief="raised")
        button.grid(column=col, row=row)

        # ボタンとメニューの紐づけ
        cls.link_button_and_menu(button)

    @classmethod
    def create_not_link_button(cls, label, button_name, path, row, col):
        # ボタン作成
        button = tk.Button(label, text=button_name,
                        command=lambda: cls.open_path(path), width=20, relief="raised")
        button.grid(column=col, row=row)

        # ボタンとメニューの紐づけ
        cls.link_button_and_menu(button)

    @classmethod
    def drop_path(cls, event):
        global nb
        global tab_list
        # 入力ダイアログを表示し、ボタン名を取得
        input_data = simpledialog.askstring("Input Box", "ボタン名を入力してください")

        # ドラッグ&ドロップされたパスから先頭と末尾の""を取り除く
        path = event.data[1:]
        path = path[:-1]

        # タブ名取得
        tab_name = nb.tab(nb.select(), "text")

        # ファイル読み込み
        file_path = f"{tab_name}.txt"
        link_dict, not_link_dict=cls.read_file(file_path)

        not_link_dict[input_data] = path

        # ファイル書き込み
        cls.write_file(file_path, link_dict, not_link_dict)

        # 現在のタブ削除
        for tab in tab_list:
            nb.forget(tab)
        # タブから再作成
        cls.create_tab(cls.TEXT_FILE_NAME_LIST)

    @classmethod
    def link_button_and_menu(cls, button):
        global pmenu
        pmenu=tk.Menu(button, tearoff=0)
        pmenu.add_command(label="ボタン名変更", command=cls.change_button_name)
        pmenu.add_command(label="ボタン削除", command=cls.delete_button)
        # 右クリックでメニュー表示
        button.bind("<Button-3>", cls.show_button_menu)

    @classmethod
    def show_button_menu(cls, e):
        global select_button
        global pmenu
        select_button=e.widget.cget("text")
        pmenu.post(e.x_root, e.y_root)

    @classmethod
    def change_button_name(cls):
        global nb
        global tab_list
        global select_button
        # 入力ダイアログを表示し、新しいボタン名を取得
        input_data = simpledialog.askstring("Input Box", "ボタン名を入力してください")

        # タブ名取得
        tab_name = nb.tab(nb.select(), "text")

        # ファイル読み込み
        file_path = f"{tab_name}.txt"
        link_dict, not_link_dict = cls.read_file(file_path)

        # リンクを格納した辞書に変更するボタン名があるか判定
        link_path = link_dict.get(select_button, None)
        if link_path is not None:
            link_dict = cls.change_key(link_dict, select_button, input_data)
        else:
            # リンク以外を格納した辞書に変更するボタン名があるか判定
            not_link_path = not_link_dict.get(select_button, None)
            if not_link_path is not None:
                not_link_dict = cls.change_key(not_link_dict, select_button, input_data)

        # ファイル書き込み
        cls.write_file(file_path, link_dict, not_link_dict)

        # 現在のタブ削除
        for tab in tab_list:
            nb.forget(tab)
        # タブから再作成
        cls.create_tab(cls.TEXT_FILE_NAME_LIST)

    @classmethod
    def change_key(cls, target_dict, old_key, new_key):
        new_dict = {}
        key_list = []
        value_list = []

        for key, value in target_dict.items():
            key_list.append(key)
            value_list.append(value)

        for i, key in enumerate(key_list):
            if key_list[i] == old_key:
                key_list[i] = new_key
                break

        for i, key in enumerate(key_list):
            new_dict[key] = value_list[i]

        return new_dict

    @classmethod
    def delete_button(cls):
        global nb
        global select_button
        global tab_list

        # タブ名取得
        tab_name = nb.tab(nb.select(), "text")

        # ファイル書き込み
        file_path = f"{tab_name}.txt"
        link_dict, not_link_dict = cls.read_file(file_path)
        # ボタン名と一致するものを削除
        for k in link_dict:
            if k == select_button:
                link_dict.pop(k, None)
                break

        for k in not_link_dict:
            if k == select_button:
                not_link_dict.pop(k, None)
                break

        # ファイル書き込み
        cls.write_file(file_path, link_dict, not_link_dict)

        # 現在のタブ削除
        for tab in tab_list:
            nb.forget(tab)
        # タブから再作成
        cls.create_tab(cls.TEXT_FILE_NAME_LIST)

    @classmethod
    def link_label_and_menu(cls, label):
        global label_menu
        label_menu=tk.Menu(label, tearoff=0)
        label_menu.add_command(label="リンク登録", command=cls.show_link_dialog)
        # 右クリックでメニュー表示
        label.bind("<Button-3>", cls.show_label_menu)

    @classmethod
    def show_label_menu(cls, e):
        global label_menu
        label_menu.post(e.x_root, e.y_root)

    @classmethod
    def show_link_dialog(cls):
        # サブウインドウの作成
        global root
        global sub_window
        sub_window = tk.Toplevel(root)
        sub_window.geometry("300x100")

        input_link_name_label = tk.Label(sub_window, text='リンク名：')
        input_link_name_label.place(x=20, y=20)

        input_link_name = tk.Entry(sub_window, width=30)
        input_link_name.place(x=80, y=20)

        input_url_label = tk.Label(sub_window, text='url        ：')
        input_url_label.place(x=20, y=50)

        input_url = tk.Entry(sub_window, width=30)
        input_url.place(x=80, y=50)

        button = tk.Button(sub_window, text='登録', command=lambda: cls.register_link(input_link_name, input_url))
        button.place(x=140, y=70)

    @classmethod
    def register_link(cls, input_link_name, input_url):
        global nb
        global tab_list
        global sub_window

        link_name = input_link_name.get()
        url = input_url.get()

        # タブ名取得
        tab_name = nb.tab(nb.select(), "text")

        # ファイル読み込み
        file_path = f"{tab_name}.txt"
        link_dict, not_link_dict=cls.read_file(file_path)

        # リンク判定
        is_link = cls.check_url(url)

        if is_link:
            link_dict[link_name] = url
        else:
            messagebox.showerror("エラー", "リンクが有効ではありません")

        # ファイル書き込み
        cls.write_file(file_path, link_dict, not_link_dict)

        # 現在のタブ削除
        for tab in tab_list:
            nb.forget(tab)
        # タブから再作成
        cls.create_tab(cls.TEXT_FILE_NAME_LIST)

        # サブウィンドウを閉じる
        sub_window.destroy()

    @classmethod
    def read_file(cls, file_path):
        # ファイル読み込み

        link_dict = {}
        not_link_dict = {}

        f = open(file_path, 'r', encoding='utf-8')
        while True:
            data = f.readline().rstrip('\n')
            if data == '':
                break

            data_list = data.split(',')
            key = data_list[0]
            value = data_list[1]

            # パスがリンクかを判定
            is_link = cls.check_url(value)

            if is_link:
                link_dict[key] = value
            else:
                not_link_dict[key] = value

        return link_dict, not_link_dict

    @classmethod
    def write_file(cls, file_path, link_dict, not_link_dict):
        # ファイル書き込み
        write_list=[]

        for name, url in link_dict.items():
            row = name + ',' + url
            write_list.append(row)

        for name, path in not_link_dict.items():
            row = name + ',' + path
            write_list.append(row)

        file_path = './' + file_path

        with open(file_path, mode='w',encoding='utf-8')as f:
            f.write('\n'.join(write_list))

    @classmethod
    def check_url(cls, url):
        # urlのリンクが有効か判定
        is_link = True
        try:
            f = urllib.request.urlopen(url)
            f.close()
        except Exception as e:
            is_link = False

        return is_link

    @classmethod
    def jump_to_link(cls, url):
        # リンクを開く
        webbrowser.open(url)

    @classmethod
    def open_path(cls, path):
        # 指定されたパスを開く
        subprocess.Popen(["start", path], shell=True)

if __name__ == "__main__":
    launcher = Launcher()
    launcher.main()

```