5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

脱Cursor!? AI開発ツールの「流行」に振り回されないGemini活用術(2)

Last updated at Posted at 2025-07-17

はじめに

前回の記事「実業務で使えるGemini活用術」では、
https://qiita.com/futayubi5656/items/ca92a9d10e0ede0cf8e9
AI、特にGeminiと協力して大規模な開発プロジェクトを進めるテクニックをご紹介しました。その核心となるのが、複数のソースコードを1つにまとめてAIに引き継ぐというワークフローです。

今回は、そのキーアイテムである ファイル文字列結合ツール に焦点を当て、その仕様、機能、そして全ソースコードを公開します。このツール自体もGeminiに仕様を伝えて作ってもらったもので、皆さんのAI活用をさらに加速させる一助となれば幸いです。

【30秒で分かる】ツール概要

このツールは、複数のファイルを1つにまとめ、AIに読み込ませやすくするためのデスクトップアプリです。開発の効率化を目的としています。

  • 何ができる?: 複数のソースコードやテキストファイルを、ドラッグ&ドロップだけで1つのファイルに結合します。
  • AIのための親切設計:
    • どのファイルの内容か一目で分かるように、ファイル名のヘッダー/フッターを自動で挿入します。
    • AIが一度に読み込める文字数には限りがあるため、指定した行数で自動的にファイルを分割する機能も搭載しています。
  • 便利な機能:
    • フォルダごとドラッグ&ドロップ可能。
    • 最後に使った設定(ファイルリストや出力先)を自動で保存し、次回起動時に復元します。

全ソースコード (Python)

このツールを動作させるには、Pythonの環境と、以下のライブラリが必要です。ターミナルやコマンドプロンプトでインストールしてください。

pip install tkinterdnd2 chardet
import tkinter as tk
import tkinter.ttk as ttk
from tkinter import filedialog, messagebox, simpledialog
from tkinterdnd2 import TkinterDnD, DND_FILES
import os
import json
import re
# 文字コード判定ライブラリをインポート
# 事前にインストールが必要です: pip install chardet
import chardet

# 設定ファイル名
SETTINGS_FILE = "file_concatenator_settings.json"

