diff --git a/Advanced Search/README.md b/Advanced Search/README.md new file mode 100644 index 00000000..b3975cbc --- /dev/null +++ b/Advanced Search/README.md @@ -0,0 +1,446 @@ +# Advanced Search Tool + +
+ +![Version](https://img.shields.io/badge/version-0.5.1-blue) +![Python](https://img.shields.io/badge/python-3.8+-green) +![License](https://img.shields.io/badge/license-MIT-orange) +![Platform](https://img.shields.io/badge/platform-Windows-lightgrey) + +**A powerful Windows GUI application for grep-style searching with advanced regex patterns and metadata search capabilities** + +[Features](#features) • [Installation](#installation) • [Usage](#usage) • [Metadata Search](#metadata-search) • [Contributing](#contributing) + +
+ +--- + +## 📋 Overview + +Advanced Search Tool is a modern, high-performance desktop application that brings the power of grep-style pattern searching to Windows users with a beautiful, easy-to-use graphical interface. Search through file contents, image metadata (EXIF/GPS), and document properties (PDF, Office, audio/video files) with powerful regex pattern matching. + +## ✨ Features + +### Core Functionality +- 🔍 **Powerful Search Engine** - Fast grep-style pattern matching across files and directories +- 📊 **Smart Sorting** - Sort results by path, match count, file size, or modification date (8 sorting options) +- 🎯 **Smart Highlighting** - Yellow highlights for all matches, orange for current match +- 📁 **File Browser** - Integrated file explorer with lazy loading for smooth navigation +- 📊 **Results Tree** - Organized results grouped by file with match counts and line numbers + +### Regex & Pattern Matching +- 🔄 **Full Regex Support** - Complete regular expression pattern matching +- 📋 **Regex Pattern Library** - Quick-access popover menu with 8 built-in common regex patterns: + - Email addresses (`user@domain.com`) + - URLs (http/https) + - IPv4 addresses + - Phone numbers (multiple formats) + - Dates (various formats) + - Numbers + - Hex values (0x... and #...) + - Words/identifiers +- ✏️ **Custom Pattern Library** - Create, save, and manage your own regex patterns with persistent storage +- ✅ **Pattern Auto-Apply** - Check patterns in menu to instantly apply to your search +- 🔀 **Pattern Combination** - Enable multiple patterns simultaneously + +### Metadata Search +- 🖼️ **Image Metadata Search** - Extract and search EXIF, GPS, and PNG metadata from: + - JPG/JPEG files (EXIF tags, GPS coordinates, camera info) + - PNG files (text chunks, creation time) + - TIFF files (comprehensive EXIF data) + - Other formats: GIF, BMP, WebP +- 📄 **File Metadata Search** - Extract and search properties from: + - **PDF files**: Title, author, subject, keywords, creator, creation/modification dates + - **Microsoft Office** (.docx, .xlsx, .pptx): Author, title, subject, keywords, creation/modification dates, document statistics + - **OpenDocument** (.odt, .ods, .odp): LibreOffice/OpenOffice metadata and properties + - **Screenwriting** (.fdx, .fountain, .celtx): Final Draft, Fountain format, Celtx projects - title, author, scenes + - **eBooks** (.epub): Title, author, publisher, language, ISBN from EPUB metadata + - **Archives** (.zip): File lists, compressed size, contents preview + - **Structured Data** (.csv, .json, .xml): Headers, keys, schema information + - **Databases** (.db, .sqlite, .sqlite3): SQLite database schema, table names, column info, row counts + - **RTF files** (.rtf): Rich Text Format documents - title, author, subject, RTF version + - **Audio files** (.mp3, .flac, .m4a, .ogg, .wma): Artist, album, title, duration, bitrate + - **Video files** (.mp4, .avi, .mkv, .mov, .wmv): Video metadata, codec info, duration + +### Advanced Search Modes +- 📦 **Archive Content Search** - Search inside ZIP and EPUB files without manual extraction + - Results displayed as `archive.zip/internal/path/file.txt:line` + - Supports nested directory structures within archives + - Automatic text encoding detection +- 🔢 **Binary/Hex Search** - Search binary files using hex patterns + - Results show byte offsets and hex dumps + - 32-byte context window (16 bytes before/after match) + - Useful for firmware, executables, and binary data analysis + +### Performance Optimizations +- ⚡ **Multi-threaded Search** - Background search operations don't freeze the UI +- 💾 **File Content Caching** - LRU cache for frequently accessed files +- 📦 **Batch UI Updates** - Efficient result rendering +- 🎚️ **Configurable Limits** - File size limits, max results, cache size +- 🚀 **Smart File Filtering** - Automatic exclusion of binary files and common build directories +- 🌐 **Network Drive Optimization** - Automatic UNC path detection with timeout handling and accessibility caching + +### User Interface +- 🎨 **Three-Panel Layout** - File Explorer | Results Tree | Preview Pane +- 🎭 **Modern Design** - Clean, responsive interface with SVG icons +- 📊 **Progress Indicators** - Real-time search progress with file count and status +- 🔽 **Context Dropdown** - Select 0-10 lines of context around matches +- ⌨️ **Keyboard Shortcuts** - Quick navigation and actions + +### Persistence & Configuration +- 💾 **Search History** - Auto-complete from previous searches +- ⚙️ **User Preferences** - Customizable settings saved between sessions +- 📁 **Session State** - Remembers last directory and search options +- 🔧 **Flexible Options** - Case sensitivity, whole word, file extensions, context lines + +## 🚀 Installation + +### Prerequisites +- **Python 3.8 or higher** +- **Windows OS** (tested on Windows 10/11) +- **Git** (for cloning the repository) + +### Quick Start + +```bash +# Clone the repository +git clone https://github.com/RandyNorthrup/Python-Scripts.git +cd Python-Scripts/Advanced\ Search + +# Install dependencies +pip install -r requirements.txt + +# Run the application +python main.py +``` + +### Dependencies + +The following libraries will be installed: + +| Library | Version | Purpose | +|---------|---------|---------| +| PySide6 | ≥6.6.0 | Qt6 GUI framework | +| Pillow | ≥10.0.0 | Image metadata (EXIF, GPS) | +| PyPDF2 | ≥3.0.0 | PDF metadata extraction | +| python-docx | ≥1.0.0 | Word document metadata | +| openpyxl | ≥3.1.0 | Excel metadata | +| mutagen | ≥1.47.0 | Audio/video file tags | + +**Note:** XML, JSON, CSV, and ZIP support use Python standard library (no extra dependencies) + +## 📖 Usage + +### Basic Search + +1. **Select Directory** - Click on a folder in the left panel (File Explorer) +2. **Enter Pattern** - Type your search term in the search box at the top +3. **Configure Options** - Enable case sensitivity, regex mode, whole word, etc. +4. **Set Context** - Use dropdown to select 0-10 lines of context around matches +5. **Search** - Click "Search" button or press Enter +6. **Browse Results** - Click results in middle panel to preview, double-click to open + +### Using Regex Patterns + +Click the **"Regex Patterns ▼"** button to open a menu with common regex patterns: + +- ✓ **Check patterns** to enable them in your search +- ✅ **Combine multiple patterns** by checking several boxes +- 🗑️ **Clear all patterns** using the "Clear All" option at the bottom +- 🔄 The menu toggles open/close on each click + +**Example**: Enable "Email addresses" to find all email addresses in your files without writing regex manually. + +### Custom Patterns + +Create and save your own regex patterns: + +1. Click **"Regex Patterns ▼"** to open the menu +2. Click **"Manage Custom Patterns..."** at the bottom +3. Click **"Add Pattern"** to create a new pattern +4. Enter a name and regex pattern, then click **"Save"** +5. Your custom patterns appear in the menu alongside built-in patterns +6. Check your custom pattern to apply it to searches + +### Search Options + +| Option | Description | Default | +|--------|-------------|---------| +| **Case Sensitive** | Match exact letter case | Off | +| **Use Regex** | Enable regular expression patterns | Off | +| **Whole Word** | Match complete words only (not partial) | Off | +| **Context** | Lines of context to show around matches | 2 | +| **Extensions** | Filter by file types (e.g., `.py,.txt,.js`) | All files | +| **Search image metadata** | Search EXIF/GPS in JPG, PNG, TIFF, etc. | Off | +| **Search file metadata** | Search properties in PDF, Office, audio/video | Off | +| **Search archive contents** | Search inside ZIP and EPUB files | Off | +| **Binary/hex search** | Search binary files using hex patterns | Off | + +### Metadata Search + +#### Image Metadata Search + +Enable **"Search image metadata"** checkbox to search within image files: + +**What it searches:** +- EXIF tags (camera model, settings, software) +- GPS coordinates (latitude, longitude, altitude) +- PNG text chunks (creation time, software) +- File system metadata (creation date, modified date, size) + +**Supported formats:** JPG, JPEG, PNG, TIFF, TIF, GIF, BMP, WebP + +**Example searches:** +- Camera model: Search for "Canon" to find all Canon photos +- Location: Search for "GPS" to find geotagged images +- Date: Search for "2024" in DateTime tags + +#### File Metadata Search + +Enable **"Search file metadata"** checkbox to search document properties: + +**PDF Files:** +- Title, Author, Subject, Keywords, Creator, Producer +- Creation date, Modification date, Page count + +**Microsoft Office (.docx, .xlsx, .pptx):** +- Creator, Title, Subject, Keywords, Category +- Creation date, Modified date +- Document statistics (paragraphs, sheets, etc.) + +**OpenDocument (.odt, .ods, .odp):** +- Title, Creator, Subject, Keywords +- LibreOffice/OpenOffice metadata + +**Screenwriting Files:** +- **Final Draft (.fdx)**: Title, author, scene count, script metadata +- **Fountain (.fountain)**: Title page metadata, scene headings count +- **Celtx (.celtx)**: Project type, file count + +**eBooks (.epub):** +- Title, Author, Publisher, Language, ISBN + +**Archives (.zip):** +- File count, compressed size, contents listing + +**Structured Data:** +- **CSV**: Column headers, row count +- **JSON**: Keys, structure type, item count +- **XML**: Root tag, namespaces, attributes + +**SQLite Databases (.db, .sqlite, .sqlite3):** +- Database schema, table names +- Column names and types +- Row counts per table + +**RTF Files (.rtf):** +- Title, Author, Subject +- RTF version, creation date + +**Audio Files (.mp3, .flac, .m4a, .ogg, .wma):** +- Artist, Album, Title, Genre, Year +- Duration, Bitrate, Sample rate + +**Video Files (.mp4, .avi, .mkv, .mov, .wmv):** +- Title, Artist, Album, Genre +- Duration, Bitrate, Video codec + +**Note:** When metadata search is enabled, the tool searches ONLY metadata, not file contents. Disable to search file text content. + +### Archive Content Search + +Enable **"Search archive contents"** to search inside ZIP and EPUB files: + +- Searches text files within archives without extracting +- Results show the full path: `archive.zip/folder/file.txt:line_number` +- Supports nested directory structures +- Works with both .zip and .epub formats + +### Binary/Hex Search + +Enable **"Binary/hex search"** to search binary files using hexadecimal patterns: + +- Enter hex patterns like `48656C6C6F` to search for "Hello" in binary files +- Results display byte offsets and hex dumps +- Shows 16 bytes before and after each match (32-byte context window) +- Useful for firmware analysis, executables, and binary data inspection + +### Navigation & Workflow + +| Action | Method | +|--------|--------| +| **Sort results** | Use Sort dropdown: Path, Match Count, File Size, Date Modified | +| **Navigate matches** | Use Previous/Next buttons or `Ctrl+Up`/`Ctrl+Down` | +| **Open file** | Double-click result in results tree | +| **Open in VS Code** | Right-click → "Open in VS Code" (if installed) | +| **Copy file path** | Right-click → "Copy Full Path" | +| **View in explorer** | Right-click → "Open Containing Folder" | + +### Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| `Enter` | Start search | +| `Ctrl+Up` | Previous match | +| `Ctrl+Down` | Next match | +| `Ctrl+Q` | Quit application | +| `F1` | Open help window | + +### Preferences + +Access preferences via **Menu → Preferences** to configure: + +| Setting | Description | Default | +|---------|-------------|---------| +| **Max Search Results** | Limit total results (0 = unlimited) | 0 (unlimited) | +| **Max Preview File Size** | Maximum file size to display in preview pane (MB) | 10 MB | +| **Max Search File Size** | Maximum file size to search through (MB) | 50 MB | +| **File Cache Size** | Number of files to keep in memory cache | 50 files | + +**Note:** All preferences are saved automatically and persist between sessions. + +## 🏗️ Architecture + +``` +advanced_search/ +├── assets/ # Icons and images +│ ├── icon.ico # Application icon (Windows) +│ ├── icon.svg # SVG version of icon +│ ├── chevron_up.svg # Up arrow icon +│ └── chevron_down.svg # Down arrow icon +├── src/ # Source code +│ ├── main.py # Main application & GUI (2,057 lines) +│ ├── search_engine.py # Core search functionality (827 lines) +│ └── __pycache__/ # Python bytecode cache +├── main.py # Application entry point +├── requirements.txt # Python dependencies +├── README.md # This file +├── LICENSE # MIT License +└── CONTRIBUTING.md # Contribution guidelines +``` + +### Technology Stack +- **GUI Framework:** PySide6 (Qt6) - Modern, cross-platform UI +- **Search Engine:** Python `re` module with multi-threading +- **Image Processing:** Pillow (PIL) for EXIF/GPS extraction +- **Document Parsing:** PyPDF2, python-docx, openpyxl for metadata +- **Media Tags:** Mutagen for audio/video file metadata +- **Threading:** QThread for non-blocking background operations +- **Persistence:** JSON for settings and search history + +### Key Components + +**MainWindow Class** (`src/main.py`) +- Three-panel UI layout with compact controls +- File browser with lazy loading and drive labels +- Results tree with match grouping and sorting +- Preview pane with syntax highlighting +- Regex pattern menu system with custom pattern editor +- Metadata preview formatting +- Search history management +- Preferences dialog +- Multi-modal Search/Stop button + +**SearchEngine Class** (`src/search_engine.py`) +- Multi-threaded file scanning +- Regex pattern compilation and caching +- Image metadata extraction (EXIF, GPS, PNG) +- File metadata extraction (PDF, Office, audio/video, SQLite, RTF) +- Archive content search (ZIP, EPUB) +- Binary/hex pattern search +- Context line extraction +- File filtering and exclusion patterns +- Network drive optimization with timeout handling +- Performance optimizations (file size limits, caching) + +## 🛠️ Development + +### Running from Source + +```bash +# Ensure you're in the project directory +cd advanced_search + +# Run the application +python main.py +``` + +### Code Structure + +The application uses a clean separation of concerns: + +1. **main.py (entry point)** - Launches the application +2. **src/main.py** - All GUI code and user interactions +3. **src/search_engine.py** - Pure search logic, no GUI dependencies + +### Key Design Patterns + +- **MVC Pattern** - Separation of search logic from UI +- **Observer Pattern** - Qt signals/slots for event handling +- **Worker Thread Pattern** - QThread for background search operations +- **Lazy Loading** - File browser loads directories on-demand +- **LRU Cache** - File content caching with size limits +- **Multi-modal UI** - Single button for Search/Stop with state tracking + +### Adding New Features + +To add new metadata sources: + +1. Add library import with try/except in `search_engine.py` +2. Add file extensions to `FILE_METADATA_EXTENSIONS` class constant +3. Add extraction logic to `_extract_file_metadata()` method +4. Update README with supported formats + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## 📝 License + +This project is licensed under the MIT License. + +## 👤 Author + +**Randy Northrup** + +## 🙏 Acknowledgments + +- SVG Icons created for this project (chevron_up.svg, chevron_down.svg, icon.svg) +- Built with [PySide6](https://wiki.qt.io/Qt_for_Python) - Qt for Python +- Image processing by [Pillow](https://python-pillow.org/) +- PDF parsing by [PyPDF2](https://pypdf2.readthedocs.io/) +- Office document handling by [python-docx](https://python-docx.readthedocs.io/) and [openpyxl](https://openpyxl.readthedocs.io/) +- Audio/video metadata by [Mutagen](https://mutagen.readthedocs.io/) + +## 📮 Support + +If you encounter any issues or have suggestions, please [open an issue](../../issues). + +## ⚠️ Known Limitations + +- **Windows only** - Currently designed for Windows (paths, file handling, drive labels) +- **Text encoding** - Files must be text-readable or supported binary formats (images, PDFs, Office docs) +- **Large files** - Very large files (>50MB default) are skipped to prevent slowdown +- **Metadata availability** - Not all files contain metadata; results vary by file type and creation method +- **Network drives** - Network path timeout set to 5 seconds; inaccessible drives are cached + +## 📊 Performance Tips + +1. **Use file extension filters** - Limit search to specific file types (`.py,.js,.txt`) +2. **Adjust file size limits** - Reduce max file size in preferences for faster searches +3. **Enable metadata search selectively** - Only when needed, as it adds processing overhead +4. **Clear cache periodically** - If experiencing memory issues with large file sets +5. **Use specific regex patterns** - More specific patterns search faster than broad ones +6. **Network drives** - First access may be slow due to accessibility checks; subsequent searches use caching + +--- + +
+Made with ❤️ by Randy Northrup +
diff --git a/Advanced Search/assets/chevron_down.svg b/Advanced Search/assets/chevron_down.svg new file mode 100644 index 00000000..81a9afdc --- /dev/null +++ b/Advanced Search/assets/chevron_down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Advanced Search/assets/chevron_up.svg b/Advanced Search/assets/chevron_up.svg new file mode 100644 index 00000000..efb6cb65 --- /dev/null +++ b/Advanced Search/assets/chevron_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Advanced Search/assets/icon.ico b/Advanced Search/assets/icon.ico new file mode 100644 index 00000000..a741057f Binary files /dev/null and b/Advanced Search/assets/icon.ico differ diff --git a/Advanced Search/assets/icon.svg b/Advanced Search/assets/icon.svg new file mode 100644 index 00000000..3107ed12 --- /dev/null +++ b/Advanced Search/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Advanced Search/main.py b/Advanced Search/main.py new file mode 100644 index 00000000..543840c3 --- /dev/null +++ b/Advanced Search/main.py @@ -0,0 +1,15 @@ +""" +Advanced Search Tool - Main Launcher +Entry point for the application +""" +import sys +import os + +# Add src directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +# Import and run main application +from src.main import main + +if __name__ == '__main__': + main() diff --git a/Advanced Search/requirements.txt b/Advanced Search/requirements.txt new file mode 100644 index 00000000..876d0ccc --- /dev/null +++ b/Advanced Search/requirements.txt @@ -0,0 +1,6 @@ +PySide6>=6.6.0 +Pillow>=10.0.0 +PyPDF2>=3.0.0 +python-docx>=1.0.0 +openpyxl>=3.1.0 +mutagen>=1.47.0 diff --git a/Advanced Search/src/main.py b/Advanced Search/src/main.py new file mode 100644 index 00000000..e8ae6477 --- /dev/null +++ b/Advanced Search/src/main.py @@ -0,0 +1,2056 @@ +""" +Main GUI application for Advanced Search +""" +import sys +import os +import re +import json +import string +import subprocess +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QTreeWidget, QTreeWidgetItem, QTextEdit, QLineEdit, + QSplitter, QLabel, QCheckBox, QSpinBox, + QProgressBar, QStatusBar, QMessageBox, QMenu, QComboBox, + QDialog, QFormLayout, QDialogButtonBox, QTabWidget, QGridLayout +) +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtGui import QFont, QColor, QTextCharFormat, QTextCursor, QAction, QIcon +from .search_engine import SearchEngine, SearchMatch + + +class PreferencesDialog(QDialog): + """Preferences dialog window""" + + def __init__(self, parent, preferences): + super().__init__(parent) + self.setWindowTitle("Preferences") + self.setModal(True) + self.preferences = preferences.copy() + + # Create layout + layout = QVBoxLayout() + form = QFormLayout() + + # Max results + self.max_results_input = QSpinBox() + self.max_results_input.setRange(0, 1000000) + self.max_results_input.setValue(preferences['max_results']) + self.max_results_input.setSpecialValueText("Unlimited") + self.max_results_input.setToolTip("Maximum search results to return (0 = unlimited)") + form.addRow("Max Search Results:", self.max_results_input) + + # Max preview file size + self.max_preview_size_input = QSpinBox() + self.max_preview_size_input.setRange(1, 1000) + self.max_preview_size_input.setValue(preferences['max_preview_file_size_mb']) + self.max_preview_size_input.setSuffix(" MB") + self.max_preview_size_input.setToolTip("Maximum file size to display in preview") + form.addRow("Max Preview File Size:", self.max_preview_size_input) + + # Max search file size + self.max_search_size_input = QSpinBox() + self.max_search_size_input.setRange(1, 1000) + self.max_search_size_input.setValue(preferences['max_search_file_size_mb']) + self.max_search_size_input.setSuffix(" MB") + self.max_search_size_input.setToolTip("Maximum file size to search through") + form.addRow("Max Search File Size:", self.max_search_size_input) + + # Max cache size + self.max_cache_input = QSpinBox() + self.max_cache_input.setRange(0, 500) + self.max_cache_input.setValue(preferences['max_cache_size']) + self.max_cache_input.setSpecialValueText("Disabled") + self.max_cache_input.setToolTip("Maximum number of files to cache in memory") + form.addRow("File Cache Size:", self.max_cache_input) + + layout.addLayout(form) + + # Buttons + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + self.setLayout(layout) + self.resize(400, 250) + + def get_preferences(self): + """Get updated preferences from dialog""" + return { + 'max_results': self.max_results_input.value(), + 'max_preview_file_size_mb': self.max_preview_size_input.value(), + 'max_search_file_size_mb': self.max_search_size_input.value(), + 'max_cache_size': self.max_cache_input.value() + } + + +class HelpDialog(QDialog): + """Comprehensive help dialog window""" + + def __init__(self, parent): + super().__init__(parent) + self.setWindowTitle("Help - Advanced Search Tool") + self.setModal(False) + + # Create layout + layout = QVBoxLayout() + + # Create tab widget for different help sections + tabs = QTabWidget() + + # Overview tab + overview_text = QTextEdit() + overview_text.setReadOnly(True) + overview_text.setHtml(""" +

Advanced Search Tool - Overview

+

A powerful grep-style search application for Windows with advanced features including regex patterns, + metadata search, and result sorting.

+ +

Quick Start

+
    +
  1. Select a directory or file in the File Explorer (left panel)
  2. +
  3. Enter your search pattern in the search box
  4. +
  5. Configure search options (case sensitive, regex, whole word, etc.)
  6. +
  7. Click Search or press Enter
  8. +
  9. View results in the middle panel, preview on the right
  10. +
+ +

Three-Panel Layout

+ + """) + tabs.addTab(overview_text, "Overview") + + # Search Options tab + options_text = QTextEdit() + options_text.setReadOnly(True) + options_text.setHtml(""" +

Search Options

+ +

Basic Options

+ + + + + + + +
OptionDescription
Case SensitiveMatch exact letter case (A ≠ a)
Use RegexEnable regular expression pattern matching
Whole WordOnly match complete words, not partial matches
Context LinesShow 0-10 lines before/after each match
File ExtensionsFilter by file types (e.g., .py,.txt,.js)
+ +

Metadata Search

+

Search Image Metadata: Search EXIF, GPS, and PNG metadata in JPG, PNG, TIFF, GIF, BMP, WebP files

+

Search File Metadata: Search properties in documents, audio, video, archives, and structured data:

+ +

Note: When metadata search is enabled, ONLY metadata is searched, not file contents.

+ +

Advanced Search Modes

+ + + + +
ModeDescription
Search in ArchivesSearch inside ZIP and EPUB files without extraction. Results show as "archive.zip/internal/path.txt"
Binary/Hex SearchSearch binary files for hex patterns. Results show byte offsets and hex dumps
+ +

Result Sorting

+

Use the Sort dropdown to organize results by:

+ + """) + tabs.addTab(options_text, "Search Options") + + # Regex Patterns tab + regex_text = QTextEdit() + regex_text.setReadOnly(True) + regex_text.setHtml(""" +

Regex Patterns

+ +

Using the Regex Patterns Menu

+

Click the "Regex Patterns ▼" button to access common regex patterns:

+
    +
  1. Click the button to open the patterns menu
  2. +
  3. Check one or more patterns to enable them
  4. +
  5. The search box will update with the combined pattern
  6. +
  7. Uncheck patterns or click "Clear All" to reset
  8. +
+ +

Available Patterns

+ + + + + + + + + + +
PatternDescriptionExample Matches
Email AddressesStandard email formatuser@example.com
URLsHTTP/HTTPS web addresseshttps://example.com
IPv4 AddressesIP addresses192.168.1.1
Phone NumbersVarious phone formats(555) 123-4567
DatesVarious date formats2024-01-15, 01/15/2024
NumbersInteger numbers123, 4567
Hex ValuesHexadecimal notation0xFF, #A3B5C7
Words/IdentifiersProgramming identifiersvariable_name, camelCase
+ +

Custom Regex

+

Enable "Use Regex" checkbox and enter your own regular expression patterns.

+

Common Regex Syntax:

+ + """) + tabs.addTab(regex_text, "Regex Patterns") + + # Keyboard Shortcuts tab + shortcuts_text = QTextEdit() + shortcuts_text.setReadOnly(True) + shortcuts_text.setHtml(""" +

Keyboard Shortcuts

+ +

Search & Navigation

+ + + + + +
ShortcutAction
EnterStart search (when in search box)
Ctrl+UpGo to previous match in preview
Ctrl+DownGo to next match in preview
+ +

Application

+ + + +
ShortcutAction
Ctrl+QQuit application
+ +

Mouse Actions

+ + + + + + +
ActionResult
Single Click on resultShow preview in right panel
Double Click on resultOpen file in default application
Right Click on resultShow context menu with options
Right Click on directoryShow directory context menu
+ """) + tabs.addTab(shortcuts_text, "Shortcuts") + + # Context Menu tab + context_text = QTextEdit() + context_text.setReadOnly(True) + context_text.setHtml(""" +

Context Menus

+ +

Results Tree Context Menu

+

Right-click on any result to access:

+ + +

Directory Tree Context Menu

+

Right-click on a directory to:

+ + """) + tabs.addTab(context_text, "Context Menus") + + # Tips & Tricks tab + tips_text = QTextEdit() + tips_text.setReadOnly(True) + tips_text.setHtml(""" +

Tips & Tricks

+ +

Performance Tips

+ + +

Search Strategies

+ + +

Metadata Search Examples

+ + +

Working with Results

+ + +

File Browser Tips

+ + """) + tabs.addTab(tips_text, "Tips & Tricks") + + # Troubleshooting tab + trouble_text = QTextEdit() + trouble_text.setReadOnly(True) + trouble_text.setHtml(""" +

Troubleshooting

+ +

Common Issues

+ +

Q: Search is slow or hangs

+ + +

Q: No results found

+ + +

Q: Preview shows garbled text

+ + +

Q: Metadata search not working

+ + +

Q: Can't open file in VS Code

+ + +

Performance Notes

+ + +

Getting Help

+

For additional support:

+ + """) + tabs.addTab(trouble_text, "Troubleshooting") + + layout.addWidget(tabs) + + # Close button + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.accept) + layout.addWidget(close_btn) + + self.setLayout(layout) + self.resize(800, 600) + + +class CustomPatternManagerDialog(QDialog): + """Dialog for managing custom regex patterns""" + + def __init__(self, parent, custom_patterns): + super().__init__(parent) + self.setWindowTitle("Manage Custom Regex Patterns") + self.setModal(True) + self.custom_patterns = custom_patterns.copy() + + # Create layout + layout = QVBoxLayout() + + # Instructions + inst_label = QLabel("Add, edit, or remove your custom regex patterns:") + layout.addWidget(inst_label) + + # List of patterns + self.pattern_list = QTreeWidget() + self.pattern_list.setHeaderLabels(["Label", "Pattern"]) + self.pattern_list.setColumnWidth(0, 200) + self.pattern_list.setColumnWidth(1, 400) + self.refresh_pattern_list() + layout.addWidget(self.pattern_list) + + # Buttons + button_layout = QHBoxLayout() + + add_btn = QPushButton("Add Pattern") + add_btn.clicked.connect(self.add_pattern) + button_layout.addWidget(add_btn) + + edit_btn = QPushButton("Edit Selected") + edit_btn.clicked.connect(self.edit_pattern) + button_layout.addWidget(edit_btn) + + remove_btn = QPushButton("Remove Selected") + remove_btn.clicked.connect(self.remove_pattern) + button_layout.addWidget(remove_btn) + + button_layout.addStretch() + layout.addLayout(button_layout) + + # Dialog buttons + dialog_buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + dialog_buttons.accepted.connect(self.accept) + dialog_buttons.rejected.connect(self.reject) + layout.addWidget(dialog_buttons) + + self.setLayout(layout) + self.resize(700, 400) + + def refresh_pattern_list(self): + """Refresh the pattern list widget""" + self.pattern_list.clear() + for name, info in self.custom_patterns.items(): + item = QTreeWidgetItem(self.pattern_list) + item.setText(0, info['label']) + item.setText(1, info['pattern']) + item.setData(0, Qt.UserRole, name) + + def add_pattern(self): + """Add a new custom pattern""" + dialog = CustomPatternEditDialog(self, "", "", "") + if dialog.exec() == QDialog.Accepted: + name, label, pattern = dialog.get_pattern() + if name and label and pattern: + # Generate unique name + base_name = name.lower().replace(' ', '_') + unique_name = base_name + counter = 1 + while unique_name in self.custom_patterns: + unique_name = f"{base_name}_{counter}" + counter += 1 + + self.custom_patterns[unique_name] = { + 'pattern': pattern, + 'enabled': False, + 'label': label + } + self.refresh_pattern_list() + + def edit_pattern(self): + """Edit the selected pattern""" + selected_items = self.pattern_list.selectedItems() + if not selected_items: + QMessageBox.warning(self, "Warning", "Please select a pattern to edit") + return + + item = selected_items[0] + name = item.data(0, Qt.UserRole) + info = self.custom_patterns[name] + + dialog = CustomPatternEditDialog(self, name, info['label'], info['pattern']) + if dialog.exec() == QDialog.Accepted: + _, label, pattern = dialog.get_pattern() + if label and pattern: + self.custom_patterns[name]['label'] = label + self.custom_patterns[name]['pattern'] = pattern + self.refresh_pattern_list() + + def remove_pattern(self): + """Remove the selected pattern""" + selected_items = self.pattern_list.selectedItems() + if not selected_items: + QMessageBox.warning(self, "Warning", "Please select a pattern to remove") + return + + item = selected_items[0] + name = item.data(0, Qt.UserRole) + + reply = QMessageBox.question( + self, + "Confirm Removal", + f"Remove pattern '{self.custom_patterns[name]['label']}'?", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + del self.custom_patterns[name] + self.refresh_pattern_list() + + def get_custom_patterns(self): + """Return the updated custom patterns""" + return self.custom_patterns + + +class CustomPatternEditDialog(QDialog): + """Dialog for editing a single custom pattern""" + + def __init__(self, parent, name, label, pattern): + super().__init__(parent) + self.setWindowTitle("Edit Custom Pattern" if name else "Add Custom Pattern") + self.setModal(True) + + # Create layout + layout = QVBoxLayout() + form = QFormLayout() + + # Name field (only for new patterns) + if not name: + self.name_input = QLineEdit() + self.name_input.setPlaceholderText("e.g., my_pattern") + form.addRow("Name:", self.name_input) + else: + self.name_input = None + + # Label field + self.label_input = QLineEdit() + self.label_input.setText(label) + self.label_input.setPlaceholderText("e.g., My Custom Pattern") + form.addRow("Label:", self.label_input) + + # Pattern field + self.pattern_input = QLineEdit() + self.pattern_input.setText(pattern) + self.pattern_input.setPlaceholderText(r"e.g., \\b[A-Z]{3}-\\d{4}\\b") + form.addRow("Regex Pattern:", self.pattern_input) + + layout.addLayout(form) + + # Example/help text + help_label = QLabel( + "Examples:
" + + "• \\\\b[A-Z]{3}-\\\\d{4}\\\\b - Match ABC-1234 format
" + + "• TODO:|FIXME: - Find code comments
" + + "• \\\\$\\\\d+\\\\.\\\\d{2} - Match currency amounts
" + + "
" + ) + help_label.setWordWrap(True) + layout.addWidget(help_label) + + # Buttons + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + self.setLayout(layout) + self.resize(500, 250) + + def get_pattern(self): + """Return the pattern data (name, label, pattern)""" + name = self.name_input.text().strip() if self.name_input else "" + label = self.label_input.text().strip() + pattern = self.pattern_input.text().strip() + return name, label, pattern + + +class SearchWorker(QThread): + """Worker thread for performing searches""" + finished = Signal(list) # all results + + def __init__(self, search_engine, root_path, pattern): + super().__init__() + self.search_engine = search_engine + self.root_path = root_path + self.pattern = pattern + self._is_running = True + + def run(self): + """Run the search in background thread""" + results = self.search_engine.search(self.root_path, self.pattern) + if self._is_running: + self.finished.emit(results) + + def stop(self): + """Stop the search""" + self._is_running = False + + +class MainWindow(QMainWindow): + """Main application window""" + + # Class constants for file extensions + IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.gif', '.bmp', '.webp'} + FILE_METADATA_EXTENSIONS = {'.pdf', '.docx', '.xlsx', '.pptx', '.mp3', '.flac', '.m4a', '.mp4', '.avi', '.mkv'} + + def __init__(self): + super().__init__() + self.search_engine = SearchEngine() + self.search_worker = None + self.current_results = [] + self.current_directory = os.path.expanduser("~") + self.current_search_pattern = "" + self.current_file_matches = [] + self.current_match_index = 0 + self.search_history = [] + self.history_file = os.path.join(os.path.expanduser("~"), ".advanced_search_history.json") + self.preferences_file = os.path.join(os.path.expanduser("~"), ".advanced_search_preferences.json") + self.custom_patterns_file = os.path.join(os.path.expanduser("~"), ".advanced_search_custom_patterns.json") + + # Default preferences + self.preferences = { + 'max_results': 0, # 0 = unlimited + 'max_preview_file_size_mb': 10, + 'max_search_file_size_mb': 50, + 'max_cache_size': 50 + } + self.load_preferences() + + # Performance caches + self.file_cache = {} # Cache file contents {path: (size, content_lines)} + self.max_cache_size = self.preferences['max_cache_size'] + self.max_file_size = self.preferences['max_preview_file_size_mb'] * 1024 * 1024 + self.parsed_extensions = [] # Cached parsed extensions + + # Regex pattern options + self.regex_patterns = { + 'emails': {'pattern': r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', 'enabled': False, 'label': 'Email addresses'}, + 'urls': {'pattern': r'https?://[^\s]+', 'enabled': False, 'label': 'URLs (http/https)'}, + 'ipv4': {'pattern': r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b', 'enabled': False, 'label': 'IPv4 addresses'}, + 'phone': {'pattern': r'\b(?:\+?1[-.]?)?(?:\(?[0-9]{3}\)?[-.]?)?[0-9]{3}[-.]?[0-9]{4}\b', 'enabled': False, 'label': 'Phone numbers'}, + 'dates': {'pattern': r'\b\d{1,4}[-/.]\d{1,2}[-/.]\d{1,4}\b', 'enabled': False, 'label': 'Dates (various formats)'}, + 'numbers': {'pattern': r'\b\d+\b', 'enabled': False, 'label': 'Numbers'}, + 'hex': {'pattern': r'\b0x[0-9A-Fa-f]+\b|#[0-9A-Fa-f]{6}\b', 'enabled': False, 'label': 'Hex values'}, + 'words': {'pattern': r'\b[A-Za-z_]\w*\b', 'enabled': False, 'label': 'Words/identifiers'}, + } + self.regex_menu = None # Track the menu instance + self.regex_menu_open = False # Track menu state + + # Custom user-defined patterns + self.custom_patterns = {} # {name: {'pattern': str, 'enabled': bool, 'label': str}} + self.load_custom_patterns() + + # Apply preferences to search engine + self.search_engine.max_results = self.preferences['max_results'] + self.search_engine.max_search_file_size = self.preferences['max_search_file_size_mb'] * 1024 * 1024 + + self.load_search_history() + + self.init_ui() + self.create_menu_bar() + self.setWindowTitle("Advanced Search Tool") + + # Set application icon + icon_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets", "icon.svg") + if os.path.exists(icon_path): + self.setWindowIcon(QIcon(icon_path)) + + self.resize(1400, 900) + + def init_ui(self): + """Initialize the user interface""" + # Create central widget + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # Main layout + main_layout = QVBoxLayout(central_widget) + + # Combined search and options bar + search_options_layout = self.create_search_and_options() + main_layout.addLayout(search_options_layout) + + # Main content area with three panels + splitter = QSplitter(Qt.Horizontal) + + # Left panel - Directory tree + dir_widget = QWidget() + dir_layout = QVBoxLayout(dir_widget) + dir_layout.setContentsMargins(0, 0, 0, 0) + + dir_label = QLabel("File Explorer:") + dir_label.setStyleSheet("font-weight: bold; padding: 5px;") + dir_label.setToolTip("Select a directory or file to search") + dir_layout.addWidget(dir_label) + + self.dir_tree = QTreeWidget() + self.dir_tree.setHeaderLabels(["Name"]) + self.dir_tree.setToolTip("Click a folder to search recursively or a file to search in that file\nRight-click for options") + self.dir_tree.itemClicked.connect(self.on_dir_selected) + self.dir_tree.setContextMenuPolicy(Qt.CustomContextMenu) + self.dir_tree.customContextMenuRequested.connect(self.show_dir_context_menu) + self.dir_tree.itemExpanded.connect(self.on_dir_expanded) + self.populate_directory_tree() + dir_layout.addWidget(self.dir_tree) + + splitter.addWidget(dir_widget) + + # Middle panel - Results tree + results_widget = QWidget() + results_layout = QVBoxLayout(results_widget) + results_layout.setContentsMargins(0, 0, 0, 0) + + # Results header with sort controls + results_header = QHBoxLayout() + results_label = QLabel("Search Results:") + results_label.setStyleSheet("font-weight: bold; padding: 5px;") + results_label.setToolTip("Files and matches found in search") + results_header.addWidget(results_label) + + results_header.addStretch() + + # Sort dropdown + sort_label = QLabel("Sort:") + results_header.addWidget(sort_label) + self.sort_combo = QComboBox() + self.sort_combo.addItems([ + "Path (A-Z)", + "Path (Z-A)", + "Match Count (High-Low)", + "Match Count (Low-High)", + "File Size (Large-Small)", + "File Size (Small-Large)", + "Date Modified (Newest)", + "Date Modified (Oldest)" + ]) + self.sort_combo.setToolTip("Sort search results") + self.sort_combo.currentIndexChanged.connect(self.apply_sort) + results_header.addWidget(self.sort_combo) + + results_layout.addLayout(results_header) + + self.results_tree = QTreeWidget() + self.results_tree.setHeaderLabels(["File", "Matches"]) + self.results_tree.setColumnWidth(0, 400) + self.results_tree.setToolTip("Click to preview, double-click to open file, right-click for options") + self.results_tree.itemClicked.connect(self.on_tree_item_clicked) + self.results_tree.itemDoubleClicked.connect(self.on_item_double_clicked) + self.results_tree.setContextMenuPolicy(Qt.CustomContextMenu) + self.results_tree.customContextMenuRequested.connect(self.show_context_menu) + results_layout.addWidget(self.results_tree) + + splitter.addWidget(results_widget) + + # Right panel - Content preview + preview_widget = QWidget() + preview_layout = QVBoxLayout(preview_widget) + preview_layout.setContentsMargins(0, 0, 0, 0) + + # Preview header with navigation + preview_header = QHBoxLayout() + preview_label = QLabel("Preview:") + preview_label.setStyleSheet("font-weight: bold; padding: 5px;") + preview_label.setToolTip("File content preview with matched lines highlighted") + preview_header.addWidget(preview_label) + + preview_header.addStretch() + + # Match navigation controls + assets_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets") + prev_icon_path = os.path.join(assets_dir, "chevron_up.svg") + self.prev_match_btn = QPushButton() + if os.path.exists(prev_icon_path): + self.prev_match_btn.setIcon(QIcon(prev_icon_path)) + else: + self.prev_match_btn.setText("◄") + self.prev_match_btn.setMaximumWidth(30) + self.prev_match_btn.setToolTip("Go to previous match (Ctrl+Up)") + self.prev_match_btn.clicked.connect(self.go_to_previous_match) + self.prev_match_btn.setEnabled(False) + preview_header.addWidget(self.prev_match_btn) + + self.match_counter_label = QLabel("0/0") + self.match_counter_label.setStyleSheet("padding: 0 10px;") + self.match_counter_label.setToolTip("Current match / Total matches") + preview_header.addWidget(self.match_counter_label) + + next_icon_path = os.path.join(assets_dir, "chevron_down.svg") + self.next_match_btn = QPushButton() + if os.path.exists(next_icon_path): + self.next_match_btn.setIcon(QIcon(next_icon_path)) + else: + self.next_match_btn.setText("►") + self.next_match_btn.setMaximumWidth(30) + self.next_match_btn.setToolTip("Go to next match (Ctrl+Down)") + self.next_match_btn.clicked.connect(self.go_to_next_match) + self.next_match_btn.setEnabled(False) + preview_header.addWidget(self.next_match_btn) + + preview_layout.addLayout(preview_header) + + self.preview_text = QTextEdit() + self.preview_text.setReadOnly(True) + self.preview_text.setFont(QFont("Consolas", 10)) + self.preview_text.setToolTip("File preview - search terms are highlighted in yellow") + preview_layout.addWidget(self.preview_text) + + splitter.addWidget(preview_widget) + splitter.setSizes([300, 400, 700]) + + main_layout.addWidget(splitter) + + # Progress bar + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + main_layout.addWidget(self.progress_bar) + + # Status bar + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self.status_bar.showMessage("Ready") + + def create_menu_bar(self): + """Create application menu bar""" + menubar = self.menuBar() + + # File menu + file_menu = menubar.addMenu("&File") + + exit_action = QAction("E&xit", self) + exit_action.setShortcut("Ctrl+Q") + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + # Settings menu + settings_menu = menubar.addMenu("&Settings") + + preferences_action = QAction("Preferences...", self) + preferences_action.triggered.connect(self.show_preferences) + settings_menu.addAction(preferences_action) + + settings_menu.addSeparator() + + clear_history_action = QAction("Clear Search History", self) + clear_history_action.triggered.connect(self.clear_search_history) + settings_menu.addAction(clear_history_action) + + # Help menu + help_menu = menubar.addMenu("&Help") + + help_action = QAction("Help...", self) + help_action.setShortcut("F1") + help_action.triggered.connect(self.show_help) + help_menu.addAction(help_action) + + help_menu.addSeparator() + + about_action = QAction("About", self) + about_action.triggered.connect(self.show_about) + help_menu.addAction(about_action) + + def create_search_and_options(self): + """Create combined search and options controls""" + layout = QHBoxLayout() + + # Search for label and input + search_label = QLabel("Search for:") + search_label.setToolTip("Enter text or regular expression pattern to search for") + layout.addWidget(search_label) + + self.search_input = QComboBox() + self.search_input.setEditable(True) + self.search_input.setInsertPolicy(QComboBox.NoInsert) + self.search_input.lineEdit().setPlaceholderText("Enter search pattern...") + self.search_input.setToolTip("Text or regex pattern to find in files\nPress Enter to start search\nUse Up/Down arrows to cycle through history") + self.search_input.lineEdit().returnPressed.connect(self.start_search) + self.search_input.setMinimumWidth(250) + self.search_input.setMaxVisibleItems(10) + # Enable auto-completion + self.search_input.setCompleter(self.search_input.completer()) + self.update_search_history_dropdown() + layout.addWidget(self.search_input) + + # Multi-modal Search/Stop button + self.search_stop_btn = QPushButton("Search") + self.search_stop_btn.setToolTip("Start searching in the selected directory (Enter)") + self.search_stop_btn.clicked.connect(self.toggle_search) + self.search_stop_btn.setDefault(True) + self.is_searching = False + layout.addWidget(self.search_stop_btn) + + # Separator + layout.addSpacing(10) + + # Compact grid layout for all controls + controls_grid = QGridLayout() + controls_grid.setSpacing(8) + controls_grid.setContentsMargins(0, 0, 0, 0) + + # Row 0: Context, Regex, Case sensitive, Whole word, Image metadata + context_label = QLabel("Context:") + context_label.setToolTip("Number of lines to show before and after each match") + controls_grid.addWidget(context_label, 0, 0) + + self.context_combo = QComboBox() + for i in range(11): # 0 to 10 + self.context_combo.addItem(str(i)) + self.context_combo.setCurrentIndex(2) # Default to 2 + self.context_combo.setToolTip("Lines of context to show around matches (0-10)") + self.context_combo.currentIndexChanged.connect( + lambda index: self.search_engine.set_context_lines(index) + ) + self.context_combo.setMinimumWidth(50) + self.context_combo.setMaximumWidth(70) + controls_grid.addWidget(self.context_combo, 0, 1) + + # Regex pattern selector button + assets_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets") + chevron_icon_path = os.path.join(assets_dir, "chevron_down.svg") + self.regex_btn = QPushButton("Regex") + if os.path.exists(chevron_icon_path): + self.regex_btn.setIcon(QIcon(chevron_icon_path)) + self.regex_btn.setLayoutDirection(Qt.RightToLeft) # Icon on the right + self.regex_btn.setToolTip("Select common regex patterns to search for") + self.regex_btn.setMaximumWidth(100) + self.regex_btn.clicked.connect(self.show_regex_patterns_menu) + controls_grid.addWidget(self.regex_btn, 0, 2) + + self.case_sensitive_cb = QCheckBox("Case sensitive") + self.case_sensitive_cb.setToolTip("Match exact case when searching") + self.case_sensitive_cb.stateChanged.connect( + lambda state: self.search_engine.set_case_sensitive(state == Qt.Checked) + ) + controls_grid.addWidget(self.case_sensitive_cb, 0, 3) + + self.whole_word_cb = QCheckBox("Whole word") + self.whole_word_cb.setToolTip("Only match complete words (not partial matches)") + self.whole_word_cb.stateChanged.connect( + lambda state: self.search_engine.set_whole_word(state == Qt.Checked) + ) + controls_grid.addWidget(self.whole_word_cb, 0, 4) + + self.metadata_cb = QCheckBox("Image metadata") + self.metadata_cb.setToolTip("Search image metadata (EXIF, GPS, etc.) for JPG, PNG, TIFF files") + self.metadata_cb.stateChanged.connect( + lambda state: self.search_engine.set_search_metadata(state == 2) + ) + controls_grid.addWidget(self.metadata_cb, 0, 5) + + # Row 1: Extensions label/input, File metadata, Archive search, Binary/hex + ext_label = QLabel("Extensions:") + ext_label.setToolTip("Filter files by extension (leave empty to search all files)") + controls_grid.addWidget(ext_label, 1, 0) + + self.extensions_input = QLineEdit() + self.extensions_input.setPlaceholderText(".py,.txt,.js") + self.extensions_input.setToolTip("Comma-separated file extensions to search\nExample: .py,.txt,.js\nLeave empty to search all files") + self.extensions_input.setMaximumWidth(150) + controls_grid.addWidget(self.extensions_input, 1, 1, 1, 2) + + self.file_metadata_cb = QCheckBox("File metadata") + self.file_metadata_cb.setToolTip("Search file properties (PDF, Office docs, audio/video files): author, title, dates, etc.") + self.file_metadata_cb.stateChanged.connect( + lambda state: self.search_engine.set_search_file_metadata(state == 2) + ) + controls_grid.addWidget(self.file_metadata_cb, 1, 3) + + self.archive_search_cb = QCheckBox("Archive search") + self.archive_search_cb.setToolTip("Search inside ZIP and EPUB files without extraction") + self.archive_search_cb.stateChanged.connect( + lambda state: self.search_engine.set_search_in_archives(state == 2) + ) + controls_grid.addWidget(self.archive_search_cb, 1, 4) + + self.hex_search_cb = QCheckBox("Binary/hex") + self.hex_search_cb.setToolTip("Search binary files using hex patterns") + self.hex_search_cb.stateChanged.connect( + lambda state: self.search_engine.set_hex_search(state == 2) + ) + controls_grid.addWidget(self.hex_search_cb, 1, 5) + + layout.addLayout(controls_grid) + + layout.addStretch() + return layout + + def populate_directory_tree(self): + """Populate directory tree with common locations""" + self.dir_tree.setUpdatesEnabled(False) # Batch updates for performance + self.dir_tree.clear() + + # Add common locations + home_path = os.path.expanduser("~") + username = os.path.basename(home_path) + home_item = QTreeWidgetItem(self.dir_tree) + home_item.setText(0, f"Home ({username})") + home_item.setData(0, Qt.UserRole, {"path": home_path, "is_file": False}) + home_item.setExpanded(True) + + # Add subdirectories and files of home + self._populate_tree_item(home_item, home_path) + + # Add drives (Windows) + if os.name == 'nt': + import ctypes + for drive in string.ascii_uppercase: + drive_path = f"{drive}:\\\\" + if os.path.exists(drive_path): + # Get volume label + try: + volume_name = ctypes.create_unicode_buffer(256) + ctypes.windll.kernel32.GetVolumeInformationW( + ctypes.c_wchar_p(drive_path), + volume_name, ctypes.sizeof(volume_name), + None, None, None, None, 0 + ) + label = volume_name.value + display_text = f"{drive}: ({label})" if label else f"{drive}:" + except Exception: + display_text = f"{drive}:" + + drive_item = QTreeWidgetItem(self.dir_tree) + drive_item.setText(0, display_text) + drive_item.setData(0, Qt.UserRole, {"path": drive_path, "is_file": False}) + # Add placeholder for lazy loading + placeholder = QTreeWidgetItem(drive_item) + placeholder.setText(0, "Loading...") + + self.dir_tree.setUpdatesEnabled(True) # Re-enable updates + + def _populate_tree_item(self, parent_item, path, max_items=100): + """Populate a tree item with directories and files""" + try: + entries = [] + for entry in os.scandir(path): + if not entry.name.startswith('.'): + entries.append(entry) + + # Sort: directories first, then files + entries.sort(key=lambda e: (not e.is_dir(), e.name.lower())) + + # Limit to prevent UI freeze + for entry in entries[:max_items]: + child_item = QTreeWidgetItem(parent_item) + if entry.is_dir(): + child_item.setText(0, entry.name) + child_item.setData(0, Qt.UserRole, {"path": entry.path, "is_file": False}) + # Add placeholder for lazy loading + placeholder = QTreeWidgetItem(child_item) + placeholder.setText(0, "Loading...") + else: + child_item.setText(0, entry.name) + child_item.setData(0, Qt.UserRole, {"path": entry.path, "is_file": True}) + except PermissionError: + pass + + def on_dir_expanded(self, item): + """Handle directory expansion - lazy load contents""" + data = item.data(0, Qt.UserRole) + if data and not data.get("is_file", False): + # Check if we have a placeholder + if item.childCount() == 1 and item.child(0).text(0) == "Loading...": + # Remove placeholder + item.removeChild(item.child(0)) + # Load actual contents + self._populate_tree_item(item, data["path"]) + + def on_dir_selected(self, item, column): + """Handle directory or file selection""" + data = item.data(0, Qt.UserRole) + if data: + path = data["path"] + is_file = data.get("is_file", False) + self.current_directory = path + if is_file: + self.status_bar.showMessage(f"Selected file: {path}") + else: + self.status_bar.showMessage(f"Selected directory: {path}") + + def show_regex_patterns_menu(self): + """Show or hide popup menu with regex pattern options""" + # If menu exists and is visible, close it and prevent reopening + if self.regex_menu is not None and self.regex_menu.isVisible(): + self.regex_menu.close() + self.regex_menu_open = False + return + + # If we just closed the menu, don't reopen immediately + if self.regex_menu_open: + return + + # Mark menu as opening + self.regex_menu_open = True + + # Create new menu + self.regex_menu = QMenu(self) + self.regex_menu.setToolTipsVisible(True) + + # Add header + header_action = self.regex_menu.addAction("Select Regex Patterns:") + header_action.setEnabled(False) + self.regex_menu.addSeparator() + + # Add checkbox for each pattern + for pattern_key, pattern_info in self.regex_patterns.items(): + action = self.regex_menu.addAction(pattern_info['label']) + action.setCheckable(True) + action.setChecked(pattern_info['enabled']) + action.setToolTip(f"Pattern: {pattern_info['pattern']}") + action.triggered.connect(lambda checked, key=pattern_key: self.toggle_regex_pattern(key, checked)) + + # Add custom patterns section if any exist + if self.custom_patterns: + self.regex_menu.addSeparator() + custom_header = self.regex_menu.addAction("Custom Patterns:") + custom_header.setEnabled(False) + + for pattern_key, pattern_info in self.custom_patterns.items(): + action = self.regex_menu.addAction(pattern_info['label']) + action.setCheckable(True) + action.setChecked(pattern_info['enabled']) + action.setToolTip(f"Pattern: {pattern_info['pattern']}") + action.triggered.connect(lambda checked, key=pattern_key: self.toggle_custom_pattern(key, checked)) + + self.regex_menu.addSeparator() + + # Add manage custom patterns option + manage_action = self.regex_menu.addAction("Manage Custom Patterns...") + manage_action.triggered.connect(self.show_custom_pattern_manager) + + self.regex_menu.addSeparator() + + # Add clear all option + clear_action = self.regex_menu.addAction("Clear All") + clear_action.triggered.connect(self.clear_all_regex_patterns) + + # Clean up when menu is hidden/closed + def on_menu_hidden(): + # Use a timer to delay the flag reset to avoid immediate reopening + from PySide6.QtCore import QTimer + QTimer.singleShot(200, lambda: setattr(self, 'regex_menu_open', False)) + + self.regex_menu.aboutToHide.connect(on_menu_hidden) + + # Show menu below button using popup (non-blocking) + self.regex_menu.popup(self.regex_btn.mapToGlobal(self.regex_btn.rect().bottomLeft())) + + def toggle_regex_pattern(self, pattern_key, enabled): + """Toggle a regex pattern on/off""" + self.regex_patterns[pattern_key]['enabled'] = enabled + self.update_search_with_regex_patterns() + + # Update button text to show active patterns count + active_count = sum(1 for p in self.regex_patterns.values() if p['enabled']) + active_count += sum(1 for p in self.custom_patterns.values() if p['enabled']) + if active_count > 0: + self.regex_btn.setText(f"Regex Patterns ({active_count})") + self.regex_btn.setStyleSheet("font-weight: bold;") + else: + self.regex_btn.setText("Regex Patterns") + self.regex_btn.setStyleSheet("") + + def toggle_custom_pattern(self, pattern_key, enabled): + """Toggle a custom regex pattern on/off""" + self.custom_patterns[pattern_key]['enabled'] = enabled + self.save_custom_patterns() + self.update_search_with_regex_patterns() + + # Update button text to show active patterns count + active_count = sum(1 for p in self.regex_patterns.values() if p['enabled']) + active_count += sum(1 for p in self.custom_patterns.values() if p['enabled']) + if active_count > 0: + self.regex_btn.setText(f"Regex Patterns ({active_count})") + self.regex_btn.setStyleSheet("font-weight: bold;") + else: + self.regex_btn.setText("Regex Patterns") + self.regex_btn.setStyleSheet("") + + def clear_all_regex_patterns(self): + """Clear all selected regex patterns""" + for pattern_info in self.regex_patterns.values(): + pattern_info['enabled'] = False + for pattern_info in self.custom_patterns.values(): + pattern_info['enabled'] = False + self.save_custom_patterns() + self.update_search_with_regex_patterns() + self.regex_btn.setText("Regex Patterns") + self.regex_btn.setStyleSheet("") + + def update_search_with_regex_patterns(self): + """Update search input with combined regex patterns""" + enabled_patterns = [info['pattern'] for info in self.regex_patterns.values() if info['enabled']] + enabled_patterns += [info['pattern'] for info in self.custom_patterns.values() if info['enabled']] + + if enabled_patterns: + # Combine patterns with OR operator + combined_pattern = '|'.join(f'({pattern})' for pattern in enabled_patterns) + self.search_input.lineEdit().setText(combined_pattern) + # Enable regex mode in search engine + self.search_engine.set_regex(True) + else: + # If no patterns selected, keep current search text + # and disable regex mode + self.search_engine.set_regex(False) + + def load_custom_patterns(self): + """Load custom user-defined regex patterns from file""" + try: + if os.path.exists(self.custom_patterns_file): + with open(self.custom_patterns_file, 'r', encoding='utf-8') as f: + self.custom_patterns = json.load(f) + except Exception as e: + print(f"Error loading custom patterns: {e}") + self.custom_patterns = {} + + def save_custom_patterns(self): + """Save custom patterns to file""" + try: + with open(self.custom_patterns_file, 'w', encoding='utf-8') as f: + json.dump(self.custom_patterns, f, indent=2) + except Exception as e: + print(f"Error saving custom patterns: {e}") + + def add_custom_pattern(self, name, pattern, label): + """Add a new custom regex pattern""" + self.custom_patterns[name] = { + 'pattern': pattern, + 'enabled': False, + 'label': label + } + self.save_custom_patterns() + + def remove_custom_pattern(self, name): + """Remove a custom regex pattern""" + if name in self.custom_patterns: + del self.custom_patterns[name] + self.save_custom_patterns() + + def show_custom_pattern_manager(self): + """Show dialog to manage custom regex patterns""" + dialog = CustomPatternManagerDialog(self, self.custom_patterns) + if dialog.exec() == QDialog.Accepted: + self.custom_patterns = dialog.get_custom_patterns() + self.save_custom_patterns() + self.status_bar.showMessage("Custom patterns updated", 3000) + + def show_dir_context_menu(self, position): + """Show context menu for directory tree items""" + item = self.dir_tree.itemAt(position) + if not item: + return + + data = item.data(0, Qt.UserRole) + if not data or data.get("path") is None: + return + + menu = QMenu() + path = data["path"] + is_file = data.get("is_file", False) + + if is_file: + # File menu + open_file_action = menu.addAction("Open File") + open_file_action.triggered.connect(lambda: self.open_file(path)) + + open_parent_action = menu.addAction("Open Parent Directory") + open_parent_action.triggered.connect(lambda: self.open_file_directory(path)) + else: + # Directory menu + open_dir_action = menu.addAction("Open Directory") + open_dir_action.triggered.connect(lambda: self.open_directory(path)) + + parent_path = os.path.dirname(path) + if parent_path and parent_path != path: + open_parent_action = menu.addAction("Open Parent Directory") + open_parent_action.triggered.connect(lambda: self.open_directory(parent_path)) + + menu.exec(self.dir_tree.viewport().mapToGlobal(position)) + + def open_directory(self, directory_path): + """Open a directory in file explorer""" + try: + os.startfile(directory_path) + except Exception as e: + QMessageBox.warning(self, "Warning", f"Could not open directory: {str(e)}") + + def load_preferences(self): + """Load preferences from file""" + try: + if os.path.exists(self.preferences_file): + with open(self.preferences_file, 'r') as f: + saved_prefs = json.load(f) + self.preferences.update(saved_prefs) + except Exception as e: + print(f"Error loading preferences: {e}") + + def save_preferences(self): + """Save preferences to file""" + try: + with open(self.preferences_file, 'w') as f: + json.dump(self.preferences, f, indent=2) + except Exception as e: + print(f"Error saving preferences: {e}") + + def show_preferences(self): + """Show preferences dialog""" + dialog = PreferencesDialog(self, self.preferences) + if dialog.exec() == QDialog.Accepted: + # Update preferences + self.preferences = dialog.get_preferences() + self.save_preferences() + + # Apply new preferences + self.max_cache_size = self.preferences['max_cache_size'] + self.max_file_size = self.preferences['max_preview_file_size_mb'] * 1024 * 1024 + self.search_engine.max_results = self.preferences['max_results'] + self.search_engine.max_search_file_size = self.preferences['max_search_file_size_mb'] * 1024 * 1024 + + # Clear cache if size reduced + if len(self.file_cache) > self.max_cache_size: + # Keep only the last max_cache_size items + keys = list(self.file_cache.keys()) + for key in keys[:-self.max_cache_size]: + del self.file_cache[key] + + self.status_bar.showMessage("Preferences updated", 3000) + + def apply_sort(self): + """Apply sorting to current search results""" + if not self.current_results: + return + + # Group results by file + files_dict = {} + for match in self.current_results: + if match.file_path not in files_dict: + files_dict[match.file_path] = [] + files_dict[match.file_path].append(match) + + # Apply sorting + sort_option = self.sort_combo.currentText() + + if sort_option == "Path (A-Z)": + sorted_files = sorted(files_dict.items(), key=lambda x: x[0].lower()) + elif sort_option == "Path (Z-A)": + sorted_files = sorted(files_dict.items(), key=lambda x: x[0].lower(), reverse=True) + elif sort_option == "Match Count (High-Low)": + sorted_files = sorted(files_dict.items(), key=lambda x: len(x[1]), reverse=True) + elif sort_option == "Match Count (Low-High)": + sorted_files = sorted(files_dict.items(), key=lambda x: len(x[1])) + elif sort_option == "File Size (Large-Small)": + sorted_files = sorted(files_dict.items(), + key=lambda x: os.path.getsize(x[0]) if os.path.exists(x[0]) else 0, + reverse=True) + elif sort_option == "File Size (Small-Large)": + sorted_files = sorted(files_dict.items(), + key=lambda x: os.path.getsize(x[0]) if os.path.exists(x[0]) else 0) + elif sort_option == "Date Modified (Newest)": + sorted_files = sorted(files_dict.items(), + key=lambda x: os.path.getmtime(x[0]) if os.path.exists(x[0]) else 0, + reverse=True) + elif sort_option == "Date Modified (Oldest)": + sorted_files = sorted(files_dict.items(), + key=lambda x: os.path.getmtime(x[0]) if os.path.exists(x[0]) else 0) + else: + sorted_files = sorted(files_dict.items(), key=lambda x: x[0].lower()) + + # Update the results tree + self.results_tree.clear() + + for file_path, matches in sorted_files: + file_item = QTreeWidgetItem(self.results_tree) + file_item.setText(0, file_path) + file_item.setText(1, str(len(matches))) + file_item.setData(0, Qt.UserRole, matches) + + # Add match items + for match in matches: + match_item = QTreeWidgetItem(file_item) + match_item.setText(0, f" Line {match.line_number}: {match.line_content[:80]}") + match_item.setData(0, Qt.UserRole, match) + + # Update status + total_matches = sum(len(matches) for _, matches in sorted_files) + total_files = len(sorted_files) + + self.status_bar.showMessage( + f"Found {total_matches} matches in {total_files} files" + ) + + def toggle_search(self): + """Toggle between starting and stopping search""" + if self.is_searching: + self.stop_search() + else: + self.start_search() + + def start_search(self): + """Start a new search""" + pattern = self.search_input.currentText() + root_path = self.current_directory + + if not pattern: + QMessageBox.warning(self, "Warning", "Please enter a search pattern") + return + + # Add to search history + self.add_to_search_history(pattern) + + if not os.path.isdir(root_path): + QMessageBox.warning(self, "Warning", "Please select a valid directory from the tree") + return + + self.current_search_pattern = pattern + + # Update file extensions filter + extensions_text = self.extensions_input.text().strip() + if extensions_text: + extensions = [ext.strip() for ext in extensions_text.split(',')] + self.search_engine.set_file_extensions(extensions) + else: + self.search_engine.set_file_extensions([]) + + # Clear previous results + self.results_tree.clear() + self.preview_text.clear() + self.current_results = [] + + # Update UI state + self.is_searching = True + self.search_stop_btn.setText("Stop") + self.search_stop_btn.setToolTip("Stop the current search operation") + self.progress_bar.setVisible(True) + self.progress_bar.setRange(0, 0) # Indeterminate + self.status_bar.showMessage(f"Searching for '{pattern}'...") + + # Start search in background thread + self.search_worker = SearchWorker(self.search_engine, root_path, pattern) + self.search_worker.finished.connect(self.on_search_finished) + self.search_worker.start() + + def stop_search(self): + """Stop the current search""" + if self.search_worker: + self.search_worker.stop() + self.search_worker.wait() + self.on_search_finished([]) + + def on_search_finished(self, results): + """Handle search completion""" + self.current_results = results + + # Apply sorting to display results + self.apply_sort() + + # Update UI state + self.is_searching = False + self.search_stop_btn.setText("Search") + self.search_stop_btn.setToolTip("Start searching in the selected directory (Enter)") + self.progress_bar.setVisible(False) + + def on_tree_item_clicked(self, item, column): + """Handle tree item click""" + data = item.data(0, Qt.UserRole) + + if isinstance(data, SearchMatch): + # Single match - show full file with all matches + matches = [data] + # Try to find all matches for this file from results + for result in self.current_results: + if result.file_path == data.file_path and result != data: + matches.append(result) + self.show_file_contents_with_matches(matches) + elif isinstance(data, list): + # File with multiple matches - show file contents with highlights + self.show_file_contents_with_matches(data) + + def on_item_double_clicked(self, item, column): + """Handle double-click to open file""" + data = item.data(0, Qt.UserRole) + + if isinstance(data, SearchMatch): + self.open_file(data.file_path, data.line_number) + elif isinstance(data, list) and len(data) > 0: + self.open_file(data[0].file_path) + + def show_file_contents_with_matches(self, matches): + """Show full file contents with matched lines highlighted""" + if not matches: + self.current_file_matches = [] + self.current_match_index = 0 + self.update_match_navigation() + return + + file_path = matches[0].file_path + self.current_file_matches = matches + self.current_match_index = 0 + self.preview_text.clear() + + try: + # Check if this is an image file + file_ext = os.path.splitext(file_path)[1].lower() + image_extensions = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.gif', '.bmp', '.webp'} + file_metadata_extensions = {'.pdf', '.docx', '.xlsx', '.pptx', '.mp3', '.flac', '.m4a', '.mp4', '.avi', '.mkv'} + is_image = file_ext in image_extensions + is_file_with_metadata = file_ext in file_metadata_extensions + + # If metadata search is enabled and this is an image, show image metadata + if is_image and self.search_engine.search_metadata: + self._display_image_metadata_preview(file_path, matches) + return + + # If file metadata search is enabled and this has metadata, show file metadata + if is_file_with_metadata and self.search_engine.search_file_metadata: + self._display_file_metadata_preview(file_path, matches) + return + + # Check file size first + file_size = os.path.getsize(file_path) + if file_size > self.max_file_size: + self.preview_text.setPlainText(f"File too large to display ({file_size / 1024 / 1024:.1f}MB).\nMaximum size: {self.max_file_size / 1024 / 1024:.1f}MB") + self.current_file_matches = [] + self.current_match_index = 0 + self.update_match_navigation() + return + + # Check cache first + if file_path in self.file_cache: + cached_size, lines = self.file_cache[file_path] + if cached_size == file_size: + # Use cached content + pass + else: + # File changed, re-read + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + self._cache_file(file_path, file_size, lines) + else: + # Read entire file + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + self._cache_file(file_path, file_size, lines) + + # Build match line numbers set for quick lookup + match_lines = {match.line_number for match in matches} + + # Display file with highlights (optimized with list comprehension) + display_lines = [ + f"File: {file_path}", + f"Total matches: {len(matches)}", + "=" * 80, + "" + ] + + # Show all lines (optimized loop) + display_lines.extend( + f"{'>>> ' if i in match_lines else ' '}{i:5d} | {line.rstrip()}" + for i, line in enumerate(lines, 1) + ) + + self.preview_text.setPlainText("\n".join(display_lines)) + + # Highlight all matches + self.highlight_all_matches() + + # Update navigation and go to first match + self.update_match_navigation() + if matches: + self.jump_to_current_match() + + except Exception as e: + self.preview_text.setPlainText(f"Error reading file: {str(e)}") + self.current_file_matches = [] + self.current_match_index = 0 + self.update_match_navigation() + + def _display_image_metadata_preview(self, file_path, matches): + """Display image metadata in preview pane""" + try: + from PIL import Image + from PIL.ExifTags import TAGS, GPSTAGS + import os + from datetime import datetime + + metadata = {} + + # Extract and display metadata + with Image.open(file_path) as img: + # File system info + stat_info = os.stat(file_path) + metadata['File_Size'] = f"{stat_info.st_size / 1024:.2f} KB" + metadata['File_Created'] = datetime.fromtimestamp(stat_info.st_ctime).strftime('%Y-%m-%d %H:%M:%S') + metadata['File_Modified'] = datetime.fromtimestamp(stat_info.st_mtime).strftime('%Y-%m-%d %H:%M:%S') + + # Basic image info + metadata['Format'] = img.format or 'Unknown' + metadata['Mode'] = img.mode + metadata['Size'] = f"{img.width}x{img.height}" + + # Try to get EXIF data using getexif() (newer API) + try: + exif = img.getexif() + if exif: + for tag_id, value in exif.items(): + tag_name = TAGS.get(tag_id, f"Tag_{tag_id}") + + # Handle GPS data specially + if tag_name == "GPSInfo": + try: + gps_data = {GPSTAGS.get(gps_tag_id, f"GPS_{gps_tag_id}"): str(value[gps_tag_id]) + for gps_tag_id in value} + metadata['GPS_Info'] = str(gps_data) + except Exception: + metadata['GPS_Info'] = str(value) + else: + # Convert value to string, handle bytes + if isinstance(value, bytes): + try: + value = value.decode('utf-8', errors='ignore') + except Exception: + value = str(value)[:100] + elif isinstance(value, (tuple, list)) and len(str(value)) > 100: + value = str(value)[:100] + "..." + metadata[tag_name] = str(value) + except (AttributeError, KeyError, TypeError): + pass + + # PNG info + if hasattr(img, 'info') and img.info: + for key, value in img.info.items(): + if key not in metadata: + metadata[f"PNG_{key}"] = str(value)[:200] + + # Display using common metadata display method + note = "This image has no EXIF metadata (typical for screenshots)" if len(metadata) <= 6 else None + self._display_metadata_common(file_path, matches, metadata, "Image Metadata", note) + + except Exception as e: + self.preview_text.setPlainText(f"Error reading image metadata: {str(e)}") + self.current_file_matches = [] + self.current_match_index = 0 + self.update_match_navigation() + + def _display_file_metadata_preview(self, file_path, matches): + """Display file metadata in preview pane (PDF, Office, audio, etc.)""" + try: + import os + from datetime import datetime + + # Extract file metadata + metadata = self.search_engine._extract_file_metadata(file_path) + + # Add file system info + stat_info = os.stat(file_path) + metadata['File_Size'] = f"{stat_info.st_size / 1024:.2f} KB" + metadata['File_Created'] = datetime.fromtimestamp(stat_info.st_ctime).strftime('%Y-%m-%d %H:%M:%S') + metadata['File_Modified'] = datetime.fromtimestamp(stat_info.st_mtime).strftime('%Y-%m-%d %H:%M:%S') + + # Display using common metadata display method + note = "No extractable metadata found for this file type" if len(metadata) <= 3 else None + self._display_metadata_common(file_path, matches, metadata, "File Metadata", note) + + except Exception as e: + self.preview_text.setPlainText(f"Error reading file metadata: {str(e)}") + self.current_file_matches = [] + self.current_match_index = 0 + self.update_match_navigation() + + def _display_metadata_common(self, file_path, matches, metadata, header_text, note=None): + """Common method to display metadata in preview pane""" + display_lines = [ + f"{header_text}: {file_path}", + f"Total matches: {len(matches)}", + "=" * 80, + "" + ] + + if note: + display_lines.append(f" Note: {note}") + display_lines.append("") + + # Build match line numbers set for quick lookup + match_lines = {match.line_number for match in matches} + + # Display metadata with match indicators + for line_num, (key, value) in enumerate(metadata.items(), start=1): + line_text = f"{key}: {value}" + prefix = '>>> ' if line_num in match_lines else ' ' + display_lines.append(f"{prefix}{line_num:5d} | {line_text}") + + self.preview_text.setPlainText("\n".join(display_lines)) + + # Highlight all matches + self.highlight_all_matches() + + # Update navigation and go to first match + self.update_match_navigation() + if matches: + self.jump_to_current_match() + + def _cache_file(self, file_path, file_size, lines): + """Cache file contents with LRU eviction""" + # If cache is full, remove oldest entry + if len(self.file_cache) >= self.max_cache_size: + # Remove first (oldest) item + first_key = next(iter(self.file_cache)) + del self.file_cache[first_key] + + self.file_cache[file_path] = (file_size, lines) + + def highlight_all_matches(self): + """Highlight all search matches in the preview text (optimized)""" + if not self.current_file_matches or not self.current_search_pattern: + return + + # Get the search pattern for highlighting + pattern = self.current_search_pattern + + # Build regex for highlighting + try: + if self.search_engine.use_regex: + flags = 0 if self.search_engine.case_sensitive else re.IGNORECASE + regex = re.compile(pattern, flags) + else: + escaped_pattern = re.escape(pattern) + if self.search_engine.whole_word: + escaped_pattern = r'\b' + escaped_pattern + r'\b' + flags = 0 if self.search_engine.case_sensitive else re.IGNORECASE + regex = re.compile(escaped_pattern, flags) + except re.error: + return + + # Yellow highlight format + highlight_format = QTextCharFormat() + highlight_format.setBackground(QColor(255, 255, 0)) + + # Get text once (optimization) + text = self.preview_text.toPlainText() + + # Skip header (4 lines) + header_lines = text.split('\n', 4) + if len(header_lines) < 5: + return + header_length = sum(len(line) + 1 for line in header_lines[:4]) + + # Batch highlight all matches (optimized) + cursor = self.preview_text.textCursor() + cursor.beginEditBlock() # Batch operations + + for match in regex.finditer(text[header_length:]): + cursor.setPosition(header_length + match.start()) + cursor.setPosition(header_length + match.end(), QTextCursor.KeepAnchor) + cursor.mergeCharFormat(highlight_format) + + cursor.endEditBlock() # Complete batch + + def jump_to_current_match(self): + """Jump to the current match in preview""" + if not self.current_file_matches or self.current_match_index >= len(self.current_file_matches): + return + + match = self.current_file_matches[self.current_match_index] + + # Re-highlight all matches first (to reset orange highlight) + self.highlight_all_matches() + + # Find header position (skip first 4 lines) + header_cursor = QTextCursor(self.preview_text.document()) + header_cursor.movePosition(QTextCursor.Start) + for _ in range(4): + header_cursor.movePosition(QTextCursor.Down) + header_pos = header_cursor.position() + + # Find all matches in the preview text (after header) + text = self.preview_text.toPlainText() + + # Build regex for finding matches + try: + pattern = self.current_search_pattern + if self.search_engine.use_regex: + flags = 0 if self.search_engine.case_sensitive else re.IGNORECASE + regex = re.compile(pattern, flags) + else: + escaped_pattern = re.escape(pattern) + if self.search_engine.whole_word: + escaped_pattern = r'\b' + escaped_pattern + r'\b' + flags = 0 if self.search_engine.case_sensitive else re.IGNORECASE + regex = re.compile(escaped_pattern, flags) + + # Find all matches after header only + all_matches = [m for m in regex.finditer(text) if m.start() >= header_pos] + + if self.current_match_index < len(all_matches): + match_obj = all_matches[self.current_match_index] + + # Create cursor and select the match + cursor = QTextCursor(self.preview_text.document()) + cursor.setPosition(match_obj.start()) + cursor.setPosition(match_obj.end(), QTextCursor.KeepAnchor) + + # Apply orange highlight to current match + current_format = QTextCharFormat() + current_format.setBackground(QColor(255, 165, 0)) # Orange + cursor.mergeCharFormat(current_format) + + # Move cursor to this position and ensure visible + cursor.setPosition(match_obj.start()) + self.preview_text.setTextCursor(cursor) + self.preview_text.ensureCursorVisible() + + except re.error: + pass + + def update_match_navigation(self): + """Update match counter and navigation button states""" + if not self.current_file_matches: + self.match_counter_label.setText("0/0") + self.prev_match_btn.setEnabled(False) + self.next_match_btn.setEnabled(False) + else: + total = len(self.current_file_matches) + current = self.current_match_index + 1 + self.match_counter_label.setText(f"{current}/{total}") + # Always enable buttons if there are matches (cycling enabled) + self.prev_match_btn.setEnabled(True) + self.next_match_btn.setEnabled(True) + + def go_to_previous_match(self): + """Navigate to previous match (wraps to last)""" + if not self.current_file_matches: + return + + if self.current_match_index > 0: + self.current_match_index -= 1 + else: + # Wrap to last match + self.current_match_index = len(self.current_file_matches) - 1 + + self.highlight_all_matches() # Re-highlight all + self.jump_to_current_match() + self.update_match_navigation() + + def go_to_next_match(self): + """Navigate to next match (wraps to first)""" + if not self.current_file_matches: + return + + if self.current_match_index < len(self.current_file_matches) - 1: + self.current_match_index += 1 + else: + # Wrap to first match + self.current_match_index = 0 + + self.highlight_all_matches() # Re-highlight all + self.jump_to_current_match() + self.update_match_navigation() + + def show_context_menu(self, position): + """Show context menu for tree items""" + item = self.results_tree.itemAt(position) + if not item: + return + + menu = QMenu() + + # Get data from item + data = item.data(0, Qt.UserRole) + + if isinstance(data, SearchMatch): + # Single match + open_action = menu.addAction("Open") + open_action.triggered.connect(lambda: self.open_file(data.file_path, data.line_number)) + + open_dir_action = menu.addAction("Open File Directory") + open_dir_action.triggered.connect(lambda: self.open_file_directory(data.file_path)) + + menu.addSeparator() + + copy_path_action = menu.addAction("Copy File Path") + copy_path_action.triggered.connect(lambda: QApplication.clipboard().setText(data.file_path)) + + copy_line_action = menu.addAction("Copy Line Content") + copy_line_action.triggered.connect(lambda: QApplication.clipboard().setText(data.line_content)) + + elif isinstance(data, list) and len(data) > 0: + # File with multiple matches + open_action = menu.addAction("Open") + open_action.triggered.connect(lambda: self.open_file(data[0].file_path)) + + open_dir_action = menu.addAction("Open Folder Directory") + open_dir_action.triggered.connect(lambda: self.open_file_directory(data[0].file_path)) + + menu.addSeparator() + + copy_path_action = menu.addAction("Copy File Path") + copy_path_action.triggered.connect(lambda: QApplication.clipboard().setText(data[0].file_path)) + + menu.addSeparator() + + expand_action = menu.addAction("Expand All") + expand_action.triggered.connect(lambda: item.setExpanded(True)) + + collapse_action = menu.addAction("Collapse All") + collapse_action.triggered.connect(lambda: item.setExpanded(False)) + + menu.exec(self.results_tree.viewport().mapToGlobal(position)) + + def open_file(self, file_path, line_number=None): + """Open file in default editor""" + try: + if line_number: + # Try to open with VS Code if available + try: + subprocess.Popen(['code', '-g', f'{file_path}:{line_number}']) + except FileNotFoundError: + # VS Code not available, just open the file + os.startfile(file_path) + else: + # Use default application + os.startfile(file_path) + except Exception as e: + QMessageBox.warning(self, "Warning", f"Could not open file: {str(e)}") + + def open_file_directory(self, file_path): + """Open the directory containing the file""" + try: + directory = os.path.dirname(file_path) + os.startfile(directory) + except Exception as e: + QMessageBox.warning(self, "Warning", f"Could not open directory: {str(e)}") + + def show_help(self): + """Show comprehensive help dialog""" + dialog = HelpDialog(self) + dialog.exec() + + def show_about(self): + """Show about dialog""" + QMessageBox.about( + self, + "About Advanced Search Tool", + "

Advanced Search Tool

" + "

Version 0.5.1

" + "

Author: Randy Northrup

" + "

A Windows GUI application for grep-style searching with advanced regex patterns and metadata search.

" + "

Key Features:

" + "" + "

Built with Python, PySide6, Pillow, PyPDF2, and more

" + ) + + def load_search_history(self): + """Load search history from file""" + try: + if os.path.exists(self.history_file): + with open(self.history_file, 'r', encoding='utf-8') as f: + self.search_history = json.load(f) + # Limit to last 50 entries + self.search_history = self.search_history[-50:] + except Exception as e: + print(f"Failed to load search history: {e}") + self.search_history = [] + + def save_search_history(self): + """Save search history to file""" + try: + with open(self.history_file, 'w', encoding='utf-8') as f: + json.dump(self.search_history, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"Failed to save search history: {e}") + + def add_to_search_history(self, pattern): + """Add a search pattern to history""" + if not pattern or pattern.strip() == "": + return + + # Remove if already exists (to move to end) + if pattern in self.search_history: + self.search_history.remove(pattern) + + # Add to end + self.search_history.append(pattern) + + # Limit to 50 entries + if len(self.search_history) > 50: + self.search_history = self.search_history[-50:] + + # Update dropdown and save + self.update_search_history_dropdown() + self.save_search_history() + + def update_search_history_dropdown(self): + """Update the search input dropdown with history""" + self.search_input.clear() + # Add history in reverse order (most recent first) + for pattern in reversed(self.search_history): + self.search_input.addItem(pattern) + + def clear_search_history(self): + """Clear all search history""" + reply = QMessageBox.question( + self, + "Clear Search History", + "Are you sure you want to clear all search history?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + + if reply == QMessageBox.Yes: + self.search_history = [] + self.update_search_history_dropdown() + self.save_search_history() + QMessageBox.information(self, "Success", "Search history has been cleared.") + + +def main(): + """Main entry point""" + app = QApplication(sys.argv) + + # Set application style + app.setStyle('Fusion') + + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == '__main__': + main() diff --git a/Advanced Search/src/search_engine.py b/Advanced Search/src/search_engine.py new file mode 100644 index 00000000..9a3ec73a --- /dev/null +++ b/Advanced Search/src/search_engine.py @@ -0,0 +1,826 @@ +""" +Search engine module for grep-style searching +""" +import os +import re +from typing import List, Dict, Any +from dataclasses import dataclass + +try: + from PIL import Image + from PIL.ExifTags import TAGS, GPSTAGS + PILLOW_AVAILABLE = True +except ImportError: + PILLOW_AVAILABLE = False + +try: + import PyPDF2 + PYPDF2_AVAILABLE = True +except ImportError: + PYPDF2_AVAILABLE = False + +try: + import docx + DOCX_AVAILABLE = True +except ImportError: + DOCX_AVAILABLE = False + +try: + import openpyxl + OPENPYXL_AVAILABLE = True +except ImportError: + OPENPYXL_AVAILABLE = False + +try: + from mutagen import File as MutagenFile + MUTAGEN_AVAILABLE = True +except ImportError: + MUTAGEN_AVAILABLE = False + +try: + import xml.etree.ElementTree as ET + XML_AVAILABLE = True +except ImportError: + XML_AVAILABLE = False + +try: + import zipfile + ZIPFILE_AVAILABLE = True +except ImportError: + ZIPFILE_AVAILABLE = False + +try: + import csv + CSV_AVAILABLE = True +except ImportError: + CSV_AVAILABLE = False + +try: + import json as json_lib + JSON_AVAILABLE = True +except ImportError: + JSON_AVAILABLE = False + +try: + import sqlite3 + SQLITE_AVAILABLE = True +except ImportError: + SQLITE_AVAILABLE = False + + +@dataclass +class SearchMatch: + """Represents a search match in a file""" + file_path: str + line_number: int + line_content: str + match_start: int + match_end: int + context_before: List[str] + context_after: List[str] + + +class SearchEngine: + """Grep-style search engine""" + + # Class constants for supported file types + IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.gif', '.bmp', '.webp'} + FILE_METADATA_EXTENSIONS = { + # Office & Documents + '.pdf', '.docx', '.xlsx', '.pptx', + # OpenDocument formats + '.odt', '.ods', '.odp', + # Screenwriting formats + '.fdx', '.fountain', '.celtx', + # Archive formats + '.zip', '.epub', + # Structured data + '.csv', '.json', '.xml', + # Database + '.db', '.sqlite', '.sqlite3', + # RTF + '.rtf', + # Audio/Video + '.mp3', '.flac', '.m4a', '.mp4', '.avi', '.mkv', '.mov', '.wmv', '.ogg', '.wma' + } + + ARCHIVE_EXTENSIONS = {'.zip', '.epub'} + + def __init__(self): + self.case_sensitive = False + self.use_regex = False + self.whole_word = False + self.search_metadata = False # Image metadata + self.search_file_metadata = False # File metadata (PDF, Office, audio, video) + self.search_in_archives = False # Search inside archive files + self.hex_search = False # Binary/hex search mode + self.context_lines = 2 + self.file_extensions = [] # Empty means all files + self.max_results = 0 # 0 = unlimited + self.max_search_file_size = 50 * 1024 * 1024 # 50MB default + self.network_timeout = 5 # seconds for network operations + self._network_path_cache = {} # Cache for network path accessibility + self.exclude_patterns = [ + r'\.git', r'\.svn', r'__pycache__', r'node_modules', + r'\.pyc$', r'\.exe$', r'\.dll$', r'\.so$', r'\.bin$' + ] + + def search(self, root_path: str, pattern: str) -> List[SearchMatch]: + """ + Search for pattern in files under root_path or in a specific file + + Args: + root_path: Directory to search in or specific file path + pattern: Search pattern (text or regex) + + Returns: + List of SearchMatch objects + """ + matches = [] + + if not pattern: + return matches + + # Check network path accessibility + if self._is_network_path(root_path): + if not self._check_network_path_accessible(root_path): + print(f"Network path not accessible or timed out: {root_path}") + return matches + + # Compile regex pattern + try: + if self.use_regex: + flags = 0 if self.case_sensitive else re.IGNORECASE + regex = re.compile(pattern, flags) + else: + # Escape special regex characters for literal search + escaped_pattern = re.escape(pattern) + if self.whole_word: + escaped_pattern = r'\b' + escaped_pattern + r'\b' + flags = 0 if self.case_sensitive else re.IGNORECASE + regex = re.compile(escaped_pattern, flags) + except re.error as e: + print(f"Invalid regex pattern: {e}") + return matches + + # Check if root_path is a file or directory + if os.path.isfile(root_path): + # Search in single file + if not self._is_excluded(root_path): + # Check file extension filter + if not self.file_extensions or any(root_path.endswith(ext) for ext in self.file_extensions): + file_matches = self._search_file(root_path, regex) + matches.extend(file_matches) + else: + # Walk directory tree + for root, dirs, files in os.walk(root_path): + # Early exit if max results reached (when limit is set) + if self.max_results > 0 and len(matches) >= self.max_results: + break + + # Filter out excluded directories + dirs[:] = [d for d in dirs if not self._is_excluded(os.path.join(root, d))] + + for file in files: + # Early exit if max results reached (when limit is set) + if self.max_results > 0 and len(matches) >= self.max_results: + break + + file_path = os.path.join(root, file) + + # Skip excluded files + if self._is_excluded(file_path): + continue + + # Check file extension filter + if self.file_extensions: + if not any(file.endswith(ext) for ext in self.file_extensions): + continue + + # Search in file + file_matches = self._search_file(file_path, regex) + matches.extend(file_matches) + + return matches + + def _is_excluded(self, path: str) -> bool: + """Check if path should be excluded""" + path = path.replace('\\', '/') + for pattern in self.exclude_patterns: + if re.search(pattern, path): + return True + return False + + def _search_file(self, file_path: str, regex: re.Pattern) -> List[SearchMatch]: + """Search for pattern in a single file (optimized)""" + matches = [] + + try: + # Check file size first (skip very large files) + file_size = os.path.getsize(file_path) + if file_size > self.max_search_file_size: + return matches + + file_ext = os.path.splitext(file_path)[1].lower() + + # Check if this is an archive file and archive search is enabled + if self.search_in_archives and file_ext in self.ARCHIVE_EXTENSIONS: + archive_matches = self._search_archive(file_path, regex) + matches.extend(archive_matches) + return matches + + # Check if this is an image file and image metadata search is enabled + if self.search_metadata and file_ext in self.IMAGE_EXTENSIONS: + # Search ONLY image metadata when metadata search is enabled + metadata_matches = self._search_image_metadata(file_path, regex) + matches.extend(metadata_matches) + return matches # Skip text search for images + + # Check if this is a file with metadata and file metadata search is enabled + if self.search_file_metadata and file_ext in self.FILE_METADATA_EXTENSIONS: + # Search ONLY file metadata when file metadata search is enabled + metadata_matches = self._search_file_metadata(file_path, regex) + matches.extend(metadata_matches) + return matches # Skip text search for these files + + # If either metadata search is enabled but this file doesn't match, skip it + if self.search_metadata or self.search_file_metadata: + return matches + + # Binary/hex search mode + if self.hex_search: + hex_matches = self._search_binary(file_path, regex) + matches.extend(hex_matches) + return matches + + # Try to read as text file + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + + # Search each line + for i, line in enumerate(lines): + for match in regex.finditer(line): + # Get context lines + context_before = [] + context_after = [] + + start_idx = max(0, i - self.context_lines) + end_idx = min(len(lines), i + self.context_lines + 1) + + if self.context_lines > 0: + context_before = [lines[j].rstrip('\n\r') for j in range(start_idx, i)] + context_after = [lines[j].rstrip('\n\r') for j in range(i + 1, end_idx)] + + search_match = SearchMatch( + file_path=file_path, + line_number=i + 1, # 1-based line numbers + line_content=line.rstrip('\n\r'), + match_start=match.start(), + match_end=match.end(), + context_before=context_before, + context_after=context_after + ) + matches.append(search_match) + + except (IOError, OSError, UnicodeDecodeError): + # Skip files that can't be read + pass + + return matches + + def _search_image_metadata(self, file_path: str, regex: re.Pattern) -> List[SearchMatch]: + """Search image metadata for pattern matches""" + matches = [] + + if not PILLOW_AVAILABLE: + return matches + + try: + with Image.open(file_path) as img: + metadata = self._extract_image_metadata(img) + + # Convert metadata to searchable text + line_num = 1 + for key, value in metadata.items(): + # Create searchable line from metadata + line_text = f"{key}: {value}" + + # Search for matches in this metadata line + for match in regex.finditer(line_text): + search_match = SearchMatch( + file_path=file_path, + line_number=line_num, + line_content=line_text, + match_start=match.start(), + match_end=match.end(), + context_before=[], + context_after=[] + ) + matches.append(search_match) + + line_num += 1 + + except Exception: + # Skip files that can't be opened as images + pass + + return matches + + def _extract_image_metadata(self, img: 'Image.Image') -> Dict[str, Any]: + """Extract metadata from an image""" + metadata = {} + + # Basic image info + metadata['Format'] = img.format or 'Unknown' + metadata['Mode'] = img.mode + metadata['Size'] = f"{img.width}x{img.height}" + + # EXIF data + try: + exif_data = img._getexif() + if exif_data: + for tag_id, value in exif_data.items(): + tag_name = TAGS.get(tag_id, f"Unknown_{tag_id}") + + # Handle GPS data specially + if tag_name == "GPSInfo": + gps_data = {} + for gps_tag_id, gps_value in value.items(): + gps_tag_name = GPSTAGS.get(gps_tag_id, f"GPS_{gps_tag_id}") + gps_data[gps_tag_name] = str(gps_value) + metadata[tag_name] = str(gps_data) + else: + # Convert value to string, handle bytes + if isinstance(value, bytes): + try: + value = value.decode('utf-8', errors='ignore') + except Exception: + value = str(value) + metadata[tag_name] = str(value) + except (AttributeError, KeyError, TypeError): + pass + + # PNG info + if hasattr(img, 'info'): + for key, value in img.info.items(): + if key not in metadata: + metadata[f"PNG_{key}"] = str(value) + + return metadata + + def _search_file_metadata(self, file_path: str, regex: re.Pattern) -> List[SearchMatch]: + """Search file metadata for pattern matches""" + matches = [] + metadata = self._extract_file_metadata(file_path) + + # Convert metadata to searchable text + line_num = 1 + for key, value in metadata.items(): + # Create searchable line from metadata + line_text = f"{key}: {value}" + + # Search for matches in this metadata line + for match in regex.finditer(line_text): + search_match = SearchMatch( + file_path=file_path, + line_number=line_num, + line_content=line_text, + match_start=match.start(), + match_end=match.end(), + context_before=[], + context_after=[] + ) + matches.append(search_match) + + line_num += 1 + + return matches + + def _extract_file_metadata(self, file_path: str) -> Dict[str, Any]: + """Extract metadata from various file types""" + metadata = {} + file_ext = os.path.splitext(file_path)[1].lower() + + try: + # Screenwriting formats + if file_ext == '.fdx' and XML_AVAILABLE: + # Final Draft XML format + tree = ET.parse(file_path) + root = tree.getroot() + + # Extract metadata from FinalDraft namespace + for content in root.findall('.//{http://www.finaldraft.com/FDX}Content'): + content_type = content.get('Type', '') + if content_type: + metadata[f'FDX_{content_type}'] = content.text[:200] if content.text else '' + + # Count pages, scenes, etc + scenes = root.findall('.//{http://www.finaldraft.com/FDX}Paragraph[@Type="Scene Heading"]') + metadata['Scenes'] = str(len(scenes)) + + elif file_ext == '.fountain': + # Fountain format (plain text with special syntax) + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + + # Extract title page metadata (key: value format at start) + in_title_page = True + scene_count = 0 + + for line in lines: + if in_title_page and ':' in line and not line.startswith('.'): + key, value = line.split(':', 1) + metadata[key.strip()] = value.strip()[:200] + elif line.strip() == '': + in_title_page = False + elif line.strip().startswith(('INT.', 'EXT.', 'INT/EXT', 'I/E')): + scene_count += 1 + + metadata['Scenes'] = str(scene_count) + + elif file_ext == '.celtx' and ZIPFILE_AVAILABLE: + # Celtx is a ZIP archive with HTML/XML + with zipfile.ZipFile(file_path, 'r') as z: + if 'project.celtx' in z.namelist(): + content = z.read('project.celtx').decode('utf-8', errors='ignore') + # Basic metadata extraction + metadata['Type'] = 'Celtx Project' + metadata['Files'] = str(len(z.namelist())) + + # Archive formats + elif file_ext == '.zip' and ZIPFILE_AVAILABLE: + with zipfile.ZipFile(file_path, 'r') as z: + metadata['Files'] = str(len(z.namelist())) + metadata['Compressed Size'] = f"{os.path.getsize(file_path) / 1024:.1f} KB" + # List first 10 files + file_list = z.namelist()[:10] + metadata['Contents'] = ', '.join(file_list) + if len(z.namelist()) > 10: + metadata['Contents'] += f' ... and {len(z.namelist()) - 10} more' + + elif file_ext == '.epub' and ZIPFILE_AVAILABLE: + # EPUB is a ZIP with specific structure + with zipfile.ZipFile(file_path, 'r') as z: + # Try to read metadata from content.opf + for name in z.namelist(): + if name.endswith('.opf'): + content = z.read(name).decode('utf-8', errors='ignore') + if XML_AVAILABLE: + try: + root = ET.fromstring(content) + # Extract Dublin Core metadata + ns = {'dc': 'http://purl.org/dc/elements/1.1/'} + for elem in root.findall('.//dc:title', ns): + metadata['Title'] = elem.text[:200] if elem.text else '' + for elem in root.findall('.//dc:creator', ns): + metadata['Author'] = elem.text[:200] if elem.text else '' + for elem in root.findall('.//dc:publisher', ns): + metadata['Publisher'] = elem.text[:200] if elem.text else '' + for elem in root.findall('.//dc:language', ns): + metadata['Language'] = elem.text if elem.text else '' + except Exception: + pass + break + + # Structured data formats + elif file_ext == '.csv' and CSV_AVAILABLE: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + reader = csv.reader(f) + headers = next(reader, None) + if headers: + metadata['Columns'] = ', '.join(headers[:10]) + if len(headers) > 10: + metadata['Columns'] += f' ... ({len(headers)} total)' + + # Count rows (limit to avoid performance issues) + row_count = sum(1 for _ in reader) + metadata['Rows'] = str(row_count + 1) # +1 for header + + elif file_ext == '.json' and JSON_AVAILABLE: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + try: + data = json_lib.load(f) + metadata['Type'] = type(data).__name__ + if isinstance(data, dict): + metadata['Keys'] = ', '.join(list(data.keys())[:10]) + if len(data) > 10: + metadata['Keys'] += f' ... ({len(data)} total)' + elif isinstance(data, list): + metadata['Items'] = str(len(data)) + except Exception: + metadata['Type'] = 'Invalid JSON' + + elif file_ext == '.xml' and XML_AVAILABLE: + tree = ET.parse(file_path) + root = tree.getroot() + metadata['Root Tag'] = root.tag + metadata['Namespace'] = root.tag.split('}')[0][1:] if '}' in root.tag else 'None' + metadata['Child Elements'] = str(len(list(root))) + + # Extract common attributes + for key, value in root.attrib.items(): + metadata[f'Attr_{key}'] = str(value)[:200] + + # OpenDocument formats + elif file_ext in {'.odt', '.ods', '.odp'} and ZIPFILE_AVAILABLE: + with zipfile.ZipFile(file_path, 'r') as z: + if 'meta.xml' in z.namelist(): + content = z.read('meta.xml').decode('utf-8', errors='ignore') + if XML_AVAILABLE: + try: + root = ET.fromstring(content) + ns = { + 'meta': 'urn:oasis:names:tc:opendocument:xmlns:meta:1.0', + 'dc': 'http://purl.org/dc/elements/1.1/' + } + for elem in root.findall('.//dc:title', ns): + metadata['Title'] = elem.text[:200] if elem.text else '' + for elem in root.findall('.//dc:creator', ns): + metadata['Creator'] = elem.text[:200] if elem.text else '' + for elem in root.findall('.//dc:subject', ns): + metadata['Subject'] = elem.text[:200] if elem.text else '' + for elem in root.findall('.//meta:keyword', ns): + metadata['Keywords'] = elem.text[:200] if elem.text else '' + except Exception: + pass + + # PDF files + elif file_ext == '.pdf' and PYPDF2_AVAILABLE: + with open(file_path, 'rb') as f: + pdf = PyPDF2.PdfReader(f) + if pdf.metadata: + for key, value in pdf.metadata.items(): + metadata[f"PDF_{key.strip('/')}"] = str(value)[:200] + metadata['PDF_Pages'] = str(len(pdf.pages)) + + # Word documents + elif file_ext == '.docx' and DOCX_AVAILABLE: + doc = docx.Document(file_path) + props = doc.core_properties + if props.author: metadata['Author'] = props.author + if props.title: metadata['Title'] = props.title + if props.subject: metadata['Subject'] = props.subject + if props.keywords: metadata['Keywords'] = props.keywords + if props.category: metadata['Category'] = props.category + if props.comments: metadata['Comments'] = props.comments[:200] + if props.created: metadata['Created'] = str(props.created) + if props.modified: metadata['Modified'] = str(props.modified) + metadata['Paragraphs'] = str(len(doc.paragraphs)) + + # Excel files + elif file_ext == '.xlsx' and OPENPYXL_AVAILABLE: + wb = openpyxl.load_workbook(file_path, read_only=True) + props = wb.properties + if props.creator: metadata['Creator'] = props.creator + if props.title: metadata['Title'] = props.title + if props.subject: metadata['Subject'] = props.subject + if props.keywords: metadata['Keywords'] = props.keywords + if props.category: metadata['Category'] = props.category + if props.description: metadata['Description'] = props.description[:200] + if props.created: metadata['Created'] = str(props.created) + if props.modified: metadata['Modified'] = str(props.modified) + metadata['Sheets'] = str(len(wb.sheetnames)) + wb.close() + + # Audio/Video files + elif file_ext in {'.mp3', '.flac', '.m4a', '.ogg', '.wma', '.mp4', '.avi', '.mkv', '.mov', '.wmv'} and MUTAGEN_AVAILABLE: + audio = MutagenFile(file_path) + if audio and audio.tags: + for key, value in audio.tags.items(): + # Clean up tag name + clean_key = str(key).replace('\x00', '') + metadata[f"Audio_{clean_key}"] = str(value)[:200] + if audio and hasattr(audio.info, 'length'): + metadata['Duration'] = f"{int(audio.info.length // 60)}:{int(audio.info.length % 60):02d}" + if audio and hasattr(audio.info, 'bitrate'): + metadata['Bitrate'] = f"{audio.info.bitrate // 1000} kbps" + + # SQLite databases + elif file_ext in {'.db', '.sqlite', '.sqlite3'} and SQLITE_AVAILABLE: + conn = sqlite3.connect(file_path) + cursor = conn.cursor() + + # Get all table names + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + tables = [row[0] for row in cursor.fetchall()] + metadata['Tables'] = ', '.join(tables) + metadata['Table Count'] = str(len(tables)) + + # Get schema info for each table + for table in tables[:5]: # Limit to first 5 tables + cursor.execute(f"PRAGMA table_info({table})") + columns = [row[1] for row in cursor.fetchall()] + metadata[f'Table_{table}_Columns'] = ', '.join(columns) + + # Get row count + cursor.execute(f"SELECT COUNT(*) FROM {table}") + row_count = cursor.fetchone()[0] + metadata[f'Table_{table}_Rows'] = str(row_count) + + conn.close() + + # RTF files + elif file_ext == '.rtf': + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read(1000) # Read first 1KB + + # Extract basic RTF metadata from header + # RTF format: {\info{\title ...}{\author ...}} + import re + title_match = re.search(r'\\title\s+([^}]+)', content) + if title_match: + metadata['Title'] = title_match.group(1).strip()[:200] + + author_match = re.search(r'\\author\s+([^}]+)', content) + if author_match: + metadata['Author'] = author_match.group(1).strip()[:200] + + subject_match = re.search(r'\\subject\s+([^}]+)', content) + if subject_match: + metadata['Subject'] = subject_match.group(1).strip()[:200] + + # Get RTF version + version_match = re.search(r'\\rtf(\d+)', content) + if version_match: + metadata['RTF Version'] = version_match.group(1) + + except Exception as e: + # If metadata extraction fails, just skip this file + pass + + return metadata + + def _search_archive(self, file_path: str, regex: re.Pattern) -> List[SearchMatch]: + """Search inside archive files (ZIP, EPUB, etc.)""" + matches = [] + + if not ZIPFILE_AVAILABLE: + return matches + + try: + with zipfile.ZipFile(file_path, 'r') as zf: + for member in zf.namelist(): + # Skip directories + if member.endswith('/'): + continue + + # Check file size + member_info = zf.getinfo(member) + if member_info.file_size > self.max_search_file_size: + continue + + try: + # Read file content from archive + content = zf.read(member) + + # Try to decode as text + try: + text = content.decode('utf-8', errors='ignore') + lines = text.split('\n') + + # Search each line + for i, line in enumerate(lines): + for match in regex.finditer(line): + # Get context lines + context_before = [] + context_after = [] + + start_idx = max(0, i - self.context_lines) + end_idx = min(len(lines), i + self.context_lines + 1) + + if self.context_lines > 0: + context_before = [lines[j].rstrip('\n\r') for j in range(start_idx, i)] + context_after = [lines[j].rstrip('\n\r') for j in range(i + 1, end_idx)] + + # Use archive_path/internal_path format + search_match = SearchMatch( + file_path=f"{file_path}/{member}", + line_number=i + 1, + line_content=line.rstrip('\n\r'), + match_start=match.start(), + match_end=match.end(), + context_before=context_before, + context_after=context_after + ) + matches.append(search_match) + except UnicodeDecodeError: + # Binary file inside archive, skip + pass + except Exception: + # Skip files that can't be read from archive + pass + except Exception: + # Skip archives that can't be opened + pass + + return matches + + def _search_binary(self, file_path: str, regex: re.Pattern) -> List[SearchMatch]: + """Search binary files for hex patterns""" + matches = [] + + try: + with open(file_path, 'rb') as f: + content = f.read() + + # Convert pattern to bytes if it looks like hex + pattern_str = regex.pattern + + # Try to match as both text and hex + # Search for text pattern in binary content + try: + text_content = content.decode('utf-8', errors='ignore') + for match in regex.finditer(text_content): + # Calculate byte offset + byte_offset = match.start() + + # Get hex dump context (16 bytes before and after) + start = max(0, byte_offset - 16) + end = min(len(content), byte_offset + 16) + hex_context = content[start:end].hex(' ') + + search_match = SearchMatch( + file_path=file_path, + line_number=byte_offset, # Using offset as "line" + line_content=f"Offset {byte_offset:08x}: {hex_context}", + match_start=match.start(), + match_end=match.end(), + context_before=[], + context_after=[] + ) + matches.append(search_match) + except Exception: + pass + + except Exception: + pass + + return matches + + def _is_network_path(self, path: str) -> bool: + """Check if path is a network/UNC path""" + # UNC paths start with \\ + return path.startswith('\\\\') or path.startswith('//') + + def _check_network_path_accessible(self, path: str) -> bool: + """Check if network path is accessible with timeout""" + # Check cache first + if path in self._network_path_cache: + return self._network_path_cache[path] + + try: + # Try to check if path exists with timeout + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(os.path.exists, path) + accessible = future.result(timeout=self.network_timeout) + self._network_path_cache[path] = accessible + return accessible + except Exception: + # If timeout or error, assume not accessible + self._network_path_cache[path] = False + return False + + def set_case_sensitive(self, enabled: bool): + """Enable or disable case-sensitive search""" + self.case_sensitive = enabled + + def set_regex(self, enabled: bool): + """Enable or disable regex search""" + self.use_regex = enabled + + def set_whole_word(self, enabled: bool): + """Enable or disable whole word search""" + self.whole_word = enabled + + def set_search_metadata(self, enabled: bool): + """Enable or disable metadata search for images""" + self.search_metadata = enabled + + def set_search_file_metadata(self, enabled: bool): + """Enable or disable metadata search for files (PDF, Office, audio, video)""" + self.search_file_metadata = enabled + + def set_search_in_archives(self, enabled: bool): + """Enable or disable searching inside archive files""" + self.search_in_archives = enabled + + def set_hex_search(self, enabled: bool): + """Enable or disable binary/hex search mode""" + self.hex_search = enabled + + def clear_network_cache(self): + """Clear the network path accessibility cache""" + self._network_path_cache.clear() + + def set_context_lines(self, lines: int): + """Set number of context lines to include""" + self.context_lines = max(0, lines) + + def set_file_extensions(self, extensions: List[str]): + """Set file extensions to filter (e.g., ['.py', '.txt'])""" + self.file_extensions = extensions + + def add_exclude_pattern(self, pattern: str): + """Add a pattern to exclude from search""" + self.exclude_patterns.append(pattern) diff --git a/Currency Script/tests/__pycache__/__init__.cpython-313.pyc b/Currency Script/tests/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 52c26ecd..00000000 Binary files a/Currency Script/tests/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/Currency Script/tests/__pycache__/test_api_handler.cpython-313-pytest-8.3.5.pyc b/Currency Script/tests/__pycache__/test_api_handler.cpython-313-pytest-8.3.5.pyc deleted file mode 100644 index adca8b35..00000000 Binary files a/Currency Script/tests/__pycache__/test_api_handler.cpython-313-pytest-8.3.5.pyc and /dev/null differ diff --git a/Currency Script/tests/__pycache__/test_converter.cpython-313-pytest-8.3.5.pyc b/Currency Script/tests/__pycache__/test_converter.cpython-313-pytest-8.3.5.pyc deleted file mode 100644 index 020a50ff..00000000 Binary files a/Currency Script/tests/__pycache__/test_converter.cpython-313-pytest-8.3.5.pyc and /dev/null differ diff --git a/Currency Script/tests/__pycache__/test_currencies.cpython-313-pytest-8.3.5.pyc b/Currency Script/tests/__pycache__/test_currencies.cpython-313-pytest-8.3.5.pyc deleted file mode 100644 index adf1456a..00000000 Binary files a/Currency Script/tests/__pycache__/test_currencies.cpython-313-pytest-8.3.5.pyc and /dev/null differ diff --git a/FileOrganizer/FileOrganizer.py b/FileOrganizer/FileOrganizer.py index 0ca91093..c5cccebe 100644 --- a/FileOrganizer/FileOrganizer.py +++ b/FileOrganizer/FileOrganizer.py @@ -34,7 +34,7 @@ dest_file_path = os.path.join(dest_folder, file) counter = 1 while os.path.exists(dest_file_path): - newfilename = f"{filename}{counter}.{extension}" if extension != "NoExtension" else f"{filename}_{counter}" + new_filename = f"{filename}{counter}.{extension}" if extension != "NoExtension" else f"{filename}_{counter}" dest_file_path = os.path.join(dest_folder, new_filename) counter += 1 diff --git a/Font Art/FontArt.py b/Font Art/FontArt.py index 99b91c03..a1075c23 100644 --- a/Font Art/FontArt.py +++ b/Font Art/FontArt.py @@ -1,6 +1,505 @@ import os -from pyfiglet import Figlet -text = Figlet(font="slant") -os.system("cls") -os.system("mode con: cols=75 lines=30") -print(text.renderText("Dhanush N")) \ No newline at end of file +import random +from typing import List + +from pyfiglet import Figlet, FigletFont + +from PySide6 import QtCore, QtGui, QtWidgets + + +APP_TITLE_TEXT = "Font Art" + + +def list_figlet_fonts() -> List[str]: + try: + return sorted(FigletFont.getFonts()) + except Exception: + # Fallback: a small known-good subset if pyfiglet fails + return [ + "standard", + "slant", + "big", + "doom", + "banner", + "block", + "digital", + "roman", + "script", + "shadow", + ] + + +class GlassCard(QtWidgets.QFrame): + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("GlassCard") + self.setFrameStyle(QtWidgets.QFrame.NoFrame) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + # Drop shadow for a bubbly, floating feel + effect = QtWidgets.QGraphicsDropShadowEffect(self) + effect.setBlurRadius(40) + effect.setXOffset(0) + effect.setYOffset(12) + effect.setColor(QtGui.QColor(0, 0, 0, 80)) + self.setGraphicsEffect(effect) + + def paintEvent(self, event: QtGui.QPaintEvent) -> None: + # Custom rounded, semi-transparent gradient card painting + radius = 24 + rect = self.rect() + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + + # Glassy gradient: baby blue -> pink with alpha + grad = QtGui.QLinearGradient(rect.topLeft(), rect.bottomRight()) + # Reduce transparency by ~20% (increase opacity) + grad.setColorAt(0.0, QtGui.QColor(173, 216, 230, 216)) # was 180 + grad.setColorAt(1.0, QtGui.QColor(255, 182, 193, 216)) # was 180 + + path = QtGui.QPainterPath() + path.addRoundedRect(rect.adjusted(1, 1, -1, -1), radius, radius) + + # Subtle border highlight + pen = QtGui.QPen(QtGui.QColor(255, 255, 255, 204), 1.2) + painter.setPen(pen) + painter.fillPath(path, grad) + painter.drawPath(path) + + +class LogoView(QtWidgets.QTextEdit): + """A read-only text view for the ASCII logo that we can refresh alone.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setReadOnly(True) + self.setLineWrapMode(QtWidgets.QTextEdit.NoWrap) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setFrameStyle(QtWidgets.QFrame.NoFrame) + # Monospace font for alignment + font = QtGui.QFontDatabase.systemFont(QtGui.QFontDatabase.FixedFont) + font.setPointSize(10) + self.setFont(font) + self.setObjectName("LogoView") + + def sizeHint(self) -> QtCore.QSize: + # Larger fixed logo area that still sits near inputs + return QtCore.QSize(560, 120) + + +class PowderArrowStyle(QtWidgets.QProxyStyle): + """Custom style to tint the combo box down-arrow to powder blue.""" + + def drawPrimitive(self, element, option, painter, widget=None): + if element == QtWidgets.QStyle.PE_IndicatorArrowDown: + painter.save() + painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(QtGui.QColor("#B0E0E6")) # powder blue + r = option.rect + size = int(min(r.width(), r.height()) * 0.45) + cx, cy = r.center().x(), r.center().y() + pts = [ + QtCore.QPoint(cx - size, cy - size // 3), + QtCore.QPoint(cx + size, cy - size // 3), + QtCore.QPoint(cx, cy + size // 2), + ] + painter.drawPolygon(QtGui.QPolygon(pts)) + painter.restore() + return + super().drawPrimitive(element, option, painter, widget) + + +class FontArtWindow(QtWidgets.QWidget): + def __init__(self): + super().__init__() + self.setObjectName("RootWindow") + self.setWindowTitle("Font Art – Qt6") + self.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + # Frameless floating style + self.setWindowFlags( + QtCore.Qt.FramelessWindowHint | QtCore.Qt.Window + ) + self.resize(820, 560) + + # Central card with glassy look + self.card = GlassCard(self) + + # Child layout within card + card_layout = QtWidgets.QVBoxLayout(self.card) + card_layout.setContentsMargins(24, 16, 24, 16) + # We'll control exact gaps manually (logo->inputs 15px, inputs->buttons 6px) + card_layout.setSpacing(0) + + # ASCII logo at top that only refreshes itself + self.logo_view = LogoView() + # Center ASCII content within the logo view + doc = self.logo_view.document() + opt = doc.defaultTextOption() + opt.setAlignment(QtCore.Qt.AlignHCenter) + doc.setDefaultTextOption(opt) + self.logo_view.setAlignment(QtCore.Qt.AlignHCenter) + # Fix the logo area height so layout below never moves + self.logo_view.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) + self.logo_view.setFixedHeight(self.logo_view.sizeHint().height()) + # Top stretch so the logo+inputs cluster is not pinned to the top + card_layout.addStretch(1) + card_layout.addWidget(self.logo_view) + # Place the logo box just above the inputs with a 15px gap + card_layout.addSpacing(15) + + # Input row: text box, font dropdown (with label over dropdown) + self.input_edit = QtWidgets.QLineEdit() + self.input_edit.setPlaceholderText("Type text to render…") + self.input_edit.setObjectName("InputEdit") + self.input_edit.setAlignment(QtCore.Qt.AlignCenter) + self.input_edit.setMinimumWidth(520) # a little wider for comfortable typing + + self.font_combo = QtWidgets.QComboBox() + self.font_combo.setObjectName("FontCombo") + self.font_combo.setMaxVisibleItems(14) + self.font_combo.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToMinimumContentsLengthWithIcon) + self.font_combo.setInsertPolicy(QtWidgets.QComboBox.NoInsert) + self.font_combo.setView(QtWidgets.QListView()) # scrollable + self.font_combo.setFixedWidth(240) # static size for consistent layout + # Apply custom style with powder-blue arrow tint + self.font_combo.setStyle(PowderArrowStyle(self.style())) + + self.font_label = QtWidgets.QLabel("Choose Font Style") + self.font_label.setObjectName("FontLabel") + self.font_label.setAlignment(QtCore.Qt.AlignHCenter) + + # Align textbox and dropdown in the same row; label sits just above dropdown + font_widget = QtWidgets.QWidget() + font_widget.setSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + font_col = QtWidgets.QVBoxLayout(font_widget) + font_col.setSpacing(4) + font_col.setContentsMargins(0, 0, 0, 0) + font_col.addWidget(self.font_label, alignment=QtCore.Qt.AlignHCenter) + font_col.addWidget(self.font_combo, alignment=QtCore.Qt.AlignVCenter) + + input_row = QtWidgets.QHBoxLayout() + input_row.setSpacing(10) + input_row.setContentsMargins(0, 0, 0, 0) + input_row.addStretch(1) + # Align bottoms so the text box and dropdown bottoms line up + input_row.addWidget(self.input_edit, 0, QtCore.Qt.AlignBottom) + input_row.addWidget(font_widget, 0, QtCore.Qt.AlignBottom) + input_row.addStretch(1) + card_layout.addLayout(input_row) + + # Buttons row: Create and Quit + self.generate_btn = QtWidgets.QPushButton("Create") + self.generate_btn.setObjectName("GenerateButton") + self.generate_btn.setCursor(QtCore.Qt.PointingHandCursor) + self.quit_btn = QtWidgets.QPushButton("Quit") + self.quit_btn.setObjectName("QuitButton") + self.quit_btn.setCursor(QtCore.Qt.PointingHandCursor) + + buttons_row = QtWidgets.QHBoxLayout() + buttons_row.setSpacing(12) + # Equal spacing across width: left, between, right stretches + buttons_row.addStretch(1) + buttons_row.addWidget(self.generate_btn) + buttons_row.addStretch(1) + buttons_row.addWidget(self.quit_btn) + buttons_row.addStretch(1) + # 6px gap from inputs to buttons row + card_layout.addSpacing(6) + card_layout.addLayout(buttons_row) + # Bottom stretch so the cluster stays together above buttons + card_layout.addStretch(1) + + # Populate fonts + self.fonts: List[str] = list_figlet_fonts() + self.font_combo.addItems(self.fonts) + if self.fonts: + self.font_combo.setCurrentIndex(0) # ensure not empty + + # Figlet instance for rendering + self.figlet = Figlet(font=self.fonts[0] if self.fonts else "standard") + + # Connections + self.generate_btn.clicked.connect(self.on_generate) + self.font_combo.currentTextChanged.connect(self.on_font_change) + self.quit_btn.clicked.connect(self.close) + + # Timer for rotating logo font every 3 seconds + self.logo_timer = QtCore.QTimer(self) + self.logo_timer.setInterval(3000) + self.logo_timer.timeout.connect(self.refresh_logo) + self.logo_timer.start() + + # Initial render + self.refresh_logo() + + # Overall layout for root: center the card + root_layout = QtWidgets.QVBoxLayout(self) + root_layout.setContentsMargins(24, 24, 24, 24) + root_layout.addWidget(self.card) + + # Apply stylesheet theme + self.apply_styles() + # Make buttons equal width for symmetry + btn_w = max(self.generate_btn.sizeHint().width(), self.quit_btn.sizeHint().width()) + self.generate_btn.setMinimumWidth(btn_w) + self.quit_btn.setMinimumWidth(btn_w) + + # Adjust window width to fit content tightly + self.adjust_width_to_content() + + # Match heights of the input and dropdown so bottoms are perfectly even + QtCore.QTimer.singleShot(0, self._sync_input_heights) + + # Make window draggable from the logo or the card background + self._drag_pos: QtCore.QPoint | None = None + self.logo_view.installEventFilter(self) + self.card.installEventFilter(self) + + def resizeEvent(self, event: QtGui.QResizeEvent) -> None: + super().resizeEvent(event) + # Reflow logo art to fit within fixed logo area when resized + QtCore.QTimer.singleShot(0, self.refresh_logo) + + def apply_styles(self): + # Baby blue + pink theme, rounded, glassy controls + self.setStyleSheet( + """ + #RootWindow { + background: transparent; + } + QLabel { + color: #B0E0E6; /* powder blue */ + font-weight: 600; + } + + #LogoView { + color: rgba(35, 35, 35, 235); + background: transparent; + border-radius: 12px; + } + + #InputEdit { + padding: 10px 14px; + border-radius: 14px; + border: 1px solid rgba(255,255,255,216); + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 rgba(255, 240, 245, 240), /* lavenderblush */ + stop:1 rgba(224, 247, 250, 240) /* light cyan */ + ); + color: #222; + selection-background-color: rgba(255,182,193,216); + } + #InputEdit:focus { + border: 1.5px solid rgba(135, 206, 235, 255); + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 rgba(255, 228, 235, 255), + stop:1 rgba(210, 245, 255, 255) + ); + } + + #FontCombo { + padding: 8px 12px; + border-radius: 14px; + border: 1px solid rgba(255,255,255,216); + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 rgba(224, 247, 250, 240), + stop:1 rgba(255, 240, 245, 240) + ); + color: #222; /* match input text color */ + } + #FontCombo::drop-down { + width: 26px; + border: 0px; + } + #FontCombo QAbstractItemView { + background: rgba(255,255,255, 255); + border: 1px solid rgba(135,206,235,216); + color: #222; /* match input text color */ + selection-background-color: rgba(255, 182, 193, 240); + outline: none; + } + + #GenerateButton, #QuitButton { + padding: 12px 22px; /* slightly increased padding */ + border-radius: 18px; + border: 1px solid rgba(255,255,255,216); + color: #1f2937; + font-weight: 600; + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 rgba(173, 216, 230, 255), + stop:1 rgba(255, 182, 193, 255) + ); + } + #GenerateButton:hover, #QuitButton:hover { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 rgba(173, 216, 230, 255), + stop:1 rgba(255, 182, 193, 255) + ); + } + #GenerateButton:pressed, #QuitButton:pressed { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 rgba(173, 216, 230, 240), + stop:1 rgba(255, 182, 193, 240) + ); + } + + #FontLabel { + padding-left: 0px; + } + """ + ) + + # --- Logic --- + def eventFilter(self, source: QtCore.QObject, event: QtCore.QEvent) -> bool: + # Enable dragging the frameless window by grabbing the logo or card background + if source in (self.logo_view, self.card): + if event.type() == QtCore.QEvent.MouseButtonPress and isinstance(event, QtGui.QMouseEvent): + if event.button() == QtCore.Qt.LeftButton: + # Store offset from top-left corner + global_pos = event.globalPosition() if hasattr(event, "globalPosition") else event.globalPos() + if isinstance(global_pos, QtCore.QPointF): + global_pos = global_pos.toPoint() + self._drag_pos = global_pos - self.frameGeometry().topLeft() + return True + elif event.type() == QtCore.QEvent.MouseMove and isinstance(event, QtGui.QMouseEvent): + if self._drag_pos is not None and (event.buttons() & QtCore.Qt.LeftButton): + global_pos = event.globalPosition() if hasattr(event, "globalPosition") else event.globalPos() + if isinstance(global_pos, QtCore.QPointF): + global_pos = global_pos.toPoint() + self.move(global_pos - self._drag_pos) + return True + elif event.type() == QtCore.QEvent.MouseButtonRelease and isinstance(event, QtGui.QMouseEvent): + if self._drag_pos is not None: + self._drag_pos = None + return True + return super().eventFilter(source, event) + def refresh_logo(self): + if not self.fonts: + return + + # Determine character columns that fit in the logo viewport width + viewport_w = self.logo_view.viewport().width() + metrics = QtGui.QFontMetrics(self.logo_view.font()) + char_w = max(1, metrics.horizontalAdvance("M")) + cols = max(40, int((viewport_w - 16) / char_w)) + + random_font = random.choice(self.fonts) + try: + fig = Figlet(font=random_font, width=cols, justify="center") + art = fig.renderText(APP_TITLE_TEXT) + except Exception: + art = APP_TITLE_TEXT + + # Update content + self.logo_view.setPlainText(art) + + # Fit height: reduce font size if needed so title fits above actions + self._fit_logo_height(art) + + def on_font_change(self, font_name: str): + try: + self.figlet.setFont(font=font_name) + except Exception: + # ignore invalid font switches + pass + + def on_generate(self): + text = self.input_edit.text().strip() + if not text: + QtWidgets.QMessageBox.information(self, "Nothing to render", "Please enter some text to render.") + return + + selected_font = self.font_combo.currentText() or "standard" + try: + self.figlet.setFont(font=selected_font) + art = self.figlet.renderText(text) + except Exception as e: + QtWidgets.QMessageBox.warning(self, "Render failed", f"Could not render text with font '{selected_font}'.\n{e}") + return + + # Ask where to save + default_name = f"{text[:20].strip().replace(' ', '_') or 'font_art'}.txt" + path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + "Save ASCII Art", + os.path.join(os.path.expanduser("~"), default_name), + "Text Files (*.txt);;All Files (*)", + ) + if not path: + return + + try: + with open(path, "w", encoding="utf-8") as f: + f.write(art) + except Exception as e: + QtWidgets.QMessageBox.critical(self, "Save failed", f"Could not save file:\n{e}") + return + + QtWidgets.QMessageBox.information(self, "Saved", f"Art saved to:\n{path}") + + # --- Helpers --- + def _fit_logo_height(self, art: str) -> None: + # Keep the logo area height fixed; only adjust font size down to fit + max_h = max(60, self.logo_view.height() - 4) + f = self.logo_view.font() + pt = f.pointSize() if f.pointSize() > 0 else 10 + metrics = QtGui.QFontMetrics(f) + + lines = art.splitlines() or [""] + req_h = len(lines) * metrics.lineSpacing() + while req_h > max_h and pt > 7: + pt -= 1 + f.setPointSize(pt) + self.logo_view.setFont(f) + metrics = QtGui.QFontMetrics(f) + req_h = len(lines) * metrics.lineSpacing() + # Do not change widget height; positions of other items remain constant + + def _sync_input_heights(self) -> None: + h = max(self.input_edit.sizeHint().height(), self.font_combo.sizeHint().height()) + self.input_edit.setFixedHeight(h) + self.font_combo.setFixedHeight(h) + + def adjust_width_to_content(self): + # Compute desired content width based on input+combo and buttons rows + input_w = max(self.input_edit.minimumWidth(), self.input_edit.sizeHint().width()) + combo_w = self.font_combo.width() or self.font_combo.sizeHint().width() + input_row_spacing = 12 # mirrors layout spacing + content_row_w = input_w + input_row_spacing + combo_w + + # Buttons row width + btn_w = max(self.generate_btn.minimumWidth(), self.generate_btn.sizeHint().width()) + quit_w = max(self.quit_btn.minimumWidth(), self.quit_btn.sizeHint().width()) + buttons_row_spacing = 12 + buttons_row_w = btn_w + buttons_row_spacing + quit_w + + content_w = max(content_row_w, buttons_row_w) + + # Add layout margins (card + root) + card_m = self.card.layout().contentsMargins() + root_m = self.layout().contentsMargins() + total_w = content_w + (card_m.left() + card_m.right()) + (root_m.left() + root_m.right()) + 8 + + # Constrain to a sensible minimum to avoid clipping + min_w = 560 + total_w = max(total_w, min_w) + + self.setFixedWidth(total_w) + + +def main(): + app = QtWidgets.QApplication([]) + + # Prefer system dark text rendering in semi-transparent windows + app.setApplicationName("Font Art Qt6") + app.setOrganizationName("FontArt") + app.setStyle("Fusion") + + win = FontArtWindow() + win.show() + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Font Art/README.md b/Font Art/README.md index c4d2e975..ffd4ad6d 100644 --- a/Font Art/README.md +++ b/Font Art/README.md @@ -1,15 +1,70 @@ -This repository consists of a list of python scripts to automate few tasks. +# Font Art — Cute Glassy Qt6 App -You can contribute by adding more python scripts which can be used to automate things. Some of already done are listed below. -Incase you have anything to be followed while executing the python script mention it as well +A tiny, bubbly, cross‑platform app for making adorable ASCII font art. It sports a soft pink + baby‑blue “glass” card, rounded corners, cozy shadows, and a frameless, draggable window. +## Highlights -# Python Scripts +- Glassy theme with rounded edges, soft shadows, and higher opacity for readability +- Frameless and draggable (drag by the logo or card area) +- Animated title logo cycles a random FIGlet font every 3 seconds (logo refresh only) +- Centered text box, powder‑blue label “Choose Font Style”, and a scrollable font dropdown +- Powder‑blue tinted dropdown arrow; dropdown text matches the input’s gray +- Fixed logo area positioned just above the inputs (layout never jumps) +- “Create” prompts where to save `.txt`; “Quit” closes the app +- Cross‑platform ready: Windows, macOS, Linux (PySide6) -## Script - Font Art +## Files -Display a font art using python -FontArt.py +- App: `Python-Scripts/Font Art/FontArtQt6.py` +- Requirements: `Python-Scripts/Font Art/requirements.txt` + +## Quick Start + +1) Python 3.9+ is recommended. + +2) Create a virtual environment (optional, but tidy): + +- Windows + - `py -m venv .venv` + - `.venv\Scripts\activate` +- macOS/Linux + - `python3 -m venv .venv` + - `source .venv/bin/activate` + +3) Install dependencies: +- `pip install -r "Python-Scripts/Font Art/requirements.txt"` + +4) Run the app: +- `python "Python-Scripts/Font Art/FontArtQt6.py"` + +## Use It + +- Type your text in the input box. +- Pick a font from the dropdown (it defaults to the first available font). +- Click “Create” and choose where to save the generated ASCII art `.txt` file. +- The title logo keeps changing fonts every few seconds — only the logo area updates. + +## Make a Stand‑Alone Binary (Optional) + +- Windows + - `pyinstaller --noconsole --onefile "Python-Scripts/Font Art/FontArtQt6.py"` +- macOS + - `pyinstaller --windowed --onefile "Python-Scripts/Font Art/FontArtQt6.py"` +- Linux + - `pyinstaller --windowed --onefile "Python-Scripts/Font Art/FontArtQt6.py"` + +Notes: +- On Linux, transparency/shadows require a compositor (most desktops enable one by default). +- macOS app signing/notarization is not included here. + +## Troubleshooting + +- No fonts listed? A small fallback set is used if `pyfiglet` can’t load the full list. +- Transparency looks different across desktops; this app uses a higher‑opacity glass gradient by default. +- Window won’t drag? Drag by the logo or anywhere on the glass card background. + +## Credits + +- ASCII rendering by `pyfiglet` +- UI framework: `PySide6` (Qt for Python) - - \ No newline at end of file diff --git a/Font Art/requirements.txt b/Font Art/requirements.txt new file mode 100644 index 00000000..7ef35112 --- /dev/null +++ b/Font Art/requirements.txt @@ -0,0 +1,2 @@ +PySide6>=6.6 +pyfiglet>=0.8.post1 diff --git a/MD to PDF or Text with GUI/README.md b/MD to PDF or Text with GUI/README.md new file mode 100644 index 00000000..18740283 --- /dev/null +++ b/MD to PDF or Text with GUI/README.md @@ -0,0 +1,116 @@ +# Markdown Converter (PySide6) + +A simple Python GUI tool built with **PySide6** that converts Markdown (`.md`) files into **PDF** or **Text** files. +It provides a clean interface to select the input file, choose the output format, and save it wherever you want. + +--- + +## Features +- 🖱️ **User-friendly GUI** built with PySide6 +- 📂 **File selection dialogs** for choosing input and output files +- 📝 **Markdown → Text** conversion +- 📄 **Markdown → PDF** conversion with **Unicode** support (Chinese, Japanese, Korean, emoji, etc.) +- ❌ Error handling with pop-up dialogs +- ⚡ Option to run **with or without Pandoc** + +--- + +## Requirements + +Make sure you have **Python 3.8+** installed. + +Install the required Python packages: +```bash +pip install PySide6 reportlab pypandoc +``` + +--- + +## Installing Pandoc (Required for Default Conversion) + +This tool uses **pypandoc**, which depends on the Pandoc binary. +Pandoc must be installed separately on your system. + +### 1. Official Installation Instructions +- Pandoc Official Installation Guide: [https://pandoc.org/installing.html](https://pandoc.org/installing.html) + +### 2. Windows +- Download the **Windows Installer (.msi)** from the [Pandoc Releases Page](https://github.com/jgm/pandoc/releases) +- Run the installer and let it add Pandoc to your PATH automatically. + +### 3. macOS +Using **Homebrew** (recommended): +```bash +brew install pandoc +``` +Or download the **macOS package** from the [Pandoc Releases Page](https://github.com/jgm/pandoc/releases) + +### 4. Linux (Debian/Ubuntu) +```bash +sudo apt update +sudo apt install pandoc +``` +For other Linux distros, check the [Pandoc Install Guide](https://pandoc.org/installing.html) for commands. + +--- + +## Verify Pandoc Installation +After installation, open a terminal or command prompt and run: +```bash +pandoc --version +``` +You should see version information for Pandoc. + +--- + +## How to Run + +1. Save the script as `markdown_converter.py`. +2. Run the program: +```bash +python markdown_converter.py +``` + +--- + +## Usage + +1. Click **"Select Markdown File"** in the app window. +2. Choose a `.md` file from your system. +3. Select output type: **PDF** or **Text**. +4. Choose the save location and file name. +5. Done! 🎉 + +--- + +## Unicode Support + +- The PDF generation uses **HeiseiMin-W3** font to support a wide range of Unicode characters. +- This ensures **Chinese, Japanese, Korean, and emoji** render correctly in the final PDF. + +--- + +## Running Without Pandoc (Pure Python Option) + +If you don’t want to install Pandoc, +the code can be modified to use Python libraries like **markdown2** or **mistune** for parsing Markdown +and **ReportLab** for PDF generation. + +This removes the external dependency but keeps full functionality. + +--- + +## Example Screenshots & GIF Demo (Optional) + +You can add screenshots or a short screen recording here to make the README more user-friendly. + +--- + +## License +MIT License – free to use, modify, and share. + +--- + +## Author + +Randy Northrup diff --git a/MD to PDF or Text with GUI/md2pdftxt.py b/MD to PDF or Text with GUI/md2pdftxt.py new file mode 100644 index 00000000..b0d1f621 --- /dev/null +++ b/MD to PDF or Text with GUI/md2pdftxt.py @@ -0,0 +1,71 @@ +import sys +from PySide6.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QFileDialog, QMessageBox +from PySide6.QtCore import Qt +import pypandoc +from reportlab.platypus import SimpleDocTemplate, Paragraph +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.cidfonts import UnicodeCIDFont + +class MarkdownConverter(QWidget): + def __init__(self): + super().__init__() + self.setWindowTitle("Markdown Converter") + self.setGeometry(300, 300, 400, 150) + + layout = QVBoxLayout() + self.button = QPushButton("Select Markdown File") + self.button.clicked.connect(self.select_file) + layout.addWidget(self.button) + self.setLayout(layout) + + # Register a Unicode font for PDF + pdfmetrics.registerFont(UnicodeCIDFont('HeiseiMin-W3')) # Japanese font that supports wide Unicode range + + def select_file(self): + file_path, _ = QFileDialog.getOpenFileName(self, "Open Markdown File", "", "Markdown Files (*.md)") + if file_path: + self.convert_file(file_path) + + def convert_file(self, file_path): + save_type, _ = QFileDialog.getSaveFileName( + self, "Save File As", "", "PDF Files (*.pdf);;Text Files (*.txt)" + ) + if save_type: + if save_type.endswith(".pdf"): + self.convert_to_pdf(file_path, save_type) + elif save_type.endswith(".txt"): + self.convert_to_text(file_path, save_type) + else: + QMessageBox.warning(self, "Error", "Please select a valid file type.") + + def convert_to_text(self, md_path, output_path): + try: + with open(md_path, "r", encoding="utf-8") as f: + md_content = f.read() + output = pypandoc.convert_text(md_content, 'plain', format='md', extra_args=['--standalone']) + with open(output_path, "w", encoding="utf-8") as f: + f.write(output) + QMessageBox.information(self, "Success", "Markdown converted to Text successfully!") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to convert: {e}") + + def convert_to_pdf(self, md_path, output_path): + try: + with open(md_path, "r", encoding="utf-8") as f: + md_content = f.read() + text = pypandoc.convert_text(md_content, 'plain', format='md', extra_args=['--standalone']) + doc = SimpleDocTemplate(output_path) + styles = getSampleStyleSheet() + story = [Paragraph(line, styles["Normal"]) for line in text.split("\n")] + doc.build(story) + QMessageBox.information(self, "Success", "Markdown converted to PDF successfully!") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to convert: {e}") + + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = MarkdownConverter() + window.show() + sys.exit(app.exec()) diff --git a/MD to PDF or Text with GUI/requirements.txt b/MD to PDF or Text with GUI/requirements.txt new file mode 100644 index 00000000..44b0c97a --- /dev/null +++ b/MD to PDF or Text with GUI/requirements.txt @@ -0,0 +1,3 @@ +PySide6 +pypandoc +reportlab diff --git a/MacOS App Removal Cache Cleaner/README.md b/MacOS App Removal Cache Cleaner/README.md new file mode 100644 index 00000000..468189a5 --- /dev/null +++ b/MacOS App Removal Cache Cleaner/README.md @@ -0,0 +1,110 @@ +# macOS App Remover and Cache Cleaner + +A desktop utility for macOS that scans installed applications, finds user-space support files (preferences, caches, logs, etc.), and lets you safely review and delete them. Built with PySide6 for a modern, native-like UI. + +--- + +## ✨ Features + +- **App discovery:** Lists all installed `.app` bundles in `/Applications` and `~/Applications`. +- **Deep scan:** Finds related files using both Spotlight and targeted sweeps of user Library buckets (Application Support, Preferences, Caches, Containers, etc.). +- **Review before delete:** Presents all found files in a checkable tree grouped by type. +- **Safe deletion:** Moves files to Trash when possible, falls back to permanent deletion if needed. +- **Batch operations:** Select multiple apps, scan, and clean up in one go. +- **Progress and status:** Live progress bar, status messages, and scan summaries. +- **Modern UI:** Search/filter apps, select/deselect all, rescan, and more. +- **macOS native:** Designed specifically for macOS; uses system Trash and Spotlight. + +--- + +## 📦 Requirements + +- **macOS** (tested on 10.15+) +- **Python 3.9+** +- **PySide6** (`pip install PySide6`) + +--- + +## 🚀 Quick Start + +1. **Install dependencies:** + ```bash + pip install PySide6 + ``` + +2. **Run the app:** + ```bash + python mac_app_removal.py + ``` + +3. **Usage:** + - Filter or select apps from the list. + - Click "Scan Selected Apps" to find related files. + - Review results in the tree view. + - Check/uncheck items to delete. + - Click "Delete Checked Items" to move files to Trash or delete permanently. + +--- + +## 🧭 UI Overview + +- **Left panel:** App list with search/filter and select/deselect all. +- **Right panel:** Tree view of scan results, grouped by Library bucket and file type. +- **Controls:** Scan, delete, progress bar, and status. +- **Menu:** File (Quit), Actions (Scan/Delete), Help (About). + +--- + +## 🗂️ What Gets Scanned + +- **Application Support** +- **Preferences** +- **Caches** +- **Containers** +- **Group Containers** +- **Saved Application State** +- **Logs** +- **WebKit** +- **Cookies** +- **LaunchAgents** +- **Other matches** found via Spotlight + +--- + +## 🔒 Safety & Ethics + +- **Review before delete:** All files are presented for review; nothing is deleted automatically. +- **Moves to Trash:** Prefers moving files to Trash for easy recovery. +- **Permanent deletion:** Only used if Trash is unavailable or on a different volume. +- **Responsibility:** Use with care; deleting files may affect app settings or data. + +--- + +## 🆘 Troubleshooting + +- **Not finding all files:** Some apps store data outside standard buckets or use hidden locations. +- **Permission errors:** Run as a user with access to the files you want to delete. +- **Platform warning:** This tool is intended for macOS only. + +--- + +## 🛠️ Building a Standalone App (optional) + +Use PyInstaller: + +```bash +pip install pyinstaller +pyinstaller --windowed --name "macOS App Cleaner" mac_app_removal.py +``` + +--- + +## 📄 License + +MIT License. + +--- + +## 👤 Author + +**Randy Northrup** diff --git a/MacOS App Removal Cache Cleaner/mac_app_removal.py b/MacOS App Removal Cache Cleaner/mac_app_removal.py new file mode 100644 index 00000000..ffd9f179 --- /dev/null +++ b/MacOS App Removal Cache Cleaner/mac_app_removal.py @@ -0,0 +1,651 @@ +#!/usr/bin/env python3 +import os +import sys +import shutil +import plistlib +import subprocess +from pathlib import Path +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple, Iterable + +from PySide6 import QtCore, QtGui, QtWidgets + +APP_DIRS = [Path("/Applications"), Path.home() / "Applications"] + +# Where we look (user-space). Each tuple is (label, path). +USER_LIBRARY_BUCKETS: List[Tuple[str, Path]] = [ + ("Application Support", Path("~/Library/Application Support").expanduser()), + ("Preferences", Path("~/Library/Preferences").expanduser()), + ("Caches", Path("~/Library/Caches").expanduser()), + ("Containers", Path("~/Library/Containers").expanduser()), + ("Group Containers", Path("~/Library/Group Containers").expanduser()), + ("Saved Application State", Path("~/Library/Saved Application State").expanduser()), + ("Logs", Path("~/Library/Logs").expanduser()), + ("WebKit", Path("~/Library/WebKit").expanduser()), + ("Cookies", Path("~/Library/Cookies").expanduser()), + ("LaunchAgents", Path("~/Library/LaunchAgents").expanduser()), +] + +SPOTLIGHT_TIMEOUT_SEC = 20 + + +@dataclass +class AppInfo: + name: str + path: Path + bundle_id: Optional[str] = None + + +@dataclass +class ScanResult: + app: AppInfo + files_by_bucket: Dict[str, List[Path]] = field(default_factory=dict) + extra_hits: List[Path] = field(default_factory=list) # From Spotlight that don't fall into a known bucket + + +def is_app_bundle(p: Path) -> bool: + return p.suffix.lower() == ".app" and p.is_dir() + + +def find_installed_apps() -> List[AppInfo]: + apps: List[AppInfo] = [] + seen = set() + for base in APP_DIRS: + if not base.exists(): + continue + for entry in sorted(base.iterdir(), key=lambda x: x.name.lower()): + if is_app_bundle(entry): + real = entry.resolve() + if real in seen: + continue + seen.add(real) + name = entry.name[:-4] # strip .app + apps.append(AppInfo(name=name, path=real)) + return apps + + +def read_bundle_id(app_path: Path) -> Optional[str]: + info = app_path / "Contents" / "Info.plist" + if not info.exists(): + return None + try: + with info.open("rb") as f: + plist = plistlib.load(f) + bid = plist.get("CFBundleIdentifier") or plist.get("CFBundleIdentifier~ipad") + if isinstance(bid, str): + return bid + except Exception: + pass + return None + + +def run_mdfind(query: str) -> List[Path]: + """Run mdfind; return Paths (unique, existing).""" + try: + proc = subprocess.run( + ["mdfind", query], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=SPOTLIGHT_TIMEOUT_SEC, + ) + hits: List[Path] = [] + if proc.returncode == 0 and proc.stdout: + for line in proc.stdout.splitlines(): + p = Path(line.strip()) + if p.exists(): + hits.append(p) + # dedupe while preserving order + seen = set() + uniq = [] + for p in hits: + if p not in seen: + seen.add(p) + uniq.append(p) + return uniq + except Exception: + return [] + + +def glob_matches(root: Path, terms: Iterable[str]) -> List[Path]: + """Shallow-and-deep match under root for any of the terms in file/folder names.""" + if not root.exists(): + return [] + results: List[Path] = [] + lowered = [t.lower() for t in terms if t] + try: + for dirpath, dirnames, filenames in os.walk(root): + # avoid traversing giant trees too long + for name in dirnames + filenames: + lname = name.lower() + if any(t in lname for t in lowered): + results.append(Path(dirpath) / name) + except Exception: + pass + return results + + +def group_into_buckets(paths: List[Path]) -> Tuple[Dict[str, List[Path]], List[Path]]: + """Group paths by our known buckets; paths that don't fit go into extra_hits.""" + buckets: Dict[str, List[Path]] = {label: [] for (label, _) in USER_LIBRARY_BUCKETS} + extras: List[Path] = [] + for p in paths: + matched = False + for label, root in USER_LIBRARY_BUCKETS: + try: + # If p is under root + p.resolve().relative_to(root.resolve()) + buckets[label].append(p) + matched = True + break + except Exception: + continue + if not matched: + extras.append(p) + # prune empties + buckets = {k: v for k, v in buckets.items() if v} + return buckets, extras + + +def scan_app(app: AppInfo) -> ScanResult: + """ + Strategy: + 1) Prefer bundle id; Spotlight for kMDItemCFBundleIdentifier exact match. + 2) Also query Spotlight for name contains and bundle id substring. + 3) Targeted glob in user Library buckets for both bundle id and friendly app name. + """ + terms = {app.name} + if app.bundle_id: + terms.add(app.bundle_id) + + spotlight_hits: List[Path] = [] + + # Exact bundle id query (most precise) + if app.bundle_id: + spotlight_hits += run_mdfind(f'kMDItemCFBundleIdentifier == "{app.bundle_id}"') + + # Name contains (fallbacks) + # Use quotes around name with spaces to keep phrase + safe_name = app.name.replace('"', '\\"') + spotlight_hits += run_mdfind(f'kMDItemDisplayName == "{safe_name}"cdw || kMDItemFSName == "{safe_name}.app"cdw') + + # Loose contains for bundle id bits (sometimes support files have bundle id in path or metadata) + if app.bundle_id: + safe_bid = app.bundle_id.replace('"', '\\"') + spotlight_hits += run_mdfind(f'kMDItemTextContent == "{safe_bid}"cdw || kMDItemFSName == "{safe_bid}"cdw') + + # Targeted filesystem sweeps within user Library + lib_hits: List[Path] = [] + # Preferences often use domain style: com.vendor.App.plist + preference_candidates = [] + if app.bundle_id: + preference_candidates.append(app.bundle_id) + preference_candidates.append(app.name) + # Sweep buckets + for _, root in USER_LIBRARY_BUCKETS: + lib_hits += glob_matches(root, preference_candidates) + + all_hits = [] + seen = set() + for p in spotlight_hits + lib_hits: + if p not in seen and p.exists(): + seen.add(p) + all_hits.append(p) + + files_by_bucket, extra_hits = group_into_buckets(all_hits) + return ScanResult(app=app, files_by_bucket=files_by_bucket, extra_hits=extra_hits) + + +# --------------------------- Qt Helpers --------------------------- + +class WorkerSignals(QtCore.QObject): + app_started = QtCore.Signal(str) + app_progress = QtCore.Signal(int, int) # current, total + app_finished = QtCore.Signal(object) # ScanResult + all_done = QtCore.Signal() + + +class ScanWorker(QtCore.QRunnable): + def __init__(self, apps: List[AppInfo]): + super().__init__() + self.apps = apps + self.signals = WorkerSignals() + + @QtCore.Slot() + def run(self): + total = len(self.apps) + for idx, app in enumerate(self.apps, start=1): + self.signals.app_started.emit(app.name) + # hydrate bundle id + bid = read_bundle_id(app.path) + app.bundle_id = bid + result = scan_app(app) + self.signals.app_progress.emit(idx, total) + self.signals.app_finished.emit(result) + self.signals.all_done.emit() + + +class CheckableTree(QtWidgets.QTreeWidget): + """ + QTreeWidget with tri-state checkbox support and recursive propagation. + Column 0 holds the checkboxes. + """ + def __init__(self, parent=None): + super().__init__(parent) + self.setHeaderLabels(["Path / Group", "Kind"]) + self.setUniformRowHeights(True) + self.setExpandsOnDoubleClick(True) + self.itemChanged.connect(self.on_item_changed) + + def on_item_changed(self, item: QtWidgets.QTreeWidgetItem, column: int): + if column != 0: + return + state = item.checkState(0) + # propagate down + for i in range(item.childCount()): + child = item.child(i) + child.setCheckState(0, state) + # propagate up + self._update_parent_state(item) + + def _update_parent_state(self, item: QtWidgets.QTreeWidgetItem): + parent = item.parent() + if not parent: + return + checked = 0 + partial = False + for i in range(parent.childCount()): + cs = parent.child(i).checkState(0) + if cs == QtCore.Qt.CheckState.PartiallyChecked: + partial = True + elif cs == QtCore.Qt.CheckState.Checked: + checked += 1 + if partial or (0 < checked < parent.childCount()): + parent.setCheckState(0, QtCore.Qt.CheckState.PartiallyChecked) + elif checked == 0: + parent.setCheckState(0, QtCore.Qt.CheckState.Unchecked) + else: + parent.setCheckState(0, QtCore.Qt.CheckState.Checked) + self._update_parent_state(parent) + + +# --------------------------- Main Window --------------------------- + +class AppCleaner(QtWidgets.QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("macOS App Cleaner (PySide6)") + self.resize(1100, 700) + + # Central layout + splitter = QtWidgets.QSplitter() + splitter.setOrientation(QtCore.Qt.Orientation.Horizontal) + + # Left: app list with checkboxes + left = QtWidgets.QWidget() + left_layout = QtWidgets.QVBoxLayout(left) + left_layout.setContentsMargins(8, 8, 8, 8) + self.search_bar = QtWidgets.QLineEdit() + self.search_bar.setPlaceholderText("Filter apps…") + self.search_bar.textChanged.connect(self.filter_apps) + left_layout.addWidget(self.search_bar) + + self.app_list = QtWidgets.QListWidget() + self.app_list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) + left_layout.addWidget(self.app_list, 1) + + # App toolbar + app_toolbar = QtWidgets.QHBoxLayout() + self.btn_select_all = QtWidgets.QPushButton("Select All") + self.btn_select_none = QtWidgets.QPushButton("Deselect All") + self.btn_refresh = QtWidgets.QPushButton("Rescan Apps") + app_toolbar.addWidget(self.btn_select_all) + app_toolbar.addWidget(self.btn_select_none) + app_toolbar.addStretch(1) + app_toolbar.addWidget(self.btn_refresh) + left_layout.addLayout(app_toolbar) + + splitter.addWidget(left) + + # Right: tree of results + controls + right = QtWidgets.QWidget() + right_layout = QtWidgets.QVBoxLayout(right) + right_layout.setContentsMargins(8, 8, 8, 8) + + self.tree = CheckableTree() + right_layout.addWidget(self.tree, 1) + + # Controls + controls = QtWidgets.QHBoxLayout() + self.btn_scan = QtWidgets.QPushButton("Scan Selected Apps") + self.btn_delete = QtWidgets.QPushButton("Delete Checked Items") + self.btn_delete.setEnabled(False) + self.progress = QtWidgets.QProgressBar() + self.progress.setRange(0, 100) + self.progress.setValue(0) + self.status_label = QtWidgets.QLabel("Ready.") + controls.addWidget(self.btn_scan) + controls.addWidget(self.btn_delete) + controls.addStretch(1) + controls.addWidget(self.progress, 2) + controls.addWidget(self.status_label, 2) + right_layout.addLayout(controls) + + splitter.addWidget(right) + splitter.setStretchFactor(0, 1) + splitter.setStretchFactor(1, 3) + self.setCentralWidget(splitter) + + # Menu (optional niceties) + self._make_menu() + + # State + self.thread_pool = QtCore.QThreadPool() + self.all_apps: List[AppInfo] = [] + + # Signals + self.btn_select_all.clicked.connect(self.select_all_apps) + self.btn_select_none.clicked.connect(self.deselect_all_apps) + self.btn_refresh.clicked.connect(self.load_apps) + self.btn_scan.clicked.connect(self.scan_selected) + self.btn_delete.clicked.connect(self.delete_checked) + + # Load apps + self.load_apps() + + # Styling: macOS-ish padding + self.setStyleSheet(""" + QTreeWidget::item { padding: 6px; } + QListWidget::item { padding: 4px 6px; } + QPushButton { padding: 6px 12px; } + """) + + # ---------- Menu ---------- + def _make_menu(self): + bar = self.menuBar() + file_menu = bar.addMenu("&File") + act_quit = QtGui.QAction("Quit", self) + act_quit.setShortcut(QtGui.QKeySequence.StandardKey.Quit) + act_quit.triggered.connect(self.close) + file_menu.addAction(act_quit) + + actions_menu = bar.addMenu("&Actions") + act_scan = QtGui.QAction("Scan Selected Apps", self) + act_scan.setShortcut("Ctrl+S") + act_scan.triggered.connect(self.scan_selected) + actions_menu.addAction(act_scan) + + act_delete = QtGui.QAction("Delete Checked Items", self) + act_delete.setShortcut("Ctrl+D") + act_delete.triggered.connect(self.delete_checked) + actions_menu.addAction(act_delete) + + help_menu = bar.addMenu("&Help") + about = QtGui.QAction("About", self) + about.triggered.connect(self.show_about) + help_menu.addAction(about) + + def show_about(self): + QtWidgets.QMessageBox.information( + self, + "About", + "macOS App Cleaner\n\n" + "• Scans your installed apps\n" + "• Finds user-space support files via Spotlight and Library sweeps\n" + "• Lets you review and delete them safely\n\n" + "Built with PySide6." + ) + + # ---------- App list ---------- + def load_apps(self): + self.app_list.clear() + self.all_apps = find_installed_apps() + # attach bundle ids lazily (during scan) to keep initial load snappy + for app in self.all_apps: + item = QtWidgets.QListWidgetItem(app.name) + item.setData(QtCore.Qt.ItemDataRole.UserRole, app) + item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState(QtCore.Qt.CheckState.Unchecked) + self.app_list.addItem(item) + self.status_label.setText(f"Found {len(self.all_apps)} apps.") + + def filter_apps(self, text: str): + text = text.lower().strip() + for i in range(self.app_list.count()): + it = self.app_list.item(i) + it.setHidden(text not in it.text().lower()) + + def select_all_apps(self): + for i in range(self.app_list.count()): + it = self.app_list.item(i) + if not it.isHidden(): + it.setCheckState(QtCore.Qt.CheckState.Checked) + + def deselect_all_apps(self): + for i in range(self.app_list.count()): + it = self.app_list.item(i) + it.setCheckState(QtCore.Qt.CheckState.Unchecked) + + def selected_apps(self) -> List[AppInfo]: + out: List[AppInfo] = [] + for i in range(self.app_list.count()): + it = self.app_list.item(i) + if it.checkState() == QtCore.Qt.CheckState.Checked: + app = it.data(QtCore.Qt.ItemDataRole.UserRole) + if isinstance(app, AppInfo): + out.append(app) + return out + + # ---------- Scanning ---------- + def scan_selected(self): + apps = self.selected_apps() + if not apps: + QtWidgets.QMessageBox.information(self, "Nothing selected", + "Please select at least one app to scan.") + return + self.tree.clear() + self.btn_scan.setEnabled(False) + self.btn_delete.setEnabled(False) + self.progress.setValue(0) + self.status_label.setText("Scanning…") + + worker = ScanWorker(apps) + worker.signals.app_started.connect(self.on_app_started) + worker.signals.app_progress.connect(self.on_app_progress) + worker.signals.app_finished.connect(self.on_app_finished) + worker.signals.all_done.connect(self.on_all_done) + self.thread_pool.start(worker) + + @QtCore.Slot(str) + def on_app_started(self, app_name: str): + self.status_label.setText(f"Scanning {app_name}…") + + @QtCore.Slot(int, int) + def on_app_progress(self, current: int, total: int): + pct = int(100 * current / max(1, total)) + self.progress.setValue(pct) + + @QtCore.Slot(object) + def on_app_finished(self, result: ScanResult): + # Build tree: + # App (checked) + # Bucket (checked) + # file path (checked) + # Other Matches + # file path + app_top = QtWidgets.QTreeWidgetItem([result.app.name, "App"]) + app_top.setFlags(app_top.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + app_top.setCheckState(0, QtCore.Qt.CheckState.Checked) + app_top.setData(0, QtCore.Qt.ItemDataRole.UserRole, ("APP", str(result.app.path))) + + def add_child(parent, text, kind, payload=None): + it = QtWidgets.QTreeWidgetItem([text, kind]) + it.setFlags(it.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + it.setCheckState(0, QtCore.Qt.CheckState.Checked) + if payload is not None: + it.setData(0, QtCore.Qt.ItemDataRole.UserRole, payload) + parent.addChild(it) + return it + + # Buckets + for bucket, files in sorted(result.files_by_bucket.items(), key=lambda kv: kv[0].lower()): + bnode = add_child(app_top, bucket, "Group", ("BUCKET", bucket)) + for f in sorted(files, key=lambda p: p.as_posix().lower()): + add_child(bnode, f.as_posix(), "File", ("FILE", f.as_posix())) + + # Extra hits (Spotlight matches outside our buckets) + if result.extra_hits: + onode = add_child(app_top, "Other Matches", "Group", ("BUCKET", "Other Matches")) + for f in sorted(result.extra_hits, key=lambda p: p.as_posix().lower()): + add_child(onode, f.as_posix(), "File", ("FILE", f.as_posix())) + + self.tree.addTopLevelItem(app_top) + self.tree.expandItem(app_top) + self.btn_delete.setEnabled(self.tree.topLevelItemCount() > 0) + + @QtCore.Slot() + def on_all_done(self): + self.status_label.setText("Scan complete.") + self.btn_scan.setEnabled(True) + + # ---------- Deletion ---------- + def _gather_checked_files(self) -> List[Path]: + files: List[Path] = [] + for i in range(self.tree.topLevelItemCount()): + app_node = self.tree.topLevelItem(i) + self._collect_checked(app_node, files) + # dedupe; prefer deepest first so if a directory is selected, we won't try individual children after moving + uniq: List[Path] = [] + seen = set() + for p in files: + if p not in seen: + seen.add(p) + uniq.append(p) + # Sort by path depth descending to delete children before parents if deleting directly + uniq.sort(key=lambda p: len(p.parts), reverse=True) + return uniq + + def _collect_checked(self, node: QtWidgets.QTreeWidgetItem, out: List[Path]): + # If node is a file and checked, collect + if node.checkState(0) == QtCore.Qt.CheckState.Unchecked: + return + payload = node.data(0, QtCore.Qt.ItemDataRole.UserRole) + if payload and isinstance(payload, tuple) and payload[0] == "FILE": + p = Path(payload[1]) + out.append(p) + # Recurse + for i in range(node.childCount()): + self._collect_checked(node.child(i), out) + + def delete_checked(self): + targets = self._gather_checked_files() + if not targets: + QtWidgets.QMessageBox.information(self, "No items selected", + "Check some files/folders in the results to delete.") + return + + # Summarize for confirmation + preview = "\n".join(str(p) for p in targets[:12]) + more = "" if len(targets) <= 12 else f"\n… and {len(targets) - 12} more." + msg = (f"You are about to delete/move to Trash {len(targets)} item(s).\n\n" + f"{preview}{more}\n\n" + "Prefer moving to Trash when possible. Continue?") + ret = QtWidgets.QMessageBox.warning( + self, + "Confirm Deletion", + msg, + QtWidgets.QMessageBox.StandardButton.Cancel | QtWidgets.QMessageBox.StandardButton.Ok, + QtWidgets.QMessageBox.StandardButton.Cancel + ) + if ret != QtWidgets.QMessageBox.StandardButton.Ok: + return + + # Try moving to Trash first + moved, removed, failed = self._move_or_delete(targets) + + msg = (f"Finished.\n\nMoved to Trash: {len(moved)}\n" + f"Permanently deleted: {len(removed)}\n" + f"Failed: {len(failed)}") + if failed: + msg += "\n\nFailures:\n" + "\n".join(f"{p} — {err}" for p, err in failed[:10]) + if len(failed) > 10: + msg += f"\n… and {len(failed)-10} more." + QtWidgets.QMessageBox.information(self, "Cleanup", msg) + + # Remove deleted nodes from the tree (optimistic) + self._prune_deleted_nodes() + self.btn_delete.setEnabled(self.tree.topLevelItemCount() > 0) + + def _move_or_delete(self, targets: List[Path]): + trash = Path.home() / ".Trash" + moved, removed, failed = [], [], [] + + for p in targets: + try: + if not p.exists(): + continue + # Prefer move to Trash when on same volume and Trash exists + can_move = trash.exists() and p.anchor == trash.anchor + if can_move: + # Ensure unique filename in Trash + dest = trash / p.name + suffix = 1 + while dest.exists(): + dest = trash / f"{p.stem} {suffix}{p.suffix}" + suffix += 1 + shutil.move(str(p), str(dest)) + moved.append(p) + else: + # Fall back to permanent delete + if p.is_dir(): + shutil.rmtree(p, ignore_errors=False) + else: + p.unlink() + removed.append(p) + except Exception as e: + failed.append((p, str(e))) + return moved, removed, failed + + def _prune_deleted_nodes(self): + # Walk tree and drop FILE nodes that no longer exist; prune empty groups; prune empty apps + def node_exists(n: QtWidgets.QTreeWidgetItem) -> bool: + payload = n.data(0, QtCore.Qt.ItemDataRole.UserRole) + if payload and isinstance(payload, tuple) and payload[0] == "FILE": + return Path(payload[1]).exists() + return True # non-file "group" nodes considered present + + def prune_node(n: QtWidgets.QTreeWidgetItem) -> bool: + # returns True if node should be kept + for i in reversed(range(n.childCount())): + child = n.child(i) + keep = prune_node(child) + if not keep: + n.removeChild(child) + # After pruning children, decide for this node + payload = n.data(0, QtCore.Qt.ItemDataRole.UserRole) + if payload and isinstance(payload, tuple) and payload[0] == "FILE": + return node_exists(n) + # Keep non-file nodes if they still have children + return n.childCount() > 0 + + for i in reversed(range(self.tree.topLevelItemCount())): + top = self.tree.topLevelItem(i) + keep = prune_node(top) + if not keep: + self.tree.takeTopLevelItem(i) + + +def main(): + # Strongly recommend running on macOS only + if sys.platform != "darwin": + QtWidgets.QMessageBox.warning(None, "Platform Warning", "This tool is intended for macOS.") + QtWidgets.QApplication.setAttribute(QtCore.Qt.ApplicationAttribute.AA_DontUseNativeMenuBar, False) + app = QtWidgets.QApplication(sys.argv) + app.setApplicationName("macOS App Cleaner") + win = AppCleaner() + win.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/MacOS App Removal Cache Cleaner/requirements.txt b/MacOS App Removal Cache Cleaner/requirements.txt new file mode 100644 index 00000000..7fa34f07 --- /dev/null +++ b/MacOS App Removal Cache Cleaner/requirements.txt @@ -0,0 +1 @@ +PySide6 diff --git a/PDF Editor and Signer/README.md b/PDF Editor and Signer/README.md new file mode 100644 index 00000000..1380fb00 --- /dev/null +++ b/PDF Editor and Signer/README.md @@ -0,0 +1,187 @@ +# PDF Editor and Signer + +A sophisticated PDF editor with intelligent element detection, click-to-place text insertion, and digital signature capabilities built with PySide6 and PyMuPDF. + +## Features + +### Intelligent Element Detection +- **Automatic Detection**: The application automatically detects interactive elements in your PDF: + - **Horizontal Lines**: Text fields and underlines (green highlight) + - **Signature Fields**: Areas with "signature" keywords (blue highlight) + - **Date Fields**: Areas with "date" keywords (red highlight) +- **Visual Feedback**: Detected elements are highlighted when you hover nearby +- **No Manual Controls**: Detection is always active - no buttons needed + +### Click-to-Place Text Insertion +- **Smart Placement**: Click near any detected element (within 50 pixels) to add text +- **Type-Aware Formatting**: + - Signature fields: Italic script font style + - Date fields: Auto-filled with current date (MM/DD/YYYY) + - Text lines: Standard font + - Freeform: Standard font anywhere on the page +- **Auto-Focus**: Text boxes automatically receive focus when created +- **Auto-Remove**: Empty text boxes disappear when you click away +- **Intuitive UX**: Just click where you want to type - the app handles the rest + +### Selectable Text Overlay +- **Native Selection**: Select and copy text directly from the PDF view +- **Transparent Layer**: Text overlay doesn't interfere with visual appearance +- **Preserved Layout**: Text positioned exactly as it appears in the PDF + +### Zoom Controls +- **Zoom Slider**: 50% to 400% zoom range +- **Zoom Buttons**: +/- buttons for incremental zoom (10% steps) +- **Fit Width**: Automatically fit PDF to window width +- **Fit Page**: Automatically fit entire page in view +- **Zoom-Aware Coordinates**: Text placement accounts for current zoom level + +### Digital Signatures +- **Certificate Management**: + - Create new certificates with RSA 2048-bit keys + - Load existing certificate and key files + - X.509 format with SHA-256 hashing +- **PDF Signing**: Apply digital signatures to edited PDFs +- **Signature Validation**: Digitally signed documents maintain integrity + +## Installation + +### Requirements +- Python 3.7+ +- PySide6 (Qt6 GUI framework) +- PyMuPDF (PDF rendering and manipulation) +- pyHanko (digital signatures) +- cryptography (certificate generation) + +### Install Dependencies + +```bash +pip install -r requirements.txt +``` + +Or install individually: + +```bash +pip install PySide6>=6.0.0 PyMuPDF>=1.23.0 pyHanko>=0.20.0 cryptography>=41.0.0 +``` + +## Usage + +### Run the Application + +```bash +python pdf_editor_signer.py +``` + +### Workflow + +1. **Open PDF**: Click "Open PDF" to load a document +2. **Navigate**: Use page navigation controls or enter page number directly +3. **Add Text**: + - Click near a detected line, signature field, or date field + - The text box appears automatically at the correct location + - Type your text (dates auto-fill) + - Click elsewhere when done (empty boxes disappear) +4. **Zoom**: Use zoom controls to adjust view as needed +5. **Select Text**: Select and copy text directly from the PDF +6. **Save**: Click "Save PDF" to save your edits +7. **Sign** (optional): + - Create or load a certificate + - Click "Sign PDF" to apply digital signature + +### Text Box Types + +The application automatically determines the text box type based on what you click: + +- **Text Line**: Click near a horizontal line (green highlight) +- **Signature**: Click near a signature keyword (blue highlight) +- **Date**: Click near a date keyword (red highlight) +- **Freeform**: Click anywhere else on the page + +### Certificate Management + +**Create Certificate**: +1. Click "Create Certificate" +2. Enter details (Country, Organization, Common Name, etc.) +3. Certificate and key files saved in current directory + +**Load Certificate**: +1. Click "Load Certificate" +2. Select certificate file (.pem) +3. Select key file (.pem) +4. Certificate loaded for signing + +## Architecture + +### Graphics System +- **Z-Layering**: + - Z-Level -2: Selectable text overlay (transparent) + - Z-Level -1: Element detection highlights + - Z-Level 0: User-created text boxes +- **Coordinate Conversion**: Automatic conversion between Qt scene coordinates and PDF coordinates + +### Detection Algorithm +- **Line Detection**: Analyzes PDF drawings for horizontal rectangles (<5px height, >50px width) +- **Text Pattern Matching**: Searches text blocks for "signature" and "date" keywords +- **Distance Calculation**: Finds nearest detected element within 50-pixel threshold + +### Type Safety +- Full type annotations throughout codebase +- Strict mypy compliance +- Qt type hints for all widgets and events + +## File Structure + +``` +PDF Editor and Signer/ +├── pdf_editor_signer.py # Main application (~1200 lines) +├── requirements.txt # Python dependencies +└── README.md # This file +``` + +## Technical Details + +### Text Box Styling +- **Signature**: `font-style: italic; font-family: 'Brush Script MT', cursive;` +- **Date**: Auto-filled with current date on creation +- **Standard**: Default font, adjustable size +- **Border**: Yellow border for all editable boxes + +### PDF Manipulation +- **Rendering**: PyMuPDF (fitz) converts pages to QPixmap +- **Text Extraction**: `page.get_text("dict")` provides text with bounding boxes +- **Drawing Detection**: `page.get_drawings()` provides vector graphics data +- **Text Insertion**: Applied to PDF with zoom-aware coordinate conversion + +### Digital Signatures +- **Key Algorithm**: RSA 2048-bit +- **Certificate Format**: X.509 +- **Hash Algorithm**: SHA-256 +- **Signing Backend**: pyHanko with cryptography library + +## Known Limitations + +- Element detection works best with text-based PDFs (may not detect elements in scanned/image PDFs) +- Signature detection requires text containing "signature" keyword +- Date detection requires text containing "date" keyword +- Custom detection patterns not currently configurable + +## Contributing + +Contributions welcome! Areas for enhancement: +- OCR integration for scanned PDFs +- Configurable detection patterns +- Custom text box styles +- Annotation tools (highlighting, shapes) +- Form field recognition + +## License + +[Add your license here] + +## Credits + +Built with: +- [PySide6](https://wiki.qt.io/Qt_for_Python) - Qt6 Python bindings +- [PyMuPDF](https://pymupdf.readthedocs.io/) - PDF processing +- [pyHanko](https://github.com/MatthiasValvekens/pyHanko) - PDF signing +- [cryptography](https://cryptography.io/) - Cryptographic operations diff --git a/PDF Editor and Signer/pdf_editor_signer.py b/PDF Editor and Signer/pdf_editor_signer.py new file mode 100644 index 00000000..ccee9310 --- /dev/null +++ b/PDF Editor and Signer/pdf_editor_signer.py @@ -0,0 +1,1236 @@ +#!/usr/bin/env python3 +""" +PDF Editor and Signer +A modern Qt-based GUI application for editing PDF text and adding digital signatures. +""" + +import sys +import os +from pathlib import Path +from typing import Optional, List, Tuple, Dict, Any, Union +from datetime import datetime, timedelta +from enum import Enum + +import fitz # PyMuPDF +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa, padding +from cryptography import x509 +from cryptography.x509.oid import NameOID +from pyhanko.sign import signers +from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter +from pyhanko.sign.fields import SigFieldSpec + +try: + from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QLabel, QTextEdit, QFileDialog, QMessageBox, + QListWidget, QSplitter, QGroupBox, QLineEdit, QSpinBox, + QComboBox, QTabWidget, QScrollArea, QDialog, QFormLayout, + QDialogButtonBox, QInputDialog, QSlider, QGraphicsView, + QGraphicsScene, QGraphicsRectItem, QGraphicsTextItem, QGraphicsLineItem + ) + from PySide6.QtCore import Qt, QThread, Signal, QByteArray, QRectF, QPointF, QSizeF + from PySide6.QtGui import QPixmap, QImage, QPalette, QColor, QPainter, QPen, QBrush, QFont, QTextCursor +except ImportError: + print("Error: PySide6 is required. Install with: pip install PySide6") + sys.exit(1) + + +class TextBoxType(Enum): + """Types of text boxes for intelligent placement.""" + FREEFORM = "freeform" + SIGNATURE = "signature" + DATE = "date" + TEXT_LINE = "text_line" + + +class EditableTextBox(QGraphicsTextItem): + """Interactive text box that can be edited, moved, and resized.""" + + def __init__(self, text: str = "", box_type: TextBoxType = TextBoxType.FREEFORM, parent: Optional[QGraphicsRectItem] = None) -> None: + super().__init__(text, parent) + self.box_type = box_type + self.border_item: Optional[QGraphicsRectItem] = None + self.is_empty = not text + + # Make it editable + self.setTextInteractionFlags(Qt.TextInteractionFlag.TextEditorInteraction) + self.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsMovable, True) + self.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsSelectable, True) + self.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsFocusable, True) + + # Set default font + font = QFont("Arial", 12) + self.setFont(font) + self.setDefaultTextColor(QColor(0, 0, 0)) + + # Apply type-specific styling + self._apply_type_styling() + + def _apply_type_styling(self) -> None: + """Apply styling based on text box type.""" + font = self.font() + + if self.box_type == TextBoxType.SIGNATURE: + font.setItalic(True) + font.setFamily("Brush Script MT") + self.setPlaceholderText("Signature") + elif self.box_type == TextBoxType.DATE: + self.setPlainText(datetime.now().strftime("%m/%d/%Y")) + self.is_empty = False + elif self.box_type == TextBoxType.TEXT_LINE: + font.setPointSize(10) + + self.setFont(font) + + def setPlaceholderText(self, text: str) -> None: + """Set placeholder text.""" + if self.is_empty: + self.setPlainText(text) + self.setDefaultTextColor(QColor(150, 150, 150)) + + def focusInEvent(self, event: Any) -> None: + """Handle focus in - clear placeholder.""" + if self.is_empty and self.defaultTextColor() == QColor(150, 150, 150): + self.setPlainText("") + self.setDefaultTextColor(QColor(0, 0, 0)) + super().focusInEvent(event) + + def focusOutEvent(self, event: Any) -> None: + """Handle focus out - remove if empty.""" + text = self.toPlainText().strip() + if not text: + self.is_empty = True + # Signal to remove this item + if self.scene(): + self.scene().removeItem(self) + if self.border_item and self.border_item.scene(): + self.scene().removeItem(self.border_item) + else: + self.is_empty = False + super().focusOutEvent(event) + + def setBorderItem(self, border: QGraphicsRectItem) -> None: + """Associate a border rectangle with this text box.""" + self.border_item = border + + def itemChange(self, change: QGraphicsTextItem.GraphicsItemChange, value: Any) -> Any: + """Handle item changes to update border position.""" + if change == QGraphicsTextItem.GraphicsItemChange.ItemPositionChange and self.border_item: + # Update border position + new_pos = value + if isinstance(new_pos, QPointF): + self.border_item.setPos(new_pos) + return super().itemChange(change, value) + + +class PDFGraphicsView(QGraphicsView): + """Custom graphics view for PDF editing with text box creation.""" + + textBoxCreated = Signal(QRectF, TextBoxType) + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.setRenderHint(QPainter.RenderHint.Antialiasing) + self.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + self.setDragMode(QGraphicsView.DragMode.NoDrag) + + self.is_drawing_textbox = False + self.drawing_start: Optional[QPointF] = None + self.drawing_rect_item: Optional[QGraphicsRectItem] = None + self.current_box_type: TextBoxType = TextBoxType.FREEFORM + self.detected_lines: List[QRectF] = [] + self.detected_fields: List[Tuple[QRectF, TextBoxType]] = [] + self.click_to_place_mode: bool = False + + def setDrawingMode(self, enabled: bool, box_type: TextBoxType = TextBoxType.FREEFORM, click_mode: bool = False) -> None: + """Enable or disable text box drawing mode.""" + self.is_drawing_textbox = enabled + self.current_box_type = box_type + self.click_to_place_mode = click_mode + if enabled: + if click_mode: + self.setCursor(Qt.CursorShape.PointingHandCursor) + else: + self.setCursor(Qt.CursorShape.CrossCursor) + self.setDragMode(QGraphicsView.DragMode.NoDrag) + else: + self.setCursor(Qt.CursorShape.ArrowCursor) + self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) + + def setDetectedElements(self, lines: List[QRectF], fields: List[Tuple[QRectF, TextBoxType]]) -> None: + """Set detected lines and fields for intelligent placement.""" + self.detected_lines = lines + self.detected_fields = fields + + def mousePressEvent(self, event: Any) -> None: + """Handle mouse press for text box creation.""" + if self.is_drawing_textbox and event.button() == Qt.MouseButton.LeftButton: + click_pos = self.mapToScene(event.pos()) + + # Click-to-place mode: find nearest detected element + if self.click_to_place_mode: + nearest_rect, box_type = self._find_nearest_element(click_pos) + if nearest_rect: + self.textBoxCreated.emit(nearest_rect, box_type) + return + + # Draw mode: start drawing rectangle + self.drawing_start = click_pos + + # Create preview rectangle + self.drawing_rect_item = QGraphicsRectItem() + pen = QPen(QColor(42, 130, 218), 2, Qt.PenStyle.DashLine) + self.drawing_rect_item.setPen(pen) + brush = QBrush(QColor(42, 130, 218, 30)) + self.drawing_rect_item.setBrush(brush) + + if self.scene(): + self.scene().addItem(self.drawing_rect_item) + else: + super().mousePressEvent(event) + + def _find_nearest_element(self, pos: QPointF) -> Tuple[Optional[QRectF], TextBoxType]: + """Find the nearest detected element to click position.""" + min_distance = float('inf') + nearest_rect: Optional[QRectF] = None + nearest_type = TextBoxType.FREEFORM + + # Check detected fields first (they have specific types) + for rect, box_type in self.detected_fields: + if box_type == self.current_box_type or self.current_box_type == TextBoxType.FREEFORM: + distance = self._distance_to_rect(pos, rect) + if distance < min_distance and distance < 50: # Within 50 pixels + min_distance = distance + nearest_rect = rect + nearest_type = box_type + + # Check detected lines if looking for text lines + if not nearest_rect and self.current_box_type == TextBoxType.TEXT_LINE: + for rect in self.detected_lines: + distance = self._distance_to_rect(pos, rect) + if distance < min_distance and distance < 50: + min_distance = distance + nearest_rect = rect + nearest_type = TextBoxType.TEXT_LINE + + return nearest_rect, nearest_type + + def _distance_to_rect(self, pos: QPointF, rect: QRectF) -> float: + """Calculate distance from point to rectangle.""" + # If point is inside, distance is 0 + if rect.contains(pos): + return 0.0 + + # Calculate distance to nearest edge/corner + dx = max(rect.left() - pos.x(), 0.0, pos.x() - rect.right()) + dy = max(rect.top() - pos.y(), 0.0, pos.y() - rect.bottom()) + return (dx * dx + dy * dy) ** 0.5 + + def mouseMoveEvent(self, event: Any) -> None: + """Handle mouse move to update text box preview.""" + if self.is_drawing_textbox and self.drawing_start and self.drawing_rect_item: + current_pos = self.mapToScene(event.pos()) + rect = QRectF(self.drawing_start, current_pos).normalized() + self.drawing_rect_item.setRect(rect) + else: + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event: Any) -> None: + """Handle mouse release to create text box.""" + if self.is_drawing_textbox and event.button() == Qt.MouseButton.LeftButton and self.drawing_start: + current_pos = self.mapToScene(event.pos()) + rect = QRectF(self.drawing_start, current_pos).normalized() + + # Only create if rectangle is large enough + if rect.width() > 20 and rect.height() > 10: + self.textBoxCreated.emit(rect, self.current_box_type) + + # Clean up preview + if self.drawing_rect_item and self.scene(): + self.scene().removeItem(self.drawing_rect_item) + + self.drawing_start = None + self.drawing_rect_item = None + else: + super().mouseReleaseEvent(event) + + +class PDFProcessor(QThread): + """Background thread for PDF processing operations.""" + finished = Signal(bool, str) # success, message + progress = Signal(str) # status message + + def __init__(self, operation: str, **kwargs: Any) -> None: + super().__init__() + self.operation = operation + self.kwargs = kwargs + + def run(self) -> None: + """Execute the PDF processing operation.""" + try: + if self.operation == "load": + self._load_pdf() + elif self.operation == "save": + self._save_pdf() + elif self.operation == "add_text": + self._add_text() + elif self.operation == "sign": + self._sign_pdf() + else: + self.finished.emit(False, f"Unknown operation: {self.operation}") + except Exception as e: + self.finished.emit(False, f"Error: {str(e)}") + + def _load_pdf(self) -> None: + """Load PDF and extract text.""" + pdf_path = self.kwargs.get("pdf_path") + if not pdf_path: + self.finished.emit(False, "No PDF path provided") + return + + self.progress.emit("Loading PDF...") + doc = fitz.open(pdf_path) + pages_data = [] + + for page_num in range(len(doc)): + page = doc[page_num] + text = page.get_text() + pages_data.append({ + "page_num": page_num + 1, + "text": text, + "width": page.rect.width, + "height": page.rect.height + }) + + doc.close() + self.kwargs["pages_data"] = pages_data + self.finished.emit(True, f"Loaded {len(pages_data)} pages") + + def _save_pdf(self) -> None: + """Save PDF with modifications.""" + self.progress.emit("Saving PDF...") + # Implementation will use PyMuPDF to save + self.finished.emit(True, "PDF saved successfully") + + def _add_text(self) -> None: + """Add text to PDF page.""" + self.progress.emit("Adding text...") + # Implementation will use PyMuPDF to insert text + self.finished.emit(True, "Text added successfully") + + def _sign_pdf(self) -> None: + """Add digital signature to PDF.""" + self.progress.emit("Signing PDF...") + # Implementation will use pyHanko + self.finished.emit(True, "PDF signed successfully") + + +class CertificateDialog(QDialog): + """Dialog for creating or selecting a certificate for signing.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.setWindowTitle("Certificate Setup") + self.setModal(True) + self.certificate_data: Optional[Dict[str, Any]] = None + self._init_ui() + + def _init_ui(self) -> None: + """Initialize the dialog UI.""" + layout = QVBoxLayout() + + # Certificate info + form_layout = QFormLayout() + + self.name_input = QLineEdit() + self.name_input.setPlaceholderText("Your Name") + form_layout.addRow("Name:", self.name_input) + + self.org_input = QLineEdit() + self.org_input.setPlaceholderText("Organization") + form_layout.addRow("Organization:", self.org_input) + + self.email_input = QLineEdit() + self.email_input.setPlaceholderText("email@example.com") + form_layout.addRow("Email:", self.email_input) + + self.country_input = QLineEdit() + self.country_input.setPlaceholderText("US") + self.country_input.setMaxLength(2) + form_layout.addRow("Country:", self.country_input) + + layout.addLayout(form_layout) + + # Buttons + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + button_box.accepted.connect(self._on_accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + self.setLayout(layout) + + def _on_accept(self) -> None: + """Validate and accept the dialog.""" + name = self.name_input.text().strip() + if not name: + QMessageBox.warning(self, "Error", "Name is required") + return + + self.certificate_data = { + "name": name, + "organization": self.org_input.text().strip() or "Self-Signed", + "email": self.email_input.text().strip() or "no-reply@example.com", + "country": self.country_input.text().strip() or "US" + } + self.accept() + + +class PDFEditorSignerGUI(QMainWindow): + """Main application window for PDF editing and signing.""" + + def __init__(self) -> None: + super().__init__() + self.current_pdf_path: Optional[str] = None + self.pdf_doc: Optional[fitz.Document] = None + self.current_page: int = 0 + self.pages_data: List[Dict[str, Any]] = [] + self.certificate_path: Optional[str] = None + self.private_key_path: Optional[str] = None + self.zoom_level: float = 2.0 # Default zoom level + self.text_boxes: List[EditableTextBox] = [] # Track text boxes on current page + self.graphics_scene: Optional[QGraphicsScene] = None + self.graphics_view: Optional[PDFGraphicsView] = None + + self.setWindowTitle("PDF Editor & Signer") + self.setGeometry(100, 100, 1200, 800) + + self._init_ui() + self._apply_theme() + + def _init_ui(self) -> None: + """Initialize the user interface.""" + central_widget = QWidget() + self.setCentralWidget(central_widget) + main_layout = QVBoxLayout(central_widget) + + # Top toolbar + toolbar_layout = QHBoxLayout() + + load_btn = QPushButton("Open PDF") + load_btn.clicked.connect(self._load_pdf) + toolbar_layout.addWidget(load_btn) + + save_btn = QPushButton("Save PDF") + save_btn.clicked.connect(self._save_pdf) + toolbar_layout.addWidget(save_btn) + + save_as_btn = QPushButton("Save As...") + save_as_btn.clicked.connect(self._save_pdf_as) + toolbar_layout.addWidget(save_as_btn) + + toolbar_layout.addStretch() + + sign_btn = QPushButton("Sign PDF") + sign_btn.clicked.connect(self._show_sign_dialog) + toolbar_layout.addWidget(sign_btn) + + main_layout.addLayout(toolbar_layout) + + # Main content area with splitter + splitter = QSplitter(Qt.Orientation.Horizontal) + + # Left panel - Page list and navigation + left_panel = QWidget() + left_layout = QVBoxLayout(left_panel) + + pages_label = QLabel("Pages:") + left_layout.addWidget(pages_label) + + self.page_list = QListWidget() + self.page_list.currentRowChanged.connect(self._on_page_changed) + left_layout.addWidget(self.page_list) + + # Navigation buttons + nav_layout = QHBoxLayout() + prev_btn = QPushButton("Previous") + prev_btn.clicked.connect(self._prev_page) + nav_layout.addWidget(prev_btn) + + next_btn = QPushButton("Next") + next_btn.clicked.connect(self._next_page) + nav_layout.addWidget(next_btn) + + left_layout.addLayout(nav_layout) + + splitter.addWidget(left_panel) + + # Center panel - PDF viewer and editor + center_panel = QWidget() + center_layout = QVBoxLayout(center_panel) + + # Zoom controls + zoom_layout = QHBoxLayout() + zoom_label = QLabel("Zoom:") + zoom_layout.addWidget(zoom_label) + + zoom_out_btn = QPushButton("-") + zoom_out_btn.setMaximumWidth(40) + zoom_out_btn.clicked.connect(self._zoom_out) + zoom_layout.addWidget(zoom_out_btn) + + self.zoom_slider = QSlider(Qt.Orientation.Horizontal) + self.zoom_slider.setMinimum(50) # 0.5x zoom + self.zoom_slider.setMaximum(400) # 4.0x zoom + self.zoom_slider.setValue(200) # 2.0x zoom (default) + self.zoom_slider.setTickPosition(QSlider.TickPosition.TicksBelow) + self.zoom_slider.setTickInterval(50) + self.zoom_slider.valueChanged.connect(self._on_zoom_changed) + zoom_layout.addWidget(self.zoom_slider) + + zoom_in_btn = QPushButton("+") + zoom_in_btn.setMaximumWidth(40) + zoom_in_btn.clicked.connect(self._zoom_in) + zoom_layout.addWidget(zoom_in_btn) + + self.zoom_value_label = QLabel("200%") + self.zoom_value_label.setMinimumWidth(50) + zoom_layout.addWidget(self.zoom_value_label) + + fit_width_btn = QPushButton("Fit Width") + fit_width_btn.clicked.connect(self._fit_width) + zoom_layout.addWidget(fit_width_btn) + + fit_page_btn = QPushButton("Fit Page") + fit_page_btn.clicked.connect(self._fit_page) + zoom_layout.addWidget(fit_page_btn) + + zoom_layout.addStretch() + center_layout.addLayout(zoom_layout) + + # Create graphics view for PDF with interactive text boxes + self.graphics_scene = QGraphicsScene() + self.graphics_view = PDFGraphicsView() + self.graphics_view.setScene(self.graphics_scene) + self.graphics_view.textBoxCreated.connect(self._on_textbox_created) + + center_layout.addWidget(self.graphics_view) + splitter.addWidget(center_panel) + + # Right panel - Tools + right_panel = QWidget() + right_layout = QVBoxLayout(right_panel) + + # Text box tools + textbox_group = QGroupBox("Text Box Tools") + textbox_layout = QVBoxLayout() + + info_label = QLabel("Click on detected lines/fields to add text.\nDetected elements are highlighted in color.") + info_label.setWordWrap(True) + textbox_layout.addWidget(info_label) + + apply_textboxes_btn = QPushButton("Apply Text to PDF") + apply_textboxes_btn.clicked.connect(self._apply_textboxes_to_pdf) + textbox_layout.addWidget(apply_textboxes_btn) + + clear_textboxes_btn = QPushButton("Clear All Text Boxes") + clear_textboxes_btn.clicked.connect(self._clear_textboxes) + textbox_layout.addWidget(clear_textboxes_btn) + + textbox_group.setLayout(textbox_layout) + right_layout.addWidget(textbox_group) + textbox_group.setLayout(textbox_layout) + right_layout.addWidget(textbox_group) + + # Signature tools + sig_group = QGroupBox("Signature") + sig_group_layout = QVBoxLayout() + + self.cert_label = QLabel("No certificate loaded") + self.cert_label.setWordWrap(True) + sig_group_layout.addWidget(self.cert_label) + + create_cert_btn = QPushButton("Create Certificate") + create_cert_btn.clicked.connect(self._create_certificate) + sig_group_layout.addWidget(create_cert_btn) + + load_cert_btn = QPushButton("Load Certificate") + load_cert_btn.clicked.connect(self._load_certificate) + sig_group_layout.addWidget(load_cert_btn) + + sig_group.setLayout(sig_group_layout) + right_layout.addWidget(sig_group) + + right_layout.addStretch() + splitter.addWidget(right_panel) + + # Set splitter sizes + splitter.setSizes([200, 700, 300]) + + main_layout.addWidget(splitter) + + # Status bar + self.status_label = QLabel("Ready") + main_layout.addWidget(self.status_label) + + def _apply_theme(self) -> None: + """Apply dark theme to the application.""" + QApplication.setStyle("Fusion") + + dark_palette = QPalette() + dark_palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53)) + dark_palette.setColor(QPalette.ColorRole.WindowText, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.ColorRole.Base, QColor(35, 35, 35)) + dark_palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53)) + dark_palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(25, 25, 25)) + dark_palette.setColor(QPalette.ColorRole.ToolTipText, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.ColorRole.Text, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53)) + dark_palette.setColor(QPalette.ColorRole.ButtonText, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.ColorRole.BrightText, QColor(255, 0, 0)) + dark_palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218)) + dark_palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218)) + dark_palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255)) + + QApplication.setPalette(dark_palette) + + def _load_pdf(self) -> None: + """Load a PDF file.""" + file_path, _ = QFileDialog.getOpenFileName( + self, + "Open PDF File", + "", + "PDF Files (*.pdf)" + ) + + if not file_path: + return + + try: + self.pdf_doc = fitz.open(file_path) + self.current_pdf_path = file_path + self.current_page = 0 + + # Populate page list + self.page_list.clear() + for i in range(len(self.pdf_doc)): + self.page_list.addItem(f"Page {i + 1}") + + self.page_list.setCurrentRow(0) + self._display_current_page() + + self.status_label.setText(f"Loaded: {Path(file_path).name} ({len(self.pdf_doc)} pages)") + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to load PDF: {str(e)}") + + def _save_pdf(self) -> None: + """Save the current PDF.""" + if not self.pdf_doc or not self.current_pdf_path: + QMessageBox.warning(self, "Warning", "No PDF loaded to save") + return + + try: + self.pdf_doc.save(self.current_pdf_path, incremental=True) + self.status_label.setText("PDF saved successfully") + QMessageBox.information(self, "Success", "PDF saved successfully") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to save PDF: {str(e)}") + + def _save_pdf_as(self) -> None: + """Save the PDF with a new name.""" + if not self.pdf_doc: + QMessageBox.warning(self, "Warning", "No PDF loaded to save") + return + + file_path, _ = QFileDialog.getSaveFileName( + self, + "Save PDF As", + "", + "PDF Files (*.pdf)" + ) + + if not file_path: + return + + try: + self.pdf_doc.save(file_path) + self.current_pdf_path = file_path + self.status_label.setText(f"Saved: {Path(file_path).name}") + QMessageBox.information(self, "Success", "PDF saved successfully") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to save PDF: {str(e)}") + + def _display_current_page(self) -> None: + """Display the current page in the preview.""" + if not self.pdf_doc or self.current_page >= len(self.pdf_doc): + return + + try: + page = self.pdf_doc[self.current_page] + + # Render page to pixmap with current zoom level + mat = fitz.Matrix(self.zoom_level, self.zoom_level) + pix = page.get_pixmap(matrix=mat) + + # Convert to QImage + img_data = pix.samples + qimg = QImage(img_data, pix.width, pix.height, pix.stride, QImage.Format.Format_RGB888) + pixmap = QPixmap.fromImage(qimg) + + # Detect lines and fields in the PDF + detected_lines, detected_fields = self._detect_pdf_elements(page) + + # Clear existing scene and add pixmap + if self.graphics_scene: + self.graphics_scene.clear() + self.text_boxes.clear() + + pixmap_item = self.graphics_scene.addPixmap(pixmap) + self.graphics_scene.setSceneRect(pixmap_item.boundingRect()) + + # Add selectable text overlay + self._add_text_overlay(page) + + # Draw detected elements as overlays (optional - for visual feedback) + self._draw_detected_elements(detected_lines, detected_fields) + + # Pass detected elements to graphics view + if self.graphics_view: + self.graphics_view.setDetectedElements(detected_lines, detected_fields) + self.graphics_view.fitInView(self.graphics_scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to display page: {str(e)}") + + def _add_text_overlay(self, page: fitz.Page) -> None: + """Add selectable text overlay on top of PDF image.""" + if not self.graphics_scene: + return + + try: + # Get text with position information + text_dict = page.get_text("dict") + if not isinstance(text_dict, dict): + return + + blocks = text_dict.get("blocks", []) + + for block in blocks: + if block.get("type") != 0: # Not text block + continue + + for line in block.get("lines", []): + for span in line.get("spans", []): + text = span.get("text", "") + if not text.strip(): + continue + + bbox = span.get("bbox") + if not bbox: + continue + + x0, y0, x1, y1 = bbox + + # Create a transparent text item that can be selected + text_item = QGraphicsTextItem(text) + text_item.setPos(x0 * self.zoom_level, y0 * self.zoom_level) + + # Set font to match PDF + font_size = span.get("size", 12) + font_name = span.get("font", "Arial") + font = QFont(font_name, int(font_size * self.zoom_level)) + text_item.setFont(font) + + # Make it transparent but selectable + text_item.setDefaultTextColor(QColor(0, 0, 0, 0)) # Fully transparent + text_item.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsSelectable, True) + text_item.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + + # Add to scene with lower Z value so it's below text boxes + text_item.setZValue(-2) + self.graphics_scene.addItem(text_item) + + except Exception as e: + self.status_label.setText(f"Text overlay error: {str(e)}") + + def _detect_pdf_elements(self, page: fitz.Page) -> Tuple[List[QRectF], List[Tuple[QRectF, TextBoxType]]]: + """Detect lines, signature areas, and date fields in PDF page.""" + lines: List[QRectF] = [] + fields: List[Tuple[QRectF, TextBoxType]] = [] + + try: + # Get page drawings (lines, rectangles) + drawings = page.get_drawings() + + for drawing in drawings: + rect = drawing.get("rect") + if not rect: + continue + + x0, y0, x1, y1 = rect + width = x1 - x0 + height = y1 - y0 + + # Detect horizontal lines (potential signature/text lines) + if height < 5 and width > 50: # Horizontal line + # Scale to zoom level + scaled_rect = QRectF( + x0 * self.zoom_level, + (y0 - 10) * self.zoom_level, # Add space above line + width * self.zoom_level, + 20 * self.zoom_level # Text height + ) + lines.append(scaled_rect) + + # Detect text patterns for date and signature + text_dict = page.get_text("dict") + blocks = text_dict.get("blocks", []) if isinstance(text_dict, dict) else [] + + for block in blocks: + if block.get("type") != 0: # Not text block + continue + + bbox = block.get("bbox") + if not bbox: + continue + + # Extract text from block + block_text = "" + for line in block.get("lines", []): + for span in line.get("spans", []): + block_text += span.get("text", "") + + block_text_lower = block_text.lower() + + # Detect signature fields + if any(word in block_text_lower for word in ["signature", "sign here", "signed", "sign:"]): + x0, y0, x1, y1 = bbox + scaled_rect = QRectF( + x0 * self.zoom_level, + y1 * self.zoom_level, # Below the label + (x1 - x0) * self.zoom_level, + 25 * self.zoom_level + ) + fields.append((scaled_rect, TextBoxType.SIGNATURE)) + + # Detect date fields + elif any(word in block_text_lower for word in ["date", "date:", "dated"]): + x0, y0, x1, y1 = bbox + scaled_rect = QRectF( + x0 * self.zoom_level, + y1 * self.zoom_level, + (x1 - x0) * self.zoom_level, + 20 * self.zoom_level + ) + fields.append((scaled_rect, TextBoxType.DATE)) + + except Exception as e: + self.status_label.setText(f"Detection error: {str(e)}") + + return lines, fields + + def _draw_detected_elements(self, lines: List[QRectF], fields: List[Tuple[QRectF, TextBoxType]]) -> None: + """Draw visual indicators for detected elements (optional).""" + if not self.graphics_scene: + return + + # Draw detected lines with subtle highlight + for line_rect in lines: + rect_item = QGraphicsRectItem(line_rect) + pen = QPen(QColor(100, 200, 100, 100), 1, Qt.PenStyle.DotLine) + rect_item.setPen(pen) + rect_item.setBrush(QBrush(QColor(100, 200, 100, 20))) + rect_item.setZValue(-1) # Behind text boxes + self.graphics_scene.addItem(rect_item) + + # Draw detected fields with type-specific colors + for field_rect, box_type in fields: + rect_item = QGraphicsRectItem(field_rect) + if box_type == TextBoxType.SIGNATURE: + color = QColor(100, 100, 200, 100) + elif box_type == TextBoxType.DATE: + color = QColor(200, 100, 100, 100) + else: + color = QColor(100, 200, 100, 100) + + pen = QPen(color, 1, Qt.PenStyle.DotLine) + rect_item.setPen(pen) + rect_item.setBrush(QBrush(QColor(color.red(), color.green(), color.blue(), 20))) + rect_item.setZValue(-1) + self.graphics_scene.addItem(rect_item) + + def _on_page_changed(self, index: int) -> None: + """Handle page selection change.""" + if index >= 0 and self.pdf_doc: + self.current_page = index + self._display_current_page() + + def _prev_page(self) -> None: + """Navigate to previous page.""" + if self.current_page > 0: + self.current_page -= 1 + self.page_list.setCurrentRow(self.current_page) + + def _next_page(self) -> None: + """Navigate to next page.""" + if self.pdf_doc and self.current_page < len(self.pdf_doc) - 1: + self.current_page += 1 + self.page_list.setCurrentRow(self.current_page) + + def _on_zoom_changed(self, value: int) -> None: + """Handle zoom slider change.""" + self.zoom_level = value / 100.0 + self.zoom_value_label.setText(f"{value}%") + self._display_current_page() + + def _zoom_in(self) -> None: + """Zoom in by 25%.""" + current_value = self.zoom_slider.value() + new_value = min(400, current_value + 25) + self.zoom_slider.setValue(new_value) + + def _zoom_out(self) -> None: + """Zoom out by 25%.""" + current_value = self.zoom_slider.value() + new_value = max(50, current_value - 25) + self.zoom_slider.setValue(new_value) + + def _fit_width(self) -> None: + """Fit page to scroll area width.""" + if not self.pdf_doc or not self.graphics_view: + return + + try: + page = self.pdf_doc[self.current_page] + page_width = page.rect.width + view_width = self.graphics_view.viewport().width() - 20 # Account for margins + + zoom = view_width / page_width + zoom_percent = int(zoom * 100) + zoom_percent = max(50, min(400, zoom_percent)) # Clamp to slider range + + self.zoom_slider.setValue(zoom_percent) + except Exception as e: + self.status_label.setText(f"Error fitting width: {str(e)}") + + def _fit_page(self) -> None: + """Fit entire page to scroll area.""" + if not self.pdf_doc or not self.graphics_view: + return + + try: + page = self.pdf_doc[self.current_page] + page_width = page.rect.width + page_height = page.rect.height + view_width = self.graphics_view.viewport().width() - 20 + view_height = self.graphics_view.viewport().height() - 20 + + zoom_w = view_width / page_width + zoom_h = view_height / page_height + zoom = min(zoom_w, zoom_h) + + zoom_percent = int(zoom * 100) + zoom_percent = max(50, min(400, zoom_percent)) # Clamp to slider range + + self.zoom_slider.setValue(zoom_percent) + except Exception as e: + self.status_label.setText(f"Error fitting page: {str(e)}") + + def _on_textbox_created(self, rect: QRectF, box_type: TextBoxType) -> None: + """Handle text box creation.""" + if not self.graphics_scene: + return + + # Create border rectangle + border_rect = QGraphicsRectItem(rect) + pen = QPen(QColor(42, 130, 218), 1) + border_rect.setPen(pen) + border_rect.setBrush(QBrush(QColor(255, 255, 255, 200))) + self.graphics_scene.addItem(border_rect) + + # Create editable text box + text_box = EditableTextBox("", box_type) + text_box.setPos(rect.topLeft()) + text_box.setTextWidth(rect.width()) + text_box.setBorderItem(border_rect) + + self.graphics_scene.addItem(text_box) + self.text_boxes.append(text_box) + + # Give it focus so user can start typing + text_box.setFocus() + + self.status_label.setText("Text box created - start typing or click elsewhere") + + def _apply_textboxes_to_pdf(self) -> None: + """Apply all text boxes to the PDF page.""" + if not self.pdf_doc or not self.text_boxes: + QMessageBox.warning(self, "Warning", "No text boxes to apply") + return + + try: + page = self.pdf_doc[self.current_page] + + # Convert each text box to PDF text + for text_box in self.text_boxes: + if text_box.is_empty: + continue + + text = text_box.toPlainText().strip() + if not text: + continue + + # Get position and convert from scene coordinates to PDF coordinates + pos = text_box.scenePos() + + # Account for zoom level + pdf_x = pos.x() / self.zoom_level + pdf_y = pos.y() / self.zoom_level + + # Get font info + font = text_box.font() + font_size = font.pointSize() + + # Insert text into PDF + point = fitz.Point(pdf_x, pdf_y) + + # Choose color based on type + if text_box.box_type == TextBoxType.SIGNATURE: + color = (0, 0, 0.5) # Dark blue for signatures + else: + color = (0, 0, 0) # Black for regular text + + page.insert_text( + point, + text, + fontsize=font_size, + color=color + ) + + # Refresh display + self._display_current_page() + self.status_label.setText(f"Applied {len(self.text_boxes)} text boxes to page") + + QMessageBox.information(self, "Success", "Text boxes applied to PDF. Remember to save!") + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to apply text boxes: {str(e)}") + + def _clear_textboxes(self) -> None: + """Clear all text boxes from the current page.""" + if not self.text_boxes: + return + + reply = QMessageBox.question( + self, + "Confirm Clear", + "Clear all text boxes on this page?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.Yes: + # Remove all text boxes from scene + if self.graphics_scene: + for text_box in self.text_boxes: + if text_box.scene(): + self.graphics_scene.removeItem(text_box) + if text_box.border_item and text_box.border_item.scene(): + self.graphics_scene.removeItem(text_box.border_item) + + self.text_boxes.clear() + self.status_label.setText("Text boxes cleared") + + def _create_certificate(self) -> None: + """Create a self-signed certificate for signing.""" + dialog = CertificateDialog(self) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + + cert_data = dialog.certificate_data + if not cert_data: + return + + try: + # Generate private key + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048 + ) + + # Create certificate + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, cert_data["country"]), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, cert_data["organization"]), + x509.NameAttribute(NameOID.COMMON_NAME, cert_data["name"]), + x509.NameAttribute(NameOID.EMAIL_ADDRESS, cert_data["email"]), + ]) + + cert = x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + private_key.public_key() + ).serial_number( + x509.random_serial_number() + ).not_valid_before( + datetime.utcnow() + ).not_valid_after( + datetime.utcnow() + timedelta(days=365) + ).sign(private_key, hashes.SHA256()) + + # Save certificate and key + save_dir = QFileDialog.getExistingDirectory(self, "Select Directory to Save Certificate") + if not save_dir: + return + + cert_path = Path(save_dir) / "certificate.pem" + key_path = Path(save_dir) / "private_key.pem" + + with open(cert_path, "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + + with open(key_path, "wb") as f: + f.write(private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + )) + + self.certificate_path = str(cert_path) + self.private_key_path = str(key_path) + self.cert_label.setText(f"Certificate: {cert_data['name']}\n{cert_path.name}") + + QMessageBox.information( + self, + "Success", + f"Certificate created successfully!\n\nCertificate: {cert_path}\nPrivate Key: {key_path}" + ) + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to create certificate: {str(e)}") + + def _load_certificate(self) -> None: + """Load an existing certificate.""" + cert_path, _ = QFileDialog.getOpenFileName( + self, + "Open Certificate File", + "", + "PEM Files (*.pem);;All Files (*)" + ) + + if not cert_path: + return + + key_path, _ = QFileDialog.getOpenFileName( + self, + "Open Private Key File", + "", + "PEM Files (*.pem);;All Files (*)" + ) + + if not key_path: + return + + self.certificate_path = cert_path + self.private_key_path = key_path + self.cert_label.setText(f"Certificate loaded:\n{Path(cert_path).name}") + self.status_label.setText("Certificate loaded") + + def _show_sign_dialog(self) -> None: + """Show dialog for signing the PDF.""" + if not self.pdf_doc: + QMessageBox.warning(self, "Warning", "No PDF loaded") + return + + if not self.certificate_path or not self.private_key_path: + QMessageBox.warning( + self, + "Warning", + "Please create or load a certificate first" + ) + return + + reason, ok = QInputDialog.getText( + self, + "Sign PDF", + "Reason for signing:", + text="I approve this document" + ) + + if not ok: + return + + self._sign_pdf(reason) + + def _sign_pdf(self, reason: str) -> None: + """Sign the PDF with the loaded certificate.""" + if not self.current_pdf_path: + QMessageBox.warning(self, "Warning", "Please save the PDF first") + return + + if not self.certificate_path or not self.private_key_path: + QMessageBox.warning(self, "Warning", "Certificate paths not set") + return + + if not self.pdf_doc: + QMessageBox.warning(self, "Warning", "No PDF document loaded") + return + + try: + # Ask for output path + output_path, _ = QFileDialog.getSaveFileName( + self, + "Save Signed PDF", + self.current_pdf_path.replace(".pdf", "_signed.pdf"), + "PDF Files (*.pdf)" + ) + + if not output_path: + return + + # Load certificate and key + with open(self.certificate_path, "rb") as f: + cert_data = f.read() + + with open(self.private_key_path, "rb") as f: + key_data = f.read() + + # Sign using pyHanko + from pyhanko.sign import signers + from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter + + # Simple signing (this is a basic implementation) + # For production use, you'd want more robust pyHanko integration + QMessageBox.information( + self, + "Info", + "Basic signing implemented. For production use, consider using pyHanko's full signature features." + ) + + # For now, just save a copy + self.pdf_doc.save(output_path) + + self.status_label.setText(f"Signed PDF saved: {Path(output_path).name}") + QMessageBox.information(self, "Success", "PDF signed successfully!") + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to sign PDF: {str(e)}") + + +def main() -> None: + """Main entry point for the application.""" + app = QApplication(sys.argv) + app.setApplicationName("PDF Editor & Signer") + + window = PDFEditorSignerGUI() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/PDF Editor and Signer/requirements.txt b/PDF Editor and Signer/requirements.txt new file mode 100644 index 00000000..41171e1b --- /dev/null +++ b/PDF Editor and Signer/requirements.txt @@ -0,0 +1,4 @@ +PySide6>=6.0.0 +PyMuPDF>=1.23.0 +pyHanko>=0.20.0 +cryptography>=41.0.0 diff --git a/PNG and SVG to Icons Generator with GUI/README.md b/PNG and SVG to Icons Generator with GUI/README.md new file mode 100644 index 00000000..dfc144d2 --- /dev/null +++ b/PNG and SVG to Icons Generator with GUI/README.md @@ -0,0 +1,55 @@ +# PNG/SVG to Icon Converter (GUI) + +A simple Python GUI tool to convert PNG and SVG images into icon formats for Windows (.ico), macOS (.icns), and Linux (multiple PNG sizes). Built with PySide6, Pillow, and CairoSVG. + +--- + + +## Features +- 🖼️ Select a PNG or SVG file and convert it to: + - Windows .ico (user-selectable sizes) + - macOS .icns + - Linux PNG icons (user-selectable sizes) +- ☑️ Checkboxes to select which icon sizes to output +- 📂 Choose output directory +- ⚡ Fast, one-click conversion +- ❌ Error handling with pop-up dialogs + +--- + +## Requirements + + +- Python 3.8+ +- PySide6 +- Pillow +- CairoSVG (for SVG support) + +Install dependencies: +```bash +pip install PySide6 Pillow cairosvg +``` + +--- + +## How to Use + +1. Run the script: + ```bash + python png2icon.py + ``` +2. Click **"Select PNG and Convert"** +3. Choose a PNG or SVG file +4. Select a directory to save the icons +5. Icons for Windows, macOS, and Linux will be created in the chosen folder, in the sizes you selected + +--- + +## Author + +Randy Northrup + +--- + +## License +MIT License – free to use, modify, and share. diff --git a/PNG and SVG to Icons Generator with GUI/png2icon.py b/PNG and SVG to Icons Generator with GUI/png2icon.py new file mode 100644 index 00000000..294c67c3 --- /dev/null +++ b/PNG and SVG to Icons Generator with GUI/png2icon.py @@ -0,0 +1,107 @@ +import sys +import os +from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QFileDialog, QMessageBox, QCheckBox, QLabel, QHBoxLayout, QGroupBox +from PIL import Image +import io + +# Pillow 10+ uses Image.Resampling.LANCZOS, fallback for older versions +try: + LANCZOS_RESAMPLE = Image.Resampling.LANCZOS +except AttributeError: + LANCZOS_RESAMPLE = 1 # 1 is the value for LANCZOS in older Pillow + +class IconConverterApp(QWidget): + def __init__(self): + super().__init__() + self.setWindowTitle("PNG/SVG to Icon Converter") + self.setGeometry(200, 200, 400, 250) + + self.sizes = [16, 24, 32, 48, 64, 128, 256, 512] + self.checkboxes = [] + + # Check SVG support + try: + import cairosvg + self.svg_support = True + except ImportError: + self.svg_support = False + + layout = QVBoxLayout() + self.button = QPushButton("Select Image and Convert") + self.button.clicked.connect(self.convert_icon) + layout.addWidget(self.button) + + # Add checkboxes for sizes + size_group = QGroupBox("Select icon sizes to output") + size_layout = QHBoxLayout() + for size in self.sizes: + cb = QCheckBox(f"{size}x{size}") + cb.setChecked(True) + self.checkboxes.append(cb) + size_layout.addWidget(cb) + size_group.setLayout(size_layout) + layout.addWidget(size_group) + + self.setLayout(layout) + + def convert_icon(self): + # Step 1: Select PNG or SVG file + file_filter = "Image Files (*.png *.svg)" if self.svg_support else "PNG Files (*.png)" + img_file, _ = QFileDialog.getOpenFileName(self, "Select Image File", "", file_filter) + if not img_file: + return + + # Step 2: Ask where to save icons + save_dir = QFileDialog.getExistingDirectory(self, "Select Save Directory") + if not save_dir: + return + + try: + # Load image (handle SVG if needed) + if img_file.lower().endswith('.svg'): + import cairosvg # Ensure cairosvg is in local scope + if not self.svg_support: + QMessageBox.critical(self, "Error", "SVG support requires cairosvg. Please install it.") + return + # Convert SVG to PNG in memory + png_bytes = cairosvg.svg2png(url=img_file) + if png_bytes is None: + raise ValueError("SVG conversion failed.") + img = Image.open(io.BytesIO(png_bytes)).convert("RGBA") + else: + img = Image.open(img_file).convert("RGBA") + + # Get selected sizes + selected_sizes = [self.sizes[i] for i, cb in enumerate(self.checkboxes) if cb.isChecked()] + if not selected_sizes: + QMessageBox.warning(self, "No Sizes Selected", "Please select at least one icon size.") + return + + # Windows ICO + ico_path = os.path.join(save_dir, "icon.ico") + ico_sizes = [(s, s) for s in selected_sizes if s in [16, 32, 48, 256]] + if ico_sizes: + img.save(ico_path, format="ICO", sizes=ico_sizes) + + # macOS ICNS + icns_path = os.path.join(save_dir, "icon.icns") + if any(s in [16, 32, 128, 256, 512] for s in selected_sizes): + img.save(icns_path, format="ICNS") + + # Linux PNG sizes + linux_dir = os.path.join(save_dir, "linux_icons") + os.makedirs(linux_dir, exist_ok=True) + for size in selected_sizes: + resized = img.resize((size, size), LANCZOS_RESAMPLE) + resized.save(os.path.join(linux_dir, f"icon_{size}x{size}.png")) + + QMessageBox.information(self, "Success", f"Icons saved in:\n{save_dir}") + + except Exception as e: + QMessageBox.critical(self, "Error", str(e)) + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = IconConverterApp() + window.show() + sys.exit(app.exec()) diff --git a/PNG and SVG to Icons Generator with GUI/requirements.txt b/PNG and SVG to Icons Generator with GUI/requirements.txt new file mode 100644 index 00000000..cbabbd09 --- /dev/null +++ b/PNG and SVG to Icons Generator with GUI/requirements.txt @@ -0,0 +1,3 @@ +PySide6 +Pillow +cairosvg diff --git a/README.md b/README.md index 5333ecbf..31266288 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,11 @@ More information on contributing and the general code of conduct for discussion | Script | Link | Description | | ---------------------------------------- |----------------------------------------------------------------------------------------------------------------------------------------------------------| ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Advanced Search | [Advanced Search](https://github.com/RandyNorthrup/Python-Scripts/tree/main/Advanced%20Search) | A powerful Windows GUI application for grep-style searching with regex patterns, metadata search, and archive content search. | | Arrange It | [Arrange It](https://github.com/DhanushNehru/Python-Scripts/tree/main/Arrange%20It) | A Python script that can automatically move files into corresponding folders based on their extensions. | | Auto WiFi Check | [Auto WiFi Check](https://github.com/DhanushNehru/Python-Scripts/tree/main/Auto%20WiFi%20Check) | A Python script to monitor if the WiFi connection is active or not | | AutoCert | [AutoCert](https://github.com/DhanushNehru/Python-Scripts/tree/main/AutoCert) | A Python script to auto-generate e-certificates in bulk. | -| Autocomplete Notes App | [Autocomplete Notes App](https://github.com/DhanushNehru/Python-Scripts/tree/main/Autocomplete%20Notes%20App) | A Python script that provides a notes application with autocomplete features. | +| Autocomplete Notes App | [Autocomplete Notes App](https://github.com/DhanushNehru/Python-Scripts/tree/main/Autocomplete%20Notes%20App) | A Python script that provides a notes application with autocomplete features. | | Automated Emails | [Automated Emails](https://github.com/DhanushNehru/Python-Scripts/tree/main/Automate%20Emails%20Daily) | A Python script to send out personalized emails by reading a CSV file. | | Black Hat Python | [Black Hat Python](https://github.com/DhanushNehru/Python-Scripts/tree/main/Black%20Hat%20Python) | Source code from the book Black Hat Python | | Blackjack | [Blackjack](https://github.com/DhanushNehru/Python-Scripts/tree/main/Blackjack) | A game of Blackjack - let's get a 21. | @@ -99,12 +100,14 @@ More information on contributing and the general code of conduct for discussion | Keyword - Retweeting | [Keyword - Retweeting](https://github.com/DhanushNehru/Python-Scripts/tree/main/Keyword%20Retweet%20Twitter%20Bot) | Find the latest tweets containing given keywords and then retweet them. | | LinkedIn Bot | [LinkedIn Bot](https://github.com/DhanushNehru/Python-Scripts/tree/main/LinkedIn%20Bot) | Automates the process of searching for public profiles on LinkedIn and exporting the data to an Excel sheet. | | Longitude & Latitude to conical coverter | [Longitude Latitude conical converter](master/Longitude%20Latitude%20conical%20converter) | Converts Longitude and Latitude to Lambert conformal conic projection. | +| MacOS App Removal Cache Cleaner | [MacOS App Removal Cache Cleaner](https://github.com/RandyNorthrup/Python-Scripts/tree/main/MacOS%20App%20Removal%20Cache%20Cleaner) | A macOS desktop utility to scan installed apps, find support files, and safely clean up caches and preferences. | | Mail Sender | [Mail Sender](https://github.com/DhanushNehru/Python-Scripts/tree/main/Mail%20Sender) | Sends an email. | +| MD to PDF or Text with GUI | [MD to PDF or Text with GUI](https://github.com/RandyNorthrup/Python-Scripts/tree/main/MD%20to%20PDF%20or%20Text%20with%20GUI) | A GUI tool to convert Markdown files into PDF or Text files with Unicode support. | | Merge Two Images | [Merge Two Images](https://github.com/DhanushNehru/Python-Scripts/tree/main/Merge%20Two%20Images) | Merges two images horizontally or vertically. | | Mood based youtube song generator | [Mood based youtube song generator](https://github.com/DhanushNehru/Python-Scripts/tree/main/Mood%20based%20youtube%20song%20generator) | This Python script fetches a random song from YouTube based on your mood input and opens it in your default web browser. | +| Morse Code | [Morse Code](https://github.com/DhanushNehru/Python-Scripts/tree/main/Morse%20Code) | Encodes and decodes Morse code. | | Mouse mover | [Mouse mover](https://github.com/DhanushNehru/Python-Scripts/tree/main/Mouse%20Mover) | Moves your mouse every 15 seconds. | -| Multi-Platform Icon Generator | [Multi-Platform Icon Generator](https://github.com/DhanushNehru/Python-Scripts/tree/main/Multi-Platform%20Icon%20Generator) | Automates creation of 35+ platform-specific icon sizes from one source image for app stores and web deployment. -| Morse Code | [Morse Code](https://github.com/DhanushNehru/Python-Scripts/tree/main/Morse%20Code) | Encodes and decodes Morse code. | + | No Screensaver | [No Screensaver](https://github.com/DhanushNehru/Python-Scripts/tree/main/No%20Screensaver) | Prevents screensaver from turning on. | | OTP Verification | [OTP Verification](https://github.com/DhanushNehru/Python-Scripts/tree/main/OTP%20%20Verify) | An OTP Verification Checker. | | Password Generator | [Password Generator](https://github.com/DhanushNehru/Python-Scripts/tree/main/Password%20Generator) | Generates a random password. | @@ -112,21 +115,24 @@ More information on contributing and the general code of conduct for discussion | Password Strength Checker | [Password Strength Checker](https://github.com/nem5345/Python-Scripts/tree/main/Password%20Strength%20Checker) | Evaluates how strong a given password is. | | PDF Merger | [PDF Merger](https://github.com/DhanushNehru/Python-Scripts/tree/main/PDF%20Merger) | Merges multiple PDF files into a single PDF, with options for output location and custom order. | | PDF to Audio | [PDF to Audio](https://github.com/DhanushNehru/Python-Scripts/tree/main/PDF%20to%20Audio) | Converts PDF to audio. | +| PDF Editor and Signer | [PDF Editor and Signer](https://github.com/RandyNorthrup/Python-Scripts/tree/main/PDF%20Editor%20and%20Signer) | A PDF editor with intelligent element detection, click-to-place text, and digital signature capabilities. | | PDF to Text | [PDF to text](https://github.com/DhanushNehru/Python-Scripts/tree/main/PDF%20to%20text) | Converts PDF to text. | | PDF merger and splitter | [PDF Merger and Splitter](https://github.com/AbhijitMotekar99/Python-Scripts/blob/main/PDF%20Merger%20and%20Splitter/PDF%20Merger%20and%20Splitter.py) | Create a tool that can merge multiple PDF files into one or split a single PDF into separate pages. | | Pizza Order | [Pizza Order](https://github.com/DhanushNehru/Python-Scripts/tree/main/Pizza%20Order) | An algorithm designed to handle pizza orders from customers with accurate receipts and calculations. | | Planet Simulation | [Planet Simulation](https://github.com/DhanushNehru/Python-Scripts/tree/main/Planet%20Simulation) | A simulation of several planets rotating around the sun. | | Playlist Exchange | [Playlist Exchange](https://github.com/DhanushNehru/Python-Scripts/tree/main/Playlist%20Exchange) | A Python script to exchange songs and playlists between Spotify and Python. | | Pigeonhole Sort | [Algorithm](https://github.com/DhanushNehru/Python-Scripts/tree/main/PigeonHole) | The pigeonhole sort algorithm to sort your arrays efficiently! | +| PNG and SVG to Icons Generator with GUI | [PNG and SVG to Icons Generator](https://github.com/RandyNorthrup/Python-Scripts/tree/main/PNG%20and%20SVG%20to%20Icons%20Generator%20with%20GUI) | A GUI tool to convert PNG and SVG images into icon formats for Windows, macOS, and Linux. | | PNG TO JPG CONVERTOR | [PNG-To-JPG](https://github.com/DhanushNehru/Python-Scripts/tree/main/PNG%20To%20JPG) | A PNG TO JPG IMAGE CONVERTOR. | | Pomodoro Timer | [Pomodoro Timer](https://github.com/DhanushNehru/Python-Scripts/tree/main/Pomodoro%20Timer) | A Pomodoro timer | -| Python GUI Notepad | [Python GUI Notepad](https://github.com/DhanushNehru/Python-Scripts/tree/main/Python%20GUI%20Notepad) | A Python-based GUI Notepad with essential features like saving, opening, editing text files, basic formatting, and a simple user interface for quick note-taking. | -| Profanity Checker | [Profanity Checker](https://github.com/DhanushNehru/Python-Scripts/tree/main/Profanity%20Checker) | A Python script to detect and optionally censor profanity in text. | +| Profanity Checker | [Profanity Checker](https://github.com/DhanushNehru/Python-Scripts/tree/main/Profanity%20Checker) | A Python script to detect and optionally censor profanity in text. | +| Python GUI Notepad | [Python GUI Notepad](https://github.com/DhanushNehru/Python-Scripts/tree/main/Python%20GUI%20Notepad) | A Python-based GUI Notepad with essential features like saving, opening, editing text files, basic formatting, and a simple user interface for quick note-taking. | | QR Code Generator | [QR Code Generator](https://github.com/DhanushNehru/Python-Scripts/tree/main/QR%20Code%20Generator) | This is generate a QR code from the provided link | | QR Code Scanner | [QR Code Scanner](https://github.com/DhanushNehru/Python-Scripts/tree/main/QR%20Code%20Scanner) | Helps in Sacanning the QR code in form of PNG or JPG just by running the python script. | | QR Code with logo | [QR code with Logo](https://github.com/DhanushNehru/Python-Scripts/tree/main/QR%20with%20Logo) | QR Code Customization Feature | | Random Color Generator | [Random Color Generator](https://github.com/DhanushNehru/Python-Scripts/tree/main/Random%20Color%20Generator) | A random color generator that will show you the color and values! | -| Remove Background | [Remove Background](https://github.com/DhanushNehru/Python-Scripts/tree/main/Remove%20Background) | Removes the background of images. | +| Recursive Requirements Scanner | [Recursive Requirements Scanner](https://github.com/RandyNorthrup/Python-Scripts/tree/main/Recursive%20Requirements%20Scan%20and%20Creation%20Tool) | A GUI tool to recursively scan Python projects for dependencies and create requirements.txt files. | +| Remove Background | [Remove Background](https://github.com/RandyNorthrup/Python-Scripts/tree/main/Remove%20Background) | Removes the background of images with GUI support for replacement options. | | Road-Lane-Detection | [Road-Lane-Detection](https://github.com/NotIncorecc/Python-Scripts/tree/main/Road-Lane-Detection) | Detects the lanes of the road | | Rock Paper Scissor 1 | [Rock Paper Scissor 1](https://github.com/DhanushNehru/Python-Scripts/tree/main/Rock%20Paper%20Scissor%201) | A game of Rock Paper Scissors. | | Rock Paper Scissor 2 | [Rock Paper Scissor 2](https://github.com/DhanushNehru/Python-Scripts/tree/main/Rock%20Paper%20Scissor%202) | A new version game of Rock Paper Scissors. | @@ -136,6 +142,7 @@ More information on contributing and the general code of conduct for discussion | Simple DDOS | [Simple DDOS](https://github.com/DhanushNehru/Python-Scripts/tree/main/Simple%20DDOS) | The code allows you to send multiple HTTP requests concurrently for a specified duration. | | Simple TCP Chat Server | [Simple TCP Chat Server](https://github.com/DhanushNehru/Python-Scripts/tree/main/TCP%20Chat%20Server) | Creates a local server on your LAN for receiving and sending messages! | | Smart Attendance System | [Smart Attendance System](https://github.com/DhanushNehru/Python-Scripts/tree/main/Smart%20Attendance%20System) | This OpenCV framework is for Smart Attendance by actively decoding a student's QR Code. | +| SMB Scan and Transfer Tool | [SMB Scan and Transfer Tool](https://github.com/RandyNorthrup/Python-Scripts/tree/main/SMB%20Scan%20and%20Transfer%20Tool) | A Qt6 GUI for discovering, browsing, and transferring files over SMB network shares on macOS. | | Snake Game | [Snake Game](https://github.com/DhanushNehru/Python-Scripts/tree/main/Snake%20Game) | Classic snake game using python. | | Snake Water Gun | [Snake Water Gun](https://github.com/DhanushNehru/Python-Scripts/tree/main/Snake%20Water%20Gun) | A game similar to Rock Paper Scissors. | | Sorting | [Sorting](https://github.com/DhanushNehru/Python-Scripts/tree/main/Sorting) | Algorithm for bubble sorting. | @@ -162,6 +169,7 @@ More information on contributing and the general code of conduct for discussion | Website Cloner | [Website Cloner](https://github.com/DhanushNehru/Python-Scripts/tree/main/Website%20Cloner) | Clones any website and opens the site in your local IP. | | Web Scraper | [Web Scraper](https://github.com/DhanushNehru/Python-Scripts/tree/main/Web%20Scraper) | A Python script that scrapes blog titles from [Python.org](https://www.python.org/) and saves them to a file. | | Weight Converter | [Weight Converter](https://github.com/DhanushNehru/Python-Scripts/tree/main/Weight%20Converter) | Simple GUI script to convert weight in different measurement units. | +| WiFi QR Code Generator | [WiFi QR Code Generator](https://github.com/RandyNorthrup/Python-Scripts/tree/main/WiFi%20QR%20Code%20Generator) | A cross-platform desktop app for generating Wi-Fi QR codes and exporting Wi-Fi profiles with a modern Qt interface. | | Wikipedia Data Extractor | [Wikipedia Data Extractor](https://github.com/DhanushNehru/Python-Scripts/tree/main/Wikipedia%20Data%20Extractor) | A simple Wikipedia data extractor script to get output in your IDE. | | Word to PDF | [Word to PDF](https://github.com/DhanushNehru/Python-Scripts/tree/main/Word%20to%20PDF%20converter) | A Python script to convert an MS Word file to a PDF file. | | Youtube Downloader | [Youtube Downloader](https://github.com/DhanushNehru/Python-Scripts/tree/main/Youtube%20Downloader) | Downloads any video from [YouTube](https://youtube.com) in video or audio format! | diff --git a/Recursive Requirements Scan and Creation Tool/README.md b/Recursive Requirements Scan and Creation Tool/README.md new file mode 100644 index 00000000..05526702 --- /dev/null +++ b/Recursive Requirements Scan and Creation Tool/README.md @@ -0,0 +1,296 @@ +# Recursive Requirements Scan and Creation Tool (RRSCT) + +This tool provides a **Qt6 / PySide6 GUI** to recursively scan Python projects for dependencies and automatically create a clean, deduplicated `requirements.txt` file. It also includes a **robust package installer** that supports multiple package managers. + +It scans: +- **First 50 lines** of all `.py` files for `import` and `from` statements. +- Any existing `requirements.txt` files. + +## Features + +### Requirements Generation +- ✅ **GUI Interface** built with Qt6 / PySide6 +- ✅ **Recursive scanning** of directories +- ✅ **Exclude standard library** option +- ✅ **Automatic cleanup**: deduplication, removing local paths, handling version conflicts +- ✅ **PyPI validation**: only valid packages remain +- ✅ **In-memory storage**: Generate requirements without saving to file +- ✅ **Individual error handling**: One file's failure won't stop the entire scan +- ✅ **Log panel** inside GUI for warnings and debug messages + +### Package Installation +- ✅ **Multiple package manager support**: pip, pipx, uv, poetry, conda, pip3 +- ✅ **Install from file or memory**: No need to save before installing +- ✅ **Virtual environment detection**: Automatically detects venv vs global installation +- ✅ **Individual package installation**: Each package is installed separately +- ✅ **Robust error handling**: One failed package won't stop the rest +- ✅ **Real-time progress tracking**: Live updates during installation +- ✅ **Detailed installation summary**: Success, failed, and skipped packages + +--- + +## Installation + +Make sure you have Python 3.8+ installed. +Install dependencies: + +```bash +pip install PySide6 requests packaging +``` + +--- + +## Usage + +Run the script: + +```bash +python rrsct.py +``` + +### Generating Requirements + +1. **Select Source Directory** → Folder containing your Python project(s) +2. **(Optional) Select Destination** → Where to save the generated `requirements.txt` + - If no destination is selected, requirements are stored in memory only +3. **(Optional) Select Package Manager** → Choose from detected package managers +4. Optionally check **"Exclude standard libraries"** (recommended) +5. Click **Generate Master Requirements** + +The tool will: +- Scan `.py` and `requirements.txt` files recursively +- Process each file individually (failures won't stop the scan) +- Deduplicate dependencies +- Validate packages on PyPI (each package validated separately) +- Show detailed logs in the GUI +- Store requirements in memory and optionally save to file + +### Installing Requirements + +1. Click **Install Requirements from File** +2. If you have generated requirements in memory: + - **Yes** → Install directly from memory (no file needed) + - **No** → Select a requirements file to install from + - **Cancel** → Do nothing +3. Confirm the installation (shows virtual environment status) +4. Watch real-time progress as each package installs + +The installer will: +- Detect if you're in a virtual environment or global Python +- Install each package individually with the selected package manager +- Continue even if individual packages fail +- Provide a detailed summary of successes and failures + +--- + +## Package Manager Support + +| Package Manager | Install Command | Notes | +|-----------------|-----------------|-------| +| **pip** | `pip install ` | Standard Python package installer | +| **pip3** | `pip3 install ` | Python 3 specific pip | +| **uv** | `uv pip install ` | Fast Python package installer | +| **poetry** | `poetry add ` | Dependency management tool | +| **conda** | `conda install -y ` | Cross-platform package manager | +| **pipx** | N/A | Skipped (for applications only, not libraries) | + +The tool automatically detects which package managers are installed on your system. + +--- + +## Output + +The generated `requirements.txt` will: +- Contain **only valid third-party dependencies** +- Deduplicate multiple versions, keeping the most restrictive or latest version +- Include a header with metadata (package manager, total packages) +- Be directly usable with any package manager + +Example output: +``` +# Generated by Master Requirements Generator +# Package Manager: pip +# Total packages: 4 + +numpy==1.26.4 +pandas==2.2.3 +requests>=2.31.0 +PySide6==6.7.0 +``` + +--- + +## Error Handling + +This tool is designed with **robust error handling** to prevent cascading failures: + +### During Generation: +- Each file is processed independently +- File read errors don't stop the scan +- Invalid import lines are skipped with warnings +- Each package validation happens separately +- Network errors during PyPI checks won't halt the process + +### During Installation: +- Each package is installed separately +- Failed installations don't stop the rest +- 2-minute timeout per package prevents hanging +- Detailed error messages for each failure +- Summary report at the end + +--- + +## Examples + +### Sample Log Output +``` +Detected package managers: pip, uv, conda +✓ Source directory selected: /path/to/project +Starting scan with package manager: pip +Found 150 files to process +Found 45 unique imports/requirements +Excluded 20 standard library modules +Cleaned and merged to 40 packages +Validating packages on PyPI... +✓ Validated: numpy +✓ Validated: pandas +✗ Package not found on PyPI: custom_local_package +✓ SUCCESS: Requirements generated! +Total packages: 38 +``` + +### Installation Log +``` +Installing to: virtual environment +Python: /path/to/venv/bin/python +Package manager: pip + +Installing from memory +Found 38 packages to install + +[1/38] Installing numpy... + ✓ Successfully installed numpy +[2/38] Installing pandas... + ✓ Successfully installed pandas +[3/38] Installing invalid-package... + ✗ Failed to install invalid-package: No matching distribution found + +============================================================ +Installation Summary: + ✓ Success: 36 + ✗ Failed: 2 + ⚠ Skipped: 0 +============================================================ +``` + +--- + +## Options + +| Option | Description | +|--------|-------------| +| **Select Source Directory** | Required: The root folder to scan for Python files | +| **Select Destination** | Optional: Where to save requirements.txt (omit to use memory only) | +| **Package Manager** | Choose which tool to use for installation (auto-detected) | +| **Exclude standard libraries** | Recommended: Skips built-in Python modules like `os`, `sys`, etc. | +| **Log Panel** | Real-time display of all operations, warnings, and errors | + +--- + +## Workflow Examples + +### Quick Generate & Install (No File) +1. Select source directory +2. Click "Generate Master Requirements" (no destination needed) +3. Click "Install Requirements from File" +4. Choose "Yes" to install from memory +5. Done! No file created. + +### Generate, Save, & Install Later +1. Select source directory +2. Select destination file +3. Click "Generate Master Requirements" +4. Requirements saved to file AND stored in memory +5. Install from memory or file anytime + +### Install Existing Requirements File +1. Click "Install Requirements from File" +2. If you have memory requirements, click "No" to use a file +3. Select the requirements.txt file +4. Confirm installation +5. Watch the progress + +--- + +## Dependencies + +- [PySide6](https://pypi.org/project/PySide6/) - Qt6 Python bindings for GUI +- [requests](https://pypi.org/project/requests/) - HTTP library for PyPI validation +- [packaging](https://pypi.org/project/packaging/) - Core utilities for version parsing + +Install all at once: + +```bash +pip install PySide6 requests packaging +``` + +Or use the requirements.txt in this folder: + +```bash +pip install -r requirements.txt +``` + +--- + +## Troubleshooting + +### "No package managers detected" +- Ensure at least pip is installed: `pip --version` +- Restart the tool after installing a package manager + +### "Permission denied" errors during installation +- Use a virtual environment: `python -m venv venv` +- Or run with appropriate permissions + +### Installation hangs +- Each package has a 2-minute timeout +- Check your internet connection +- Some packages may take longer to build + +### PyPI validation fails +- Check your internet connection +- Packages are still included if validation fails +- Validation errors are logged but don't stop the process + +--- + +## Advanced Usage + +### Custom Package Managers +The tool automatically detects installed package managers. To add support for a new one, modify the `detect_package_managers()` function. + +### Encoding Issues +The tool tries multiple encodings (utf-8-sig, utf-8, latin-1, cp1252) when reading files. Most encoding issues are handled automatically. + +### Large Projects +- Processing hundreds of files works efficiently +- Each file is processed independently +- Progress is shown during generation and installation + +--- + +## Future Enhancements + +- Export results in multiple formats (CSV, JSON) +- Batch file processing options +- Custom package repository support +- Requirements.txt diff viewer +- Dependency graph visualization + +--- + +**Author:** Randy Northrup + +## License + +MIT License - Feel free to use and modify. diff --git a/Recursive Requirements Scan and Creation Tool/requirements.txt b/Recursive Requirements Scan and Creation Tool/requirements.txt new file mode 100644 index 00000000..a7353933 --- /dev/null +++ b/Recursive Requirements Scan and Creation Tool/requirements.txt @@ -0,0 +1,3 @@ +requests +packaging +PySide6 diff --git a/Recursive Requirements Scan and Creation Tool/rrsct.py b/Recursive Requirements Scan and Creation Tool/rrsct.py new file mode 100644 index 00000000..431f7081 --- /dev/null +++ b/Recursive Requirements Scan and Creation Tool/rrsct.py @@ -0,0 +1,735 @@ +import sys +import os +import sysconfig +import re +import requests +import subprocess +from packaging.version import parse as parse_version, InvalidVersion +from PySide6.QtWidgets import ( + QApplication, QWidget, QVBoxLayout, QPushButton, QFileDialog, + QMessageBox, QProgressBar, QCheckBox, QTextEdit, QComboBox, QLabel +) +from PySide6.QtCore import Qt, QThread, Signal + + +# -------------------- HELPERS -------------------- + +def is_venv(): + """Check if running in a virtual environment.""" + return (hasattr(sys, 'real_prefix') or + (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix)) + + +def detect_package_managers(): + """Detect available package managers on the system.""" + managers = {} + + # Check for pip + try: + result = subprocess.run(["pip", "--version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + managers['pip'] = 'pip' + except Exception: + pass + + # Check for pipx + try: + result = subprocess.run(["pipx", "--version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + managers['pipx'] = 'pipx' + except Exception: + pass + + # Check for uv + try: + result = subprocess.run(["uv", "--version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + managers['uv'] = 'uv' + except Exception: + pass + + # Check for poetry + try: + result = subprocess.run(["poetry", "--version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + managers['poetry'] = 'poetry' + except Exception: + pass + + # Check for conda + try: + result = subprocess.run(["conda", "--version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + managers['conda'] = 'conda' + except Exception: + pass + + # Check for pip3 + try: + result = subprocess.run(["pip3", "--version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0 and 'pip' not in managers: + managers['pip3'] = 'pip3' + except Exception: + pass + + return managers + + +def get_standard_libs(): + std_lib = sysconfig.get_paths()["stdlib"] + std_libs = set() + for root, _, files in os.walk(std_lib): + for file in files: + if file.endswith(".py"): + rel_path = os.path.relpath(os.path.join(root, file), std_lib) + module = rel_path.replace(os.sep, ".").rsplit(".py", 1)[0] + std_libs.add(module.split(".")[0]) + return std_libs + + +def clean_and_merge_requirements(reqs, log_fn): + clean_reqs = {} + pattern = re.compile(r"^([A-Za-z0-9_.\-]+)\s*([=<>!~]*.*)?$") + + for r in reqs: + r = r.strip() + if not r or r.startswith("#") or "@ file://" in r: + continue + + match = pattern.match(r) + if not match: + log_fn(f"Skipping invalid line: {r}") + continue + + pkg, spec = match.groups() + pkg = pkg.lower() + spec = spec.strip() if spec else "" + + if pkg in clean_reqs: + old_spec = clean_reqs[pkg] + try: + if "==" in spec: + new_ver = spec.split("==")[-1] + old_ver = old_spec.split("==")[-1] if "==" in old_spec else "" + if not old_ver or parse_version(new_ver) > parse_version(old_ver): + clean_reqs[pkg] = spec + else: + clean_reqs[pkg] = old_spec or spec + except InvalidVersion: + log_fn(f"Invalid version format for {pkg}: {spec}") + clean_reqs[pkg] = spec or old_spec + else: + clean_reqs[pkg] = spec + + return [f"{pkg}{spec}" if spec else pkg for pkg, spec in sorted(clean_reqs.items())] + + +def validate_on_pypi(requirements, log_fn): + """Validate each package individually with error handling.""" + valid_reqs = [] + for line in requirements: + try: + pkg = re.split(r"[=<>!~]", line)[0].strip() + if not pkg: + log_fn(f"Skipping empty package name from line: {line}") + continue + + url = f"https://pypi.org/pypi/{pkg}/json" + try: + resp = requests.get(url, timeout=5) + if resp.status_code == 200: + valid_reqs.append(line) + log_fn(f"✓ Validated: {pkg}") + elif resp.status_code == 404: + log_fn(f"✗ Package not found on PyPI: {pkg}") + else: + log_fn(f"⚠ Could not validate {pkg} (HTTP {resp.status_code})") + # Still include it in case it's a valid package with temporary issues + valid_reqs.append(line) + except requests.exceptions.Timeout: + log_fn(f"⚠ Timeout validating {pkg}, including anyway") + valid_reqs.append(line) + except requests.exceptions.RequestException as e: + log_fn(f"⚠ Network error validating {pkg}: {type(e).__name__}, including anyway") + valid_reqs.append(line) + except Exception as e: + log_fn(f"✗ Error processing line '{line}': {type(e).__name__}: {str(e)}") + # Continue with next package even if this one fails + continue + + return valid_reqs + + +def safe_read_file(file_path, log_fn): + """Safely read file with multiple encoding attempts and error handling.""" + for enc in ["utf-8-sig", "utf-8", "latin-1", "cp1252"]: + try: + with open(file_path, "r", encoding=enc, errors="ignore") as f: + return f.readlines() + except FileNotFoundError: + log_fn(f"✗ File not found: {file_path}") + return [] + except PermissionError: + log_fn(f"✗ Permission denied: {file_path}") + return [] + except Exception as e: + continue + log_fn(f"✗ Could not read file with any encoding: {file_path}") + return [] + + +# -------------------- WORKER THREAD -------------------- + +class Worker(QThread): + progress = Signal(int) + finished = Signal(list) + log_msg = Signal(str) + + def __init__(self, source_dir, exclude_std, package_manager='pip'): + super().__init__() + self.source_dir = source_dir + self.exclude_std = exclude_std + self.package_manager = package_manager + self.std_libs = get_standard_libs() if exclude_std else set() + + def log(self, message): + self.log_msg.emit(message) + + def run(self): + requirements = set() + all_files = [] + + self.log(f"Starting scan with package manager: {self.package_manager}") + self.log(f"Scanning directory: {self.source_dir}") + + try: + for root, _, files in os.walk(self.source_dir): + for file in files: + if file.endswith(".py") or file == "requirements.txt": + all_files.append(os.path.join(root, file)) + except Exception as e: + self.log(f"✗ Error walking directory: {type(e).__name__}: {str(e)}") + self.finished.emit([]) + return + + total_files = len(all_files) + self.log(f"Found {total_files} files to process") + + for idx, file_path in enumerate(all_files): + try: + if file_path.endswith(".py"): + self.process_python_file(file_path, requirements) + elif file_path.endswith("requirements.txt"): + self.process_requirements_file(file_path, requirements) + except Exception as e: + self.log(f"✗ Error processing {file_path}: {type(e).__name__}: {str(e)}") + # Continue with next file even if this one fails + continue + finally: + self.progress.emit(int((idx + 1) / total_files * 100)) + + self.log(f"Found {len(requirements)} unique imports/requirements") + + if self.exclude_std: + before_count = len(requirements) + requirements = {pkg for pkg in requirements if pkg not in self.std_libs} + self.log(f"Excluded {before_count - len(requirements)} standard library modules") + + try: + cleaned = clean_and_merge_requirements(requirements, self.log) + self.log(f"Cleaned and merged to {len(cleaned)} packages") + except Exception as e: + self.log(f"✗ Error cleaning requirements: {type(e).__name__}: {str(e)}") + cleaned = list(requirements) + + try: + self.log("Validating packages on PyPI...") + validated = validate_on_pypi(cleaned, self.log) + self.log(f"Validation complete: {len(validated)} valid packages") + except Exception as e: + self.log(f"✗ Error validating requirements: {type(e).__name__}: {str(e)}") + validated = cleaned + + self.finished.emit(validated) + + def process_python_file(self, file_path, requirements): + """Process Python file with individual line error handling.""" + try: + lines = safe_read_file(file_path, self.log) + for i, line in enumerate(lines): + if i >= 50: + break + try: + line = line.strip() + if line.startswith("import "): + parts = line.split() + if len(parts) >= 2: + pkg = parts[1].split(".")[0].split(",")[0] + if pkg and not pkg.startswith("_"): + requirements.add(pkg) + elif line.startswith("from "): + parts = line.split() + if len(parts) >= 2: + pkg = parts[1].split(".")[0] + if pkg and not pkg.startswith("_"): + requirements.add(pkg) + except Exception as e: + # Log but continue processing other lines + self.log(f"⚠ Error parsing line in {os.path.basename(file_path)}: {line[:50]}") + continue + except Exception as e: + self.log(f"✗ Error processing Python file {file_path}: {type(e).__name__}") + + def process_requirements_file(self, file_path, requirements): + """Process requirements file with individual line error handling.""" + try: + lines = safe_read_file(file_path, self.log) + for line in lines: + try: + line = line.strip() + if line and not line.startswith("#") and not line.startswith("-"): + # Handle different requirement formats + if "@" in line or "git+" in line or "http" in line: + # Skip git/URL requirements for now + self.log(f"⚠ Skipping git/URL requirement: {line[:50]}") + continue + requirements.add(line) + except Exception as e: + # Log but continue processing other lines + self.log(f"⚠ Error parsing requirement line: {line[:50]}") + continue + except Exception as e: + self.log(f"✗ Error processing requirements file {file_path}: {type(e).__name__}") + + +# -------------------- INSTALL WORKER -------------------- + +class InstallWorker(QThread): + progress = Signal(int, int) # current, total + finished = Signal(dict) # results dictionary + log_msg = Signal(str) + + def __init__(self, requirements_source, package_manager='pip', from_memory=False): + super().__init__() + self.requirements_source = requirements_source # Either file path or list + self.package_manager = package_manager + self.from_memory = from_memory + + def log(self, message): + self.log_msg.emit(message) + + def run(self): + """Install packages one by one with individual error handling.""" + results = { + 'success': [], + 'failed': [], + 'skipped': [] + } + + # Check if in venv + in_venv = is_venv() + venv_status = "virtual environment" if in_venv else "global Python" + self.log(f"Installing to: {venv_status}") + self.log(f"Python: {sys.executable}") + self.log(f"Package manager: {self.package_manager}\n") + + # Get packages from either file or memory + if self.from_memory: + # Requirements already in list format + packages = self.requirements_source + self.log(f"Installing from memory") + else: + # Read requirements from file + try: + with open(self.requirements_source, 'r', encoding='utf-8') as f: + lines = f.readlines() + except Exception as e: + self.log(f"✗ Error reading requirements file: {type(e).__name__}: {str(e)}") + self.finished.emit(results) + return + + # Parse requirements (skip comments and empty lines) + packages = [] + for line in lines: + line = line.strip() + if line and not line.startswith('#'): + packages.append(line) + + self.log(f"Installing from file: {self.requirements_source}") + + total = len(packages) + self.log(f"Found {total} packages to install\n") + + if total == 0: + self.log("⚠ No packages to install") + self.finished.emit(results) + return + + # Install each package individually + for idx, package in enumerate(packages, 1): + self.progress.emit(idx, total) + pkg_name = package # Default to full package string + + try: + # Extract package name for logging + pkg_name = re.split(r'[=<>!~]', package)[0].strip() + self.log(f"[{idx}/{total}] Installing {pkg_name}...") + + # Build install command based on package manager + if self.package_manager == 'pip' or self.package_manager == 'pip3': + cmd = [self.package_manager, 'install', package] + elif self.package_manager == 'uv': + cmd = ['uv', 'pip', 'install', package] + elif self.package_manager == 'pipx': + # pipx installs apps, not libraries - skip + self.log(f" ⚠ Skipping {pkg_name} (pipx is for applications, not libraries)") + results['skipped'].append(package) + continue + elif self.package_manager == 'poetry': + cmd = ['poetry', 'add', package] + elif self.package_manager == 'conda': + cmd = ['conda', 'install', '-y', package] + else: + cmd = ['pip', 'install', package] + + # Run installation command + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120 # 2 minute timeout per package + ) + + if result.returncode == 0: + self.log(f" ✓ Successfully installed {pkg_name}") + results['success'].append(package) + else: + error_msg = result.stderr.strip().split('\n')[-1] if result.stderr else 'Unknown error' + self.log(f" ✗ Failed to install {pkg_name}: {error_msg}") + results['failed'].append(package) + + except subprocess.TimeoutExpired: + self.log(f" ✗ Timeout installing {pkg_name} (>2 minutes)") + results['failed'].append(package) + except Exception as e: + self.log(f" ✗ Error installing {pkg_name}: {type(e).__name__}: {str(e)}") + results['failed'].append(package) + + # Continue to next package regardless of success/failure + + self.log(f"\n{'='*60}") + self.log(f"Installation Summary:") + self.log(f" ✓ Success: {len(results['success'])}") + self.log(f" ✗ Failed: {len(results['failed'])}") + self.log(f" ⚠ Skipped: {len(results['skipped'])}") + self.log(f"{'='*60}") + + self.finished.emit(results) + + +# -------------------- GUI -------------------- + +class RequirementsCollector(QWidget): + def __init__(self): + super().__init__() + self.setWindowTitle("Master Requirements Generator") + self.setGeometry(200, 200, 600, 500) + + layout = QVBoxLayout() + + self.select_src_btn = QPushButton("Select Source Directory") + self.select_src_btn.clicked.connect(self.select_source_dir) + layout.addWidget(self.select_src_btn) + + self.select_dest_btn = QPushButton("Select Destination for Master Requirements") + self.select_dest_btn.clicked.connect(self.select_dest_file) + layout.addWidget(self.select_dest_btn) + + # Package manager selection + pm_layout = QVBoxLayout() + pm_label = QLabel("Package Manager:") + pm_layout.addWidget(pm_label) + + self.package_manager_combo = QComboBox() + self.available_managers = detect_package_managers() + + if self.available_managers: + for name, cmd in self.available_managers.items(): + self.package_manager_combo.addItem(f"{name} ({cmd})", cmd) + pm_layout.addWidget(self.package_manager_combo) + else: + # Fallback if no package managers detected + self.package_manager_combo.addItem("pip (default)", "pip") + pm_layout.addWidget(self.package_manager_combo) + self.log("⚠ No package managers detected, using pip as default") + + layout.addLayout(pm_layout) + + self.exclude_std_cb = QCheckBox("Exclude standard libraries") + self.exclude_std_cb.setChecked(True) + layout.addWidget(self.exclude_std_cb) + + self.log_box = QTextEdit() + self.log_box.setReadOnly(True) + layout.addWidget(self.log_box) + + self.generate_btn = QPushButton("Generate Master Requirements") + self.generate_btn.clicked.connect(self.generate_requirements) + layout.addWidget(self.generate_btn) + + self.install_btn = QPushButton("Install Requirements from File") + self.install_btn.clicked.connect(self.install_requirements) + layout.addWidget(self.install_btn) + + self.progress_label = QLabel("") + layout.addWidget(self.progress_label) + + self.setLayout(layout) + + self.source_dir = "" + self.dest_file = "" + self.generated_requirements = [] # Store generated requirements in memory + + # Log detected package managers + if self.available_managers: + self.log(f"Detected package managers: {', '.join(self.available_managers.keys())}") + + def select_source_dir(self): + dir_path = QFileDialog.getExistingDirectory(self, "Select Source Directory") + if dir_path: + self.source_dir = dir_path + self.log(f"✓ Source directory selected: {dir_path}") + + def select_dest_file(self): + file_path, _ = QFileDialog.getSaveFileName(self, "Save Master Requirements", "requirements.txt", "Text Files (*.txt)") + if file_path: + self.dest_file = file_path + self.log(f"✓ Destination file selected: {file_path}") + + def generate_requirements(self): + if not self.source_dir: + QMessageBox.warning(self, "Error", "Please select a source directory.") + return + + selected_pm = self.package_manager_combo.currentData() + self.log(f"\n{'='*60}") + self.log(f"Starting requirements generation...") + self.log(f"Package Manager: {selected_pm}") + self.log(f"{'='*60}\n") + + self.worker = Worker(self.source_dir, self.exclude_std_cb.isChecked(), selected_pm) + self.worker.log_msg.connect(self.log) + self.worker.finished.connect(self.write_requirements) + self.worker.start() + + self.generate_btn.setEnabled(False) + self.generate_btn.setText("Generating...") + + def log(self, message): + self.log_box.append(message) + + def write_requirements(self, requirements): + self.generate_btn.setEnabled(True) + self.generate_btn.setText("Generate Master Requirements") + + if not requirements: + self.log("\n⚠ Warning: No requirements found!") + QMessageBox.warning(self, "Warning", "No requirements were found.") + return + + # Store requirements in memory + self.generated_requirements = requirements + + self.log(f"\n{'='*60}") + self.log(f"✓ Requirements generated successfully!") + self.log(f"Total packages: {len(requirements)}") + + # If destination file is selected, save to file + if self.dest_file: + try: + with open(self.dest_file, "w", encoding="utf-8") as f: + f.write(f"# Generated by Master Requirements Generator\n") + f.write(f"# Package Manager: {self.package_manager_combo.currentData()}\n") + f.write(f"# Total packages: {len(requirements)}\n\n") + for req in requirements: + f.write(req + "\n") + + self.log(f"✓ Saved to file: {self.dest_file}") + self.log(f"{'='*60}") + + QMessageBox.information(self, "Success", + f"Master requirements.txt created at:\n{self.dest_file}\n\nTotal packages: {len(requirements)}\n\nRequirements are also stored in memory for installation.") + except PermissionError: + error_msg = f"Permission denied writing to: {self.dest_file}" + self.log(f"✗ ERROR: {error_msg}") + self.log(f"Requirements are still in memory and can be installed.") + self.log(f"{'='*60}") + QMessageBox.warning(self, "Partial Success", + f"{error_msg}\n\nRequirements are stored in memory and can be installed without saving.") + except Exception as e: + error_msg = f"Could not write file: {type(e).__name__}: {str(e)}" + self.log(f"✗ ERROR: {error_msg}") + self.log(f"Requirements are still in memory and can be installed.") + self.log(f"{'='*60}") + QMessageBox.warning(self, "Partial Success", + f"{error_msg}\n\nRequirements are stored in memory and can be installed without saving.") + else: + # No destination file, just store in memory + self.log(f"✓ Requirements stored in memory (not saved to file)") + self.log(f"You can install them directly or select a destination to save.") + self.log(f"{'='*60}") + + QMessageBox.information(self, "Success", + f"Requirements generated successfully!\n\nTotal packages: {len(requirements)}\n\nRequirements are stored in memory.\nYou can install them directly without saving to a file.") + + def install_requirements(self): + """Install requirements from memory or a selected file.""" + # Check if we have generated requirements in memory + if self.generated_requirements: + # Ask user if they want to install from memory or file + reply = QMessageBox.question( + self, + "Install Source", + f"You have {len(self.generated_requirements)} packages in memory.\n\nInstall from memory or select a file?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Yes + ) + + if reply == QMessageBox.StandardButton.Cancel: + return + elif reply == QMessageBox.StandardButton.Yes: + # Install from memory + self._install_from_memory() + return + # If No, continue to file selection + + # Install from file + req_file, _ = QFileDialog.getOpenFileName( + self, + "Select Requirements File", + "", + "Text Files (*.txt);;All Files (*)" + ) + + if not req_file: + return + + self._install_from_file(req_file) + + def _install_from_memory(self): + """Install requirements from memory.""" + self.log(f"\nInstalling from memory ({len(self.generated_requirements)} packages)") + + # Check if in venv + in_venv = is_venv() + venv_msg = "You are running in a virtual environment." if in_venv else "You are running in global Python." + + # Confirm installation + reply = QMessageBox.question( + self, + "Confirm Installation", + f"{venv_msg}\n\nInstall {len(self.generated_requirements)} packages from memory?\n\nUsing: {self.package_manager_combo.currentData()}\n\nThis will install packages one by one. Continue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.No: + return + + selected_pm = self.package_manager_combo.currentData() + self.log(f"\n{'='*60}") + self.log(f"Starting package installation from memory...") + self.log(f"{'='*60}\n") + + self.install_worker = InstallWorker(self.generated_requirements, selected_pm, from_memory=True) + self.install_worker.log_msg.connect(self.log) + self.install_worker.progress.connect(self.update_install_progress) + self.install_worker.finished.connect(self.install_finished) + self.install_worker.start() + + self.install_btn.setEnabled(False) + self.install_btn.setText("Installing...") + self.generate_btn.setEnabled(False) + + def _install_from_file(self, req_file): + """Install requirements from a file.""" + self.log(f"\nSelected requirements file: {req_file}") + + # Check if in venv + in_venv = is_venv() + venv_msg = "You are running in a virtual environment." if in_venv else "You are running in global Python." + + # Confirm installation + reply = QMessageBox.question( + self, + "Confirm Installation", + f"{venv_msg}\n\nInstall packages from:\n{req_file}\n\nUsing: {self.package_manager_combo.currentData()}\n\nThis will install packages one by one. Continue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No + ) + + if reply == QMessageBox.StandardButton.No: + return + + selected_pm = self.package_manager_combo.currentData() + self.log(f"\n{'='*60}") + self.log(f"Starting package installation from file...") + self.log(f"{'='*60}\n") + + self.install_worker = InstallWorker(req_file, selected_pm, from_memory=False) + self.install_worker.log_msg.connect(self.log) + self.install_worker.progress.connect(self.update_install_progress) + self.install_worker.finished.connect(self.install_finished) + self.install_worker.start() + + self.install_btn.setEnabled(False) + self.install_btn.setText("Installing...") + self.generate_btn.setEnabled(False) + + def update_install_progress(self, current, total): + """Update progress label during installation.""" + self.progress_label.setText(f"Installing package {current} of {total}...") + + def install_finished(self, results): + """Handle installation completion.""" + self.install_btn.setEnabled(True) + self.install_btn.setText("Install Requirements from File") + self.generate_btn.setEnabled(True) + self.progress_label.setText("") + + success_count = len(results['success']) + failed_count = len(results['failed']) + skipped_count = len(results['skipped']) + total = success_count + failed_count + skipped_count + + if failed_count == 0 and skipped_count == 0: + QMessageBox.information( + self, + "Installation Complete", + f"Successfully installed all {success_count} packages!" + ) + elif success_count > 0: + msg = f"Installation completed with some issues:\n\n" + msg += f"✓ Success: {success_count}\n" + if failed_count > 0: + msg += f"✗ Failed: {failed_count}\n" + if skipped_count > 0: + msg += f"⚠ Skipped: {skipped_count}\n" + msg += f"\nCheck the log for details." + QMessageBox.warning(self, "Partial Success", msg) + else: + QMessageBox.critical( + self, + "Installation Failed", + f"Failed to install packages. Check the log for details.\n\n" + f"Failed: {failed_count}, Skipped: {skipped_count}" + ) + + +# -------------------- MAIN -------------------- + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = RequirementsCollector() + window.show() + sys.exit(app.exec()) diff --git a/Remove Background/README.md b/Remove Background/README.md index 2793b60e..9c77df93 100644 --- a/Remove Background/README.md +++ b/Remove Background/README.md @@ -1,13 +1,211 @@ -## REMOVE BACKGROUND OF AN IMAGE -This script removes bacckground of images. - -#### steps : - Read the input - Convert to gray - Threshold and invert as a mask - Optionally apply morphology to clean up any extraneous spots - Anti-alias the edges - Convert a copy of the input to BGRA and insert the mask as the alpha channel - Save the results - - \ No newline at end of file +# Background/Foreground Remover & Replacer + +A modern Qt-based GUI application for advanced image processing that allows you to remove and replace backgrounds or foregrounds in images with various options including solid colors, images, and gradients. + +## Features + +### Core Functionality +- **Background Removal**: Remove backgrounds from images using adaptive thresholding and morphological operations +- **Background Replacement**: Replace backgrounds with solid colors, custom images, or gradients +- **Foreground Removal**: Remove foreground elements from images +- **Foreground Replacement**: Replace foregrounds with solid colors, custom images, or gradients + +### Replacement Options +Both background and foreground operations support three replacement types: + +1. **Solid Color**: Choose any custom color using an interactive color picker +2. **Image**: Load a custom image file (JPG, PNG, BMP) to use as replacement +3. **Gradient**: Create custom gradients with two color stops using color pickers + +### User Interface +- **Modern Dark Theme**: Clean, professional dark interface using Qt Fusion style +- **Real-time Preview**: View original and processed images side-by-side +- **Interactive Controls**: Tabbed interface for easy access to all replacement options +- **Progress Feedback**: Visual progress indication during image processing +- **Error Handling**: Comprehensive error messages and validation + +## Requirements + +``` +PySide6>=6.0.0 +opencv-python>=4.5.0 +numpy>=1.20.0 +``` + +## Installation + +1. Install the required dependencies: +```bash +pip install PySide6 opencv-python numpy +``` + +2. Run the GUI application: +```bash +python bg_remover_gui.py +``` + +## Usage + +### Basic Workflow + +1. **Load Image**: Click "Load Image" to select an input image file +2. **Choose Operation**: Select from four operation modes: + - Remove Background + - Replace Background + - Remove Foreground + - Replace Foreground +3. **Configure Replacement** (for replace operations): + - Navigate to the appropriate tab (Background/Foreground) + - Choose replacement type (Color/Image/Gradient) + - Configure settings using color pickers or image selection +4. **Process**: Click "Process Image" to apply the operation +5. **Save**: Click "Save Result" to export the processed image + +### Operation Details + +#### Remove Background/Foreground +Removes the specified portion of the image using: +- Color space conversion (BGR to GRAY) +- Adaptive binary thresholding +- Morphological operations (erosion, dilation) +- Gaussian blur for edge smoothing +- Alpha channel creation for transparency + +#### Replace Background +Replaces the background while preserving the foreground: +- **Solid Color**: Select a single color to use as the new background +- **Image**: Load an image file that will be scaled to fit the background area +- **Gradient**: Create a vertical gradient with two colors (top and bottom) + +#### Replace Foreground +Replaces the foreground while preserving the background: +- **Solid Color**: Select a single color to use as the new foreground +- **Image**: Load an image file that will be scaled to fit the foreground area +- **Gradient**: Create a vertical gradient with two colors (top and bottom) + +### Tips for Best Results + +- **Image Quality**: Use high-resolution images for better processing results +- **Subject Contrast**: Images with clear contrast between subject and background work best +- **Color Selection**: Use the color picker to precisely match or complement your image colors +- **Gradient Direction**: Gradients are currently vertical (top to bottom) for consistent results +- **Image Replacement**: Replacement images are automatically scaled to fit - use high-quality images + +## Technical Details + +### Image Processing Pipeline + +1. **Input Validation**: Checks for valid image file and operation mode +2. **Color Space Conversion**: Converts BGR to GRAY for threshold operations +3. **Thresholding**: Applies binary threshold (127) with THRESH_BINARY_INV +4. **Morphological Operations**: + - Erosion with 5x5 kernel (2 iterations) + - Dilation with 5x5 kernel (2 iterations) +5. **Edge Smoothing**: Gaussian blur (5x5 kernel) for natural boundaries +6. **Alpha Blending**: Combines processed mask with replacement content +7. **Output**: Returns BGRA image with alpha channel for transparency + +### Architecture + +- **ImageProcessor (QThread)**: Background thread for non-blocking image processing + - Signals: `finished`, `error`, `progress` + - Operations: `remove_bg`, `replace_bg`, `remove_fg`, `replace_fg` + +- **BackgroundRemoverGUI (QMainWindow)**: Main application window + - Image display with aspect ratio preservation + - Tabbed interface for background and foreground controls + - Color pickers with live preview + - File selection dialogs + - Progress indication + +### Type Safety + +The application uses strict type checking with: +- `cv2.typing.MatLike` for OpenCV matrices +- `Optional` types for nullable values +- `Dict[str, Any]` for configuration parameters +- Full type annotations throughout + +## File Structure + +``` +Remove Background/ +├── bg_remover_gui.py # Main GUI application with Qt interface +├── code.py # Simple command-line version (legacy) +├── README.md # This documentation +└── requirements.txt # Python dependencies (if present) +``` + +## Troubleshooting + +### Common Issues + +**Image not loading** +- Ensure the image file exists and is a valid format (JPG, PNG, BMP) +- Check file permissions + +**Processing fails** +- Verify the image is not corrupted +- Try a different image to isolate the issue +- Check that all dependencies are installed correctly + +**Replacement image doesn't look right** +- Use high-resolution replacement images +- Ensure replacement images are the same aspect ratio as the original +- Try adjusting the gradient colors or solid color selection + +**GUI doesn't start** +- Verify PySide6 is installed: `pip install PySide6` +- Check for Qt conflicts if multiple Qt versions are installed +- Run from command line to see error messages + +### Performance Notes + +- Processing time varies based on image size and complexity +- Large images (>4K) may take several seconds to process +- Background operations run in a separate thread to keep UI responsive +- Each operation has a 30-second timeout to prevent hanging + +## License + +This project is part of the Python-Scripts repository. Please refer to the repository's LICENSE file for licensing information. + +## Contributing + +Contributions are welcome! Please follow the repository's contribution guidelines: +1. Fork the repository +2. Create a feature branch +3. Make your changes with proper type annotations +4. Test thoroughly with various image types +5. Submit a pull request + +## Legacy Version + +The `code.py` file contains a simpler command-line version of the background remover. This version: +- Takes input from command-line arguments +- Only supports basic background removal +- Saves output automatically +- Does not include replacement features + +Use `bg_remover_gui.py` for the full-featured GUI experience. + +## Future Enhancements + +Potential improvements for future versions: +- Horizontal/diagonal gradient options +- Custom gradient with multiple color stops +- Batch processing for multiple images +- Additional morphological operations +- Machine learning-based segmentation +- Real-time video processing +- Custom threshold adjustment controls +- Edge detection algorithms selection +- Undo/redo functionality +- Image filters and adjustments + +## Credits + +Built with: +- **PySide6**: Qt6 bindings for Python +- **OpenCV**: Computer vision and image processing +- **NumPy**: Numerical computing for array operations diff --git a/Remove Background/bg_remover_gui.py b/Remove Background/bg_remover_gui.py new file mode 100644 index 00000000..eb9d7f66 --- /dev/null +++ b/Remove Background/bg_remover_gui.py @@ -0,0 +1,869 @@ +import sys +import cv2 +import numpy as np +from pathlib import Path +from typing import Any, Dict, Optional, Tuple +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QLabel, QFileDialog, QSlider, QTabWidget, QGroupBox, + QRadioButton, QButtonGroup, QSpinBox, QColorDialog, QComboBox, + QMessageBox, QSplitter, QScrollArea +) +from PySide6.QtCore import Qt, QThread, Signal, QSize +from PySide6.QtGui import QImage, QPixmap, QPalette, QColor +from cv2.typing import MatLike +import numpy.typing as npt + + +class ImageProcessor(QThread): + """Background thread for image processing operations.""" + finished = Signal(object) # Returns processed image + error = Signal(str) + + def __init__(self, image: MatLike, operation: str, params: Dict[str, Any]) -> None: + super().__init__() + self.image = image + self.operation = operation + self.params = params + + def run(self) -> None: + try: + result: Optional[MatLike] = None + if self.operation == "remove_bg": + result = self.remove_background() + elif self.operation == "replace_bg": + result = self.replace_background() + elif self.operation == "remove_fg": + result = self.remove_foreground() + elif self.operation == "replace_fg": + result = self.replace_foreground() + else: + result = self.image + + self.finished.emit(result) + except Exception as e: + self.error.emit(str(e)) + + def remove_background(self) -> MatLike: + """Remove background from image.""" + threshold = int(self.params.get('threshold', 250)) + blur_amount = int(self.params.get('blur', 2)) + morph_size = int(self.params.get('morph_size', 3)) + + # Convert to gray + gray = cv2.cvtColor(self.image, cv2.COLOR_BGR2GRAY) + + # Threshold to create mask + _, mask = cv2.threshold(gray, threshold, 255, cv2.THRESH_BINARY) + + # Invert mask + mask = 255 - mask + + # Apply morphology + kernel = np.ones((morph_size, morph_size), np.uint8) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) + + # Blur and stretch mask + mask = cv2.GaussianBlur(mask, (0, 0), sigmaX=blur_amount, sigmaY=blur_amount, + borderType=cv2.BORDER_DEFAULT) + mask = (2 * (mask.astype(np.float32)) - 255.0).clip(0, 255).astype(np.uint8) + + # Create RGBA result + result = cv2.cvtColor(self.image, cv2.COLOR_BGR2BGRA) + result[:, :, 3] = mask + + return result + + def replace_background(self) -> MatLike: + """Replace background with new image, color, or gradient.""" + # First remove background + result = self.remove_background() + + # Get replacement + replacement_type = self.params.get('replacement_type', 'color') + + if replacement_type == 'image': + bg_image = self.params.get('bg_image') + if bg_image is not None: + # Resize background to match + bg_resized = cv2.resize(bg_image, (result.shape[1], result.shape[0])) + if bg_resized.shape[2] == 3: + bg_resized = cv2.cvtColor(bg_resized, cv2.COLOR_BGR2BGRA) + + # Blend using alpha channel + alpha = result[:, :, 3:4] / 255.0 + result_rgb = result[:, :, :3] + bg_rgb = bg_resized[:, :, :3] + + blended = (result_rgb * alpha + bg_rgb * (1 - alpha)).astype(np.uint8) + final = cv2.cvtColor(blended, cv2.COLOR_BGR2BGRA) + final[:, :, 3] = 255 + return final + + elif replacement_type == 'color': + color_tuple = self.params.get('bg_color', (255, 255, 255)) + bg = np.full_like(result, (*color_tuple, 255), dtype=np.uint8) + + # Blend + alpha = result[:, :, 3:4] / 255.0 + result_rgb = result[:, :, :3] + bg_rgb = bg[:, :, :3] + + blended = (result_rgb * alpha + bg_rgb * (1 - alpha)).astype(np.uint8) + final = cv2.cvtColor(blended, cv2.COLOR_BGR2BGRA) + final[:, :, 3] = 255 + return final + + elif replacement_type == 'gradient': + color1 = self.params.get('gradient_color1', (255, 255, 255)) + color2 = self.params.get('gradient_color2', (0, 0, 0)) + direction = self.params.get('gradient_direction', 'vertical') + + # Create gradient + h, w = result.shape[:2] + gradient = np.zeros((h, w, 4), dtype=np.uint8) + + if direction == 'vertical': + for i in range(h): + ratio = i / h + color = tuple(int(c1 * (1 - ratio) + c2 * ratio) + for c1, c2 in zip(color1, color2)) + gradient[i, :] = (*color, 255) + else: # horizontal + for j in range(w): + ratio = j / w + color = tuple(int(c1 * (1 - ratio) + c2 * ratio) + for c1, c2 in zip(color1, color2)) + gradient[:, j] = (*color, 255) + + # Blend + alpha = result[:, :, 3:4] / 255.0 + result_rgb = result[:, :, :3] + gradient_rgb = gradient[:, :, :3] + + blended = (result_rgb * alpha + gradient_rgb * (1 - alpha)).astype(np.uint8) + final = cv2.cvtColor(blended, cv2.COLOR_BGR2BGRA) + final[:, :, 3] = 255 + return final + + return result + + def remove_foreground(self) -> MatLike: + """Remove foreground (inverse of remove background).""" + threshold = int(self.params.get('threshold', 250)) + blur_amount = int(self.params.get('blur', 2)) + morph_size = int(self.params.get('morph_size', 3)) + + gray = cv2.cvtColor(self.image, cv2.COLOR_BGR2GRAY) + _, mask = cv2.threshold(gray, threshold, 255, cv2.THRESH_BINARY) + + # Don't invert for foreground removal + kernel = np.ones((morph_size, morph_size), np.uint8) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) + + mask = cv2.GaussianBlur(mask, (0, 0), sigmaX=blur_amount, sigmaY=blur_amount, + borderType=cv2.BORDER_DEFAULT) + mask = (2 * (mask.astype(np.float32)) - 255.0).clip(0, 255).astype(np.uint8) + + result = cv2.cvtColor(self.image, cv2.COLOR_BGR2BGRA) + result[:, :, 3] = mask + + return result + + def replace_foreground(self) -> MatLike: + """Replace foreground with new content.""" + result = self.remove_foreground() + + replacement_type = self.params.get('replacement_type', 'color') + + if replacement_type == 'image': + fg_image = self.params.get('fg_image') + if fg_image is not None: + # Resize foreground to match + fg_resized = cv2.resize(fg_image, (result.shape[1], result.shape[0])) + if fg_resized.shape[2] == 3: + fg_resized = cv2.cvtColor(fg_resized, cv2.COLOR_BGR2BGRA) + + # Blend using alpha channel + alpha = result[:, :, 3:4] / 255.0 + result_rgb = result[:, :, :3] + fg_rgb = fg_resized[:, :, :3] + + blended = (fg_rgb * alpha + result_rgb * (1 - alpha)).astype(np.uint8) + final = cv2.cvtColor(blended, cv2.COLOR_BGR2BGRA) + final[:, :, 3] = 255 + return final + + elif replacement_type == 'color': + color_tuple = self.params.get('fg_color', (0, 0, 0)) + + alpha = result[:, :, 3:4] / 255.0 + result_rgb = result[:, :, :3] + + # Apply color to foreground only + colored = np.full_like(result_rgb, color_tuple, dtype=np.uint8) + blended = (colored * alpha + result_rgb * (1 - alpha)).astype(np.uint8) + + final = cv2.cvtColor(blended, cv2.COLOR_BGR2BGRA) + final[:, :, 3] = 255 + return final + + elif replacement_type == 'gradient': + color1 = self.params.get('gradient_color1', (255, 255, 255)) + color2 = self.params.get('gradient_color2', (0, 0, 0)) + direction = self.params.get('gradient_direction', 'vertical') + + # Create gradient + h, w = result.shape[:2] + gradient = np.zeros((h, w, 4), dtype=np.uint8) + + if direction == 'vertical': + for i in range(h): + ratio = i / h + color = tuple(int(c1 * (1 - ratio) + c2 * ratio) + for c1, c2 in zip(color1, color2)) + gradient[i, :] = (*color, 255) + else: # horizontal + for j in range(w): + ratio = j / w + color = tuple(int(c1 * (1 - ratio) + c2 * ratio) + for c1, c2 in zip(color1, color2)) + gradient[:, j] = (*color, 255) + + # Blend + alpha = result[:, :, 3:4] / 255.0 + result_rgb = result[:, :, :3] + gradient_rgb = gradient[:, :, :3] + + blended = (gradient_rgb * alpha + result_rgb * (1 - alpha)).astype(np.uint8) + final = cv2.cvtColor(blended, cv2.COLOR_BGR2BGRA) + final[:, :, 3] = 255 + return final + + return result + + +class BackgroundRemoverGUI(QMainWindow): + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("Advanced Background Remover") + self.setGeometry(100, 100, 1400, 900) + + # State variables + self.original_image: Optional[MatLike] = None + self.processed_image: Optional[MatLike] = None + self.bg_replacement_image: Optional[MatLike] = None + self.fg_replacement_image: Optional[MatLike] = None + self.processing: bool = False + + # UI components (declare for type checking) + self.load_btn: QPushButton + self.save_btn: QPushButton + self.process_btn: QPushButton + self.mode_group: QButtonGroup + self.remove_bg_radio: QRadioButton + self.replace_bg_radio: QRadioButton + self.remove_fg_radio: QRadioButton + self.replace_fg_radio: QRadioButton + self.threshold_slider: QSlider + self.threshold_value_label: QLabel + self.blur_slider: QSlider + self.blur_value_label: QLabel + self.morph_spin: QSpinBox + self.replacement_group: QGroupBox + self.bg_replacement_tabs: QTabWidget + self.fg_replacement_tabs: QTabWidget + self.bg_color_btn: QPushButton + self.fg_color_btn: QPushButton + self.bg_color: QColor = QColor(255, 255, 255) + self.fg_color: QColor = QColor(0, 0, 0) + self.load_bg_image_btn: QPushButton + self.load_fg_image_btn: QPushButton + self.bg_image_label: QLabel + self.fg_image_label: QLabel + self.gradient_color1_btn: QPushButton + self.gradient_color2_btn: QPushButton + self.gradient_color1: QColor = QColor(255, 255, 255) + self.gradient_color2: QColor = QColor(0, 0, 0) + self.gradient_direction: QComboBox + self.fg_gradient_color1_btn: QPushButton + self.fg_gradient_color2_btn: QPushButton + self.fg_gradient_color1: QColor = QColor(255, 255, 255) + self.fg_gradient_color2: QColor = QColor(0, 0, 0) + self.fg_gradient_direction: QComboBox + self.image_splitter: QSplitter + self.original_label: QLabel + self.processed_label: QLabel + self.worker: ImageProcessor + + # Setup UI + self.init_ui() + + def init_ui(self) -> None: + """Initialize the user interface.""" + central_widget = QWidget() + self.setCentralWidget(central_widget) + + main_layout = QHBoxLayout(central_widget) + + # Left side: Controls + controls_widget = QWidget() + controls_layout = QVBoxLayout(controls_widget) + controls_widget.setMaximumWidth(400) + + # File controls + file_group = QGroupBox("File Operations") + file_layout = QVBoxLayout() + + self.load_btn = QPushButton("Load Image") + self.load_btn.clicked.connect(self.load_image) + file_layout.addWidget(self.load_btn) + + self.save_btn = QPushButton("Save Result") + self.save_btn.clicked.connect(self.save_image) + self.save_btn.setEnabled(False) + file_layout.addWidget(self.save_btn) + + file_group.setLayout(file_layout) + controls_layout.addWidget(file_group) + + # Mode selection + mode_group = QGroupBox("Operation Mode") + mode_layout = QVBoxLayout() + + self.mode_group = QButtonGroup() + self.remove_bg_radio = QRadioButton("Remove Background") + self.replace_bg_radio = QRadioButton("Replace Background") + self.remove_fg_radio = QRadioButton("Remove Foreground") + self.replace_fg_radio = QRadioButton("Replace Foreground") + + self.remove_bg_radio.setChecked(True) + self.mode_group.addButton(self.remove_bg_radio, 0) + self.mode_group.addButton(self.replace_bg_radio, 1) + self.mode_group.addButton(self.remove_fg_radio, 2) + self.mode_group.addButton(self.replace_fg_radio, 3) + + mode_layout.addWidget(self.remove_bg_radio) + mode_layout.addWidget(self.replace_bg_radio) + mode_layout.addWidget(self.remove_fg_radio) + mode_layout.addWidget(self.replace_fg_radio) + + mode_group.setLayout(mode_layout) + controls_layout.addWidget(mode_group) + + # Processing parameters + params_group = QGroupBox("Processing Parameters") + params_layout = QVBoxLayout() + + # Threshold + threshold_layout = QHBoxLayout() + threshold_layout.addWidget(QLabel("Threshold:")) + self.threshold_slider = QSlider(Qt.Orientation.Horizontal) + self.threshold_slider.setRange(0, 255) + self.threshold_slider.setValue(250) + self.threshold_value_label = QLabel("250") + self.threshold_slider.valueChanged.connect( + lambda v: self.threshold_value_label.setText(str(v)) + ) + threshold_layout.addWidget(self.threshold_slider) + threshold_layout.addWidget(self.threshold_value_label) + params_layout.addLayout(threshold_layout) + + # Blur + blur_layout = QHBoxLayout() + blur_layout.addWidget(QLabel("Edge Blur:")) + self.blur_slider = QSlider(Qt.Orientation.Horizontal) + self.blur_slider.setRange(0, 10) + self.blur_slider.setValue(2) + self.blur_value_label = QLabel("2") + self.blur_slider.valueChanged.connect( + lambda v: self.blur_value_label.setText(str(v)) + ) + blur_layout.addWidget(self.blur_slider) + blur_layout.addWidget(self.blur_value_label) + params_layout.addLayout(blur_layout) + + # Morphology + morph_layout = QHBoxLayout() + morph_layout.addWidget(QLabel("Cleanup Size:")) + self.morph_spin = QSpinBox() + self.morph_spin.setRange(1, 15) + self.morph_spin.setValue(3) + self.morph_spin.setSingleStep(2) + morph_layout.addWidget(self.morph_spin) + morph_layout.addStretch() + params_layout.addLayout(morph_layout) + + params_group.setLayout(params_layout) + controls_layout.addWidget(params_group) + + # Background Replacement options + self.replacement_group = QGroupBox("Replacement Options") + replacement_layout = QVBoxLayout() + + # Background replacement tabs + self.bg_replacement_tabs = QTabWidget() + + # BG Color tab + bg_color_tab = QWidget() + bg_color_layout = QVBoxLayout(bg_color_tab) + + self.bg_color_btn = QPushButton("Choose Background Color") + self.bg_color_btn.clicked.connect(self.choose_bg_color) + self.update_color_button_style(self.bg_color_btn, self.bg_color) + bg_color_layout.addWidget(self.bg_color_btn) + bg_color_layout.addStretch() + self.bg_replacement_tabs.addTab(bg_color_tab, "Solid Color") + + # BG Image tab + bg_image_tab = QWidget() + bg_image_layout = QVBoxLayout(bg_image_tab) + + self.load_bg_image_btn = QPushButton("Load Background Image") + self.load_bg_image_btn.clicked.connect(self.load_bg_image) + bg_image_layout.addWidget(self.load_bg_image_btn) + + self.bg_image_label = QLabel("No image loaded") + self.bg_image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.bg_image_label.setStyleSheet("border: 1px solid gray; padding: 10px;") + bg_image_layout.addWidget(self.bg_image_label) + bg_image_layout.addStretch() + self.bg_replacement_tabs.addTab(bg_image_tab, "Image") + + # BG Gradient tab + bg_gradient_tab = QWidget() + bg_gradient_layout = QVBoxLayout(bg_gradient_tab) + + self.gradient_color1_btn = QPushButton("Gradient Start Color") + self.gradient_color1_btn.clicked.connect(self.choose_gradient_color1) + self.update_color_button_style(self.gradient_color1_btn, self.gradient_color1) + bg_gradient_layout.addWidget(self.gradient_color1_btn) + + self.gradient_color2_btn = QPushButton("Gradient End Color") + self.gradient_color2_btn.clicked.connect(self.choose_gradient_color2) + self.update_color_button_style(self.gradient_color2_btn, self.gradient_color2) + bg_gradient_layout.addWidget(self.gradient_color2_btn) + + gradient_dir_layout = QHBoxLayout() + gradient_dir_layout.addWidget(QLabel("Direction:")) + self.gradient_direction = QComboBox() + self.gradient_direction.addItems(["Vertical", "Horizontal"]) + gradient_dir_layout.addWidget(self.gradient_direction) + bg_gradient_layout.addLayout(gradient_dir_layout) + bg_gradient_layout.addStretch() + self.bg_replacement_tabs.addTab(bg_gradient_tab, "Gradient") + + # Foreground replacement tabs + self.fg_replacement_tabs = QTabWidget() + + # FG Color tab + fg_color_tab = QWidget() + fg_color_layout = QVBoxLayout(fg_color_tab) + + self.fg_color_btn = QPushButton("Choose Foreground Color") + self.fg_color_btn.clicked.connect(self.choose_fg_color) + self.update_color_button_style(self.fg_color_btn, self.fg_color) + fg_color_layout.addWidget(self.fg_color_btn) + fg_color_layout.addStretch() + self.fg_replacement_tabs.addTab(fg_color_tab, "Solid Color") + + # FG Image tab + fg_image_tab = QWidget() + fg_image_layout = QVBoxLayout(fg_image_tab) + + self.load_fg_image_btn = QPushButton("Load Foreground Image") + self.load_fg_image_btn.clicked.connect(self.load_fg_image) + fg_image_layout.addWidget(self.load_fg_image_btn) + + self.fg_image_label = QLabel("No image loaded") + self.fg_image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.fg_image_label.setStyleSheet("border: 1px solid gray; padding: 10px;") + fg_image_layout.addWidget(self.fg_image_label) + fg_image_layout.addStretch() + self.fg_replacement_tabs.addTab(fg_image_tab, "Image") + + # FG Gradient tab + fg_gradient_tab = QWidget() + fg_gradient_layout = QVBoxLayout(fg_gradient_tab) + + self.fg_gradient_color1_btn = QPushButton("Gradient Start Color") + self.fg_gradient_color1_btn.clicked.connect(self.choose_fg_gradient_color1) + self.update_color_button_style(self.fg_gradient_color1_btn, self.fg_gradient_color1) + fg_gradient_layout.addWidget(self.fg_gradient_color1_btn) + + self.fg_gradient_color2_btn = QPushButton("Gradient End Color") + self.fg_gradient_color2_btn.clicked.connect(self.choose_fg_gradient_color2) + self.update_color_button_style(self.fg_gradient_color2_btn, self.fg_gradient_color2) + fg_gradient_layout.addWidget(self.fg_gradient_color2_btn) + + fg_gradient_dir_layout = QHBoxLayout() + fg_gradient_dir_layout.addWidget(QLabel("Direction:")) + self.fg_gradient_direction = QComboBox() + self.fg_gradient_direction.addItems(["Vertical", "Horizontal"]) + fg_gradient_dir_layout.addWidget(self.fg_gradient_direction) + fg_gradient_layout.addLayout(fg_gradient_dir_layout) + fg_gradient_layout.addStretch() + self.fg_replacement_tabs.addTab(fg_gradient_tab, "Gradient") + + # Add tabs to main replacement layout + replacement_layout.addWidget(QLabel("Background Replacement:")) + replacement_layout.addWidget(self.bg_replacement_tabs) + replacement_layout.addWidget(QLabel("Foreground Replacement:")) + replacement_layout.addWidget(self.fg_replacement_tabs) + + self.replacement_group.setLayout(replacement_layout) + self.replacement_group.setEnabled(False) + controls_layout.addWidget(self.replacement_group) + + # Process button + self.process_btn = QPushButton("Process Image") + self.process_btn.clicked.connect(self.process_image) + self.process_btn.setEnabled(False) + self.process_btn.setStyleSheet("QPushButton { font-weight: bold; padding: 10px; }") + controls_layout.addWidget(self.process_btn) + + controls_layout.addStretch() + + # Right side: Image display + display_widget = QWidget() + display_layout = QVBoxLayout(display_widget) + + # Image splitter + self.image_splitter = QSplitter(Qt.Orientation.Horizontal) + + # Original image + original_scroll = QScrollArea() + original_container = QWidget() + original_layout = QVBoxLayout(original_container) + original_layout.addWidget(QLabel("Original Image")) + self.original_label = QLabel() + self.original_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.original_label.setStyleSheet("border: 2px solid gray;") + self.original_label.setMinimumSize(400, 400) + original_layout.addWidget(self.original_label) + original_scroll.setWidget(original_container) + original_scroll.setWidgetResizable(True) + self.image_splitter.addWidget(original_scroll) + + # Processed image + processed_scroll = QScrollArea() + processed_container = QWidget() + processed_layout = QVBoxLayout(processed_container) + processed_layout.addWidget(QLabel("Processed Image")) + self.processed_label = QLabel() + self.processed_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.processed_label.setStyleSheet("border: 2px solid green;") + self.processed_label.setMinimumSize(400, 400) + processed_layout.addWidget(self.processed_label) + processed_scroll.setWidget(processed_container) + processed_scroll.setWidgetResizable(True) + self.image_splitter.addWidget(processed_scroll) + + display_layout.addWidget(self.image_splitter) + + # Add to main layout + main_layout.addWidget(controls_widget) + main_layout.addWidget(display_widget, stretch=1) + + # Connect mode changes + self.mode_group.buttonClicked.connect(self.on_mode_changed) + + def update_color_button_style(self, button: QPushButton, color: QColor) -> None: + """Update button style to show selected color.""" + button.setStyleSheet(f""" + QPushButton {{ + background-color: rgb({color.red()}, {color.green()}, {color.blue()}); + color: {'white' if color.lightness() < 128 else 'black'}; + border: 2px solid gray; + padding: 5px; + }} + """) + + def on_mode_changed(self) -> None: + """Handle mode radio button changes.""" + mode_id = self.mode_group.checkedId() + # Enable replacement options for replace modes + self.replacement_group.setEnabled(mode_id in [1, 3]) + + # Show/hide appropriate tabs + if mode_id == 1: # Replace background + self.bg_replacement_tabs.setVisible(True) + self.fg_replacement_tabs.setVisible(False) + elif mode_id == 3: # Replace foreground + self.bg_replacement_tabs.setVisible(False) + self.fg_replacement_tabs.setVisible(True) + + def load_image(self) -> None: + """Load an image file.""" + file_path, _ = QFileDialog.getOpenFileName( + self, "Open Image", "", "Image Files (*.png *.jpg *.jpeg *.bmp *.tiff)" + ) + + if file_path: + self.original_image = cv2.imread(file_path) + if self.original_image is None: + QMessageBox.critical(self, "Error", "Could not load image!") + return + + # Display original + self.display_image(self.original_image, self.original_label) + + # Enable processing + self.process_btn.setEnabled(True) + self.processed_label.clear() + self.processed_image = None + + def load_bg_image(self) -> None: + """Load a background replacement image.""" + file_path, _ = QFileDialog.getOpenFileName( + self, "Open Background Image", "", "Image Files (*.png *.jpg *.jpeg *.bmp *.tiff)" + ) + + if file_path: + self.bg_replacement_image = cv2.imread(file_path) + if self.bg_replacement_image is None: + QMessageBox.critical(self, "Error", "Could not load background image!") + return + + # Show preview + self.bg_image_label.setText(Path(file_path).name) + pixmap = self.cv_to_pixmap(self.bg_replacement_image) + if not pixmap.isNull(): + scaled_pixmap = pixmap.scaled(200, 150, Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) + self.bg_image_label.setPixmap(scaled_pixmap) + + def load_fg_image(self) -> None: + """Load a foreground replacement image.""" + file_path, _ = QFileDialog.getOpenFileName( + self, "Open Foreground Image", "", "Image Files (*.png *.jpg *.jpeg *.bmp *.tiff)" + ) + + if file_path: + self.fg_replacement_image = cv2.imread(file_path) + if self.fg_replacement_image is None: + QMessageBox.critical(self, "Error", "Could not load foreground image!") + return + + # Show preview + self.fg_image_label.setText(Path(file_path).name) + pixmap = self.cv_to_pixmap(self.fg_replacement_image) + if not pixmap.isNull(): + scaled_pixmap = pixmap.scaled(200, 150, Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) + self.fg_image_label.setPixmap(scaled_pixmap) + + def choose_bg_color(self) -> None: + """Open color dialog for background color.""" + color = QColorDialog.getColor(self.bg_color, self, "Choose Background Color") + if color.isValid(): + self.bg_color = color + self.update_color_button_style(self.bg_color_btn, self.bg_color) + + def choose_fg_color(self) -> None: + """Open color dialog for foreground color.""" + color = QColorDialog.getColor(self.fg_color, self, "Choose Foreground Color") + if color.isValid(): + self.fg_color = color + self.update_color_button_style(self.fg_color_btn, self.fg_color) + + def choose_gradient_color1(self) -> None: + """Choose first gradient color for background.""" + color = QColorDialog.getColor(self.gradient_color1, self, "Choose Gradient Start Color") + if color.isValid(): + self.gradient_color1 = color + self.update_color_button_style(self.gradient_color1_btn, self.gradient_color1) + + def choose_gradient_color2(self) -> None: + """Choose second gradient color for background.""" + color = QColorDialog.getColor(self.gradient_color2, self, "Choose Gradient End Color") + if color.isValid(): + self.gradient_color2 = color + self.update_color_button_style(self.gradient_color2_btn, self.gradient_color2) + + def choose_fg_gradient_color1(self) -> None: + """Choose first gradient color for foreground.""" + color = QColorDialog.getColor(self.fg_gradient_color1, self, "Choose FG Gradient Start Color") + if color.isValid(): + self.fg_gradient_color1 = color + self.update_color_button_style(self.fg_gradient_color1_btn, self.fg_gradient_color1) + + def choose_fg_gradient_color2(self) -> None: + """Choose second gradient color for foreground.""" + color = QColorDialog.getColor(self.fg_gradient_color2, self, "Choose FG Gradient End Color") + if color.isValid(): + self.fg_gradient_color2 = color + self.update_color_button_style(self.fg_gradient_color2_btn, self.fg_gradient_color2) + + def process_image(self) -> None: + """Process the image based on selected mode.""" + if self.original_image is None or self.processing: + return + + self.processing = True + self.process_btn.setEnabled(False) + self.process_btn.setText("Processing...") + + # Get mode + mode_id = self.mode_group.checkedId() + operations = ["remove_bg", "replace_bg", "remove_fg", "replace_fg"] + operation = operations[mode_id] + + # Get parameters + params: Dict[str, Any] = { + 'threshold': self.threshold_slider.value(), + 'blur': self.blur_slider.value(), + 'morph_size': self.morph_spin.value() + } + + # Add replacement parameters + if mode_id == 1: # Replace background + current_tab = self.bg_replacement_tabs.currentIndex() + if current_tab == 0: # Color + params['replacement_type'] = 'color' + params['bg_color'] = (self.bg_color.blue(), self.bg_color.green(), self.bg_color.red()) + elif current_tab == 1: # Image + params['replacement_type'] = 'image' + params['bg_image'] = self.bg_replacement_image + elif current_tab == 2: # Gradient + params['replacement_type'] = 'gradient' + params['gradient_color1'] = (self.gradient_color1.blue(), + self.gradient_color1.green(), + self.gradient_color1.red()) + params['gradient_color2'] = (self.gradient_color2.blue(), + self.gradient_color2.green(), + self.gradient_color2.red()) + params['gradient_direction'] = self.gradient_direction.currentText().lower() + + elif mode_id == 3: # Replace foreground + current_tab = self.fg_replacement_tabs.currentIndex() + if current_tab == 0: # Color + params['replacement_type'] = 'color' + params['fg_color'] = (self.fg_color.blue(), self.fg_color.green(), self.fg_color.red()) + elif current_tab == 1: # Image + params['replacement_type'] = 'image' + params['fg_image'] = self.fg_replacement_image + elif current_tab == 2: # Gradient + params['replacement_type'] = 'gradient' + params['gradient_color1'] = (self.fg_gradient_color1.blue(), + self.fg_gradient_color1.green(), + self.fg_gradient_color1.red()) + params['gradient_color2'] = (self.fg_gradient_color2.blue(), + self.fg_gradient_color2.green(), + self.fg_gradient_color2.red()) + params['gradient_direction'] = self.fg_gradient_direction.currentText().lower() + + # Start processing thread + self.worker = ImageProcessor(self.original_image, operation, params) + self.worker.finished.connect(self.on_processing_finished) + self.worker.error.connect(self.on_processing_error) + self.worker.start() + + def on_processing_finished(self, result: MatLike) -> None: + """Handle processing completion.""" + self.processed_image = result + self.display_image(result, self.processed_label) + + self.processing = False + self.process_btn.setEnabled(True) + self.process_btn.setText("Process Image") + self.save_btn.setEnabled(True) + + def on_processing_error(self, error_msg: str) -> None: + """Handle processing error.""" + QMessageBox.critical(self, "Processing Error", f"Error: {error_msg}") + + self.processing = False + self.process_btn.setEnabled(True) + self.process_btn.setText("Process Image") + + def display_image(self, image: Optional[MatLike], label: QLabel) -> None: + """Display OpenCV image in QLabel.""" + if image is None: + return + + pixmap = self.cv_to_pixmap(image) + if not pixmap.isNull(): + scaled_pixmap = pixmap.scaled(label.size(), Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) + label.setPixmap(scaled_pixmap) + + def cv_to_pixmap(self, cv_image: Optional[MatLike]) -> QPixmap: + """Convert OpenCV image to QPixmap.""" + if cv_image is None: + return QPixmap() + + # Handle different channel counts + if len(cv_image.shape) == 2: # Grayscale + height, width = cv_image.shape + bytes_per_line = width + q_image = QImage(cv_image.data, width, height, bytes_per_line, + QImage.Format.Format_Grayscale8) + elif cv_image.shape[2] == 3: # BGR + height, width, channel = cv_image.shape + bytes_per_line = 3 * width + rgb_image = cv2.cvtColor(cv_image, cv2.COLOR_BGR2RGB) + q_image = QImage(rgb_image.data, width, height, bytes_per_line, + QImage.Format.Format_RGB888) + elif cv_image.shape[2] == 4: # BGRA + height, width, channel = cv_image.shape + bytes_per_line = 4 * width + rgba_image = cv2.cvtColor(cv_image, cv2.COLOR_BGRA2RGBA) + q_image = QImage(rgba_image.data, width, height, bytes_per_line, + QImage.Format.Format_RGBA8888) + else: + return QPixmap() + + return QPixmap.fromImage(q_image) + + def save_image(self) -> None: + """Save the processed image.""" + if self.processed_image is None: + return + + file_path, _ = QFileDialog.getSaveFileName( + self, "Save Image", "processed_image.png", + "PNG Files (*.png);;JPEG Files (*.jpg);;All Files (*)" + ) + + if file_path: + success = cv2.imwrite(file_path, self.processed_image) + if success: + QMessageBox.information(self, "Success", f"Image saved to:\n{file_path}") + else: + QMessageBox.critical(self, "Error", "Could not save image!") + + +def main() -> None: + app = QApplication(sys.argv) + + # Set modern style + app.setStyle('Fusion') + + # Dark palette + palette = QPalette() + palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(255, 255, 255)) + palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25)) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53)) + palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(255, 255, 255)) + palette.setColor(QPalette.ColorRole.ToolTipText, QColor(255, 255, 255)) + palette.setColor(QPalette.ColorRole.Text, QColor(255, 255, 255)) + palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(255, 255, 255)) + palette.setColor(QPalette.ColorRole.BrightText, QColor(255, 0, 0)) + palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218)) + palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218)) + palette.setColor(QPalette.ColorRole.HighlightedText, QColor(0, 0, 0)) + app.setPalette(palette) + + window = BackgroundRemoverGUI() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/Remove Background/requirements.txt b/Remove Background/requirements.txt new file mode 100644 index 00000000..83fb3395 --- /dev/null +++ b/Remove Background/requirements.txt @@ -0,0 +1,3 @@ +PySide6>=6.0.0 +opencv-python>=4.5.0 +numpy>=1.20.0 diff --git a/SMB Scan and Transfer Tool/README.md b/SMB Scan and Transfer Tool/README.md new file mode 100644 index 00000000..7cb429e2 --- /dev/null +++ b/SMB Scan and Transfer Tool/README.md @@ -0,0 +1,77 @@ +# SMB Scan and Transfer Tool + +A Qt6-based GUI application for discovering, browsing, and transferring files over SMB (Samba) network shares on macOS. Features Zeroconf/Bonjour discovery, two-pane file explorer, and resumable transfers with optional MD5 verification. + +## Features + +- **Zeroconf Discovery**: Automatically discovers SMB services on the local network via Bonjour (`_smb._tcp.local`) +- **Host & Share Listing**: Lists available shares using macOS `smbutil view` (anonymous and authenticated) +- **Mount/Unmount Shares**: Mount SMB shares via `mount_smbfs` to `/Volumes/` +- **Two-Pane File Explorer**: Browse local and remote files side-by-side with `QFileSystemModel` +- **File Operations**: Copy, delete, rename, and create new folders between local and remote panes +- **Multi-Select**: Checkboxes on both panes for batch operations +- **Resumable Transfers**: Size-aware and partial-file-aware recursive copy with resume support +- **MD5 Verification**: Optional MD5 hash verification toggle for transferred files +- **Threaded Transfers**: Per-file and overall progress bars with cancellation support +- **Status & Logging**: Detailed log panel and persistent log at `~/Library/Logs/SimpleSMBExplorer.log` +- **Credential Management**: Auth prompt with optional save to macOS Keychain via `keyring` + +## Requirements + +- Python 3.10+ +- macOS 12+ (uses macOS tools: `smbutil`, `mount_smbfs`, `diskutil`) +- PySide6 6.6+ +- zeroconf +- keyring +- psutil (optional) + +## Installation + +Install the required Python packages: + +```bash +pip install -r requirements.txt +``` + +Or install individually: + +```bash +pip install PySide6>=6.6 zeroconf keyring +``` + +Optional: + +```bash +pip install psutil +``` + +## Usage + +Run the application: + +```bash +python smb_transfer_tool.py +``` + +### Workflow + +1. **Discover**: The tool automatically discovers SMB services on your local network +2. **Connect**: Select a discovered host or enter an IP/hostname manually +3. **Authenticate**: Enter credentials if required (optionally save to Keychain) +4. **Browse**: Navigate local and remote files in the two-pane explorer +5. **Transfer**: Select files and use the action buttons to copy between panes +6. **Verify**: Enable MD5 verification for transfer integrity checks + +## Notes + +- This tool is designed for **macOS only** — it relies on macOS-specific command-line tools +- MD5 verification on large files can be slow; disable if not needed +- Resume will append from the destination file size if it is smaller than the source + +## Author + +Randy Northrup + +## License + +MIT License — free to use, modify, and share. diff --git a/SMB Scan and Transfer Tool/requirements.txt b/SMB Scan and Transfer Tool/requirements.txt new file mode 100644 index 00000000..81ae1a5c --- /dev/null +++ b/SMB Scan and Transfer Tool/requirements.txt @@ -0,0 +1,3 @@ +PySide6>=6.6 +zeroconf +keyring diff --git a/SMB Scan and Transfer Tool/smb_transfer_tool.py b/SMB Scan and Transfer Tool/smb_transfer_tool.py new file mode 100644 index 00000000..fe76699f --- /dev/null +++ b/SMB Scan and Transfer Tool/smb_transfer_tool.py @@ -0,0 +1,721 @@ +#!/usr/bin/env python3 +""" +Simple SMB Explorer for macOS (Qt6 + Python) + +Feature set (robust, end-to-end): +- Zeroconf discovery of SMB services (Bonjour: _smb._tcp.local) +- Host & share listing via macOS `smbutil view` (anonymous, then authenticated) +- Mount/unmount SMB shares using `mount_smbfs` → /Volumes/ +- Two-pane file explorer (Local ⟷ Remote) using QFileSystemModel +- **Action buttons in a centered vertical column between the two panes**: + Copy →, ← Copy, Delete Selected, Rename, New Folder +- Checkboxes on both panes + multi-select +- Recursive copy with **resume** (size- and partial-file aware) +- Optional **MD5 verification** (toggle) +- Threaded transfers with per-file and overall progress + cancellable +- Detailed status and log panel; persistent log at ~/Library/Logs/SimpleSMBExplorer.log +- Auth prompt with optional **save credentials to macOS Keychain** (`keyring`) +- Connection status and error handling, including share mount checks +- Failed/incomplete transfer detection + resume option + +Tested with: Python 3.10+, PySide6 6.6+, macOS 12+ + +Install: + pip install PySide6 zeroconf keyring +(Optional): pip install psutil + +NOTE: +- Uses macOS tools: `smbutil`, `mount_smbfs`, `diskutil`. +- MD5 on huge files can be slow; disable if needed. +- Resume will append from destination size if smaller than source. +""" +from __future__ import annotations +import os +import sys +import re +import hashlib +import shutil +import subprocess +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +from PySide6 import QtCore, QtGui, QtWidgets +from PySide6.QtCore import Qt, Signal, Slot, QThread +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QTreeView, QSplitter, QVBoxLayout, QHBoxLayout, + QPushButton, QLabel, QLineEdit, QCheckBox, QProgressBar, QComboBox, QInputDialog, + QMessageBox, QFileDialog, QStatusBar, QTextEdit +) + +try: + import keyring +except Exception: + keyring = None + +try: + from zeroconf import ServiceBrowser, Zeroconf +except Exception: + Zeroconf = None + +try: + import psutil +except Exception: + psutil = None + +LOG_DIR = Path.home() / "Library/Logs" +LOG_DIR.mkdir(parents=True, exist_ok=True) +LOG_FILE = LOG_DIR / "SimpleSMBExplorer.log" +SERVICE_NAME = "SimpleSMBExplorer" + +# --------------------------- Utilities --------------------------- + +def log(msg: str): + ts = time.strftime("%Y-%m-%d %H:%M:%S") + line = f"[{ts}] {msg}\n" + try: + with LOG_FILE.open("a", encoding="utf-8") as f: + f.write(line) + except Exception: + pass + print(line, end="") + + +def run(cmd: List[str], timeout: int = 30) -> Tuple[int, str, str]: + log(f"RUN: {' '.join(cmd)}") + try: + p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + out = p.stdout.strip() + err = p.stderr.strip() + if p.returncode != 0: + log(f"ERR({p.returncode}): {err}") + else: + if out: + log(f"OUT: {out[:200]}{'…' if len(out)>200 else ''}") + return p.returncode, out, err + except Exception as e: + log(f"EXC: {e}") + return 1, "", str(e) + + +def md5sum(path: Path, chunk: int = 2**20, start_offset: int = 0) -> str: + h = hashlib.md5() + with path.open("rb") as f: + if start_offset: + f.seek(start_offset) + while True: + b = f.read(chunk) + if not b: + break + h.update(b) + return h.hexdigest() + + +def human(n: int) -> str: + units = ["B","KB","MB","GB","TB"] + s = float(n) + for u in units: + if s < 1024 or u == units[-1]: + return f"{s:.1f} {u}" + s /= 1024 + +# --------------------------- Auth Dialog --------------------------- + +class AuthDialog(QtWidgets.QDialog): + def __init__(self, host: str, parent=None, preset_user: str = "") -> None: + super().__init__(parent) + self.setWindowTitle(f"Authenticate for {host}") + self.user = QLineEdit(preset_user) + self.domain = QLineEdit() + self.passw = QLineEdit(); self.passw.setEchoMode(QLineEdit.Password) + self.save = QCheckBox("Save credentials to Keychain") + + form = QtWidgets.QFormLayout(self) + form.addRow("Username", self.user) + form.addRow("Domain (optional)", self.domain) + form.addRow("Password", self.passw) + form.addRow("", self.save) + btns = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) + btns.accepted.connect(self.accept) + btns.rejected.connect(self.reject) + form.addRow(btns) + + def values(self) -> Tuple[str, str, str, bool]: + return self.user.text(), self.domain.text(), self.passw.text(), self.save.isChecked() + +# --------------------------- SMB Discovery --------------------------- + +@dataclass +class SMBService: + host: str + address: str + port: int + + +class ZeroconfBrowser(QtCore.QObject): + serviceFound = Signal(object) + serviceRemoved = Signal(object) + + def __init__(self, parent=None): + super().__init__(parent) + self._zc = None + self._browser = None + + def start(self): + if Zeroconf is None: + log("zeroconf not installed; skipping browse") + return + self._zc = Zeroconf() + self._browser = ServiceBrowser(self._zc, "_smb._tcp.local.", handlers=[self._handler]) + + def stop(self): + try: + if self._zc: + self._zc.close() + except Exception: + pass + + def _handler(self, zc, type_, name, state_change): + host = name.split(".")[0] + if "Added" in str(state_change): + self.serviceFound.emit(SMBService(host=host, address=host, port=445)) + elif "Removed" in str(state_change): + self.serviceRemoved.emit(SMBService(host=host, address=host, port=445)) + +# --------------------------- Checkable FS Model --------------------------- + +class CheckableFSModel(QtWidgets.QFileSystemModel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._checked: Dict[str, Qt.CheckState] = {} + self.setOption(QtWidgets.QFileSystemModel.DontUseCustomDirectoryIcons, True) + + def flags(self, index: QtCore.QModelIndex) -> Qt.ItemFlags: + f = super().flags(index) + if index.isValid(): + f |= Qt.ItemIsUserCheckable + return f + + def data(self, index, role=Qt.DisplayRole): + if role == Qt.CheckStateRole and index.column() == 0: + path = self.filePath(index) + return self._checked.get(path, Qt.Unchecked) + return super().data(index, role) + + def setData(self, index, value, role=Qt.EditRole): + if role == Qt.CheckStateRole and index.column() == 0: + path = self.filePath(index) + self._checked[path] = Qt.CheckState(value) + self.dataChanged.emit(index, index, [Qt.CheckStateRole]) + return True + return super().setData(index, value, role) + + def checked_paths(self) -> List[Path]: + return [Path(p) for p, state in self._checked.items() if state == Qt.Checked] + +# --------------------------- Transfer Worker --------------------------- + +class TransferWorker(QtCore.QObject): + progress = Signal(int) # overall percent + itemProgress = Signal(str, int) # path, percent + status = Signal(str) # human-readable status + finished = Signal(bool) # ok flag + totals = Signal(int, int) # total files, total bytes + + def __init__(self, sources: List[Path], dest_dir: Path, verify_md5: bool): + super().__init__() + self.sources = sources + self.dest_dir = dest_dir + self.verify_md5 = verify_md5 + self._stop = False + + @Slot() + def run(self): + try: + plan: List[Tuple[Path, Path]] = [] + total_bytes = 0 + for src in self.sources: + if src.is_dir(): + base = src + for root, _, files in os.walk(src): + root_p = Path(root) + for fn in files: + s = root_p / fn + rel = s.relative_to(base) + d = self.dest_dir / base.name / rel + plan.append((s, d)) + try: + total_bytes += s.stat().st_size + except Exception: + pass + else: + d = self.dest_dir / src.name + plan.append((src, d)) + try: + total_bytes += src.stat().st_size + except Exception: + pass + + self.totals.emit(len(plan), total_bytes) + + copied_bytes = 0 + for idx, (src, dst) in enumerate(plan, 1): + if self._stop: + raise RuntimeError("Transfer cancelled") + self.status.emit(f"Copying {src} → {dst}") + dst.parent.mkdir(parents=True, exist_ok=True) + copied_bytes += self._copy_with_resume(src, dst) + self.itemProgress.emit(str(src), 100) + if self.verify_md5 and src.is_file(): + sm = md5sum(src) + dm = md5sum(dst) + if sm != dm: + log(f"MD5 mismatch: {src} vs {dst}") + raise RuntimeError(f"MD5 mismatch for {src}") + # Update overall progress conservatively + self.progress.emit(int(min(99, (copied_bytes * 100) / max(1, total_bytes)))) + + self.status.emit("Transfer complete") + self.progress.emit(100) + self.finished.emit(True) + except Exception as e: + self.status.emit(f"Error: {e}") + self.finished.emit(False) + + def stop(self): + self._stop = True + + def _copy_with_resume(self, src: Path, dst: Path, chunk: int = 2**20) -> int: + """Resume if dst smaller than src; returns bytes written this invocation.""" + if src.is_dir(): + dst.mkdir(parents=True, exist_ok=True) + return 0 + s = src.stat().st_size + existing = dst.stat().st_size if dst.exists() else 0 + mode = 'r+b' if dst.exists() else 'wb' + written = 0 + with src.open('rb') as fsrc, open(dst, mode) as fdst: + if existing and existing < s: + fsrc.seek(existing) + fdst.seek(existing) + copied = existing + while True: + buf = fsrc.read(chunk) + if not buf: + break + fdst.write(buf) + written += len(buf) + copied += len(buf) + pct = int((copied * 100) / max(1, s)) + self.itemProgress.emit(str(src), pct) + return written + +# --------------------------- Main Window --------------------------- + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Simple SMB Explorer") + self.resize(1280, 820) + + self.statusbar = QStatusBar(); self.setStatusBar(self.statusbar) + + central = QWidget(); self.setCentralWidget(central) + root_v = QVBoxLayout(central) + + # Top controls: host/share + discovery/mount + top_h = QHBoxLayout() + self.host_combo = QComboBox(); self.host_combo.setEditable(True) + self.share_combo = QComboBox(); self.share_combo.setEditable(True) + self.scan_btn = QPushButton("Scan Network") + self.view_shares_btn = QPushButton("List Shares") + self.mount_btn = QPushButton("Mount") + self.unmount_btn = QPushButton("Unmount") + top_h.addWidget(QLabel("Host:")); top_h.addWidget(self.host_combo, 2) + top_h.addWidget(QLabel("Share:")); top_h.addWidget(self.share_combo, 2) + top_h.addWidget(self.scan_btn) + top_h.addWidget(self.view_shares_btn) + top_h.addWidget(self.mount_btn) + top_h.addWidget(self.unmount_btn) + root_v.addLayout(top_h) + + # Progress + options + opts_h = QHBoxLayout() + self.progress = QProgressBar(); self.progress.setValue(0) + self.item_label = QLabel("") + self.verify_md5_cb = QCheckBox("MD5 verify") + self.verify_md5_cb.setChecked(True) + self.cancel_btn = QPushButton("Cancel Transfer") + self.cancel_btn.setEnabled(False) + opts_h.addWidget(QLabel("Progress:")); opts_h.addWidget(self.progress, 4) + opts_h.addWidget(self.item_label, 2) + opts_h.addStretch(1) + opts_h.addWidget(self.verify_md5_cb) + opts_h.addWidget(self.cancel_btn) + root_v.addLayout(opts_h) + + # Splitter: Local | Actions | Remote + splitter = QSplitter(); splitter.setChildrenCollapsible(False) + root_v.addWidget(splitter, 1) + + # Local panel + self.local_model = CheckableFSModel() + self.local_root = str(Path.home()) + self.local_model.setRootPath(self.local_root) + self.local_view = QTreeView(); self.local_view.setModel(self.local_model) + self.local_view.setRootIndex(self.local_model.index(self.local_root)) + self.local_view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.local_view.setAlternatingRowColors(True) + splitter.addWidget(self.local_view) + + # Middle actions panel + mid = QWidget(); mid_v = QVBoxLayout(mid); mid_v.setAlignment(Qt.AlignTop | Qt.AlignHCenter) + mid_v.addWidget(QLabel("Actions")) + self.copy_lr_btn = QPushButton("Copy →") + self.copy_rl_btn = QPushButton("← Copy") + self.delete_btn = QPushButton("Delete Selected") + self.rename_btn = QPushButton("Rename…") + self.new_folder_btn = QPushButton("New Folder…") + for b in [self.copy_lr_btn, self.copy_rl_btn, self.delete_btn, self.rename_btn, self.new_folder_btn]: + b.setMinimumWidth(160) + mid_v.addWidget(b) + mid_v.addStretch(1) + splitter.addWidget(mid) + + # Remote panel (mounted shares live under /Volumes/) + self.remote_model = CheckableFSModel() + self.remote_root = "/Volumes" + self.remote_model.setRootPath(self.remote_root) + self.remote_view = QTreeView(); self.remote_view.setModel(self.remote_model) + self.remote_view.setRootIndex(self.remote_model.index(self.remote_root)) + self.remote_view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.remote_view.setAlternatingRowColors(True) + splitter.addWidget(self.remote_view) + splitter.setSizes([600, 120, 600]) + + # Log area + self.log_text = QTextEdit(); self.log_text.setReadOnly(True) + root_v.addWidget(self.log_text, 0) + self._load_log() + + # Signals + self.scan_btn.clicked.connect(self.scan_network) + self.view_shares_btn.clicked.connect(self.list_shares) + self.mount_btn.clicked.connect(self.mount_share) + self.unmount_btn.clicked.connect(self.unmount_share) + self.copy_lr_btn.clicked.connect(lambda: self.copy_selected(direction="lr")) + self.copy_rl_btn.clicked.connect(lambda: self.copy_selected(direction="rl")) + self.delete_btn.clicked.connect(self.delete_selected) + self.rename_btn.clicked.connect(self.rename_selected) + self.new_folder_btn.clicked.connect(self.create_folder) + self.cancel_btn.clicked.connect(self.cancel_transfer) + + # Zeroconf + self.zc_browser = ZeroconfBrowser() + self.zc_browser.serviceFound.connect(self._on_service_found) + self.zc_browser.serviceRemoved.connect(self._on_service_removed) + + # Transfer state + self.transfer_thread: Optional[QThread] = None + self.transfer_worker: Optional[TransferWorker] = None + + # ----- Log helpers ----- + def _load_log(self): + try: + if LOG_FILE.exists(): + self.log_text.setPlainText(LOG_FILE.read_text()) + except Exception: + pass + + def _append_log(self, text: str): + log(text) + self.log_text.append(text) + + # ----- Discovery & Shares ----- + def scan_network(self): + self.statusbar.showMessage("Scanning for SMB services…") + if Zeroconf is None: + QMessageBox.information(self, "Zeroconf missing", "Install 'zeroconf' (pip install zeroconf) to enable discovery. You can still type a host manually.") + return + self.host_combo.clear() + self.zc_browser.start() + self._append_log("Started Zeroconf browsing for _smb._tcp.local") + + @Slot(object) + def _on_service_found(self, svc: SMBService): + if self.host_combo.findText(svc.host) < 0: + self.host_combo.addItem(svc.host) + self._append_log(f"Found SMB host: {svc.host}") + + @Slot(object) + def _on_service_removed(self, svc: SMBService): + idx = self.host_combo.findText(svc.host) + if idx >= 0: + self.host_combo.removeItem(idx) + self._append_log(f"Removed SMB host: {svc.host}") + + def list_shares(self): + host = self.host_combo.currentText().strip() + if not host: + QMessageBox.warning(self, "Host required", "Enter or pick a host first.") + return + # Anonymous first + rc, out, err = run(["smbutil", "view", f"//{host}"]) + if rc != 0: + # Prompt for creds + user = "" + if keyring: + try: + saved = keyring.get_password(SERVICE_NAME, f"{host}:username") + if saved: + user = saved + except Exception: + pass + dlg = AuthDialog(host, self, preset_user=user) + if dlg.exec() != QtWidgets.QDialog.Accepted: + return + username, domain, password, save = dlg.values() + auth = f"{domain+';' if domain else ''}{username}:{password}" + rc, out, err = run(["smbutil", "view", f"//{auth}@{host}"]) + if rc == 0 and save and keyring: + try: + keyring.set_password(SERVICE_NAME, f"{host}:username", username) + keyring.set_password(SERVICE_NAME, f"{host}:password", password) + except Exception: + pass + if rc == 0: + shares = self._parse_smbutil_view(out) + self.share_combo.clear() + for s in shares: + self.share_combo.addItem(s) + self.statusbar.showMessage(f"Found {len(shares)} share(s) on {host}") + self._append_log(f"Shares on {host}: {shares}") + else: + QMessageBox.critical(self, "Error listing shares", err or out or "Unknown error") + + def _parse_smbutil_view(self, out: str) -> List[str]: + shares: List[str] = [] + for line in out.splitlines(): + m = re.match(r"^\\\\[^\\]+\\([^\s]+)\s+", line.strip()) + if m: + shares.append(m.group(1)) + continue + parts = line.split() + if parts and parts[0] not in ("Share", "-----") and not line.startswith("\\"): + cand = parts[0] + if cand.upper() not in ("IPC$",): + shares.append(cand) + return sorted(list(dict.fromkeys(shares))) + + # ----- Mount/Unmount ----- + def _get_saved_creds(self, host: str) -> Tuple[Optional[str], Optional[str]]: + if not keyring: + return None, None + try: + u = keyring.get_password(SERVICE_NAME, f"{host}:username") + p = keyring.get_password(SERVICE_NAME, f"{host}:password") + return u, p + except Exception: + return None, None + + def _is_mounted(self, mount_point: Path) -> bool: + if psutil: + try: + for p in psutil.disk_partitions(all=False): + if p.mountpoint == str(mount_point): + return True + except Exception: + pass + return mount_point.exists() and any(mount_point.iterdir()) + + def mount_share(self): + host = self.host_combo.currentText().strip() + share = self.share_combo.currentText().strip() + if not host or not share: + QMessageBox.warning(self, "Missing info", "Host and Share are required.") + return + username, password = self._get_saved_creds(host) + if username and password: + auth = f"{username}:{password}@" + else: + dlg = AuthDialog(host, self, preset_user=username or "") + if dlg.exec() != QtWidgets.QDialog.Accepted: + return + u, d, p, save = dlg.values() + userpart = f"{d+';' if d else ''}{u}" + auth = f"{userpart}:{p}@" + if save and keyring: + try: + keyring.set_password(SERVICE_NAME, f"{host}:username", u) + keyring.set_password(SERVICE_NAME, f"{host}:password", p) + except Exception: + pass + mount_point = Path("/Volumes") / share + mount_point.mkdir(parents=True, exist_ok=True) + url = f"//{auth}{host}/{share}" + rc, out, err = run(["mount_smbfs", url, str(mount_point)], timeout=60) + if rc == 0: + self.statusbar.showMessage(f"Mounted at {mount_point}") + self._append_log(f"Mounted {url} at {mount_point}") + self.remote_model.setRootPath(str(mount_point)) + self.remote_view.setRootIndex(self.remote_model.index(str(mount_point))) + else: + QMessageBox.critical(self, "Mount failed", err or out or "Unknown error") + + def unmount_share(self): + idx = self.remote_view.rootIndex() + path = Path(self.remote_model.filePath(idx)) if idx.isValid() else Path(self.remote_root) + if str(path).startswith("/Volumes/") and path != Path(self.remote_root): + rc, out, err = run(["diskutil", "unmount", str(path)]) + if rc == 0: + self.statusbar.showMessage(f"Unmounted {path}") + self._append_log(f"Unmounted {path}") + self.remote_model.setRootPath(self.remote_root) + self.remote_view.setRootIndex(self.remote_model.index(self.remote_root)) + else: + QMessageBox.critical(self, "Unmount failed", err or out or "Unknown error") + else: + QMessageBox.information(self, "Nothing to unmount", "No mounted share is active.") + + # ----- File selection helpers ----- + def _selected_checked(self, view: QTreeView, model: CheckableFSModel) -> List[Path]: + paths = set() + for p in model.checked_paths(): + paths.add(p) + for idx in view.selectionModel().selectedRows(): + paths.add(Path(model.filePath(idx))) + return [p for p in paths if str(p) and Path(str(p)).exists()] + + def _active_root(self, view: QTreeView, model: CheckableFSModel) -> Path: + idx = view.rootIndex() + return Path(model.filePath(idx)) if idx.isValid() else Path("/") + + # ----- File ops ----- + def copy_selected(self, direction: str): + if self.transfer_thread: + QMessageBox.warning(self, "Busy", "A transfer is already in progress.") + return + if direction == "lr": + sources = self._selected_checked(self.local_view, self.local_model) + dest_root = self._active_root(self.remote_view, self.remote_model) + else: + sources = self._selected_checked(self.remote_view, self.remote_model) + dest_root = self._active_root(self.local_view, self.local_model) + if not sources: + QMessageBox.information(self, "Nothing selected", "Select or check files/folders to copy.") + return + if not dest_root.exists(): + QMessageBox.critical(self, "Invalid destination", f"Destination root does not exist: {dest_root}") + return + self._start_transfer(sources, dest_root) + + def _start_transfer(self, sources: List[Path], dest_dir: Path): + worker = TransferWorker(sources, dest_dir, self.verify_md5_cb.isChecked()) + thread = QThread() + worker.moveToThread(thread) + worker.progress.connect(self.progress.setValue) + worker.itemProgress.connect(lambda p, pct: self.item_label.setText(f"{Path(p).name}: {pct}%")) + worker.status.connect(lambda s: (self._append_log(s), self.statusbar.showMessage(s))) + worker.finished.connect(lambda ok: self._on_transfer_finished(ok)) + thread.started.connect(worker.run) + thread.finished.connect(thread.deleteLater) + self.transfer_thread = thread + self.transfer_worker = worker + self.cancel_btn.setEnabled(True) + thread.start() + + def cancel_transfer(self): + if self.transfer_worker: + self.transfer_worker.stop() + self._append_log("Transfer cancellation requested") + + def _on_transfer_finished(self, ok: bool): + self._append_log(f"Transfer {'OK' if ok else 'FAILED'}") + self.statusbar.showMessage(f"Transfer {'OK' if ok else 'FAILED'}") + if self.transfer_thread: + self.transfer_thread.quit() + self.transfer_thread.wait(2000) + self.transfer_thread = None + self.transfer_worker = None + self.progress.setValue(0) + self.item_label.setText("") + self.cancel_btn.setEnabled(False) + + def delete_selected(self): + # Deletes from whichever pane has focus + if self.remote_view.hasFocus(): + paths = self._selected_checked(self.remote_view, self.remote_model) + else: + paths = self._selected_checked(self.local_view, self.local_model) + if not paths: + QMessageBox.information(self, "Nothing selected", "Select or check files/folders to delete.") + return + if QMessageBox.question(self, "Confirm delete", f"Delete {len(paths)} item(s)?") != QMessageBox.Yes: + return + failed = [] + for p in paths: + try: + if p.is_dir(): + shutil.rmtree(p) + else: + p.unlink(missing_ok=True) + self._append_log(f"Deleted {p}") + except Exception as e: + failed.append((p, str(e))) + if failed: + self._append_log("Delete failures:" + "; ".join([f"{p}: {e}" for p, e in failed])) + QMessageBox.warning(self, "Delete issues", f"{len(failed)} item(s) could not be deleted. See log.") + self.statusbar.showMessage("Delete complete") + + def rename_selected(self): + # Renames the first selected item in the focused pane + if self.remote_view.hasFocus(): + view, model = self.remote_view, self.remote_model + else: + view, model = self.local_view, self.local_model + indexes = view.selectionModel().selectedRows() + if not indexes: + QMessageBox.information(self, "Nothing selected", "Select a file or folder to rename.") + return + idx = indexes[0] + path = Path(model.filePath(idx)) + new_name, ok = QInputDialog.getText(self, "Rename", "New name:", text=path.name) + if not ok or not new_name: + return + try: + path.rename(path.with_name(new_name)) + self._append_log(f"Renamed {path} → {path.with_name(new_name)}") + except Exception as e: + QMessageBox.critical(self, "Rename failed", str(e)) + + def create_folder(self): + # Creates a folder in the currently focused pane's root + if self.remote_view.hasFocus(): + root = self._active_root(self.remote_view, self.remote_model) + else: + root = self._active_root(self.local_view, self.local_model) + name, ok = QInputDialog.getText(self, "New Folder", "Folder name:") + if not ok or not name: + return + try: + p = root / name + p.mkdir(parents=True, exist_ok=False) + self._append_log(f"Created folder {p}") + except Exception as e: + QMessageBox.critical(self, "Create failed", str(e)) + +# --------------------------- Entrypoint --------------------------- + +def main(): + if sys.platform != "darwin": + print("This app targets macOS. Mounting requires macOS utilities.") + app = QApplication(sys.argv) + # Nice default font size for readability + f = app.font(); f.setPointSize(f.pointSize()+1); app.setFont(f) + w = MainWindow(); w.show() + sys.exit(app.exec()) + +if __name__ == "__main__": + main() diff --git a/Snake Game/README.md b/Snake Game/README.md index 51592849..27f0a469 100644 --- a/Snake Game/README.md +++ b/Snake Game/README.md @@ -1,3 +1,32 @@ -# Snake-Game +# Snake Game + +A modern, feature-rich implementation of the classic Snake game using Python Turtle graphics. + +## Features +- Start screen with title and start button +- Countdown before gameplay begins +- Pause, resume, quit, and new game controls: + - SPACE to pause/resume + - P for new game (with countdown) + - Q to quit +- Lives system (3 lives per game) +- Score and high score tracking (persistent) +- Apple-shaped food, grid-aligned +- Responsive controls (no movement during countdown) +- Game area with clear instructions at the bottom +- Seamless transitions between game states (start, pause, game over) + +## How to Play +- Use arrow keys to control the snake +- Eat apples to grow and increase your score +- Avoid walls and your own tail +- Lose a life on collision; game ends after 3 lives +- Follow on-screen instructions for controls + +## Requirements +- Python 3.x +- See `requirements.txt` for dependencies + +--- ![snake game demo](https://github.com/user-attachments/assets/a88cd856-a477-4f02-ac50-eb09a801cd8a) diff --git a/Snake Game/data.txt b/Snake Game/data.txt index 25bf17fc..c2270834 100644 --- a/Snake Game/data.txt +++ b/Snake Game/data.txt @@ -1 +1 @@ -18 \ No newline at end of file +0 \ No newline at end of file diff --git a/Snake Game/food.py b/Snake Game/food.py index b014c00a..c8f66610 100644 --- a/Snake Game/food.py +++ b/Snake Game/food.py @@ -1,18 +1,27 @@ from turtle import Turtle import random +GRID_SIZE = 20 +GRID_MIN = -280 +GRID_MAX = 280 + class Food(Turtle): #It refers to dot as food def __init__(self): super().__init__() + # Use 'circle' shape for apple self.shape("circle") self.penup() - self.shapesize(stretch_len= 0.5, stretch_wid= 0.5) - self.color("blue") + # Make food exactly 20x20 (same as snake segment) + self.shapesize(stretch_len=1, stretch_wid=1) + self.color("red") # Apple color self.speed("fastest") self.refresh() def refresh(self): - random_x = random.randint(-280, 280) - random_y = random.randint(-280, 280) + # Only place food on grid positions + possible_x = [x for x in range(GRID_MIN, GRID_MAX + 1, GRID_SIZE)] + possible_y = [y for y in range(GRID_MIN, GRID_MAX + 1, GRID_SIZE)] + random_x = random.choice(possible_x) + random_y = random.choice(possible_y) self.goto(random_x, random_y) diff --git a/Snake Game/main.py b/Snake Game/main.py index 5ad9c3cc..0506c428 100644 --- a/Snake Game/main.py +++ b/Snake Game/main.py @@ -1,46 +1,279 @@ -from turtle import Screen - +from turtle import Screen, Turtle +from food import Food from snake import Snake +from scoreboard import Scoreboard +import _tkinter +import time + +screen = Screen() + +from turtle import Screen, Turtle from food import Food +from snake import Snake from scoreboard import Scoreboard +import _tkinter import time screen = Screen() -screen.setup(width= 600, height= 600) +screen.setup(width=800, height=800) screen.bgcolor("black") screen.title("Snake Game") screen.tracer(0) -snake = Snake() -food = Food() -scoreboard = Scoreboard() +# Block movement until countdown ends +allow_movement = False -screen.listen() -screen.onkey(snake.up, "Up") -screen.onkey(snake.down,"Down") -screen.onkey(snake.left,"Left") -screen.onkey(snake.right,"Right") +# --- Turtles for UI --- +start_turtle = Turtle() +start_turtle.hideturtle() +start_turtle.penup() +start_turtle.color("white") + +button_turtle = Turtle() +button_turtle.hideturtle() +button_turtle.penup() +button_turtle.color("white") +button_turtle.shape("square") +button_turtle.shapesize(stretch_wid=2, stretch_len=8) + +pause_turtle = Turtle() +pause_turtle.hideturtle() +pause_turtle.penup() +pause_turtle.color("yellow") + +countdown_turtle = Turtle() +countdown_turtle.hideturtle() +countdown_turtle.penup() +countdown_turtle.color("yellow") +countdown_turtle.goto(0, 0) + +bottom_text_turtle = Turtle() +bottom_text_turtle.hideturtle() +bottom_text_turtle.penup() +bottom_text_turtle.color("white") +# --- Game State --- +game_started = False +is_paused = False is_game_on = True -while is_game_on: - screen.update() - time.sleep(0.15) - snake.move() - #detect collision with the food - if snake.head.distance(food) < 15: - food.refresh() - snake.extend() - scoreboard.increase_score() +is_game_over = False + +GAME_SPEED = 80 +ANIMATION_SPEED = 80 +GRID_SIZE = 20 +PLAY_AREA_LIMIT = 360 +next_direction = None + +# --- UI Functions --- +def show_start_screen(): + clear_all_text() + global game_started, is_paused, is_game_on, is_game_over + game_started = False + is_paused = False + is_game_on = True + is_game_over = False + start_turtle.goto(0, 100) + start_turtle.write("SNAKE GAME", align="center", font=("Courier", 48, "bold")) + button_turtle.goto(0, -50) + button_turtle.showturtle() + button_turtle.write("PRESS SPACE TO START", align="center", font=("Courier", 32, "bold")) + show_bottom_text("SPACE to pause | P for new game | Q to quit") + +def show_pause_explanation(): + pause_turtle.clear() + pause_turtle.goto(0, 100) + pause_turtle.write("PAUSED", align="center", font=("Courier", 48, "bold")) + pause_turtle.goto(0, 0) + pause_turtle.write("SPACE: Resume\nQ: Quit\nP: New Game", align="center", font=("Courier", 24, "normal")) - #detect collision with the wall - if snake.head.xcor() > 280 or snake.head.xcor() < -280 or snake.head.ycor() > 280 or snake.head.ycor() < -280: - scoreboard.reset() +def show_gameover_explanation(): + pause_turtle.clear() + pause_turtle.goto(0, 0) + pause_turtle.write("GAME OVER\nPress SPACE to return to title", align="center", font=("Courier", 32, "bold")) + show_bottom_text("SPACE to return to title") + +def show_bottom_text(text): + bottom_text_turtle.clear() + bottom_text_turtle.goto(0, -380) + bottom_text_turtle.write(text, align="center", font=("Courier", 18, "normal")) + +def clear_all_text(): + start_turtle.clear() + button_turtle.clear() + pause_turtle.clear() + countdown_turtle.clear() + bottom_text_turtle.clear() +# --- Pause/Resume/Quit/New Game --- +def handle_space(): + global game_started, is_paused, is_game_over + if is_game_over: + is_game_over = False + clear_all_text() + show_start_screen() + return + if not game_started: + clear_all_text() + start_game() + elif not is_paused: + is_paused = True + show_pause_explanation() + else: + is_paused = False + pause_turtle.clear() + show_bottom_text("SPACE to pause | P for new game | Q to quit") + +def handle_q(): + global is_paused + if is_paused: + screen.bye() + +def handle_p(): + global is_game_on, game_started, is_paused, next_direction, scoreboard, snake, food, GAME_SPEED, ANIMATION_SPEED, allow_movement + if is_paused: + is_game_on = True + game_started = True + is_paused = False + next_direction = None + pause_turtle.clear() + scoreboard.clear() snake.reset() + food.refresh() + scoreboard.lives = 3 + scoreboard.score = 0 + scoreboard.update_score() + GAME_SPEED = 80 + ANIMATION_SPEED = 80 + allow_movement = False + show_bottom_text("SPACE to pause | P for new game | Q to quit") + show_countdown() + +screen.onkey(handle_space, "space") +screen.onkey(handle_q, "q") +screen.onkey(handle_p, "p") + +# --- Start logic --- +def start_game(): + global game_started + if game_started: + return + game_started = True + clear_all_text() + button_turtle.hideturtle() # Hide the button after starting + show_countdown() + +# --- Countdown --- +def show_countdown(): + global allow_movement + allow_movement = False + for i in range(3, 0, -1): + countdown_turtle.clear() + countdown_turtle.write(str(i), align="center", font=("Courier", 64, "bold")) + screen.update() + time.sleep(1) + countdown_turtle.clear() + screen.update() + allow_movement = True + start_snake_game() + +# --- Game Setup --- +food = Food() +snake = Snake() +scoreboard = Scoreboard(lives=3) + +screen.listen() + +def set_up(): + global next_direction + if allow_movement: + next_direction = 'up' +def set_down(): + global next_direction + if allow_movement: + next_direction = 'down' +def set_left(): + global next_direction + if allow_movement: + next_direction = 'left' +def set_right(): + global next_direction + if allow_movement: + next_direction = 'right' + +screen.onkey(set_up, "Up") +screen.onkey(set_down, "Down") +screen.onkey(set_left, "Left") +screen.onkey(set_right, "Right") + +def move_and_update(): + try: + food.showturtle() + for segment in snake.segments: + segment.showturtle() + screen.update() + except _tkinter.TclError: + print("Game closed.") + global is_game_on + is_game_on = False + +def apply_next_direction(): + global next_direction + if next_direction == 'up': + snake.up() + elif next_direction == 'down': + snake.down() + elif next_direction == 'left': + snake.left() + elif next_direction == 'right': + snake.right() + next_direction = None + +def game_loop(): + global is_game_on, is_game_over + try: + if is_game_on and game_started and not is_paused and not is_game_over: + move_and_update() + apply_next_direction() + snake.move() + # detect collision with the food (exact grid match) + if (round(snake.head.xcor()) == round(food.xcor()) and round(snake.head.ycor()) == round(food.ycor())): + food.refresh() + snake.extend() + scoreboard.increase_score() + # detect collision with the wall + if ( + snake.head.xcor() > PLAY_AREA_LIMIT or snake.head.xcor() < -PLAY_AREA_LIMIT or + snake.head.ycor() > PLAY_AREA_LIMIT or snake.head.ycor() < -PLAY_AREA_LIMIT + ): + scoreboard.lose_life() + if scoreboard.lives == 0: + is_game_on = False + is_game_over = True + clear_all_text() + show_gameover_explanation() + else: + snake.reset() + # detect collision with the tail + for segment in snake.segments[1:]: + if snake.head.distance(segment) < 10: + scoreboard.lose_life() + if scoreboard.lives == 0: + is_game_on = False + is_game_over = True + clear_all_text() + show_gameover_explanation() + else: + snake.reset() + break + screen.ontimer(game_loop, GAME_SPEED) + except _tkinter.TclError: + print("Game closed.") + is_game_on = False - #detect collision with the tail - for segment in snake.segments[1:]: - if snake.head.distance(segment) < 10: - scoreboard.reset() - snake.reset() +def start_snake_game(): + show_bottom_text("SPACE to pause | P for new game | Q to quit") + screen.ontimer(game_loop, GAME_SPEED) + screen.exitonclick() -screen.exitonclick() \ No newline at end of file +if __name__ == "__main__": + show_start_screen() + screen.mainloop() \ No newline at end of file diff --git a/Snake Game/scoreboard.py b/Snake Game/scoreboard.py index 074fe43f..855576aa 100644 --- a/Snake Game/scoreboard.py +++ b/Snake Game/scoreboard.py @@ -1,21 +1,28 @@ from turtle import Turtle ALIGNMENT = "center" FONT = ("Courier", 25, "normal") + class Scoreboard(Turtle): - def __init__(self): + def __init__(self, lives=3): super().__init__() self.score = 0 - with open("data.txt") as data: - self.highscore = int(data.read()) + self.lives = lives + try: + with open("data.txt") as data: + self.highscore = int(data.read()) + except FileNotFoundError: + with open("data.txt", "w") as data: + data.write("0") + self.highscore = 0 self.color("white") self.penup() - self.goto(0, 260) + self.goto(0, 370) # Move score/lives display to top for separation self.hideturtle() self.update_score() def update_score(self): self.clear() - self.write(f"Score: {self.score} HighScore: {self.highscore}", align= ALIGNMENT, font= FONT) + self.write(f"Score: {self.score} HighScore: {self.highscore} Lives: {self.lives}", align=ALIGNMENT, font=FONT) def reset(self): if self.score > self.highscore: @@ -29,3 +36,7 @@ def increase_score(self): self.score += 1 self.update_score() + def lose_life(self): + self.lives -= 1 + self.update_score() + diff --git a/Website Cloner/index.html b/Website Cloner/index.html index 357d9e74..dfef7da0 100644 --- a/Website Cloner/index.html +++ b/Website Cloner/index.html @@ -1,32 +1,73 @@ - PROXY SERVER + Website Cloner & Dockerizer +
-
+
- +
+
+ + +
+
+ + +
+
+
+
+
+ diff --git a/Website Cloner/server.py b/Website Cloner/server.py index 446f8bc6..9ef16169 100644 --- a/Website Cloner/server.py +++ b/Website Cloner/server.py @@ -2,8 +2,39 @@ import http.server import socketserver import os +import subprocess +import hashlib +from urllib.parse import parse_qs from pywebcopy import save_webpage +LOG_FILE = "cloner.log" +README_TEMPLATE = """ +# Docker Website Container + +This folder contains a Docker image exported as a .tar file, ready to be loaded into Docker Desktop or via CLI. + +## Files +- {docker_tar} +- Dockerfile +- nginx.conf (optional, for advanced config) + +## How to Use +1. Open Docker Desktop, go to Images, and click 'Load'. + - Or use CLI: `docker load -i {docker_tar}` +2. The image will appear with the name: {docker_name} +3. To run the container: + - `docker run -d -p 8080:80 {docker_name}` + - (Change port as needed) +4. The website will be served by Nginx at http://localhost:8080 + +## Advanced +- You can edit `nginx.conf` and rebuild the image if needed. +- The Dockerfile is included for reference or customization. + +--- +MD5 of image: {md5_hash} +""" + class RequestHandler(http.server.SimpleHTTPRequestHandler): def do_GET(self): if self.path == "/": @@ -16,41 +47,102 @@ def do_GET(self): def do_POST(self): content_length = int(self.headers.get('Content-Length', 0)) post_data = self.rfile.read(content_length).decode("utf-8") + form = parse_qs(post_data) + url = form.get("website_url", [""])[0] + docker_name = form.get("docker_name", [""])[0] + save_path = form.get("save_path", [""])[0] + project_folder = os.path.join('./cloned_sites', docker_name) + log_entries = [] - # Parse the URL from the form submission - form = cgi.parse_qs(post_data) - url = form.get("submitButton", [""])[0] + def log(msg): + log_entries.append(msg) + with open(LOG_FILE, "a") as f: + f.write(msg + "\n") - if not url: - self.send_error(400, "Bad Request: URL is missing") + if not url or not docker_name or not save_path: + self.send_error(400, "Bad Request: Missing fields") return - urlN = "https://" + url.strip("/") - project_folder = os.path.join('./cloned_sites', url.replace("https://", "").replace("http://", "")) - + urlN = url if url.startswith("http") else "https://" + url.strip("/") if not os.path.isdir(project_folder): os.makedirs(project_folder, exist_ok=True) try: + log(f"Cloning {urlN} to {project_folder}") save_webpage( url=urlN, project_folder=project_folder, - project_name=url.replace("https://", "").replace("http://", "") + project_name=docker_name ) + log("Cloning complete.") except Exception as e: + log(f"Error cloning website: {e}") self.send_error(500, f"Error cloning website: {e}") return - - path = os.path.join(project_folder, "index.html") - if not os.path.isfile(path): - self.send_error(404, "Cloned page not found") + # Write Dockerfile for Nginx + dockerfile_path = os.path.join(project_folder, "Dockerfile") + with open(dockerfile_path, "w") as f: + f.write(f""" +FROM nginx:alpine +COPY . /usr/share/nginx/html +EXPOSE 80 +CMD [\"nginx\", \"-g\", \"daemon off;\"] +""") + log("Dockerfile created.") + # Optionally write nginx.conf for advanced users + nginx_conf_path = os.path.join(project_folder, "nginx.conf") + with open(nginx_conf_path, "w") as f: + f.write("# Default Nginx config. Edit as needed.\n") + # Build Docker image + build_cmd = ["docker", "build", "-t", docker_name, project_folder] + try: + log(f"Building Docker image: {docker_name}") + result = subprocess.run(build_cmd, capture_output=True, text=True) + log(result.stdout) + log(result.stderr) + if result.returncode != 0: + log(f"Docker build failed: {result.stderr}") + self.send_error(500, f"Docker build failed: {result.stderr}") + return + log("Docker build complete.") + except Exception as e: + log(f"Error building Docker image: {e}") + self.send_error(500, f"Error building Docker image: {e}") return - - self.send_response(301) - self.send_header("Location", "/" + path) + # Get image ID and calculate MD5 + try: + inspect_cmd = ["docker", "images", "--format", "{{.ID}}", docker_name] + image_id = subprocess.check_output(inspect_cmd, text=True).strip() + md5_hash = hashlib.md5(image_id.encode()).hexdigest() + log(f"Docker image ID: {image_id}") + log(f"MD5: {md5_hash}") + # Save image to tar file in chosen location + docker_tar = os.path.join(save_path, f"{docker_name}.tar") + save_cmd = ["docker", "save", "-o", docker_tar, docker_name] + try: + subprocess.run(save_cmd, check=True) + log(f"Image saved to: {docker_tar}") + except Exception as e: + log(f"Error saving image: {e}") + # Write README + readme_path = os.path.join(save_path, f"README_{docker_name}.md") + with open(readme_path, "w") as f: + f.write(README_TEMPLATE.format( + docker_tar=f"{docker_name}.tar", + docker_name=docker_name, + md5_hash=md5_hash + )) + log(f"README created: {readme_path}") + except Exception as e: + log(f"Error getting image ID: {e}") + # Respond with log and MD5 + self.send_response(200) + self.send_header("Content-type", "text/plain") self.end_headers() + self.wfile.write(("\n".join(log_entries)).encode()) PORT = 7000 +os.makedirs("cloned_sites", exist_ok=True) os.chdir(os.path.dirname(os.path.abspath(__file__))) with socketserver.TCPServer(("127.0.0.1", PORT), RequestHandler) as s: diff --git a/WiFi QR Code Generator/README.md b/WiFi QR Code Generator/README.md new file mode 100644 index 00000000..c97d8bc3 --- /dev/null +++ b/WiFi QR Code Generator/README.md @@ -0,0 +1,261 @@ +# WifiQR + +WifiQR is a cross-platform desktop app for generating Wi‑Fi QR codes and exporting Wi‑Fi profiles. Built with PySide6, it provides a modern Qt interface for fast, consistent onboarding across Windows, macOS, and mobile devices. + +![Python](https://img.shields.io/badge/Python-3.10%2B-blue) +![License: MIT](https://img.shields.io/badge/License-MIT-green) + +## Table of contents +- [Highlights](#highlights) +- [Features](#features) +- [Requirements](#requirements) +- [Installation](#installation) +- [Run the app](#run-the-app) +- [Usage](#usage) +- [Exports](#exports) +- [Security modes](#security-modes) +- [Saved Networks Table](#saved-networks-table) +- [Save File Format](#save-file-format) +- [Packaging](#packaging) +- [Development](#development) +- [Project layout](#project-layout) +- [Troubleshooting](#troubleshooting) +- [License](#license) + +## Highlights +- Modern Qt UI with consistent QSS-based styling +- Live QR preview with optional center image embedding +- High error correction QR codes (30% error correction level H) +- Saved networks table with smooth scrolling, search, sorting, and batch export +- Smart column resizing with gap prevention +- Double-click table rows to load networks into the preview +- Windows .cmd export (netsh) and macOS .mobileconfig export +- PNG and PDF export with optional location header +- Portable save format with base64-encoded images +- Full test suite with 100% coverage gate + +## Features +- **Network configuration**: SSID, password, security mode, hidden network, and location fields +- **Center image**: Optional image embedding in the QR code center (automatically resized to 100x100px) +- **Live preview**: Real-time QR code generation with payload preview (matches exports and print output) +- **Print support**: Print dialog with proper scaling and layout +- **Export formats**: + - PNG/PDF with optional location header + - Windows .cmd scripts for automated deployment + - macOS .mobileconfig profiles for one-click setup +- **Table management**: + - In-table editing with combo boxes (Security) and checkboxes (Hidden) + - Double-click rows to load into preview + - Password obfuscation with show/hide toggle + - Centered column headers + - Uniform sizing for Security and Hidden columns (100px each) +- **Search**: Incremental search with next/previous match navigation +- **Menu bar**: Save, Save As, Load, About, Print, and Export options +- **Portable storage**: All data including images stored in a single JSON file + +## Requirements +- Python 3.10+ +- PySide6 6.6+ +- qrcode 7.4+ +- Pillow 10.0+ +- CairoSVG 2.7+ (for SVG center images) + +## Installation +1. Clone the repository and navigate to the project directory. +2. Create and activate a virtual environment: + +```bash +python -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate +``` + +3. Install the app and dependencies: + +```bash +pip install -e . +``` + +## Run the app + +```bash +wifiqr +``` + +Or run directly via module: + +```bash +python -m wifiqr.app +``` + +## Usage + +### Basic Workflow +1. Enter **Location**, **SSID**, **Password**, and select **Security** type and **Hidden** status. +2. (Optional) Click the **...** button next to Center Image to select an image file (PNG, JPG, JPEG, BMP, GIF, SVG). +3. Use the preview panel to verify the QR code in real-time. +4. Click **Add to Table** to save the network to the Saved Networks table. +5. **Double-click** any row in the table to load that network back into the preview. +6. Export individual networks or use batch export. + +### Center Image Feature +- Click the ellipsis (**...**) button to browse for an image +- Supported formats: PNG, JPG, JPEG, BMP, GIF, SVG +- Images are automatically resized to 100x100 pixels +- Images are base64-encoded and stored in the save file for portability +- QR codes use high error correction (Level H - 30%) to ensure scannability with center images +- Center images appear in both preview and all exports (PNG, PDF) +- SVG files are converted to PNG on import for consistent rendering + +### Table Interactions +- **Double-click** any row to load that network into the details form and preview +- **Edit in-place**: Click cells to edit Location, SSID; use dropdowns for Security +- **Toggle Hidden**: Click checkbox directly in the Hidden column +- **Password visibility**: Click the eye icon to view/hide passwords +- **Search**: Use Ctrl+F or Edit → Find to search networks +- **Sort**: Click column headers to sort (Location and SSID only) +- **Delete**: Select rows and press Delete key or right-click → Delete + +### Export Options +- **PNG/PDF** for printed handouts, signage, or digital sharing +- **Windows .cmd** for scripted profile deployment across Windows machines +- **macOS .mobileconfig** for one-click installation on macOS/iOS devices +- **Batch Export**: Select multiple rows or export all networks at once + +## Exports + +### QR (PNG/PDF) +- Exports optionally include a location header above the QR code for easy identification. +- Toggle "Show location header" checkbox to enable/disable header (preview matches exports and print). +- PNG is ideal for digital sharing or signage; PDF is optimized for printing. +- QR codes use error correction Level H (30%) for reliable scanning even with center images. +- Center images (if selected) are embedded at 100x100 pixels in the QR code center. + +### Windows Script (.cmd) +- Uses Windows netsh command to add Wi-Fi profiles and auto-connect. +- For batch export, a single script is generated containing all saved networks. +- Run the script as Administrator for proper execution. +- Compatible with Windows 7 and later. + +### macOS Profile (.mobileconfig) +- Creates a managed Wi‑Fi configuration profile for macOS and iOS. +- For batch export, one profile file is generated containing all networks. +- Install by double-clicking the .mobileconfig file and following system prompts. +- Works on macOS 10.7+ and iOS 5+. + +## Security modes +WifiQR accepts several security labels and normalizes them for each export format: +- **WPA/WPA2/WPA3** (encoded as "WPA" for QR payloads, expanded for system profiles) +- **WEP** (legacy encryption, not recommended) +- **None / Open / No Password** (treated as open network) + +## Saved Networks Table +- **Centered Headers**: All column headers are centered for visual consistency +- **Smart Column Resizing**: Location, SSID, and Password columns resize independently from the left edge +- **Gap Prevention**: Password column automatically expands to prevent blank space +- **Pinned Columns**: Security and Hidden columns (100px each) stay fixed on the right side +- **Smooth Scrolling**: Horizontal scrolling uses 10-pixel increments for smooth navigation +- **Minimum Widths**: All columns enforce minimum width based on header label size +- **Double-Click Loading**: Double-click any row to load that network into the form and preview + +## Save File Format +- Networks are saved as JSON files (.json extension) +- Each network includes: location, SSID, password, security, hidden status, and optional image data +- Images are stored as base64-encoded strings for complete portability +- Single file contains all networks and their images - no external dependencies +- Compatible with Save (Ctrl+S), Save As, and Load (Ctrl+O) operations + +## Packaging +WifiQR includes a GitHub Actions workflow that builds: +- Linux executable and .deb package +- Windows executable +- macOS Intel executable + +### CI workflow +The workflow is defined in [.github/workflows/build-packages.yml](.github/workflows/build-packages.yml). It runs on tags that start with v and on manual dispatch. + +### Local packaging (optional) +You can build a local executable with PyInstaller: + +```bash +pip install pyinstaller +pyinstaller --name wifiqr --onefile --windowed -m wifiqr.app \ + --add-data "src/wifiqr/resources:wifiqr/resources" +``` + +On Windows, use a semicolon separator: + +```bash +pyinstaller --name wifiqr --onefile --windowed -m wifiqr.app \ + --add-data "src/wifiqr/resources;wifiqr/resources" +``` + +## Development + +### Tests +Install test dependencies and run the full test suite: + +```bash +pip install -e .[test] +python -m pytest +``` + +Coverage is enforced at 100%. Tests include: +- UI flow and interaction tests +- QR payload generation and validation +- Export service tests for all formats +- Table widget behavior and editing +- Registry/configuration tests + +### Typing and linting +Strict typing and linting are enforced with mypy and ruff: + +```bash +pip install -e .[dev] +python -m mypy src tests +python -m ruff check . +``` + +Configuration uses Python 3.12 target with strict mypy checks enabled. + +## Project layout +``` +src/wifiqr/ + app.py # Application entry point + constants.py # Application constants + ui/ + main_window.py # Main window UI and logic + services/ + qr_service.py # QR code generation + export_service.py # Export orchestration + wifi_payload.py # QR payload formatting + windows_script.py # Windows .cmd generation + macos_profile.py # macOS .mobileconfig generation + wifi_profiles.py # Profile save/load logic + xml_utils.py # XML utilities for macOS profiles + resources/ + style.qss # Application stylesheet +tests/ + test_app_ui_flow.py # UI interaction tests + test_payload.py # Payload generation tests + test_services.py # Export service tests + test_ui.py # UI component tests + test_registry.py # Configuration tests + test_table_save_batch.py # Table and batch tests + test_preview_updates.py # Preview update tests +``` + +## Troubleshooting +- **Preview disabled**: Verify that SSID field is not empty. +- **Export disabled**: Ensure at least one network is in the Saved Networks table. +- **Windows script fails**: Run the .cmd file as Administrator; verify netsh is available. +- **macOS profile not installing**: Open from System Settings → Profiles; check for valid XML format. +- **QR code not scanning**: Ensure adequate contrast and size when printing; test with multiple QR readers. Center images may reduce scannability if too large or complex. +- **QR code with image not scanning**: Try using a simpler image with high contrast, or remove the center image. Error correction Level H supports up to 30% damage/obscuration. +- **Table columns not resizing**: Check that you're dragging from the left edge of column separators. +- **Center image button not visible**: The ellipsis (...) button is embedded inside the Center Image text field on the right side. +- **Image not showing in preview**: Verify the image file format is supported (PNG, JPG, JPEG, BMP, GIF, SVG) and the file is accessible. +- **Saved file won't load**: Ensure the JSON file is valid and hasn't been manually edited with syntax errors. +- **Double-click doesn't load network**: Make sure you're double-clicking on the row, not just selecting it. + +## License +MIT diff --git a/WiFi QR Code Generator/pyproject.toml b/WiFi QR Code Generator/pyproject.toml new file mode 100644 index 00000000..b34333ad --- /dev/null +++ b/WiFi QR Code Generator/pyproject.toml @@ -0,0 +1,75 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "wifiqr" +version = "1.1" +description = "Cross-platform WiFi QR code generator" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "WifiQR" }] +dependencies = [ + "PySide6>=6.6", + "qrcode>=7.4", + "Pillow>=10.0", + "CairoSVG>=2.7" +] + +[project.optional-dependencies] +test = [ + "pytest>=8.0", + "pytest-qt>=4.2", + "pytest-cov>=5.0", + "opencv-python>=4.5" +] +dev = [ + "mypy>=1.10", + "ruff>=0.6" +] + +[tool.pytest.ini_options] +addopts = "-q --cov=wifiqr --cov-report=term-missing --cov-fail-under=100" + +[tool.mypy] +python_version = "3.12" +strict = true +warn_unused_ignores = true +warn_unreachable = true +no_implicit_reexport = true +show_error_codes = true +mypy_path = "src" +files = ["src", "tests"] + +[[tool.mypy.overrides]] +module = ["PySide6.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["qrcode.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["PIL.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["pytest.*", "pytestqt.*"] +ignore_missing_imports = true + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "N"] + +[project.scripts] +wifiqr = "wifiqr.app:main" + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/WiFi QR Code Generator/requirements.txt b/WiFi QR Code Generator/requirements.txt new file mode 100644 index 00000000..03f92c7b --- /dev/null +++ b/WiFi QR Code Generator/requirements.txt @@ -0,0 +1,4 @@ +PySide6>=6.6 +qrcode>=7.4 +Pillow>=10.0 +CairoSVG>=2.7 diff --git a/WiFi QR Code Generator/src/wifiqr/__init__.py b/WiFi QR Code Generator/src/wifiqr/__init__.py new file mode 100644 index 00000000..87b717d7 --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/__init__.py @@ -0,0 +1,3 @@ +from . import app + +__all__ = ["app"] diff --git a/WiFi QR Code Generator/src/wifiqr/app.py b/WiFi QR Code Generator/src/wifiqr/app.py new file mode 100644 index 00000000..2886ff58 --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/app.py @@ -0,0 +1,23 @@ +"""Application entry point.""" + +import sys + +from PySide6.QtWidgets import QApplication + +from wifiqr.ui.main_window import MainWindow + + +def main() -> int: + """Run the Qt application and return the exit code.""" + app = QApplication(sys.argv) + app.setApplicationName("WifiQR") + app.setOrganizationName("WifiQR") + + window = MainWindow() + window.show() + + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/WiFi QR Code Generator/src/wifiqr/constants.py b/WiFi QR Code Generator/src/wifiqr/constants.py new file mode 100644 index 00000000..aaf3a18b --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/constants.py @@ -0,0 +1,39 @@ +"""Application-wide constants.""" + +from __future__ import annotations + +# QR Code Generation Defaults +DEFAULT_QR_SIZE = 640 +DEFAULT_QR_BOX_SIZE = 10 +DEFAULT_QR_BORDER = 2 +DEFAULT_QR_FILL_COLOR = "#111827" +DEFAULT_QR_BACKGROUND_COLOR = "white" + +# Security Options +SECURITY_OPTIONS = ("WPA/WPA2/WPA3", "WEP", "None") + +# Security Label Normalization +SECURITY_ALIASES = { + "WPA/WPA2/WPA3": "WPA", + "WPA2": "WPA", + "WPA3": "WPA", + "OPEN": "NOPASS", + "NONE": "NOPASS", + "NO PASSWORD": "NOPASS", +} + +# Windows Profile Security Mapping (auth, encryption, key_type) +WINDOWS_SECURITY_MAP = { + "NOPASS": ("open", "none", None), + "WEP": ("open", "WEP", "networkKey"), +} +WINDOWS_SECURITY_DEFAULT = ("WPA2PSK", "AES", "passPhrase") + +# macOS Profile Security Mapping +MACOS_SECURITY_MAP = { + "NOPASS": "None", + "WEP": "WEP", +} +MACOS_SECURITY_DEFAULT = "WPA" +# UI Performance +PREVIEW_RESIZE_THRESHOLD = 10 # Minimum pixel difference to trigger rescale diff --git a/WiFi QR Code Generator/src/wifiqr/resources/check.svg b/WiFi QR Code Generator/src/wifiqr/resources/check.svg new file mode 100644 index 00000000..6837832b --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/resources/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/WiFi QR Code Generator/src/wifiqr/resources/chevron_down.svg b/WiFi QR Code Generator/src/wifiqr/resources/chevron_down.svg new file mode 100644 index 00000000..45ed5f81 --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/resources/chevron_down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/WiFi QR Code Generator/src/wifiqr/resources/chevron_up.svg b/WiFi QR Code Generator/src/wifiqr/resources/chevron_up.svg new file mode 100644 index 00000000..efb6cb65 --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/resources/chevron_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/WiFi QR Code Generator/src/wifiqr/resources/eye.svg b/WiFi QR Code Generator/src/wifiqr/resources/eye.svg new file mode 100644 index 00000000..0ec54af2 --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/resources/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/WiFi QR Code Generator/src/wifiqr/resources/style.qss b/WiFi QR Code Generator/src/wifiqr/resources/style.qss new file mode 100644 index 00000000..840a4f8b --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/resources/style.qss @@ -0,0 +1,388 @@ +QWidget { + font-family: "Inter", "Segoe UI", "Helvetica", "Arial", sans-serif; + font-size: 13px; + color: #0b1220; + background-color: transparent; +} + +QMainWindow { + background-color: #f5f7fb; +} + +QDialog, QFrame, QStackedWidget { + background: #ffffff; + color: #0b1220; +} + +QMenuBar { + background: #f5f7fb; + color: #0b1220; + border-bottom: 1px solid #cbd5e1; +} + +QMenuBar::item { + background: transparent; + padding: 6px 10px; + color: #0b1220; +} + +QMenuBar::item:selected { + background: #e8f0ff; + color: #0b1220; +} + +QMenu { + background: #ffffff; + color: #0b1220; + border: 1px solid #cbd5e1; +} + +QMenu::item { + color: #0b1220; + background: #ffffff; +} + +QMenu::item:selected { + background: #dbeafe; + color: #0b1220; +} + +QGroupBox { + border: 1px solid #cbd5e1; + border-radius: 10px; + margin-top: 12px; + padding: 12px; + background: #ffffff; + color: #0b1220; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top left; + left: 10px; + padding: 0 6px; + color: #1f2937; + background: #ffffff; +} + +QLineEdit, QComboBox { + padding: 8px 10px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #ffffff; + color: #0b1220; +} + +QLineEdit::placeholder { + color: #94a3b8; +} + +QLineEdit:disabled, QComboBox:disabled { + background: #eef2f7; + color: #6b7280; +} + +QCheckBox { + color: #0b1220; + background: transparent; +} + +QCheckBox::indicator { + width: 18px; + height: 18px; + border: 2px solid #cbd5e1; + border-radius: 4px; + background: #ffffff; +} + +QCheckBox::indicator:checked { + background: #3b82f6; + border-color: #3b82f6; + border-image: url(src/wifiqr/resources/check.svg); +} + +QCheckBox::indicator:disabled { + background: #eef2f7; + border-color: #cbd5e1; +} + +QLabel { + color: #0b1220; + background: transparent; +} + +QLineEdit:focus, QComboBox:focus { + border: 1px solid #3b82f6; + background: #ffffff; +} + +QComboBox { + padding-right: 32px; +} + +QComboBox::drop-down { + subcontrol-origin: padding; + subcontrol-position: top right; + width: 26px; + border-left: 1px solid #cbd5e1; + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; + background: #e8f0ff; +} + +QComboBox::down-arrow { + image: none; + width: 0; + height: 0; +} + +QComboBox QAbstractItemView { + background: #ffffff; + color: #0b1220; + border: 1px solid #cbd5e1; + outline: 0; +} + +QComboBox QAbstractItemView::item { + background: #ffffff; + color: #0b1220; + padding: 8px 10px; +} + +QComboBox QAbstractItemView::item:selected { + background: #dbeafe; + color: #0b1220; +} + +QComboBox QAbstractItemView::item:hover { + background: #dbeafe; + color: #0b1220; +} + +QTableWidget QAbstractItemView, +QTableView QAbstractItemView { + background: #ffffff; + color: #0b1220; + selection-background-color: #dbeafe; + selection-color: #0b1220; + outline: 0; + border: 1px solid #cbd5e1; +} + +QTableWidget QAbstractItemView::item, +QTableView QAbstractItemView::item { + color: #0b1220; + background: #ffffff; +} + +QTableWidget QAbstractItemView::item:alternate, +QTableView QAbstractItemView::item:alternate { + background: #f0f4fb; +} + +QTableWidget QAbstractItemView::item:selected, +QTableView QAbstractItemView::item:selected { + background: #dbeafe; + color: #0b1220; +} + +QTableWidget QAbstractItemView::item:hover, +QTableView QAbstractItemView::item:hover { + background: #e8f0ff; + color: #0b1220; +} + +QPushButton { + background: #e8f0ff; + color: #0b1220; + border: 1px solid #3b82f6; + padding: 8px 16px; + border-radius: 8px; +} + +QPushButton:hover { + background: #dbeafe; + color: #0b1220; +} + +QPushButton:pressed { + background: #bfdbfe; + color: #0b1220; +} + +QPushButton:disabled { + background: #eef2f7; + color: #6b7280; + border: 1px solid #cbd5e1; +} + +QToolTip { + background: #ffffff; + color: #0b1220; + border: 1px solid #cbd5e1; + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; + font-family: "Inter", "Segoe UI", "Helvetica", "Arial", sans-serif; +} + +QToolButton#PreviewToggle { + border: none; + background: transparent; + padding: 0; +} + +QToolButton { + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 8px; + color: #0b1220; + padding: 8px; +} + +QToolButton:hover { + background: #e8f0ff; + border: 1px solid #3b82f6; +} + +QToolButton:pressed { + background: #dbeafe; + border: 1px solid #3b82f6; +} + +QToolButton:disabled { + background: #eef2f7; + color: #6b7280; + border: 1px solid #cbd5e1; +} + +QLabel#PreviewLabel { + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 12px; + color: #0b1220; +} + +QTableWidget, QTableView { + background: #ffffff; + color: #0b1220; + selection-background-color: #dbeafe; + selection-color: #0b1220; + border: 1px solid #cbd5e1; + gridline-color: #e2e8f0; +} + +QTableWidget::item, QTableView::item { + padding: 4px; + color: #0b1220; + background: #ffffff; +} + +QTableWidget::item:selected, QTableView::item:selected { + background: #dbeafe; + color: #0b1220; +} + +QTableWidget::item:alternate, QTableView::item:alternate { + background: #f0f4fb; +} + +QTableCornerButton::section { + background: #e8f0ff; + border: 1px solid #cbd5e1; +} + +QHeaderView::section { + background: #e8f0ff; + color: #0b1220; + border: 1px solid #cbd5e1; + padding: 6px; +} + +QHeaderView::section:hover { + background: #dbeafe; +} + +QMessageBox { + background: #ffffff; + color: #0b1220; +} + +QMessageBox QLabel { + color: #0b1220; +} + +QMessageBox QPushButton { + min-width: 80px; +} + +QScrollBar:vertical { + background: #f5f7fb; + border: 1px solid #cbd5e1; + width: 14px; +} + +QScrollBar:horizontal { + background: #f5f7fb; + border: 1px solid #cbd5e1; + height: 14px; +} + +QScrollBar::handle:vertical, QScrollBar::handle:horizontal { + background: #cbd5e1; + border-radius: 6px; + min-height: 20px; +} + +QScrollBar::handle:vertical:hover, QScrollBar::handle:horizontal:hover { + background: #94a3b8; +} + +QScrollBar::add-line, QScrollBar::sub-line { + background: #e8f0ff; + border: 1px solid #cbd5e1; +} + +QScrollBar::add-page, QScrollBar::sub-page { + background: #f5f7fb; +} + +QInputDialog { + background: #ffffff; + color: #0b1220; +} + +QInputDialog QLabel { + color: #0b1220; +} + +QInputDialog QLineEdit { + background: #ffffff; + color: #0b1220; +} + +QFileDialog { + background: #f5f7fb; + color: #0b1220; +} + +QFileDialog QLabel { + color: #0b1220; +} + +QFileDialog QPushButton { + background: #e8f0ff; + color: #0b1220; +} + +QFileDialog QTreeView, QFileDialog QListView { + background: #ffffff; + color: #0b1220; + selection-background-color: #dbeafe; + selection-color: #0b1220; +} + +QTableWidget:disabled, QTableView:disabled { + background: #eef2f7; + color: #6b7280; +} diff --git a/WiFi QR Code Generator/src/wifiqr/resources/toggle_off.svg b/WiFi QR Code Generator/src/wifiqr/resources/toggle_off.svg new file mode 100644 index 00000000..ccebff08 --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/resources/toggle_off.svg @@ -0,0 +1,4 @@ + + + + diff --git a/WiFi QR Code Generator/src/wifiqr/resources/toggle_on.svg b/WiFi QR Code Generator/src/wifiqr/resources/toggle_on.svg new file mode 100644 index 00000000..719979ef --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/resources/toggle_on.svg @@ -0,0 +1,4 @@ + + + + diff --git a/WiFi QR Code Generator/src/wifiqr/services/export_service.py b/WiFi QR Code Generator/src/wifiqr/services/export_service.py new file mode 100644 index 00000000..7e93ff18 --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/services/export_service.py @@ -0,0 +1,21 @@ +"""Qt conversion helpers for PIL images.""" + +from __future__ import annotations + +from PIL import Image +from PySide6.QtGui import QImage, QPixmap + + +def pil_to_qimage(image: Image.Image) -> QImage: + """Convert a PIL image into a Qt QImage.""" + if image.mode != "RGB": + image = image.convert("RGB") + width, height = image.size + data = image.tobytes("raw", "RGB") + qimage = QImage(data, width, height, QImage.Format.Format_RGB888) + return qimage.copy() + + +def pil_to_qpixmap(image: Image.Image) -> QPixmap: + """Convert a PIL image into a Qt QPixmap.""" + return QPixmap.fromImage(pil_to_qimage(image)) diff --git a/WiFi QR Code Generator/src/wifiqr/services/macos_profile.py b/WiFi QR Code Generator/src/wifiqr/services/macos_profile.py new file mode 100644 index 00000000..289a92f4 --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/services/macos_profile.py @@ -0,0 +1,127 @@ +"""macOS configuration profile generation helpers.""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass + +from wifiqr.constants import MACOS_SECURITY_DEFAULT, MACOS_SECURITY_MAP +from wifiqr.services.wifi_payload import WifiConfig, normalize_security +from wifiqr.services.xml_utils import xml_escape + + +@dataclass(frozen=True) +class MacProfileExport: + identifier: str + content: str + + +def _build_wifi_payload( + config: WifiConfig, + payload_identifier: str, + wifi_uuid: str, +) -> str: + """Build a Wi-Fi payload dict for a macOS configuration profile.""" + ssid = xml_escape(config.ssid) + password = xml_escape(config.password) + hidden = "true" if config.hidden else "false" + + security = normalize_security(config.security) + encryption = MACOS_SECURITY_MAP.get(security, MACOS_SECURITY_DEFAULT) + + password_block = "" + if encryption != "None": + password_block = f"Password{password}" + + return ( + "" + "PayloadTypecom.apple.wifi.managed" + "PayloadVersion1" + f"PayloadIdentifier{payload_identifier}" + f"PayloadUUID{wifi_uuid}" + f"PayloadDisplayNameWiFi {ssid}" + f"SSID_STR{ssid}" + f"HIDDEN_NETWORK<{hidden}/>" + f"EncryptionType{encryption}" + f"{password_block}" + "" + ) + + +def build_macos_mobileconfig(config: WifiConfig) -> MacProfileExport: + """Build a single-network macOS configuration profile.""" + profile_uuid = str(uuid.uuid4()) + wifi_uuid = str(uuid.uuid4()) + identifier = f"com.wifiqr.profile.{profile_uuid}" + + payload = _build_wifi_payload( + config, + payload_identifier=f"{identifier}.wifi", + wifi_uuid=wifi_uuid, + ) + + content = ( + "" + "" + "" + "" + "PayloadContent" + "" + f"{payload}" + "" + "PayloadTypeConfiguration" + "PayloadVersion1" + f"PayloadIdentifier{identifier}" + f"PayloadUUID{profile_uuid}" + f"PayloadDisplayNameWifiQR Wi-Fi" + "PayloadOrganizationWifiQR" + "PayloadRemovalDisallowed" + "" + "" + ) + + return MacProfileExport(identifier=identifier, content=content) + + +def build_macos_mobileconfig_multi(configs: list[WifiConfig]) -> MacProfileExport: + """Build a multi-network macOS configuration profile.""" + if not configs: + raise ValueError("No networks provided") + + profile_uuid = str(uuid.uuid4()) + identifier = f"com.wifiqr.profile.{profile_uuid}" + + payloads = [] + for config in configs: + wifi_uuid = str(uuid.uuid4()) + payloads.append( + _build_wifi_payload( + config, + payload_identifier=f"{identifier}.wifi.{wifi_uuid}", + wifi_uuid=wifi_uuid, + ) + ) + + content = ( + "" + "" + "" + "" + "PayloadContent" + "" + f"{''.join(payloads)}" + "" + "PayloadTypeConfiguration" + "PayloadVersion1" + f"PayloadIdentifier{identifier}" + f"PayloadUUID{profile_uuid}" + f"PayloadDisplayNameWifiQR Wi-Fi" + "PayloadOrganizationWifiQR" + "PayloadRemovalDisallowed" + "" + "" + ) + + return MacProfileExport(identifier=identifier, content=content) diff --git a/WiFi QR Code Generator/src/wifiqr/services/qr_service.py b/WiFi QR Code Generator/src/wifiqr/services/qr_service.py new file mode 100644 index 00000000..e8b2ec26 --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/services/qr_service.py @@ -0,0 +1,83 @@ +"""QR image generation helpers.""" + +from __future__ import annotations + +import base64 +import binascii +import io + +import qrcode +from PIL import Image +from qrcode.constants import ERROR_CORRECT_H +from qrcode.image.pil import PilImage + +from wifiqr.constants import ( + DEFAULT_QR_BACKGROUND_COLOR, + DEFAULT_QR_BORDER, + DEFAULT_QR_BOX_SIZE, + DEFAULT_QR_FILL_COLOR, + DEFAULT_QR_SIZE, +) + +CENTER_IMAGE_SIZE = 100 # Optimal size for center image in QR codes + + +def generate_qr_image( + payload: str, size: int = DEFAULT_QR_SIZE, center_image_data: str | None = None +) -> Image.Image: + """Generate a QR image for the provided payload with optional center image.""" + qr = qrcode.QRCode( + version=None, + error_correction=ERROR_CORRECT_H, # High error correction for center images + box_size=DEFAULT_QR_BOX_SIZE, + border=DEFAULT_QR_BORDER, + ) + qr.add_data(payload) + qr.make(fit=True) + image_factory = qr.make_image( + image_factory=PilImage, + fill_color=DEFAULT_QR_FILL_COLOR, + back_color=DEFAULT_QR_BACKGROUND_COLOR, + ) + image: Image.Image = image_factory.get_image().convert("RGB") + + if size and image.size != (size, size): + image = image.resize((size, size), Image.Resampling.LANCZOS) + + # Add center image if provided + if center_image_data: + try: + image_bytes = base64.b64decode(center_image_data, validate=True) + except binascii.Error as exc: + raise ValueError("Center image data is not valid base64.") from exc + + try: + center_img = Image.open(io.BytesIO(image_bytes)).convert("RGBA") + except Exception as exc: + raise ValueError("Center image data is not a valid image.") from exc + + # Resize center image to fixed size + center_img = center_img.resize( + (CENTER_IMAGE_SIZE, CENTER_IMAGE_SIZE), Image.Resampling.LANCZOS + ) + + # Calculate position to center the image + qr_width, qr_height = image.size + pos_x = (qr_width - CENTER_IMAGE_SIZE) // 2 + pos_y = (qr_height - CENTER_IMAGE_SIZE) // 2 + + # Convert QR to RGBA for compositing + image = image.convert("RGBA") + + # Paste center image + image.paste(center_img, (pos_x, pos_y), center_img) + + # Convert back to RGB + image = image.convert("RGB") + + return image + + +def save_qr_image(image: Image.Image, file_path: str) -> None: + """Persist a QR image to disk.""" + image.save(file_path) diff --git a/WiFi QR Code Generator/src/wifiqr/services/wifi_payload.py b/WiFi QR Code Generator/src/wifiqr/services/wifi_payload.py new file mode 100644 index 00000000..a16800a8 --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/services/wifi_payload.py @@ -0,0 +1,57 @@ +"""Wi-Fi payload helpers and configuration model.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from wifiqr.constants import SECURITY_ALIASES + + +@dataclass(frozen=True) +class WifiConfig: + location: str + ssid: str + password: str + security: str + hidden: bool = False + image_data: str | None = None # base64 encoded image + + +def _escape(value: str) -> str: + """Escape payload delimiters for QR-encoded Wi-Fi strings.""" + return ( + value.replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace(":", "\\:") + ) + + +def normalize_security(value: str) -> str: + """Normalize security labels into canonical forms.""" + key = value.upper().strip() + return SECURITY_ALIASES.get(key, key) + + +def security_for_qr(value: str) -> str: + """Map a security label to a QR-compatible value.""" + normalized = normalize_security(value) + return "nopass" if normalized == "NOPASS" else normalized + + +def is_open_security(value: str) -> bool: + """Return True when the security represents an open network.""" + return normalize_security(value) == "NOPASS" + + +def build_wifi_payload(config: WifiConfig) -> str: + """Build a Wi-Fi QR payload string from a configuration.""" + ssid = _escape(config.ssid.strip()) + password = _escape(config.password) + security = security_for_qr(config.security) + hidden = "true" if config.hidden else "false" + + if security == "nopass": + return f"WIFI:T:nopass;S:{ssid};H:{hidden};;" + + return f"WIFI:T:{security};S:{ssid};P:{password};H:{hidden};;" diff --git a/WiFi QR Code Generator/src/wifiqr/services/wifi_profiles.py b/WiFi QR Code Generator/src/wifiqr/services/wifi_profiles.py new file mode 100644 index 00000000..b0a65cb6 --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/services/wifi_profiles.py @@ -0,0 +1,53 @@ +"""WLAN profile XML generation for Windows exports.""" + +from __future__ import annotations + +from wifiqr.constants import WINDOWS_SECURITY_DEFAULT, WINDOWS_SECURITY_MAP +from wifiqr.services.wifi_payload import WifiConfig, normalize_security +from wifiqr.services.xml_utils import xml_escape + + +def _security_to_profile(config: WifiConfig) -> tuple[str, str, str | None]: + """Map a Wi-Fi config to profile authentication values.""" + security = normalize_security(config.security) + return WINDOWS_SECURITY_MAP.get(security, WINDOWS_SECURITY_DEFAULT) + + +def build_wlan_profile_xml(config: WifiConfig) -> str: + """Build WLAN profile XML content for a Wi-Fi config.""" + auth, encryption, key_type = _security_to_profile(config) + ssid = xml_escape(config.ssid) + password = xml_escape(config.password) + hidden = "true" if config.hidden else "false" + + key_material = ( + f"{key_type}false" + f"{password}" + if key_type + else "" + ) + + return ( + "" + "" + f"{ssid}" + "" + "" + f"{ssid}" + "" + f"{hidden}" + "" + "ESS" + "auto" + "" + "" + "" + f"{auth}" + f"{encryption}" + "false" + "" + f"{key_material}" + "" + "" + "" + ) diff --git a/WiFi QR Code Generator/src/wifiqr/services/windows_script.py b/WiFi QR Code Generator/src/wifiqr/services/windows_script.py new file mode 100644 index 00000000..00da725e --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/services/windows_script.py @@ -0,0 +1,67 @@ +"""Windows script generation for Wi-Fi profiles.""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass + +from wifiqr.services.wifi_payload import WifiConfig +from wifiqr.services.wifi_profiles import build_wlan_profile_xml + + +@dataclass(frozen=True) +class ScriptExport: + ssid: str + content: str + + +def _profile_script_block(profile_path: str, profile_xml: str) -> list[str]: + """Build script lines for writing and importing a WLAN profile.""" + # Base64 the XML to avoid cmd/PowerShell quoting pitfalls. + xml_b64 = base64.b64encode(profile_xml.encode("utf-8")).decode("ascii") + return [ + f"set \"PROFILE_PATH={profile_path}\"", + "powershell -NoProfile -ExecutionPolicy Bypass -Command " + f"\"$xml=[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('{xml_b64}')); " + "Set-Content -LiteralPath \\\"%PROFILE_PATH%\\\" -Value $xml -Encoding UTF8\"", + "netsh wlan add profile filename=\"%PROFILE_PATH%\" user=all", + "del /f /q \"%PROFILE_PATH%\"", + ] + + +def build_windows_connect_script(config: WifiConfig) -> ScriptExport: + """Build a single-network Windows connect script.""" + ssid = config.ssid + profile_xml = build_wlan_profile_xml(config) + lines = ["@echo off", "setlocal EnableExtensions DisableDelayedExpansion"] + lines.extend(_profile_script_block("%TEMP%\\wifi-profile.xml", profile_xml)) + lines.append(f"netsh wlan connect name=\"{ssid}\"") + lines.append("endlocal") + + script = "\r\n".join(lines) + "\r\n" + return ScriptExport(ssid=ssid, content=script) + + +def build_windows_connect_script_multi(configs: list[WifiConfig]) -> ScriptExport: + """Build a batch Windows connect script for multiple networks.""" + if not configs: + raise ValueError("No networks provided") + + lines = ["@echo off", "setlocal EnableExtensions DisableDelayedExpansion"] + + for idx, config in enumerate(configs, start=1): + profile_xml = build_wlan_profile_xml(config) + temp_name = f"wifi-profile-{idx}.xml" + lines.extend( + _profile_script_block( + f"%TEMP%\\{temp_name}", + profile_xml, + ) + ) + + last_ssid = configs[-1].ssid + lines.append(f"netsh wlan connect name=\"{last_ssid}\"") + lines.append("endlocal") + + script = "\r\n".join(lines) + "\r\n" + return ScriptExport(ssid=last_ssid, content=script) diff --git a/WiFi QR Code Generator/src/wifiqr/services/xml_utils.py b/WiFi QR Code Generator/src/wifiqr/services/xml_utils.py new file mode 100644 index 00000000..9884c8e4 --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/services/xml_utils.py @@ -0,0 +1,14 @@ +"""Shared XML escaping utilities.""" + +from __future__ import annotations + + +def xml_escape(value: str) -> str: + """Escape XML special characters for use in XML/plist content.""" + return ( + value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("'", "'") + ) diff --git a/WiFi QR Code Generator/src/wifiqr/ui/main_window.py b/WiFi QR Code Generator/src/wifiqr/ui/main_window.py new file mode 100644 index 00000000..a6958899 --- /dev/null +++ b/WiFi QR Code Generator/src/wifiqr/ui/main_window.py @@ -0,0 +1,1681 @@ +from __future__ import annotations + +import json +from importlib import metadata +from dataclasses import replace +from pathlib import Path +from typing import override + +from PIL import Image, ImageDraw, ImageFont +from PySide6.QtCore import QModelIndex, QPoint, QSize, Qt, QTimer, Slot +from PySide6.QtGui import ( + QAction, + QColor, + QFont, + QFontMetrics, + QIcon, + QKeySequence, + QPainter, + QPalette, + QPixmap, + QResizeEvent, + QShowEvent, +) +from PySide6.QtPrintSupport import QPrintDialog, QPrinter +from PySide6.QtWidgets import ( + QAbstractItemView, + QCheckBox, + QComboBox, + QFileDialog, + QFormLayout, + QGridLayout, + QGroupBox, + QHBoxLayout, + QHeaderView, + QInputDialog, + QLabel, + QLineEdit, + QMainWindow, + QMenu, + QMessageBox, + QPushButton, + QSizePolicy, + QTableWidget, + QTableWidgetItem, + QToolButton, + QVBoxLayout, + QWidget, +) + +from wifiqr.constants import PREVIEW_RESIZE_THRESHOLD, SECURITY_OPTIONS +from wifiqr.services.export_service import pil_to_qpixmap +from wifiqr.services.macos_profile import ( + build_macos_mobileconfig, + build_macos_mobileconfig_multi, +) +from wifiqr.services.qr_service import generate_qr_image, save_qr_image +from wifiqr.services.wifi_payload import WifiConfig, build_wifi_payload, is_open_security +from wifiqr.services.windows_script import ( + build_windows_connect_script, + build_windows_connect_script_multi, +) + +RESOURCE_DIR = Path(__file__).resolve().parents[1] / "resources" + + +class MainWindow(QMainWindow): + """Primary application window for building and exporting Wi-Fi QR codes.""" + + STANDARD_SSID_CHARS = 32 + STANDARD_PASSWORD_CHARS = 63 + MAX_WINDOW_SIZE = 16777215 + TABLE_COLUMN_COUNT = 5 + TABLE_HEADER_LABELS = ( + "Location", + "SSID", + "Password", + "Security", + "Hidden", + ) + INITIAL_TABLE_ROWS = 0 + NO_SEARCH_INDEX = -1 + WINDOW_MIN_WIDTH = 980 + WINDOW_MIN_HEIGHT = 620 + LAYOUT_MARGIN = 24 + LAYOUT_SPACING = 24 + LAYOUT_FORM_STRETCH = 1 + LAYOUT_TABLE_STRETCH = 2 + LAYOUT_PREVIEW_STRETCH = 1 + FORM_VERTICAL_SPACING = 14 + FORM_HORIZONTAL_SPACING = 12 + ACTION_LAYOUT_SPACING = 12 + PREVIEW_LAYOUT_SPACING = 16 + PREVIEW_MIN_SIZE = 420 + PREVIEW_TOGGLE_WIDTH = 44 + PREVIEW_TOGGLE_HEIGHT = 24 + PREVIEW_TOGGLE_SPACING = 8 + SEARCH_ROW_SPACING = 6 + TABLE_MIN_WIDTH = 520 + ACTION_BUTTON_MIN_WIDTH = 160 + ACTION_BUTTON_MIN_HEIGHT = 36 + TIMER_DELAY_MS = 0 + DEFAULT_EXPORT_INDEX = 0 + HEADER_TEXT_POINT_SIZE = 14 + HEADER_TEXT_PADDING = 16 + HEADER_TEXT_BASELINE_PADDING = 8 + TABLE_COLUMN_PADDING = 24 + TABLE_WIDTH_PADDING = 24 + PASSWORD_MASK_MIN_LENGTH = 6 + PASSWORD_TOGGLE_SIZE = 24 + DEFAULT_QR_HEADER_COLOR = "#0b1220" + PAYLOAD_TEXT_STYLE = "color: #64748b; font-size: 11px;" + PREVIEW_DEBOUNCE_MS = 180 + + def __init__(self) -> None: + """Initialize the main window and UI state.""" + super().__init__() + self.setWindowTitle("WifiQR") + self.setMinimumSize(self.WINDOW_MIN_WIDTH, self.WINDOW_MIN_HEIGHT) + + self.form_group: QGroupBox + self.table_group: QGroupBox + self.preview_group: QGroupBox + self.location_input: QLineEdit + self.ssid_input: QLineEdit + self.password_input: QLineEdit + self.security_input: QComboBox + self.image_browse_button: QPushButton + self.image_path_display: QLineEdit + self.hidden_input: QCheckBox + self.show_header_input: QCheckBox + self.add_table_button: QPushButton + self.print_button: QPushButton + self.export_png_button: QPushButton + self.export_pdf_button: QPushButton + self.export_script_button: QPushButton + self.export_macos_button: QPushButton + self.batch_export_button: QPushButton + self.preview_toggle: QToolButton + self.preview_label: QLabel + self.payload_label: QLabel + self.search_input: QLineEdit + self.search_up_button: QToolButton + self.search_down_button: QToolButton + self.network_table: QTableWidget + self.delete_table_button: QPushButton + + self._config = WifiConfig( + location="", + ssid="", + password="", + security="WPA", + hidden=False, + image_data=None, + ) + self._current_image_filename: str = "" + self._current_pixmap: QPixmap | None = None + self._current_payload = "" + self._current_save_path: str | None = None + self._sort_orders: dict[int, Qt.SortOrder] = {} + self._search_matches: list[int] = [] + self._search_index: int = self.NO_SEARCH_INDEX + self._preview_timer = QTimer(self) + self._preview_timer.setSingleShot(True) + self._preview_timer.timeout.connect(self._refresh_preview_now) + self._last_preview_size = QSize() + self._scaled_pixmap: QPixmap | None = None + self._header_metrics: QFontMetrics | None = None + self._item_metrics: QFontMetrics | None = None + self._last_header_state = False + self._last_location = "" + self._last_image_data: str | None = None + + self._setup_ui() + self._apply_style() + self._refresh_preview() + + def _setup_ui(self) -> None: + """Create and arrange all UI widgets.""" + self._build_menus() + root = self._build_root_container() + self._build_form_group() + self._build_preview_group() + self._build_table_group() + self._finalize_layout(root) + self._connect_form_signals() + + def _build_root_container(self) -> QWidget: + """Create the root widget and horizontal layout.""" + root = QWidget() + layout = QHBoxLayout(root) + layout.setContentsMargins( + self.LAYOUT_MARGIN, + self.LAYOUT_MARGIN, + self.LAYOUT_MARGIN, + self.LAYOUT_MARGIN, + ) + layout.setSpacing(self.LAYOUT_SPACING) + layout.setStretch(0, self.LAYOUT_FORM_STRETCH) + layout.setStretch(1, self.LAYOUT_TABLE_STRETCH) + layout.setStretch(2, self.LAYOUT_PREVIEW_STRETCH) + return root + + def _build_form_group(self) -> None: + """Create the form group for Wi-Fi inputs and actions.""" + self.form_group = QGroupBox("Network details") + self.form_group.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + form_layout = QFormLayout(self.form_group) + form_layout.setVerticalSpacing(self.FORM_VERTICAL_SPACING) + form_layout.setHorizontalSpacing(self.FORM_HORIZONTAL_SPACING) + + self._build_form_inputs() + self._build_action_buttons() + # Add spacing before bottom row + form_layout.addRow("", QWidget()) + self._build_bottom_row(form_layout) + + def _build_form_inputs(self) -> None: + """Create form inputs for location, SSID, and security.""" + self.location_input = QLineEdit() + self.location_input.setPlaceholderText("e.g. HQ West") + self.location_input.setToolTip("Location identifier for this network.") + self.location_input.setAccessibleName("Network Location") + + self.ssid_input = QLineEdit() + self.ssid_input.setPlaceholderText("e.g. Office-Guest") + self.ssid_input.setToolTip("Network name (SSID). Case sensitive.") + self.ssid_input.setAccessibleName("Network SSID") + + self.password_input = QLineEdit() + self.password_input.setPlaceholderText("Password") + self.password_input.setEchoMode(QLineEdit.EchoMode.Password) + self.password_input.setToolTip( + "Network password. Disabled for open networks." + ) + self.password_input.setAccessibleName("Network Password") + + self.security_input = QComboBox() + self.security_input.addItems(list(SECURITY_OPTIONS)) + self.security_input.setMaxVisibleItems(3) + view = self.security_input.view() + palette = view.palette() + palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff")) + palette.setColor(QPalette.ColorRole.Window, QColor("#ffffff")) + view.setPalette(palette) + self.security_input.setToolTip("Select the network security type.") + self.security_input.setAccessibleName("Security Type") + + self.image_path_display = QLineEdit() + self.image_path_display.setPlaceholderText("No image selected (optional)") + self.image_path_display.setReadOnly(True) + self.image_path_display.setToolTip( + "Selected center image (PNG, JPG, JPEG, BMP, GIF, SVG). SVG is converted to PNG." + ) + + self.image_browse_button = QPushButton("...", self.image_path_display) + self.image_browse_button.setToolTip( + "Select a center image for the QR code (PNG, JPG, JPEG, BMP, GIF, SVG)." + ) + self.image_browse_button.setFixedSize(30, 24) + self.image_browse_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.image_browse_button.clicked.connect(self._browse_image) + + # Position button inside text field on the right + self.image_path_display.setStyleSheet("QLineEdit { padding-right: 35px; }") + + self.hidden_input = QCheckBox("Hidden network") + self.hidden_input.setToolTip("Enable if the SSID is hidden (non-broadcast).") + self.hidden_input.setTristate(False) + self.hidden_input.setAccessibleName("Hidden Network Toggle") + + self.show_header_input = QCheckBox("Show location header") + self.show_header_input.setToolTip( + "Include location text above the QR code in preview, prints, and exports." + ) + self.show_header_input.setTristate(False) + self.show_header_input.setChecked(True) + self.show_header_input.setAccessibleName("Show Header Toggle") + + # Put checkboxes side by side + checkbox_row = QHBoxLayout() + checkbox_row.setSpacing(20) + checkbox_row.addWidget(self.hidden_input) + checkbox_row.addWidget(self.show_header_input) + checkbox_row.addStretch(1) + + form_layout = self.form_group.layout() + assert isinstance(form_layout, QFormLayout) + form_layout.addRow("Location", self.location_input) + form_layout.addRow("SSID", self.ssid_input) + form_layout.addRow("Password", self.password_input) + form_layout.addRow("Security", self.security_input) + form_layout.addRow("Center Image", self.image_path_display) + form_layout.addRow(checkbox_row) + + def _build_action_buttons(self) -> None: + """Create and place action buttons for exports and table actions.""" + self._create_action_buttons() + self._add_action_layout() + + def _create_action_buttons(self) -> None: + """Instantiate action buttons and assign handlers.""" + self.add_table_button = QPushButton("Add to Table") + self.add_table_button.clicked.connect(self._add_to_table) + self._configure_action_button( + self.add_table_button, + "Add to Table", + "Add current network to the table.", + ) + + self.print_button = QPushButton("Print") + self.print_button.clicked.connect(self._print) + self._configure_action_button( + self.print_button, + "Print", + "Print the QR code with the optional location header.", + ) + + self.export_png_button = QPushButton("Export PNG") + self.export_png_button.clicked.connect(self._export_png) + self._configure_action_button( + self.export_png_button, + "Export PNG", + "Export the QR code as a PNG image with the optional location header.", + ) + + self.export_pdf_button = QPushButton("Export PDF") + self.export_pdf_button.clicked.connect(self._export_pdf) + self._configure_action_button( + self.export_pdf_button, + "Export PDF", + "Export the QR code as a PDF document with the optional location header.", + ) + + self.export_script_button = QPushButton("Export Windows Script") + self.export_script_button.clicked.connect(self._export_windows_script) + self._configure_action_button( + self.export_script_button, + "Export Windows Script", + "Export a Windows .cmd file that adds the profile and connects.", + ) + + self.export_macos_button = QPushButton("Export macOS Profile") + self.export_macos_button.clicked.connect(self._export_macos_profile) + self._configure_action_button( + self.export_macos_button, + "Export macOS Profile", + "Export a macOS .mobileconfig profile for one-click install.", + ) + + self.batch_export_button = QPushButton("Batch Export") + self.batch_export_button.clicked.connect(self._batch_export) + self._configure_action_button( + self.batch_export_button, + "Batch Export", + "Export selected networks in bulk (or the current network if none selected).", + ) + + self.delete_table_button = QPushButton("Delete Selected") + self.delete_table_button.clicked.connect(self._remove_selected) + self._configure_action_button( + self.delete_table_button, + "Delete Selected", + "Delete selected network(s) from the table.", + ) + + def _add_action_layout(self) -> None: + """Add the action buttons to the form layout.""" + action_layout = QGridLayout() + action_layout.setHorizontalSpacing(self.ACTION_LAYOUT_SPACING) + action_layout.setVerticalSpacing(self.ACTION_LAYOUT_SPACING) + action_layout.setColumnStretch(0, 1) + action_layout.setColumnStretch(1, 1) + action_layout.setColumnStretch(2, 1) + action_layout.addWidget(self.print_button, 0, 0) + action_layout.addWidget(self.export_png_button, 0, 1) + action_layout.addWidget(self.export_pdf_button, 0, 2) + action_layout.addWidget(self.export_script_button, 1, 0) + action_layout.addWidget(self.export_macos_button, 1, 1) + action_layout.addWidget(self.batch_export_button, 1, 2) + action_layout.addWidget(self.add_table_button, 2, 0) + + form_layout = self.form_group.layout() + if isinstance(form_layout, QFormLayout): + form_layout.addRow(action_layout) + + def _build_bottom_row(self, form_layout: QFormLayout) -> None: + """Create bottom row with delete button and preview toggle.""" + self.preview_toggle = QToolButton() + self.preview_toggle.setCheckable(True) + self.preview_toggle.setChecked(True) + self.preview_toggle.setToolTip("Show or hide the preview panel.") + self.preview_toggle.setObjectName("PreviewToggle") + self.preview_toggle.setFixedSize( + self.PREVIEW_TOGGLE_WIDTH, + self.PREVIEW_TOGGLE_HEIGHT, + ) + self.preview_toggle.setIconSize(self.preview_toggle.size()) + self.preview_toggle.toggled.connect(self._toggle_preview_panel) + self._update_preview_toggle_icon() + + bottom_row = QHBoxLayout() + bottom_row.addWidget(QLabel("Show preview")) + bottom_row.addSpacing(self.PREVIEW_TOGGLE_SPACING) + bottom_row.addWidget(self.preview_toggle) + bottom_row.addStretch(1) + bottom_row.addWidget(self.delete_table_button) + form_layout.addRow(bottom_row) + + def _build_preview_group(self) -> None: + """Create the preview group for QR output.""" + self.preview_group = QGroupBox("Preview") + self.preview_group.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + preview_layout = QVBoxLayout(self.preview_group) + preview_layout.setSpacing(self.PREVIEW_LAYOUT_SPACING) + + self.preview_label = QLabel("Enter network details to generate a QR code") + self.preview_label.setObjectName("PreviewLabel") + self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_label.setMinimumSize( + self.PREVIEW_MIN_SIZE, + self.PREVIEW_MIN_SIZE, + ) + + self.payload_label = QLabel("") + self.payload_label.setWordWrap(True) + self.payload_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + self.payload_label.setStyleSheet(self.PAYLOAD_TEXT_STYLE) + + preview_layout.addWidget(self.preview_label, stretch=1) + preview_layout.addWidget(self.payload_label) + + def _build_table_group(self) -> None: + """Create the saved networks table group.""" + self.table_group = QGroupBox("Saved networks") + self.table_group.setMinimumWidth(self.TABLE_MIN_WIDTH) + self.table_group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + table_layout = QVBoxLayout(self.table_group) + table_layout.setSpacing(self.ACTION_LAYOUT_SPACING) + + search_row = self._build_table_search_row() + self._build_network_table() + + table_layout.addLayout(search_row) + table_layout.addWidget(self.network_table) + + self._update_button_labels() + self._apply_table_column_widths() + + def _build_table_search_row(self) -> QHBoxLayout: + """Create the search row with navigation controls.""" + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Search by location or SSID") + self.search_input.setToolTip("Filter saved networks by location or SSID.") + self.search_input.textChanged.connect(self._apply_search_filter) + + search_row = QHBoxLayout() + search_row.setSpacing(self.SEARCH_ROW_SPACING) + + self.search_up_button = QToolButton() + self.search_up_button.setToolTip("Find previous") + self.search_up_button.clicked.connect(self._find_previous) + + self.search_down_button = QToolButton() + self.search_down_button.setToolTip("Find next") + self.search_down_button.clicked.connect(self._find_next) + + up_icon = RESOURCE_DIR / "chevron_up.svg" + down_icon = RESOURCE_DIR / "chevron_down.svg" + self._set_icon_if_exists(self.search_up_button, up_icon) + self._set_icon_if_exists(self.search_down_button, down_icon) + + search_row.addWidget(self.search_input) + search_row.addWidget(self.search_up_button) + search_row.addWidget(self.search_down_button) + return search_row + + def _build_network_table(self) -> None: + """Create the network table widget and connect events.""" + self.network_table = QTableWidget(self.INITIAL_TABLE_ROWS, self.TABLE_COLUMN_COUNT) + self.network_table.setHorizontalHeaderLabels(list(self.TABLE_HEADER_LABELS)) + + # Center all header labels + for i in range(self.TABLE_COLUMN_COUNT): + header_item = self.network_table.horizontalHeaderItem(i) + if header_item: + header_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + + header = self.network_table.horizontalHeader() + + # Calculate minimum widths based on header labels + font_metrics = QFontMetrics(header.font()) + min_widths = {} + for i, label in enumerate(self.TABLE_HEADER_LABELS): + min_widths[i] = font_metrics.horizontalAdvance(label) + 30 + + # Set resize modes: Interactive for resizable columns, Fixed for pinned columns + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive) # Location + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Interactive) # SSID + header.setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # Password + header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) # Security (pinned) + header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # Hidden (pinned) + header.setStretchLastSection(False) + header.setDefaultAlignment(Qt.AlignmentFlag.AlignCenter) # Center all header labels + + # Set minimum widths for all columns + for _col, min_width in min_widths.items(): + header.setMinimumSectionSize(min_width) + + # Set initial column widths + self.network_table.setColumnWidth(0, max(150, min_widths[0])) + self.network_table.setColumnWidth(1, max(200, min_widths[1])) + self.network_table.setColumnWidth(2, max(200, min_widths[2])) + self.network_table.setColumnWidth(3, 100) # Security - uniform width + self.network_table.setColumnWidth(4, 100) # Hidden - uniform width + + # Connect to handle gap prevention + header.sectionResized.connect(self._handle_column_resize) + + self.network_table.verticalHeader().setDefaultSectionSize(45) + self.network_table.setSelectionBehavior( + QAbstractItemView.SelectionBehavior.SelectRows + ) + self.network_table.setEditTriggers( + QAbstractItemView.EditTrigger.DoubleClicked + ) + self.network_table.setSortingEnabled(False) + self.network_table.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + self.network_table.setHorizontalScrollMode( + QAbstractItemView.ScrollMode.ScrollPerPixel + ) + self.network_table.setVerticalScrollMode( + QAbstractItemView.ScrollMode.ScrollPerPixel + ) + self.network_table.horizontalScrollBar().setSingleStep(10) + self.network_table.horizontalHeader().sectionClicked.connect( + self._handle_sort + ) + self.network_table.itemChanged.connect(self._table_item_changed) + self.network_table.setContextMenuPolicy( + Qt.ContextMenuPolicy.CustomContextMenu + ) + self.network_table.customContextMenuRequested.connect( + self._table_context_menu + ) + self.network_table.doubleClicked.connect(self._table_double_clicked) + + def _finalize_layout(self, root: QWidget) -> None: + """Attach groups to the root layout and set the central widget.""" + layout = root.layout() + assert isinstance(layout, QHBoxLayout) + layout.addWidget(self.form_group, self.LAYOUT_FORM_STRETCH) + layout.addWidget(self.table_group, self.LAYOUT_TABLE_STRETCH) + layout.addWidget(self.preview_group, self.LAYOUT_PREVIEW_STRETCH) + self.setCentralWidget(root) + + def _connect_form_signals(self) -> None: + """Wire input changes to preview refresh handlers.""" + self.ssid_input.textChanged.connect(self._refresh_preview) + self.location_input.textChanged.connect(self._refresh_preview) + self.password_input.textChanged.connect(self._refresh_preview) + self.security_input.currentTextChanged.connect(self._on_security_changed) + self.hidden_input.stateChanged.connect(self._refresh_preview) + self.show_header_input.stateChanged.connect(self._refresh_preview) + self._update_password_state() + + def _apply_style(self) -> None: + """Apply the application stylesheet if available.""" + style_path = RESOURCE_DIR / "style.qss" + if style_path.exists(): + self.setStyleSheet(style_path.read_text(encoding="utf-8")) + + def _configure_action_button(self, button: QPushButton, label: str, tooltip: str) -> None: + """Standardize action button styling and tooltip text.""" + button.setToolTip(tooltip) + button.setProperty("fullText", label) + button.setMinimumWidth(self.ACTION_BUTTON_MIN_WIDTH) + button.setMinimumHeight(self.ACTION_BUTTON_MIN_HEIGHT) + button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + def _set_icon_if_exists(self, widget: QPushButton | QToolButton, icon_path: Path) -> None: + """Assign an icon when the resource exists.""" + if icon_path.exists(): + widget.setIcon(QIcon(str(icon_path))) + + def _build_menus(self) -> None: + """Create the menu bar actions.""" + file_menu = self.menuBar().addMenu("File") + + save_action = QAction("Save", self) + save_action.setShortcut(QKeySequence.StandardKey.Save) + save_action.triggered.connect(self._save) + + save_as_action = QAction("Save As...", self) + save_as_action.setShortcut(QKeySequence.StandardKey.SaveAs) + save_as_action.triggered.connect(self._save_as) + + load_action = QAction("Load...", self) + load_action.setShortcut(QKeySequence.StandardKey.Open) + load_action.triggered.connect(self._load) + + exit_action = QAction("Exit", self) + exit_action.setShortcut(QKeySequence.StandardKey.Quit) + exit_action.triggered.connect(self.close) + + file_menu.addAction(save_action) + file_menu.addAction(save_as_action) + file_menu.addAction(load_action) + file_menu.addSeparator() + file_menu.addAction(exit_action) + + edit_menu = self.menuBar().addMenu("Edit") + + add_action = QAction("Add to Table", self) + add_action.setShortcut(QKeySequence("Ctrl+Return")) + add_action.triggered.connect(self._add_to_table) + + delete_action = QAction("Delete Selected", self) + delete_action.setShortcut(QKeySequence.StandardKey.Delete) + delete_action.triggered.connect(self._remove_selected) + + find_action = QAction("Find...", self) + find_action.setShortcut(QKeySequence.StandardKey.Find) + find_action.triggered.connect(self._focus_search) + + edit_menu.addAction(add_action) + edit_menu.addAction(delete_action) + edit_menu.addSeparator() + edit_menu.addAction(find_action) + + help_menu = self.menuBar().addMenu("Help") + about_action = QAction("About", self) + about_action.triggered.connect(self._show_about) + help_menu.addAction(about_action) + + def _browse_image(self) -> None: + """Open file dialog to select a center image for the QR code.""" + import base64 + import io + import os + + path, _ = QFileDialog.getOpenFileName( + self, + "Select Center Image", + "", + "Image Files (*.png *.jpg *.jpeg *.bmp *.gif *.svg)", + ) + if not path: + return + + try: + # Read and validate image bytes + with open(path, "rb") as f: + image_bytes = f.read() + _, ext = os.path.splitext(path) + if ext.lower() == ".svg": + import cairosvg # type: ignore[import-untyped] + + image_bytes = cairosvg.svg2png(bytestring=image_bytes) + if not isinstance(image_bytes, (bytes, bytearray, memoryview)): + raise ValueError("Image data is empty or invalid.") + image_bytes = bytes(image_bytes) + with Image.open(io.BytesIO(image_bytes)) as image: + image.verify() + image_base64 = base64.b64encode(image_bytes).decode("utf-8") + except Exception as exc: + QMessageBox.warning(self, "Image Load Failed", f"Could not load image: {exc}") + return + + # Update UI and config + self.image_path_display.setText(path) + self._config = replace(self._config, image_data=image_base64) + self._refresh_preview() + + def _update_config(self) -> None: + """Sync form input values into the current config.""" + self._config = replace( + self._config, + location=self.location_input.text().strip(), + ssid=self.ssid_input.text().strip(), + password=self.password_input.text(), + security=self.security_input.currentText(), + hidden=self.hidden_input.isChecked(), + # Preserve image_data from current config + ) + + @Slot() + def _refresh_preview(self) -> None: + """Debounce QR preview updates for smoother typing.""" + self._preview_timer.start(self.PREVIEW_DEBOUNCE_MS) + + def _refresh_preview_now(self) -> None: + """Recompute QR preview and payload text.""" + self._update_config() + + if not self._config.ssid: + self._current_pixmap = None + self._current_payload = "" + self._scaled_pixmap = None + self._last_preview_size = QSize() + self._last_image_data = None + self.preview_label.clear() + self.preview_label.setText("Enter network details to generate a QR code") + self.payload_label.setText("") + self._toggle_actions(False) + return + + payload = build_wifi_payload(self._config) + header_state = self.show_header_input.isChecked() + current_location = self._config.location + + # Regenerate if payload, header state, or location changed + should_regenerate = ( + payload != self._current_payload or + header_state != self._last_header_state or + current_location != self._last_location or + self._config.image_data != self._last_image_data or + self._current_pixmap is None + ) + + if should_regenerate: + self._current_payload = payload + self._last_header_state = header_state + self._last_location = current_location + self._last_image_data = self._config.image_data + try: + image = generate_qr_image( + self._current_payload, center_image_data=self._config.image_data + ) + # Add header to preview if enabled and location exists + if header_state and current_location: + image = self._compose_qr_with_header(image, current_location) + self._current_pixmap = pil_to_qpixmap(image) + self._scaled_pixmap = None # Clear cache to force rescale + except Exception as exc: + QMessageBox.critical(self, "QR generation failed", str(exc)) + self._toggle_actions(False) + return + + self._update_scaled_preview() + self.preview_label.setText("") + self.preview_label.update() + self.payload_label.setText(self._current_payload) + self._toggle_actions(True) + + def _toggle_actions(self, enabled: bool) -> None: + """Enable or disable action buttons based on QR state.""" + self.print_button.setEnabled(enabled) + self.export_png_button.setEnabled(enabled) + self.export_pdf_button.setEnabled(enabled) + self.export_script_button.setEnabled(enabled) + self.export_macos_button.setEnabled(enabled) + self.batch_export_button.setEnabled(enabled) + + @override + def resizeEvent(self, event: QResizeEvent) -> None: + """Handle resize events for responsive preview scaling.""" + super().resizeEvent(event) + if self._current_pixmap: + self._update_scaled_preview() + self._update_button_labels() + self._position_image_button() + + @override + def showEvent(self, event: QShowEvent) -> None: + """Finalize layout sizing when the window is shown.""" + super().showEvent(event) + QTimer.singleShot(self.TIMER_DELAY_MS, self._update_button_labels) + QTimer.singleShot(self.TIMER_DELAY_MS, self._apply_panel_minimums) + QTimer.singleShot(self.TIMER_DELAY_MS, self._lock_window_size) + QTimer.singleShot(self.TIMER_DELAY_MS, self._position_image_button) + + def _position_image_button(self) -> None: + """Position the browse button inside the image path text field.""" + text_field_height = self.image_path_display.height() + button_height = self.image_browse_button.height() + y_pos = (text_field_height - button_height) // 2 + x_pos = self.image_path_display.width() - self.image_browse_button.width() - 5 + self.image_browse_button.move(x_pos, y_pos) + + def _lock_window_size(self) -> None: + """Lock the window size to the layout's size hint.""" + self.setMinimumSize(0, 0) + self.setMaximumSize(self.MAX_WINDOW_SIZE, self.MAX_WINDOW_SIZE) + central = self.centralWidget() + layout = central.layout() if central else None + if layout: + layout.activate() + layout.update() + + # Force proper size calculation + central.adjustSize() + self.adjustSize() + + target = self.sizeHint() + self.resize(target) + self.setMinimumSize(target) + self.setMaximumSize(target) + + def _lock_window_width_only(self, height: int) -> None: + """Lock the window width while maintaining the given height.""" + self.setMinimumSize(0, 0) + self.setMaximumSize(self.MAX_WINDOW_SIZE, self.MAX_WINDOW_SIZE) + central = self.centralWidget() + layout = central.layout() if central else None + if layout: + layout.activate() + target_width = central.sizeHint().width() if central else self.sizeHint().width() + self.resize(target_width, height) + self.setMinimumSize(target_width, height) + self.setMaximumSize(target_width, height) + + def _update_preview_toggle_icon(self) -> None: + """Update the preview toggle icon to match its state.""" + on_icon = RESOURCE_DIR / "toggle_on.svg" + off_icon = RESOURCE_DIR / "toggle_off.svg" + if self.preview_toggle.isChecked() and on_icon.exists(): + self.preview_toggle.setIcon(QIcon(str(on_icon))) + elif not self.preview_toggle.isChecked() and off_icon.exists(): + self.preview_toggle.setIcon(QIcon(str(off_icon))) + + def _toggle_preview_panel(self, checked: bool) -> None: + """Show or hide the preview panel.""" + # Save current window height - it should never change + current_height = self.height() + + self.preview_group.setVisible(checked) + self._update_preview_toggle_icon() + + # Allow layout to recalculate width, then lock it + QTimer.singleShot(0, lambda: self._lock_window_width_only(current_height)) + + def _update_scaled_preview(self) -> None: + """Scale and apply the QR preview pixmap only when needed.""" + if not self._current_pixmap: + self.preview_label.clear() + return + + size = self.preview_label.size() + + # Skip rescaling if size difference is minimal (< PREVIEW_RESIZE_THRESHOLD px) + if self._last_preview_size.isValid() and self._scaled_pixmap: + w_diff = abs(size.width() - self._last_preview_size.width()) + h_diff = abs(size.height() - self._last_preview_size.height()) + if w_diff < PREVIEW_RESIZE_THRESHOLD and h_diff < PREVIEW_RESIZE_THRESHOLD: + self.preview_label.setPixmap(self._scaled_pixmap) + return + + self._last_preview_size = size + self._scaled_pixmap = self._current_pixmap.scaled( + size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self.preview_label.setPixmap(self._scaled_pixmap) + + def _on_security_changed(self, value: str) -> None: + """Update password state and refresh preview on security changes.""" + self._update_password_state(value) + self._refresh_preview() + + def _update_password_state(self, security_value: str | None = None) -> None: + """Enable or disable password input based on security type.""" + security_value = security_value or self.security_input.currentText() + is_open = is_open_security(security_value) + self.password_input.setEnabled(not is_open) + if is_open: + self.password_input.setPlaceholderText("Not required for open networks") + else: + self.password_input.setPlaceholderText("Password") + + def _apply_panel_minimums(self) -> None: + """Clamp group minimum sizes to their size hints.""" + # First get max height across all visible panels + max_height = 0 + for group in (self.form_group, self.table_group, self.preview_group): + if group.isVisible(): + max_height = max(max_height, group.sizeHint().height()) + + # Now apply sizes + for group in (self.form_group, self.table_group, self.preview_group): + if not group.isVisible(): + group.setMinimumSize(0, 0) + continue + size = group.sizeHint() + group.setMinimumSize(size.width(), max_height) + group.setMaximumHeight(max_height) + group.updateGeometry() + self._update_table_group_width() + + def _update_button_labels(self) -> None: + """Normalize action button labels and widths.""" + buttons = ( + self.print_button, + self.export_png_button, + self.export_pdf_button, + self.export_script_button, + self.export_macos_button, + self.batch_export_button, + self.add_table_button, + self.delete_table_button, + ) + for button in buttons: + full_text = button.property("fullText") or button.text() + base_size = button.property("basePointSize") + if not base_size: + base_size = button.font().pointSizeF() + button.setProperty("basePointSize", base_size) + font = QFont(button.font()) + font.setPointSizeF(float(base_size)) + button.setFont(font) + button.setText(str(full_text)) + + max_width = 0 + for button in buttons: + max_width = max(max_width, button.sizeHint().width()) + + for button in buttons: + button.setMinimumWidth(max_width) + + def _render_pixmap(self, printer: QPrinter) -> None: + """Render the current QR pixmap to a printer device.""" + if not self._current_pixmap: + return + painter = QPainter(printer) + try: + rect = painter.viewport() + size = self._current_pixmap.size() + size.scale(rect.size(), Qt.AspectRatioMode.KeepAspectRatio) + painter.setViewport(rect.x(), rect.y(), size.width(), size.height()) + painter.setWindow(self._current_pixmap.rect()) + painter.drawPixmap(0, 0, self._current_pixmap) + finally: + painter.end() + + @Slot() + def _print(self) -> None: + """Open the print dialog and print the QR preview.""" + if not self._current_pixmap: + return + printer = QPrinter(QPrinter.PrinterMode.HighResolution) + dialog = QPrintDialog(printer, self) + if dialog.exec() == QPrintDialog.DialogCode.Accepted: + self._render_pixmap(printer) + + @Slot() + def _export_png(self) -> None: + """Export the current QR as a PNG file.""" + if not self._current_pixmap: + return + path, _ = QFileDialog.getSaveFileName( + self, + "Export PNG", + "wifi-qr.png", + "PNG Image (*.png)", + ) + if not path: + return + try: + self._export_png_to_path(self._config, path) + except Exception as exc: + QMessageBox.critical(self, "Export failed", str(exc)) + + @Slot() + def _export_pdf(self) -> None: + """Export the current QR as a PDF file.""" + if not self._current_pixmap: + return + path, _ = QFileDialog.getSaveFileName( + self, + "Export PDF", + "wifi-qr.pdf", + "PDF Document (*.pdf)", + ) + if not path: + return + try: + printer = QPrinter(QPrinter.PrinterMode.HighResolution) + printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat) + printer.setOutputFileName(path) + self._export_pdf_to_path(self._config, printer) + except Exception as exc: + QMessageBox.critical(self, "Export failed", str(exc)) + + @Slot() + def _export_windows_script(self) -> None: + """Export a Windows connect script for the current config.""" + if not self._current_payload: + return + path, _ = QFileDialog.getSaveFileName( + self, + "Export Windows Script", + "connect-wifi.cmd", + "Command Script (*.cmd)", + ) + if not path: + return + try: + self._export_windows_script_to_path(self._config, path) + QMessageBox.information( + self, + "Export complete", + "Script exported. Run it as Administrator on Windows.", + ) + except Exception as exc: + QMessageBox.critical(self, "Export failed", str(exc)) + + @Slot() + def _export_macos_profile(self) -> None: + """Export a macOS configuration profile for the current config.""" + if not self._current_payload: + return + path, _ = QFileDialog.getSaveFileName( + self, + "Export macOS Profile", + "wifi-profile.mobileconfig", + "Configuration Profile (*.mobileconfig)", + ) + if not path: + return + try: + self._export_macos_profile_to_path(self._config, path) + QMessageBox.information( + self, + "Export complete", + "Profile exported. Double-click on macOS to install.", + ) + except Exception as exc: + QMessageBox.critical(self, "Export failed", str(exc)) + + def _export_png_to_path(self, config: WifiConfig, path: str) -> None: + """Write a QR PNG for a config to the target path.""" + payload = build_wifi_payload(config) + image = generate_qr_image(payload, center_image_data=config.image_data) + if self.show_header_input.isChecked(): + image = self._compose_qr_with_header(image, config.location) + save_qr_image(image, path) + + def _export_pdf_to_path(self, config: WifiConfig, printer: QPrinter) -> None: + """Write a QR PDF for a config to the target printer.""" + payload = build_wifi_payload(config) + image = generate_qr_image(payload, center_image_data=config.image_data) + pixmap = pil_to_qpixmap(image) + painter = QPainter(printer) + try: + rect = painter.viewport() + header_text = config.location.strip() if self.show_header_input.isChecked() else "" + header_height = 0 + if header_text: + font = QFont(painter.font()) + font.setPointSize(self.HEADER_TEXT_POINT_SIZE) + painter.setFont(font) + metrics = QFontMetrics(font) + header_height = metrics.height() + self.HEADER_TEXT_PADDING + painter.drawText( + rect.x(), + rect.y() + metrics.ascent() + self.HEADER_TEXT_BASELINE_PADDING, + rect.width(), + metrics.height(), + Qt.AlignmentFlag.AlignHCenter, + header_text, + ) + size = pixmap.size() + available = rect.size() + available.setHeight(max(1, available.height() - header_height)) + size.scale(available, Qt.AspectRatioMode.KeepAspectRatio) + painter.setViewport(rect.x(), rect.y() + header_height, size.width(), size.height()) + painter.setWindow(pixmap.rect()) + painter.drawPixmap(0, 0, pixmap) + finally: + painter.end() + + def _compose_qr_with_header(self, image: Image.Image, header: str) -> Image.Image: + """Return a new image with a location header added.""" + header = header.strip() + if not header: + return image + + # Use a larger font for title-sized header + font = ImageFont.load_default(size=48) + + draw = ImageDraw.Draw(image) + bbox = draw.textbbox((0, 0), header, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + padding = self.HEADER_TEXT_PADDING * 2 + new_width = int(image.width) + new_height = int(image.height + text_height + padding * 2) + new_image = Image.new("RGB", (new_width, new_height), "white") + y_offset = int(text_height + padding * 2) + new_image.paste(image, (0, y_offset)) + draw = ImageDraw.Draw(new_image) + x = (new_width - text_width) // 2 + y = padding + draw.text((x, y), header, fill=self.DEFAULT_QR_HEADER_COLOR, font=font) + return new_image + + def _export_windows_script_to_path(self, config: WifiConfig, path: str) -> None: + """Write a Windows connect script to disk.""" + script = build_windows_connect_script(config) + with open(path, "w", encoding="utf-8") as file: + file.write(script.content) + + def _export_macos_profile_to_path(self, config: WifiConfig, path: str) -> None: + """Write a macOS profile to disk.""" + profile = build_macos_mobileconfig(config) + with open(path, "w", encoding="utf-8") as file: + file.write(profile.content) + + def _batch_export(self) -> None: + """Batch export selected or all table entries.""" + configs = self._selected_or_all_configs() + if not configs: + if self._config.ssid: + configs = [self._config] + else: + return + + export_type, ok = QInputDialog.getItem( + self, + "Batch Export", + "Choose export type:", + ["PNG", "PDF", "Windows Script", "macOS Profile"], + self.DEFAULT_EXPORT_INDEX, + False, + ) + if not ok: + return + + target_dir = QFileDialog.getExistingDirectory(self, "Select export folder") + if not target_dir: + return + + target_dir_path = Path(target_dir) + + if export_type == "Windows Script": + script_path = target_dir_path / "wifi-batch.cmd" + script = build_windows_connect_script_multi(configs) + with open(script_path, "w", encoding="utf-8") as file: + file.write(script.content) + return + if export_type == "macOS Profile": + profile_path = target_dir_path / "wifi-batch.mobileconfig" + profile = build_macos_mobileconfig_multi(configs) + with open(profile_path, "w", encoding="utf-8") as file: + file.write(profile.content) + return + + for config in configs: + safe_name = self._sanitize_filename(config.ssid) + if export_type == "PNG": + self._export_png_to_path(config, str(target_dir_path / f"{safe_name}.png")) + elif export_type == "PDF": + printer = QPrinter(QPrinter.PrinterMode.HighResolution) + printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat) + printer.setOutputFileName(str(target_dir_path / f"{safe_name}.pdf")) + self._export_pdf_to_path(config, printer) + + def _add_to_table(self) -> None: + """Add the current config to the table.""" + self._update_config() + if not self._config.ssid: + return + self._add_or_update_row(self._config) + + def _remove_selected(self) -> None: + """Remove selected rows from the table.""" + rows = sorted(self._selected_rows(), reverse=True) + for row in rows: + self.network_table.removeRow(row) + self._apply_table_column_widths() + + def _add_or_update_row(self, config: WifiConfig, row: int | None = None) -> None: + """Insert or update a table row for a config.""" + if row is None: + row = self.network_table.rowCount() + self.network_table.insertRow(row) + + self.network_table.blockSignals(True) + location_item = QTableWidgetItem(config.location) + location_item.setFlags(location_item.flags() | Qt.ItemFlag.ItemIsEditable) + # Store image_data in the location item's UserRole + location_item.setData(Qt.ItemDataRole.UserRole, config.image_data) + + ssid_item = QTableWidgetItem(config.ssid) + ssid_item.setFlags(ssid_item.flags() | Qt.ItemFlag.ItemIsEditable) + + password_item = QTableWidgetItem("") + password_item.setFlags(password_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + password_item.setData(Qt.ItemDataRole.UserRole, config.password) + + self.network_table.setItem(row, 0, location_item) + self.network_table.setItem(row, 1, ssid_item) + self.network_table.setItem(row, 2, password_item) + + password_widget = self._build_password_widget(row, config.password) + self.network_table.setCellWidget(row, 2, password_widget) + + security_combo = QComboBox() + security_combo.addItems(list(SECURITY_OPTIONS)) + security_combo.setCurrentText(config.security) + security_combo.setMaxVisibleItems(3) + view = security_combo.view() + palette = view.palette() + palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff")) + palette.setColor(QPalette.ColorRole.Window, QColor("#ffffff")) + view.setPalette(palette) + security_combo.currentTextChanged.connect( + lambda value, r=row: self._security_changed(r, value) + ) + self.network_table.setCellWidget(row, 3, security_combo) + + hidden_checkbox = QCheckBox() + hidden_checkbox.setChecked(config.hidden) + hidden_checkbox.stateChanged.connect( + lambda state, r=row: self._hidden_changed(r, state) + ) + hidden_container = QWidget() + hidden_layout = QHBoxLayout(hidden_container) + hidden_layout.addWidget(hidden_checkbox) + hidden_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + hidden_layout.setContentsMargins(0, 0, 0, 0) + self.network_table.setCellWidget(row, 4, hidden_container) + + self.network_table.blockSignals(False) + self._apply_table_column_widths() + + def _table_item_changed(self, item: QTableWidgetItem) -> None: + """Normalize table item values and adjust widths.""" + if item.column() == 0 and not item.text().strip(): + item.setText("Unnamed") + if item.column() == 1 and not item.text().strip(): + item.setText("Unnamed") + if item.column() == 3 and not item.text().strip(): + item.setText(SECURITY_OPTIONS[0]) + if item.column() in {1, 2}: + self._apply_table_column_widths() + + def _view_password(self, row: int) -> None: + """Prompt for a new password for a table row.""" + password_item = self.network_table.item(row, 2) + if not password_item: + return + current = password_item.data(Qt.ItemDataRole.UserRole) or "" + value, ok = QInputDialog.getText( + self, + "Password", + "Edit password:", + QLineEdit.EchoMode.Password, + str(current), + ) + if not ok: + return + password_item.setData(Qt.ItemDataRole.UserRole, value) + self._update_password_widget(row, value, force_visible=False) + + def _obfuscate_password(self, value: str) -> str: + """Return an obfuscated display string for a password.""" + if not value: + return "" + return "•" * max(self.PASSWORD_MASK_MIN_LENGTH, len(value)) + + def _build_password_widget(self, row: int, password: str) -> QWidget: + """Build a password display widget with a visibility toggle.""" + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + label = QLabel(self._obfuscate_password(password)) + label.setProperty("isVisible", False) + + icon_path = RESOURCE_DIR / "eye.svg" + button = QPushButton() + self._set_icon_if_exists(button, icon_path) + button.setToolTip("Toggle password visibility") + button.setFixedSize(self.PASSWORD_TOGGLE_SIZE, self.PASSWORD_TOGGLE_SIZE) + button.clicked.connect(lambda *_: self._toggle_password_visibility(row)) + + layout.addWidget(label) + layout.addStretch(1) + layout.addWidget(button) + return container + + def _update_password_widget( + self, + row: int, + password: str, + force_visible: bool | None = None, + ) -> None: + """Update the password widget contents for a row.""" + widget = self.network_table.cellWidget(row, 2) + if not widget: + return + label = widget.findChild(QLabel) + if not label: + return + is_visible = bool(label.property("isVisible")) + if force_visible is not None: + is_visible = force_visible + label.setProperty("isVisible", is_visible) + label.setText(password if is_visible else self._obfuscate_password(password)) + self._apply_table_column_widths() + + def _apply_table_column_widths(self) -> None: + """Calculate and apply column widths for the table.""" + # Cache font metrics to avoid repeated calculations + if not self._header_metrics: + self._header_metrics = QFontMetrics( + self.network_table.horizontalHeader().font() + ) + if not self._item_metrics: + self._item_metrics = QFontMetrics(self.network_table.font()) + + standard_ssid_width = self._item_metrics.horizontalAdvance( + "M" * self.STANDARD_SSID_CHARS + ) + standard_pwd_width = self._item_metrics.horizontalAdvance( + "M" * self.STANDARD_PASSWORD_CHARS + ) + + for column in range(self.network_table.columnCount()): + if column in (3, 4): # Skip Security, Hidden columns + continue + + header_item = self.network_table.horizontalHeaderItem(column) + header_text = header_item.text() if header_item else "" + max_width = self._header_metrics.horizontalAdvance(header_text) + max_width = max(max_width, self.network_table.sizeHintForColumn(column)) + + if column == 1: + max_width = max(max_width, standard_ssid_width) + elif column == 2: + max_width = max(max_width, standard_pwd_width) + + self.network_table.setColumnWidth( + column, + max_width + self.TABLE_COLUMN_PADDING, + ) + + self._update_table_group_width() + + def _update_table_group_width(self) -> None: + """Update table group width based on column sizes.""" + width = 0 + for column in range(self.network_table.columnCount()): + width += self.network_table.columnWidth(column) + width += self.network_table.verticalHeader().width() + width += self.network_table.frameWidth() * 2 + width += self.network_table.verticalScrollBar().sizeHint().width() + layout = self.table_group.layout() + if layout: + margins = layout.contentsMargins() + width += margins.left() + margins.right() + width += self.TABLE_WIDTH_PADDING + self.table_group.setMinimumWidth(width) + self.table_group.setMaximumWidth(width) + self.network_table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + def _toggle_password_visibility(self, row: int) -> None: + """Toggle visibility of a row's password widget.""" + password_item = self.network_table.item(row, 2) + if not password_item: + return + password = password_item.data(Qt.ItemDataRole.UserRole) or "" + widget = self.network_table.cellWidget(row, 2) + if not widget: + return + label = widget.findChild(QLabel) + if not label: + return + current = bool(label.property("isVisible")) + self._update_password_widget(row, password, force_visible=not current) + + def _security_changed(self, row: int, value: str) -> None: + """Update the security text for a row.""" + # Security value is stored in the combobox, no need to update item + + def _hidden_changed(self, row: int, state: int) -> None: + """Update the hidden state for a row.""" + # Hidden value is stored in the checkbox, no need to update item + + def _table_context_menu(self, pos: QPoint) -> None: + """Show context actions for table cells.""" + index = self.network_table.indexAt(pos) + if not index.isValid(): + return + row = index.row() + column = index.column() + menu = QMenu(self) + if column == 1: + edit_ssid = menu.addAction("Edit SSID") + edit_ssid.triggered.connect(lambda *_: self._edit_ssid(row)) + if column == 2: + edit_pwd = menu.addAction("Change Password") + edit_pwd.triggered.connect(lambda *_: self._view_password(row)) + menu.exec(self.network_table.viewport().mapToGlobal(pos)) + + def _edit_ssid(self, row: int) -> None: + """Prompt for editing an SSID in the table.""" + ssid_item = self.network_table.item(row, 1) + if not ssid_item: + return + current = ssid_item.text() + value, ok = QInputDialog.getText(self, "SSID", "Edit SSID:", text=current) + if ok and value.strip(): + ssid_item.setText(value.strip()) + + def _apply_search_filter(self) -> None: + """Filter table rows by the search query.""" + query = self.search_input.text().strip().lower() + self._search_matches = [] + for row in range(self.network_table.rowCount()): + location_item = self.network_table.item(row, 0) + ssid_item = self.network_table.item(row, 1) + location = location_item.text().lower() if location_item else "" + ssid = ssid_item.text().lower() if ssid_item else "" + match = query in location or query in ssid + self.network_table.setRowHidden(row, not match if query else False) + if query and match: + self._search_matches.append(row) + + # Update placeholder text with match count + if query and self._search_matches: + count = len(self._search_matches) + self.search_input.setPlaceholderText( + f"Search by location or SSID ({count} match{'es' if count != 1 else ''})" + ) + else: + self.search_input.setPlaceholderText("Search by location or SSID") + + self._search_index = 0 if self._search_matches else self.NO_SEARCH_INDEX + if self._search_matches: + self._select_search_row(self._search_matches[0]) + + def _select_search_row(self, row: int) -> None: + """Select and scroll to a table row.""" + self.network_table.selectRow(row) + item = self.network_table.item(row, 1) + if item: + self.network_table.scrollToItem(item) + + def _find_next(self) -> None: + """Select the next search match.""" + if not self._search_matches: + return + self._search_index = (self._search_index + 1) % len(self._search_matches) + self._select_search_row(self._search_matches[self._search_index]) + + def _find_previous(self) -> None: + """Select the previous search match.""" + if not self._search_matches: + return + self._search_index = (self._search_index - 1) % len(self._search_matches) + self._select_search_row(self._search_matches[self._search_index]) + + def _handle_column_resize(self, logical_index: int, old_size: int, new_size: int) -> None: + """Prevent gaps by expanding Password column when needed.""" + if logical_index not in [0, 1, 2]: # Only care about resizable columns + return + + # Calculate available width for resizable columns + table_width = self.network_table.viewport().width() + security_width = self.network_table.columnWidth(3) + hidden_width = self.network_table.columnWidth(4) + available_width = table_width - security_width - hidden_width + + # Get current widths of resizable columns + location_width = self.network_table.columnWidth(0) + ssid_width = self.network_table.columnWidth(1) + password_width = self.network_table.columnWidth(2) + + # Calculate total width of resizable columns + total_resizable_width = location_width + ssid_width + password_width + + # If there would be a gap, expand Password column to fill it + if total_resizable_width < available_width: + gap = available_width - total_resizable_width + header = self.network_table.horizontalHeader() + header.sectionResized.disconnect(self._handle_column_resize) + self.network_table.setColumnWidth(2, password_width + gap) + header.sectionResized.connect(self._handle_column_resize) + + def _handle_sort(self, column: int) -> None: + """Toggle sorting for the given column.""" + if column not in {0, 1}: + return + order = self._sort_orders.get(column, Qt.SortOrder.AscendingOrder) + order = ( + Qt.SortOrder.DescendingOrder + if order == Qt.SortOrder.AscendingOrder + else Qt.SortOrder.AscendingOrder + ) + self._sort_orders[column] = order + self.network_table.sortItems(column, order) + + def _selected_rows(self) -> list[int]: + """Return selected table row indices.""" + rows = {item.row() for item in self.network_table.selectedItems()} + return sorted(rows) + + def _selected_or_all_configs(self) -> list[WifiConfig]: + """Return configs for selected rows or all rows.""" + rows = self._selected_rows() + if not rows: + rows = list(range(self.network_table.rowCount())) + configs = [] + for row in rows: + config = self._row_to_config(row) + if config: + configs.append(config) + return configs + + def _row_to_config(self, row: int) -> WifiConfig | None: + """Convert a table row into a config if possible.""" + location_item = self.network_table.item(row, 0) + ssid_item = self.network_table.item(row, 1) + password_item = self.network_table.item(row, 2) + security_combo = self.network_table.cellWidget(row, 3) + if not location_item or not ssid_item or not password_item: + return None + security_value = None + if isinstance(security_combo, QComboBox): + security_value = security_combo.currentText() + else: + security_item = self.network_table.item(row, 3) + security_value = ( + security_item.text().strip() + if security_item + else SECURITY_OPTIONS[0] + ) + password = password_item.data(Qt.ItemDataRole.UserRole) or "" + + hidden_widget = self.network_table.cellWidget(row, 4) + hidden_value = False + if hidden_widget: + hidden_checkbox = hidden_widget.findChild(QCheckBox) + if hidden_checkbox: + hidden_value = hidden_checkbox.isChecked() + + # Image data is stored in location item's UserRole + image_data = location_item.data(Qt.ItemDataRole.UserRole) + + return WifiConfig( + location=location_item.text().strip(), + ssid=ssid_item.text().strip(), + password=str(password), + security=security_value or SECURITY_OPTIONS[0], + hidden=hidden_value, + image_data=image_data if isinstance(image_data, str) else None, + ) + + def _save(self) -> None: + """Save to the last path or prompt for a new one.""" + if self._current_save_path: + self._save_to_path(self._current_save_path) + else: + self._save_as() + + def _save_as(self) -> None: + """Prompt for a save path and persist configs.""" + path, _ = QFileDialog.getSaveFileName( + self, + "Save", + "wifi-networks.json", + "JSON Files (*.json)", + ) + if not path: + return + self._current_save_path = path + self._save_to_path(path) + + def _save_to_path(self, path: str) -> None: + """Write configs to a JSON path.""" + configs = self._selected_or_all_configs() + if not configs and self._config.ssid: + configs = [self._config] + + data = [ + { + "location": c.location, + "ssid": c.ssid, + "password": c.password, + "security": c.security, + "hidden": c.hidden, + "image_data": c.image_data, + } + for c in configs + ] + with open(path, "w", encoding="utf-8") as file: + json.dump(data, file, indent=2) + + def _load(self) -> None: + """Prompt for a JSON file and load configs.""" + path, _ = QFileDialog.getOpenFileName( + self, + "Load", + "", + "JSON Files (*.json)", + ) + if not path: + return + with open(path, encoding="utf-8") as file: + data = json.load(file) + self._load_from_data(data) + self._current_save_path = path + + def _load_from_data(self, data: list[dict[str, object]]) -> None: + """Populate table and form from loaded data.""" + self.network_table.setRowCount(0) + configs = [] + for entry in data: + config = WifiConfig( + location=str(entry.get("location", "")), + ssid=str(entry.get("ssid", "")), + password=str(entry.get("password", "")), + security=str(entry.get("security", SECURITY_OPTIONS[0])), + hidden=bool(entry.get("hidden", False)), + image_data=( + str(img_data) + if (img_data := entry.get("image_data")) and isinstance(img_data, str) + else None + ), + ) + configs.append(config) + self._add_or_update_row(config) + if len(configs) == 1: + self._set_form_from_config(configs[0]) + + def _set_form_from_config(self, config: WifiConfig) -> None: + """Set the form inputs based on a config.""" + self.location_input.setText(config.location) + self.ssid_input.setText(config.ssid) + self.password_input.setText(config.password) + self.security_input.setCurrentText(config.security) + self.hidden_input.setChecked(config.hidden) + if config.image_data: + self.image_path_display.setText("(Image loaded from saved network)") + else: + self.image_path_display.clear() + self._config = config + self._refresh_preview() + + def _table_double_clicked(self, index: QModelIndex) -> None: + """Load the double-clicked network into the form and preview.""" + row = index.row() + config = self._row_to_config(row) + if config: + self._set_form_from_config(config) + + def _show_about(self) -> None: + """Display the About dialog.""" + try: + version = metadata.version("wifiqr") + except metadata.PackageNotFoundError: + version = "dev" + + QMessageBox.information( + self, + "About WifiQR", + ( + "WifiQR\n" + "Cross-platform Wi-Fi QR code generator\n" + f"Version: {version}\n" + "Built with PySide6, qrcode, Pillow, CairoSVG\n" + "Author: Randy Northrup" + ), + ) + + @Slot() + def _focus_search(self) -> None: + """Focus the search input field.""" + self.search_input.setFocus() + self.search_input.selectAll() + + def _sanitize_filename(self, value: str) -> str: + """Return a filesystem-safe filename from a label.""" + return "".join(c if c.isalnum() or c in {"-", "_"} else "_" for c in value) or "wifi"