From e14886bade34985bde6794746708c1244ec967ee Mon Sep 17 00:00:00 2001 From: Tobia Rigon <42972324+TobiaRigon@users.noreply.github.com> Date: Wed, 16 Jul 2025 11:21:05 +0200 Subject: [PATCH 1/2] Add progress bar threading and CLI --- promptpack/__init__.py | 4 +- promptpack/__main__.py | 2 +- promptpack/cli.py | 76 ++++++++++++++++++++ promptpack/gui.py | 158 ++++++++++++++++++++++++++--------------- promptpack/utils.py | 11 ++- 5 files changed, 190 insertions(+), 61 deletions(-) create mode 100644 promptpack/cli.py diff --git a/promptpack/__init__.py b/promptpack/__init__.py index 0bf09a1..a4587f6 100644 --- a/promptpack/__init__.py +++ b/promptpack/__init__.py @@ -1,11 +1,11 @@ -from .gui import PromptPackApp from .settings import load_settings, save_settings from .utils import generate_output +from .cli import main as cli_main __all__ = [ - "PromptPackApp", "load_settings", "save_settings", "generate_output", + "cli_main", ] diff --git a/promptpack/__main__.py b/promptpack/__main__.py index 1dab67f..7bee293 100644 --- a/promptpack/__main__.py +++ b/promptpack/__main__.py @@ -1,6 +1,6 @@ import tkinter as tk -from promptpack import PromptPackApp +from .gui import PromptPackApp def main(): diff --git a/promptpack/cli.py b/promptpack/cli.py new file mode 100644 index 0000000..8ddb68d --- /dev/null +++ b/promptpack/cli.py @@ -0,0 +1,76 @@ +import argparse +from pathlib import Path +from .settings import load_settings +from .utils import generate_output +from pathspec import PathSpec + + +def collect_files(start_folder: str, settings) -> list[Path]: + folder = Path(start_folder) + gitignore = folder / ".gitignore" + spec = None + if gitignore.exists(): + with gitignore.open("r", encoding="utf-8") as f: + spec = PathSpec.from_lines("gitwildmatch", f) + files = [ + p + for p in folder.rglob("*") + if p.is_file() + and p.suffix in settings["allowed_exts"] + and p.name not in settings["excluded_files"] + and not any(excl in p.parts for excl in settings["excluded_dirs"]) + and not (spec and spec.match_file(str(p.relative_to(folder)))) + ] + return files + + +def main(): + parser = argparse.ArgumentParser(description="PromptPack CLI") + parser.add_argument("source", help="cartella sorgente") + parser.add_argument("dest", help="cartella di destinazione") + parser.add_argument("--format", choices=["txt", "md", "json"], dest="format") + parser.add_argument("--tree-only", action="store_true") + parser.add_argument("--no-heading", action="store_true") + parser.add_argument("--no-code-block", action="store_true") + parser.add_argument("--max-tokens", type=int) + parser.add_argument("--progress", action="store_true") + args = parser.parse_args() + + settings = load_settings() + export_format = args.format or settings.get("export_format", "md") + tree_only = args.tree_only or settings.get("tree_only", False) + include_heading = settings.get("include_heading", True) + if args.no_heading: + include_heading = False + use_code_block = settings.get("use_code_block", True) + if args.no_code_block: + use_code_block = False + max_tokens = args.max_tokens or settings.get("max_tokens", 200000) + + files = collect_files(args.source, settings) + total = len(files) + + def cb(current, maximum): + if args.progress: + percent = int(current / maximum * 100) + print(f"\r{percent}% ({current}/{maximum})", end="", flush=True) + + output_paths = generate_output( + args.source, + args.dest, + files, + export_format, + tree_only, + include_heading, + use_code_block, + max_tokens, + progress_callback=cb if args.progress else None, + ) + if args.progress: + print() + for p in output_paths: + print(p) + + +if __name__ == "__main__": + main() diff --git a/promptpack/gui.py b/promptpack/gui.py index 062cb77..0937284 100644 --- a/promptpack/gui.py +++ b/promptpack/gui.py @@ -5,6 +5,7 @@ from tempfile import NamedTemporaryFile import webbrowser import markdown +import threading from .settings import load_settings, save_settings from pathspec import PathSpec @@ -92,6 +93,9 @@ def __init__(self, root: tk.Tk): self.preview_window = None self.preview_text = None + self.progress_win = None + self.progress_bar = None + self.progress_var = tk.IntVar(value=0) style = ttk.Style(self.root) style.configure("Heading.TLabel", font=("TkDefaultFont", 15, "bold")) @@ -281,6 +285,75 @@ def apply_theme(self): if self.preview_text: self.preview_text.configure(bg=palette["background"], fg=palette["foreground"]) + def show_progress(self, message: str, maximum: int | None = None): + if self.progress_win and self.progress_win.winfo_exists(): + self.progress_win.destroy() + self.progress_win = Toplevel(self.root) + self.progress_win.title(message) + apply_icon(self.progress_win) + ttk.Label(self.progress_win, text=message).pack(padx=10, pady=10) + mode = "indeterminate" if maximum is None else "determinate" + self.progress_bar = ttk.Progressbar( + self.progress_win, + variable=self.progress_var, + maximum=maximum if maximum is not None else 100, + mode=mode, + ) + self.progress_bar.pack(padx=10, pady=10) + if mode == "indeterminate": + self.progress_bar.start() + self.progress_win.transient(self.root) + self.progress_win.grab_set() + + def update_progress(self, value: int, maximum: int): + if self.progress_bar and self.progress_bar["mode"] == "determinate": + self.progress_bar.config(maximum=maximum) + self.progress_var.set(value) + self.progress_bar.update_idletasks() + + def hide_progress(self): + if self.progress_bar: + if self.progress_bar["mode"] == "indeterminate": + self.progress_bar.stop() + if self.progress_win and self.progress_win.winfo_exists(): + self.progress_win.destroy() + + def build_preview_async(self, files): + self.show_progress(self.t("preview")) + + def worker(): + text = self.get_preview_text(files) + + def update(): + if self.preview_window is None or not self.preview_window.winfo_exists(): + self.preview_window = Toplevel(self.root) + self.preview_window.title(self.t("preview")) + self.preview_window.resizable(True, True) + apply_icon(self.preview_window) + + frame = ttk.Frame(self.preview_window) + frame.pack(fill="both", expand=True) + + self.preview_text = tk.Text(frame, wrap="word") + yscroll = ttk.Scrollbar(frame, command=self.preview_text.yview) + self.preview_text.configure(yscrollcommand=yscroll.set) + self.preview_text.pack(side="left", fill="both", expand=True) + yscroll.pack(side="right", fill="y") + + ttk.Button( + self.preview_window, + text=self.t("copy"), + command=lambda: self.copy_text_widget(self.preview_text), + ).pack(pady=5) + self.apply_theme() + self.preview_text.delete("1.0", "end") + self.preview_text.insert("1.0", text) + self.hide_progress() + + self.root.after(0, update) + + threading.Thread(target=worker, daemon=True).start() + def browse_start(self): folder = filedialog.askdirectory() if folder: @@ -431,30 +504,7 @@ def update_default_selected_files(self, folder_path: Path): def toggle_preview_window(self): if self.enable_preview.get(): - preview_text = self.get_preview_text(self.selected_files) - if self.preview_window is None or not self.preview_window.winfo_exists(): - self.preview_window = Toplevel(self.root) - self.preview_window.title(self.t("preview")) - self.preview_window.resizable(True, True) - apply_icon(self.preview_window) - - frame = ttk.Frame(self.preview_window) - frame.pack(fill="both", expand=True) - - self.preview_text = tk.Text(frame, wrap="word") - yscroll = ttk.Scrollbar(frame, command=self.preview_text.yview) - self.preview_text.configure(yscrollcommand=yscroll.set) - self.preview_text.pack(side="left", fill="both", expand=True) - yscroll.pack(side="right", fill="y") - - ttk.Button( - self.preview_window, - text=self.t("copy"), - command=lambda: self.copy_text_widget(self.preview_text), - ).pack(pady=5) - self.apply_theme() - self.preview_text.delete("1.0", "end") - self.preview_text.insert("1.0", preview_text) + self.build_preview_async(self.selected_files) else: if self.preview_window and self.preview_window.winfo_exists(): self.preview_window.destroy() @@ -632,24 +682,8 @@ def insert_items(parent, path: Path): def update_preview_live(): if self.enable_preview.get(): - preview_text = self.get_preview_text({Path(p) for p, var in checkbox_vars.items() if var.get()}) - if self.preview_window is None or not self.preview_window.winfo_exists(): - self.preview_window = Toplevel(self.root) - self.preview_window.title(self.t("preview")) - self.preview_window.resizable(True, True) - apply_icon(self.preview_window) - - frame = ttk.Frame(self.preview_window) - frame.pack(fill="both", expand=True) - - self.preview_text = tk.Text(frame, wrap="word") - yscroll = ttk.Scrollbar(frame, command=self.preview_text.yview) - self.preview_text.configure(yscrollcommand=yscroll.set) - self.preview_text.pack(side="left", fill="both", expand=True) - yscroll.pack(side="right", fill="y") - self.apply_theme() - self.preview_text.delete("1.0", "end") - self.preview_text.insert("1.0", preview_text) + files = {Path(p) for p, var in checkbox_vars.items() if var.get()} + self.build_preview_async(files) update_token_label() def toggle_checkbox(event): @@ -765,18 +799,28 @@ def generate(self): if not self.selected_files: messagebox.showerror(self.t("error"), self.t("no_selected")) return - try: - output_paths = generate_output( - self.start_folder.get(), - self.dest_folder.get(), - list(self.selected_files), - self.export_format.get(), - self.tree_only.get(), - self.include_heading.get(), - self.use_code_block.get(), - self.settings.get("max_tokens", 200000), - ) - msg = "\n".join(str(p) for p in output_paths) - messagebox.showinfo(self.t("done"), self.t("files_generated", msg=msg)) - except Exception as e: - messagebox.showerror(self.t("error"), str(e)) + total = len(self.selected_files) + self.show_progress(self.t("generate"), maximum=total) + + def callback(current, maximum): + self.root.after(0, lambda: self.update_progress(current, maximum)) + + def worker(): + try: + output_paths = generate_output( + self.start_folder.get(), + self.dest_folder.get(), + list(self.selected_files), + self.export_format.get(), + self.tree_only.get(), + self.include_heading.get(), + self.use_code_block.get(), + self.settings.get("max_tokens", 200000), + progress_callback=callback, + ) + msg = "\n".join(str(p) for p in output_paths) + self.root.after(0, lambda: [self.hide_progress(), messagebox.showinfo(self.t("done"), self.t("files_generated", msg=msg))]) + except Exception as e: + self.root.after(0, lambda: [self.hide_progress(), messagebox.showerror(self.t("error"), str(e))]) + + threading.Thread(target=worker, daemon=True).start() diff --git a/promptpack/utils.py b/promptpack/utils.py index c2fdc83..d176e8a 100644 --- a/promptpack/utils.py +++ b/promptpack/utils.py @@ -5,7 +5,6 @@ from datetime import datetime from tempfile import NamedTemporaryFile import webbrowser -import markdown from .tokenizer import estimate_token_count @@ -76,6 +75,7 @@ def generate_output( include_heading: bool, use_code_block: bool, max_tokens: int = 200000, + progress_callback=None, ): """Export selected files in the chosen format. @@ -111,6 +111,8 @@ def _flush(): part += 1 return output_file + total_files = len(included_files) + processed = 0 for path in included_files: rel_path = path.relative_to(start_folder) if tree_only: @@ -132,6 +134,9 @@ def _flush(): lines = [header] lines.append(line) token_count += block_tokens + processed += 1 + if progress_callback: + progress_callback(processed, total_files) continue try: @@ -166,6 +171,10 @@ def _flush(): lines.append(block) token_count += block_tokens + processed += 1 + if progress_callback: + progress_callback(processed, total_files) + output_files.append(_flush()) return output_files From de504000061c29de5eb95cb3df1ece663e90a3df Mon Sep 17 00:00:00 2001 From: TobiaRigon Date: Wed, 16 Jul 2025 11:53:09 +0200 Subject: [PATCH 2/2] Readme --- README.md | 90 +++++++++++++++++++++++++++++++++++--- promptpack/locales/zh.json | 51 +++++++++++++++++++++ 2 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 promptpack/locales/zh.json diff --git a/README.md b/README.md index f9aef49..7e572dd 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,10 @@ python promptpack.py ``` - ## Project Structure The source code is organized in the `promptpack` package: + - `gui.py` contains the graphical interface. - `settings.py` manages the application settings. - `utils.py` includes supporting functions. @@ -55,7 +55,7 @@ The file `promptpack.py` simply launches the application. ## How to Use -1. **Start Folder**: Click *Browse* to select the folder containing the files you want to include. +1. **Start Folder**: Click _Browse_ to select the folder containing the files you want to include. 2. **Select Files**: Opens an expandable tree of all folders and files. You can include/exclude each item via checkboxes. - Default selections are based on the current settings. 3. **Settings**: Define default allowed extensions, excluded folders and files. Also choose: @@ -76,10 +76,10 @@ User preferences are saved in a file named `promptpack_settings.json` in the sam { "allowed_exts": [".php", ".js", ".ts", ".html", ".css", ".py"], "excluded_dirs": ["vendor", ".git", "node_modules"], - "excluded_files": [".env", "README.md"], - "export_format": "md", - "tree_only": false, - "include_heading": true, + "excluded_files": [".env", "README.md"], + "export_format": "md", + "tree_only": false, + "include_heading": true, "use_code_block": true, "max_tokens": 200000, "last_start_folder": "", @@ -109,6 +109,84 @@ body { ``` ```` +````markdown +## Command Line Usage (CLI) + +In addition to the graphical interface, **PromptPack** can be used from the command line to automate file exports. The CLI module is separate from the GUI and is executed using: + +```bash +python -m promptpack.cli [options] +``` +```` + +### Examples + +```bash +# Export source files to Markdown with progress display +python -m promptpack.cli ./src ./out --format md --progress + +# Export only the folder structure as JSON +python -m promptpack.cli ./project ./out --tree-only --format json + +# Exclude headings and code blocks from the output +python -m promptpack.cli ./input ./out --no-heading --no-code-block +``` + +### Available Options + +| Option | Description | +| ----------------- | ------------------------------------------------------------------- | +| `source` | Source folder to analyze | +| `dest` | Destination folder for the generated output | +| `--format` | Export format: `txt`, `md`, `json` | +| `--tree-only` | Export only the folder structure, without file contents | +| `--no-heading` | Do not include headings for each file | +| `--no-code-block` | Do not wrap contents in code blocks (Markdown only) | +| `--max-tokens N` | Maximum number of tokens allowed (default: 200000 or from settings) | +| `--progress` | Show a progress bar during file processing | + +### Notes + +- Absolute paths are supported (including on Windows). +- The destination folder is automatically created if it does not exist. +- Outputs are split into multiple parts if the token limit is exceeded. +- CLI settings override the defaults from `promptpack_settings.json`. +- Useful for scripting, automation, and batch workflows. + +### Integration Tip + +If you use PromptPack frequently, you can add an alias for convenience: + +**PowerShell (Windows):** + +```powershell +Set-Alias promptpackcli "python -m promptpack.cli" +``` + +**Bash (Linux/macOS):** + +```bash +alias promptpackcli='python -m promptpack.cli' +``` + +You can then run: + +```bash +promptpackcli ./src ./out --format md --progress +``` + +Alternatively, use `pyinstaller` to generate a portable `.exe`: + +```bash +pyinstaller --onefile promptpackcli.py +``` + +This creates `promptpackcli.exe` in the `dist/` folder, usable from any terminal without needing Python installed. + +``` + +``` + ## Notes - Only files with allowed extensions are included by default. diff --git a/promptpack/locales/zh.json b/promptpack/locales/zh.json new file mode 100644 index 0000000..7929ed0 --- /dev/null +++ b/promptpack/locales/zh.json @@ -0,0 +1,51 @@ +{ + "source": "来源", + "source_folder": "源文件夹", + "browse": "浏览", + "select_files": "选择文件", + "preview": "预览", + "copy": "复制到剪贴板", + "browser": "在浏览器中预览", + "live_preview": "实时预览", + "output": "输出", + "dest_folder": "目标文件夹", + "generate": "生成", + "settings": "⚙️ 设置", + "settings_title": "设置", + "default_selection": "默认选择", + "allowed_exts": "默认允许的扩展名", + "excluded_dirs": "默认排除的文件夹", + "excluded_files": "默认排除的文件", + "output_opts": "输出选项", + "export_format": "导出格式:", + "include_headings": "包含文件标题", + "use_code": "使用代码块", + "tree_only": "仅结构", + "token_limit": "令牌限制", + "theme": "主题", + "light": "浅色", + "dark": "深色", + "language": "语言", + "english": "英语", + "italian": "意大利语", + "save": "保存", + "select_files_title": "选择要包含的文件", + "select_deselect": "全选/取消全选", + "confirm": "确认选择", + "no_files": "没有文件", + "no_preview": "未选择任何文件进行预览。", + "copied": "已复制", + "content_copied": "内容已复制到剪贴板", + "preview_copied": "预览已复制到剪贴板", + "error": "错误", + "select_source_first": "请先选择源文件夹", + "limit_reached": "达到限制", + "limit_msg": "已达到 {max} 令牌限制。部分文件被省略。", + "done": "完成", + "files_generated": "生成的文件:\n{msg}", + "no_selected": "未选择任何文件", + "need_folders": "请选择源文件夹和目标文件夹", + "token_header": "令牌估算:{count}(剩余 {remaining})\n{sep}\n", + "tokens": "令牌:{tokens} / {max}", + "project_label": "项目:{name} - {date}" +}