class FileConcatenatorApp(TkinterDnD.Tk):
    def __init__(self):
        # TkinterDnD.Tk を継承
        super().__init__()
        self.title("ファイル内容結合ツール")
        # ウィンドウサイズを設定
        self.geometry("600x550")

        # ドロップされたファイルのリストを保持
        self.dropped_files = []
        # 出力先ファイルのパスを保持
        self.output_path = ""
        # 最後に選択した出力ディレクトリを保持
        self.last_output_dir = ""
        # 最大出力行数
        self.max_lines_per_file = 8000 # デフォルト値

        # 設定の読み込み
        self.load_settings()

        # GUI部品の作成と配置
        self.create_widgets()
        # ドラッグ&ドロップ設定
        self.setup_dnd()

        # 読み込んだ設定をGUIに反映
        self.update_gui_from_settings()

    def load_settings(self):
        """設定ファイルを読み込む"""
        if os.path.exists(SETTINGS_FILE):
            try:
                # UTF-8エンコーディングで設定ファイルを読み込み
                with open(SETTINGS_FILE, 'r', encoding='utf-8') as f:
                    settings = json.load(f)
                    # 'last_output_dir' キーが存在すればその値を、なければ空文字列を使用
                    self.last_output_dir = settings.get('last_output_dir', '')
                    # 'dropped_files' キーが存在すればその値を、なければ空リストを使用
                    # ファイルが存在するかチェックしてからリストに追加
                    loaded_files = settings.get('dropped_files', [])
                    self.dropped_files = [f for f in loaded_files if os.path.isfile(f)]

                    # 'output_path' キーが存在すればその値を、なければ空文字列を使用
                    self.output_path = settings.get('output_path', '')

                    # 'max_lines_per_file' キーが存在すればその値を、なければデフォルト値を使用
                    self.max_lines_per_file = settings.get('max_lines_per_file', 8000)

            except (json.JSONDecodeError, FileNotFoundError):
                # JSONデコードエラーやファイルが見つからない場合は各設定を初期値にする
                self.last_output_dir = ""
                self.dropped_files = []
                self.output_path = ""
                self.max_lines_per_file = 8000

    def save_settings(self):
        """設定ファイルを保存する"""
        settings = {
            'last_output_dir': self.last_output_dir,
            'dropped_files': self.dropped_files,
            'output_path': self.output_path,
            'max_lines_per_file': self.max_lines_per_file
        }
        try:
            # UTF-8エンコーディングで設定ファイルを書き込み
            with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
                json.dump(settings, f, indent=4)
        except IOError:
            # 保存に失敗してもアプリの動作に致命的ではないため、エラー表示は行わない
            pass

    def update_gui_from_settings(self):
        """読み込んだ設定をGUIに反映する"""
        self.files_listbox.delete(0, tk.END)
        for f_path in self.dropped_files:
            self.files_listbox.insert(tk.END, os.path.basename(f_path))

        if self.output_path:
            self.output_path_entry.config(state='normal')
            self.output_path_entry.delete(0, tk.END)
            self.output_path_entry.insert(0, self.output_path)
            self.output_path_entry.config(state='readonly')

        self.max_lines_entry.config(state='normal')
        self.max_lines_entry.delete(0, tk.END)
        self.max_lines_entry.insert(0, str(self.max_lines_per_file))
        self.max_lines_entry.config(state='readonly')

        self.status_label.config(text=f"{len(self.dropped_files)}個のファイルがリストにあります")

    def create_widgets(self):
        """GUI部品を作成・配置する"""
        self.drop_label = ttk.Label(self, text="ここにファイルやフォルダをドラッグ&ドロップして追加", relief="solid",
                                    justify="center", padding=15, background="#cceeff",
                                    borderwidth=2)
        self.drop_label.pack(pady=10, padx=10, fill="x")

        listbox_frame = ttk.Frame(self)
        listbox_frame.pack(pady=5, padx=10, fill="both", expand=True)

        scrollbar = ttk.Scrollbar(listbox_frame)
        scrollbar.pack(side=tk.RIGHT, fill=tk.Y)

        self.files_listbox = tk.Listbox(listbox_frame, yscrollcommand=scrollbar.set)
        self.files_listbox.pack(side=tk.LEFT, fill="both", expand=True)
        scrollbar.config(command=self.files_listbox.yview)

        list_buttons_frame = ttk.Frame(self)
        list_buttons_frame.pack(pady=5, padx=10, fill="x")

        self.clear_list_button = ttk.Button(list_buttons_frame, text="リストをクリア", command=self.clear_files_list)
        self.clear_list_button.pack(side="left", padx=5)

        max_lines_frame = ttk.Frame(self)
        max_lines_frame.pack(pady=5, padx=10, fill="x")

        self.max_lines_label = ttk.Label(max_lines_frame, text="最大出力行数:")
        self.max_lines_label.pack(side="left")

        self.max_lines_entry = ttk.Entry(max_lines_frame, width=10, state='readonly')
        self.max_lines_entry.pack(side="left", padx=5)

        self.set_max_lines_button = ttk.Button(max_lines_frame, text="変更", command=self.set_max_lines)
        self.set_max_lines_button.pack(side="left")

        output_frame = ttk.Frame(self)
        output_frame.pack(pady=5, padx=10, fill="x")

        self.output_label = ttk.Label(output_frame, text="出力先ファイル:")
        self.output_label.pack(side="left")

        self.output_path_entry = ttk.Entry(output_frame, state='readonly')
        self.output_path_entry.pack(side="left", fill="x", expand=True, padx=5)

        self.select_output_button = ttk.Button(output_frame, text="選択", command=self.select_output_file)
        self.select_output_button.pack(side="left")

        self.concatenate_button = ttk.Button(self, text="ファイルを結合", command=self.concatenate_files)
        self.concatenate_button.pack(pady=10)

        self.status_label = ttk.Label(self, text="準備完了", anchor="center")
        self.status_label.pack(pady=5, padx=10, fill="x")

        self.files_listbox.bind('<Delete>', self.on_delete_key)

    def setup_dnd(self):
        """ドラッグ&ドロップ設定"""
        self.drop_label.drop_target_register(DND_FILES)
        self.drop_label.dnd_bind('<<Drop>>', self.on_drop)

    def on_drop(self, event):
        """ファイルがドロップされた時の処理"""
        paths_str = event.data
        paths = re.findall(r'[^ ]+|{[^}]+}', paths_str)

        processed_paths = []
        for path in paths:
            if path.startswith('{') and path.endswith('}'):
                processed_paths.append(path[1:-1])
            else:
                processed_paths.append(path)

        added_count = 0
        for path in processed_paths:
            if os.path.isdir(path):
                for root, _, filenames in os.walk(path):
                    for filename in filenames:
                        file_path = os.path.join(root, filename)
                        if file_path not in self.dropped_files:
                            self.dropped_files.append(file_path)
                            self.files_listbox.insert(tk.END, os.path.basename(file_path))
                            added_count += 1
            elif os.path.isfile(path):
                if path not in self.dropped_files:
                    self.dropped_files.append(path)
                    self.files_listbox.insert(tk.END, os.path.basename(path))
                    added_count += 1

        if added_count > 0:
            self.status_label.config(text=f"{added_count}個のファイルを追加しました ({len(self.dropped_files)}個のファイルがリストにあります)")
            self.save_settings()
        else:
            self.status_label.config(text=f"追加されたファイルはありませんでした ({len(self.dropped_files)}個のファイルがリストにあります)")

    def clear_files_list(self):
        """ファイルリストをクリアする"""
        if messagebox.askyesno("確認", "ファイルリストをすべてクリアしますか?"):
            self.files_listbox.delete(0, tk.END)
            self.dropped_files = []
            self.status_label.config(text="ファイルリストをクリアしました")
            self.save_settings()

    def on_delete_key(self, event):
        """リストボックスでDeleteキーが押された時の処理"""
        selected_indices = self.files_listbox.curselection()
        if not selected_indices:
            return

        for index in sorted(selected_indices, reverse=True):
            self.files_listbox.delete(index)
            if 0 <= index < len(self.dropped_files):
                del self.dropped_files[index]

        self.status_label.config(text=f"{len(selected_indices)}個のファイルをリストから削除しました ({len(self.dropped_files)}個のファイルがリストにあります)")
        self.save_settings()

    def set_max_lines(self):
        """最大出力行数を設定するダイアログを表示"""
        current_value = self.max_lines_per_file
        new_value_str = simpledialog.askstring("最大行数設定", "最大出力行数を入力してください:",
                                             initialvalue=str(current_value))
        if new_value_str is not None:
            try:
                new_value = int(new_value_str)
                if new_value > 0:
                    self.max_lines_per_file = new_value
                    self.max_lines_entry.config(state='normal')
                    self.max_lines_entry.delete(0, tk.END)
                    self.max_lines_entry.insert(0, str(self.max_lines_per_file))
                    self.max_lines_entry.config(state='readonly')
                    self.status_label.config(text=f"最大出力行数を {self.max_lines_per_file} に設定しました")
                    self.save_settings()
                else:
                    messagebox.showwarning("不正な入力", "1以上の整数を入力してください。")
            except ValueError:
                messagebox.showwarning("不正な入力", "有効な整数を入力してください。")

    def select_output_file(self):
        """出力先ファイルを選択するダイアログを表示"""
        initial_dir = self.last_output_dir if self.last_output_dir and os.path.isdir(self.last_output_dir) else os.getcwd()
        output_file = filedialog.asksaveasfilename(
            title="結合結果を保存",
            initialdir=initial_dir,
            defaultextension=".txt",
            filetypes=[("テキストファイル", "*.txt"), ("すべてのファイル", "*.*")]
        )
        if output_file:
            self.output_path = output_file
            self.output_path_entry.config(state='normal')
            self.output_path_entry.delete(0, tk.END)
            self.output_path_entry.insert(0, self.output_path)
            self.output_path_entry.config(state='readonly')
            self.last_output_dir = os.path.dirname(self.output_path)
            self.save_settings()

    def detect_encoding(self, file_path):
        """
        chardetライブラリを使用してファイルの文字コードを判定する。
        """
        try:
            with open(file_path, 'rb') as f:
                # ファイル全体を読み込んで判定精度を上げる
                raw_data = f.read()
                # ファイルが空の場合は判定しない
                if not raw_data:
                    return 'utf-8' # デフォルト
                result = chardet.detect(raw_data)
                encoding = result['encoding']

                # 判定結果がNoneの場合や信頼性が低い場合はUTF-8をフォールバックとして使用
                if encoding is None or result['confidence'] < 0.7:
                    return 'utf-8'

                # Shift_JIS系のエンコーディングは、より互換性の高いcp932に統一
                if encoding.lower() in ['shift_jis', 'windows-31j']:
                    return 'cp932'

                return encoding
        except (FileNotFoundError, Exception):
            # 何らかのエラーが発生した場合もUTF-8を返す
            return 'utf-8'

    def get_next_output_filename(self, base_path, index):
        """連番付きの出力ファイル名を生成する"""
        if index == 0:
            return base_path

        dirname, basename = os.path.split(base_path)
        name, ext = os.path.splitext(basename)

        # 連番を3桁ゼロ埋めで生成
        numbered_name = f"{name}_{index:03d}{ext}"
        return os.path.join(dirname, numbered_name)

    def concatenate_files(self):
        """
        ファイルの結合を実行。
        各ファイルの文字コードを自動判定し、UTF-8に変換して結合する。
        """
        if not self.dropped_files:
            messagebox.showwarning("警告", "結合するファイルがドロップされていません。")
            return
        if not self.output_path:
            messagebox.showwarning("警告", "出力先ファイルが選択されていません。")
            return
        if not isinstance(self.max_lines_per_file, int) or self.max_lines_per_file <= 0:
            messagebox.showwarning("警告", "最大出力行数が正しく設定されていません。")
            return

        current_output_file = None
        try:
            self.status_label.config(text="結合中...")
            self.update()

            output_file_index = 0
            current_lines_in_output = 0

            for infile_path in self.dropped_files:
                try:
                    # 1. 文字コードを判定
                    encoding = self.detect_encoding(infile_path)

                    # 2. 判定した文字コードでファイル内容を読み込む
                    #    デコードエラーが発生した場合は'?'で置換する
                    with open(infile_path, 'r', encoding=encoding, errors='replace') as infile:
                        content = infile.read()

                    # 3. 読み込んだ内容から行数を計算
                    infile_lines = content.count('\n')
                    if content and not content.endswith('\n'):
                        infile_lines += 1

                    # 4. 新しい出力ファイルが必要か判断
                    #    出力ファイルが既に開かれており、現在の行数と追加する行数が最大値を超える場合
                    if (current_output_file is not None and
                        current_lines_in_output > 0 and
                        (current_lines_in_output + infile_lines) > self.max_lines_per_file):

                        current_output_file.close()
                        current_output_file = None
                        output_file_index += 1
                        current_lines_in_output = 0
                        self.status_label.config(text=f"最大行数を超過。新しいファイルを作成します...")
                        self.update()

                    # 5. 出力ファイルを開く
                    if current_output_file is None:
                        output_filename = self.get_next_output_filename(self.output_path, output_file_index)
                        # 出力はUTF-8で統一
                        current_output_file = open(output_filename, 'w', encoding='utf-8')
                        self.status_label.config(text=f"出力ファイル: {os.path.basename(output_filename)}")
                        self.update()

                    # 6. ヘッダー、ファイル内容、フッターを出力ファイルに書き込む
                    header_line = f"--- ファイル開始: {infile_path} ---\n"
                    separator_line = "-" * 30 + "\n"
                    footer_line = f"--- ファイル終了: {os.path.basename(infile_path)} ---\n"

                    current_output_file.write(header_line)
                    current_output_file.write(separator_line)
                    current_output_file.write(content)

                    # 内容の末尾が改行でない場合は改行を追加
                    if not content.endswith('\n'):
                        current_output_file.write('\n')

                    current_output_file.write(separator_line)
                    current_output_file.write(footer_line)
                    current_output_file.write("\n") # ファイル間のスペース

                    # 書き込んだ行数を加算
                    current_lines_in_output += (infile_lines + 5) # ヘッダー(1) + 区切り(1) + 内容 + 区切り(1) + フッター(1) + 空行(1)

                    self.status_label.config(text=f"'{os.path.basename(infile_path)}' を結合しました ({current_lines_in_output} / {self.max_lines_per_file} 行)")
                    self.update()

                except FileNotFoundError:
                    messagebox.showerror("エラー", f"入力ファイルが見つかりません:\n{os.path.basename(infile_path)}")
                    continue # 次のファイルへ
                except Exception as e:
                    messagebox.showerror("エラー", f"ファイル読み込み中に問題が発生しました:\n{os.path.basename(infile_path)}\n{e}")
                    continue # 次のファイルへ

            self.status_label.config(text=f"結合完了。{output_file_index + 1}個のファイルに出力しました。")
            messagebox.showinfo("成功", f"ファイルは正常に結合されました。\n出力先: {os.path.dirname(self.output_path)}")

        except PermissionError:
            self.status_label.config(text="エラー: 出力ファイルへの書き込み権限がありません。")
            messagebox.showerror("エラー", "出力ファイルへの書き込み権限がありません。\n指定したファイルが他のアプリケーションで開かれていないか確認してください。")
        except Exception as e:
            self.status_label.config(text=f"エラー: 結合中に予期せぬ問題が発生しました ({e})")
            messagebox.showerror("エラー", f"結合中に予期せぬ問題が発生しました:\n{e}")
        finally:
            if current_output_file and not current_output_file.closed:
                current_output_file.close()

    def on_closing(self):
        """ウィンドウを閉じる時の処理"""
        self.save_settings()
        self.destroy()

if __name__ == "__main__":
    app = FileConcatenatorApp()
    app.protocol("WM_DELETE_WINDOW", app.on_closing)
    app.mainloop()

詳細仕様

このツールの具体的な機能やロジックは以下の通りです。

1. 機能一覧

  • ファイル追加(ドラッグ&ドロップ): 結合したいファイルをGUI上にドラッグ&ドロップすることで、リストに追加します。フォルダごとドロップすることも可能です。
  • ファイルリスト表示: ドロップされたファイルのファイル名を一覧表示します。
  • ファイルリストクリア: 追加されたファイルリストをすべてクリアします。
  • ファイルリストからの削除: リストボックスで選択したファイルをDeleteキーで削除できます。
  • 最大出力行数設定: 出力ファイルあたりの最大行数を指定できます。この行数を超えた場合、自動的に新しいファイルに分割して出力します。
  • 出力先ファイル選択: 結合結果を保存するファイル名を指定します。分割出力される場合、指定されたファイル名に連番が付与されます。
  • ファイル結合実行: リストに追加されたファイルを指定された設定に基づき結合・出力します。
  • 設定の自動保存・読み込み: 最後に使用した設定(ファイルリスト、出力パス、最大行数など)を自動的に保存し、次回起動時に復元します。
  • ステータス表示: 現在の操作状況やエラーメッセージをユーザーに通知します。

2. ユーザーインターフェース (UI)

  • メインウィンドウ: アプリケーションの基本となるウィンドウです。
  • ファイルドロップエリア: 「ここにファイルをドラッグ&ドロップして追加」と表示されたエリア。
  • ドロップされたファイルリスト: 追加されたファイル名が表示されるリストボックス。
  • リスト操作ボタン:
    • リストをクリア: ファイルリストを空にします。
  • 最大行数設定エリア:
    • 変更ボタン: クリックするとダイアログが表示され、最大出力行数を変更できます。
  • 出力ファイル選択エリア:
    • 選択ボタン: ファイル保存ダイアログを開き、出力先を指定できます。
  • 結合実行ボタン:
    • ファイルを結合: クリックすると結合処理を開始します。
  • ステータス表示ラベル: アプリケーションの現在の状態やメッセージが表示されます。

3. 処理ロジック

  • ファイルの追加と管理: ドラッグ&ドロップされたファイルパスを内部的にリストで保持します。重複するファイルは追加されません。
  • 設定の保存と読み込み: file_concatenator_settings.jsonというファイル名で設定をJSON形式で保存・読み込みます。
  • ファイル結合処理:
    • 事前チェック: ファイルリストや出力先が空でないかなどを確認します。
    • 文字コード判定: chardetライブラリを使い、各ファイルの文字コードを自動で判定します。これにより文字化けを防ぎます。
    • 結合と分割:
      • ファイルを順番に読み込み、出力ファイルに書き込んでいきます。
      • 書き込み行数が「最大出力行数」を超えた場合、自動で新しいファイル(output_001.txt, output_002.txt...)を作成し、そちらに書き込みを続けます。
    • ヘッダー/フッターの挿入: AIがどのファイルの内容かを理解しやすいように、各ファイルの先頭と末尾にファイルパス情報を含むヘッダーとフッターを自動で挿入します。
  • エラーハンドリング: ファイルが見つからない、書き込み権限がない等のエラーが発生した場合は、メッセージボックスでユーザーに通知します。
5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?