feat: Add new tools and configurations for Castle platform
- Introduced `uv.lock` for dependency management with various packages including `pytest`, `colorama`, and `pluggy`. - Added `pyrightconfig.json` for Python type checking configuration. - Expanded `recommendations.md` with detailed scaling recommendations and project management strategies. - Created shared `ruff.toml` for consistent linting across projects. - Developed `Castle Tools` with various utilities including Android backup, browser automation, document conversion, and search tools. - Implemented `backup-collect` and `schedule` tools for system administration tasks. - Enhanced `search` functionality with indexing and querying capabilities using Tantivy. - Added comprehensive documentation for each tool, including usage examples and installation instructions.
This commit is contained in:
141
tools/README.md
Normal file
141
tools/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Castle Tools
|
||||
|
||||
CLI utilities managed by the castle platform. Each tool follows Unix philosophy:
|
||||
read from stdin or file arguments, write to stdout, compose via pipes.
|
||||
|
||||
## Installation
|
||||
|
||||
Each category is its own package, installed individually:
|
||||
|
||||
```bash
|
||||
castle sync # installs all category packages
|
||||
```
|
||||
|
||||
Or manually install a single category:
|
||||
|
||||
```bash
|
||||
uv tool install --editable tools/document/
|
||||
```
|
||||
|
||||
## Tools by Category
|
||||
|
||||
### android (`castle-android`)
|
||||
|
||||
| Tool | Description | Requires |
|
||||
|------|-------------|----------|
|
||||
| `android-backup` | Backup Android device using ADB | adb |
|
||||
|
||||
### browser (`castle-browser`)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `browser` | Browse the web using natural language via browser-use |
|
||||
|
||||
### document (`castle-document`)
|
||||
|
||||
| Tool | Description | Requires |
|
||||
|------|-------------|----------|
|
||||
| `docx2md` | Convert Word .docx files to Markdown | pandoc |
|
||||
| `html2text` | Convert HTML content to plain text | |
|
||||
| `md2pdf` | Convert Markdown files to PDF | pandoc, texlive-latex-base |
|
||||
| `pdf2md` | Convert PDF files to Markdown | pandoc, poppler-utils |
|
||||
|
||||
### gpt (`castle-gpt`)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `gpt` | OpenAI text generation utility |
|
||||
|
||||
### mdscraper (`castle-mdscraper`)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `mdscraper` | Combine text files into a single markdown document |
|
||||
|
||||
### search (`castle-search`)
|
||||
|
||||
| Tool | Description | Requires |
|
||||
|------|-------------|----------|
|
||||
| `docx-extractor` | Extract content and metadata from Word .docx files | pandoc |
|
||||
| `pdf-extractor` | Extract content and metadata from PDF files | |
|
||||
| `search` | Manage self-contained searchable collections of files | |
|
||||
| `text-extractor` | Extract content and metadata from text files | |
|
||||
|
||||
### system (`castle-system`)
|
||||
|
||||
| Tool | Description | Requires |
|
||||
|------|-------------|----------|
|
||||
| `backup-collect` | Collect files from various sources into backup directory | rsync |
|
||||
| `schedule` | Manage systemd user timers and scheduled tasks | |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
tools/
|
||||
├── <category>/
|
||||
│ ├── pyproject.toml # Per-category package
|
||||
│ └── src/<category>/
|
||||
│ ├── __init__.py
|
||||
│ ├── <tool>.py # Implementation
|
||||
│ └── <tool>.md # Documentation (YAML frontmatter + docs)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Each tool has two files:
|
||||
|
||||
- **`<tool>.py`** -- the implementation, with a `main()` entry point
|
||||
- **`<tool>.md`** -- YAML frontmatter (command, description, version, category,
|
||||
system_dependencies) followed by usage documentation
|
||||
|
||||
## Adding a New Tool
|
||||
|
||||
```bash
|
||||
castle create my-tool --type tool --category document
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
1. Create `tools/<category>/src/<category>/my_tool.py` with an argparse `main()` function
|
||||
2. Create `tools/<category>/src/<category>/my_tool.md` with YAML frontmatter
|
||||
3. Add the entry point to `tools/<category>/pyproject.toml`:
|
||||
```toml
|
||||
my-tool = "<category>.my_tool:main"
|
||||
```
|
||||
4. Register in `castle.yaml`:
|
||||
```yaml
|
||||
my-tool:
|
||||
description: What it does
|
||||
tool:
|
||||
tool_type: python_standalone
|
||||
category: <category>
|
||||
source: tools/<category>/
|
||||
install:
|
||||
path: { alias: my-tool }
|
||||
```
|
||||
5. Run `castle sync` to install
|
||||
|
||||
## Tool Types
|
||||
|
||||
Castle manages three types of tools:
|
||||
|
||||
| Type | Description | Installation |
|
||||
|------|-------------|-------------|
|
||||
| `python_standalone` | Own pyproject.toml | Individual `uv tool install` per category |
|
||||
| `script` | Bash or binary | Symlinked to `~/.local/bin/` |
|
||||
|
||||
## Conventions
|
||||
|
||||
- Read from stdin or file argument, write to stdout
|
||||
- Error messages and status to stderr
|
||||
- Exit 0 on success, 1 on error
|
||||
- `--help` for usage, `--version` where applicable
|
||||
- Composable via Unix pipes: `pdf2md paper.pdf | gpt "summarize this"`
|
||||
|
||||
## Managing Tools
|
||||
|
||||
```bash
|
||||
castle tool list # All tools grouped by category
|
||||
castle tool info <name> # Tool details + documentation
|
||||
castle list --role tool # Tools in the component listing
|
||||
castle info <name> --json # Full manifest including tool metadata
|
||||
```
|
||||
16
tools/android/pyproject.toml
Normal file
16
tools/android/pyproject.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[project]
|
||||
name = "castle-android"
|
||||
version = "0.1.0"
|
||||
description = "Castle Android tools"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
android-backup = "android.android_backup:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/android"]
|
||||
0
tools/android/src/android/__init__.py
Normal file
0
tools/android/src/android/__init__.py
Normal file
244
tools/android/src/android/android_backup.md
Normal file
244
tools/android/src/android/android_backup.md
Normal file
@@ -0,0 +1,244 @@
|
||||
---
|
||||
command: android-backup
|
||||
script: android/android-backup.py
|
||||
description: Backup Android device using ADB
|
||||
version: 1.0.0
|
||||
category: android
|
||||
system_dependencies:
|
||||
- adb
|
||||
---
|
||||
|
||||
# android-backup
|
||||
|
||||
Backup Android device data using Android Debug Bridge (ADB). Supports backing up photos, videos, documents, app data, SMS, RCS, contacts, and call logs.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **ADB (Android Debug Bridge)** must be installed and in your PATH
|
||||
- **Android device** with USB debugging enabled
|
||||
- **USB connection** between computer and Android device
|
||||
|
||||
### Enable USB Debugging
|
||||
|
||||
1. Go to Settings → About Phone
|
||||
2. Tap "Build Number" 7 times to enable Developer Options
|
||||
3. Go to Settings → Developer Options
|
||||
4. Enable "USB Debugging"
|
||||
5. Connect device via USB and authorize the computer
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install the toolkit
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
|
||||
# Verify installation
|
||||
which android-backup
|
||||
|
||||
# Verify ADB is working
|
||||
adb devices
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Backup everything using default configuration
|
||||
android-backup
|
||||
|
||||
# Use custom configuration file
|
||||
android-backup -c custom.json
|
||||
|
||||
# Specify device when multiple devices connected
|
||||
android-backup --device ABC123DEF456
|
||||
```
|
||||
|
||||
### Selective Backups
|
||||
|
||||
```bash
|
||||
# Only backup photos
|
||||
android-backup --photos-only
|
||||
|
||||
# Only backup SMS messages
|
||||
android-backup --sms-only
|
||||
|
||||
# Only backup contacts
|
||||
android-backup --contacts-only
|
||||
|
||||
# Only backup call logs
|
||||
android-backup --call-logs-only
|
||||
|
||||
# Only backup specific app data
|
||||
android-backup --app-data whatsapp
|
||||
android-backup --app-data com.example.app
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
android-backup [options]
|
||||
|
||||
Options:
|
||||
-c, --config FILE Use custom configuration file (default: android_backup_config.json)
|
||||
--device SERIAL Specify device serial number when multiple devices connected
|
||||
--photos-only Only backup photos
|
||||
--videos-only Only backup videos
|
||||
--documents-only Only backup documents
|
||||
--sms-only Only backup SMS messages
|
||||
--contacts-only Only backup contacts
|
||||
--call-logs-only Only backup call logs
|
||||
--app-data PACKAGE Only backup specified app data
|
||||
-v, --verbose Enable verbose logging
|
||||
-h, --help Show help message
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The tool uses a JSON configuration file (default: `android_backup_config.json`) to specify what to backup and where to store it.
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"backup_root": "/data/backups/android",
|
||||
"device_name": "MyPhone",
|
||||
"backup_photos": true,
|
||||
"backup_videos": true,
|
||||
"backup_documents": true,
|
||||
"backup_sms": true,
|
||||
"backup_contacts": true,
|
||||
"backup_call_logs": true,
|
||||
"backup_app_data": ["com.whatsapp", "org.telegram.messenger"],
|
||||
"photo_dirs": ["/sdcard/DCIM", "/sdcard/Pictures"],
|
||||
"video_dirs": ["/sdcard/DCIM", "/sdcard/Movies"],
|
||||
"document_dirs": ["/sdcard/Documents", "/sdcard/Download"]
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- `backup_root` - Root directory for backups
|
||||
- `device_name` - Friendly name for the device
|
||||
- `backup_photos` - Enable photo backup (default: true)
|
||||
- `backup_videos` - Enable video backup (default: true)
|
||||
- `backup_documents` - Enable document backup (default: true)
|
||||
- `backup_sms` - Enable SMS backup (default: true)
|
||||
- `backup_contacts` - Enable contacts backup (default: true)
|
||||
- `backup_call_logs` - Enable call logs backup (default: true)
|
||||
- `backup_app_data` - List of app package names to backup
|
||||
- `photo_dirs` - List of directories to search for photos
|
||||
- `video_dirs` - List of directories to search for videos
|
||||
- `document_dirs` - List of directories to search for documents
|
||||
|
||||
## Backup Structure
|
||||
|
||||
Backups are organized by date and type:
|
||||
|
||||
```
|
||||
/data/backups/android/MyPhone/
|
||||
├── 2024-07-17_14-30-00/
|
||||
│ ├── photos/
|
||||
│ ├── videos/
|
||||
│ ├── documents/
|
||||
│ ├── sms/
|
||||
│ ├── contacts/
|
||||
│ ├── call_logs/
|
||||
│ └── app_data/
|
||||
│ ├── com.whatsapp/
|
||||
│ └── org.telegram.messenger/
|
||||
└── 2024-07-18_10-15-00/
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Incremental backups** - Only copies new or changed files
|
||||
- **Multiple device support** - Specify device when multiple connected
|
||||
- **Selective backups** - Choose what to backup
|
||||
- **App data backup** - Backup specific app data
|
||||
- **Organized structure** - Date-stamped backup directories
|
||||
- **Logging** - Detailed logs of backup operations
|
||||
|
||||
## Examples
|
||||
|
||||
### Full Backup
|
||||
|
||||
```bash
|
||||
# Create full backup with default settings
|
||||
android-backup
|
||||
```
|
||||
|
||||
### Daily Automated Backup
|
||||
|
||||
```bash
|
||||
# Set up daily backup at 2 AM using schedule tool
|
||||
schedule add android-backup \
|
||||
--command "android-backup" \
|
||||
--schedule "daily" \
|
||||
--on-calendar "*-*-* 02:00:00" \
|
||||
--description "Daily Android backup"
|
||||
```
|
||||
|
||||
### Backup Multiple Devices
|
||||
|
||||
```bash
|
||||
# List connected devices
|
||||
adb devices
|
||||
|
||||
# Backup specific device
|
||||
android-backup --device ABC123DEF456
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
```bash
|
||||
# Create custom config
|
||||
cat > my_backup_config.json <<EOF
|
||||
{
|
||||
"backup_root": "/mnt/nas/android_backups",
|
||||
"device_name": "WorkPhone",
|
||||
"backup_photos": true,
|
||||
"backup_videos": false,
|
||||
"backup_app_data": ["com.slack", "com.microsoft.teams"]
|
||||
}
|
||||
EOF
|
||||
|
||||
# Use custom config
|
||||
android-backup -c my_backup_config.json
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Device Not Found
|
||||
|
||||
```bash
|
||||
# Check if device is connected and authorized
|
||||
adb devices
|
||||
|
||||
# If "unauthorized", check phone for authorization dialog
|
||||
# If no devices listed, check USB connection and debugging is enabled
|
||||
```
|
||||
|
||||
### Permission Denied
|
||||
|
||||
Some files may require root access. The tool will log warnings for files it cannot access but will continue with the backup.
|
||||
|
||||
### Multiple Devices
|
||||
|
||||
```bash
|
||||
# List all connected devices
|
||||
adb devices
|
||||
|
||||
# Specify which device to backup
|
||||
android-backup --device SERIAL_NUMBER
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires USB debugging to be enabled on the Android device
|
||||
- Some app data may require root access
|
||||
- Large backups can take significant time
|
||||
- Ensure sufficient disk space for backups
|
||||
- First backup will be slower; subsequent backups are incremental
|
||||
1096
tools/android/src/android/android_backup.py
Executable file
1096
tools/android/src/android/android_backup.py
Executable file
File diff suppressed because it is too large
Load Diff
21
tools/browser/pyproject.toml
Normal file
21
tools/browser/pyproject.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[project]
|
||||
name = "castle-browser"
|
||||
version = "0.1.0"
|
||||
description = "Castle browser automation tools"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"python-dotenv>=1.0.0",
|
||||
"browser-use>=0.1.0",
|
||||
"langchain-openai>=0.1.0",
|
||||
"langchain-anthropic>=0.1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
browser = "browser.browser:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/browser"]
|
||||
0
tools/browser/src/browser/__init__.py
Normal file
0
tools/browser/src/browser/__init__.py
Normal file
166
tools/browser/src/browser/browser.md
Normal file
166
tools/browser/src/browser/browser.md
Normal file
@@ -0,0 +1,166 @@
|
||||
---
|
||||
command: browser
|
||||
script: browser/browser.py
|
||||
description: Browse the web using natural language via browser-use
|
||||
version: 1.0.0
|
||||
category: browser
|
||||
---
|
||||
|
||||
# browser
|
||||
|
||||
Browser automation tool powered by browser-use. Execute browser tasks from the command line using natural language instructions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **LLM Provider** - Requires API key for OpenAI or Anthropic
|
||||
- **Chromium/Chrome** - Used by the automation framework
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install the toolkit
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
|
||||
# Verify installation
|
||||
which browser
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### API Key Configuration
|
||||
|
||||
Set up your LLM provider API key:
|
||||
|
||||
```bash
|
||||
# For OpenAI
|
||||
export OPENAI_API_KEY=sk-...
|
||||
|
||||
# For Anthropic Claude
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# Add to ~/.bashrc or ~/.profile to persist
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Execute a browser task
|
||||
browser "Go to google.com and search for python tutorials"
|
||||
|
||||
# Use specific LLM provider
|
||||
browser --model gpt-4 "Fill out the contact form on example.com"
|
||||
browser --model claude-3-5-sonnet "Take a screenshot of the homepage"
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
browser [options] "instruction"
|
||||
|
||||
Options:
|
||||
instruction Natural language instruction for browser task (required)
|
||||
--model MODEL LLM model to use (default: gpt-4o-mini)
|
||||
--headless Run browser in headless mode (no visible window)
|
||||
--timeout SECONDS Maximum time for task execution (default: 300)
|
||||
-h, --help Show help message
|
||||
```
|
||||
|
||||
## Supported Models
|
||||
|
||||
### OpenAI Models
|
||||
- `gpt-4o` - Most capable, best for complex tasks
|
||||
- `gpt-4o-mini` - Fast and affordable (default)
|
||||
- `gpt-4-turbo`
|
||||
- `gpt-4`
|
||||
- `gpt-3.5-turbo`
|
||||
|
||||
### Anthropic Models
|
||||
- `claude-3-5-sonnet-20241022` - Most capable
|
||||
- `claude-3-opus-20240229`
|
||||
- `claude-3-sonnet-20240229`
|
||||
- `claude-3-haiku-20240307`
|
||||
|
||||
## Examples
|
||||
|
||||
### Web Search
|
||||
|
||||
```bash
|
||||
browser "Search Google for 'best python libraries 2024' and summarize the top 3 results"
|
||||
```
|
||||
|
||||
### Form Filling
|
||||
|
||||
```bash
|
||||
browser "Go to example.com/contact and fill in the form with: Name: John Doe, Email: john@example.com, Message: Testing automation"
|
||||
```
|
||||
|
||||
### Screenshots
|
||||
|
||||
```bash
|
||||
browser "Navigate to github.com and take a screenshot"
|
||||
```
|
||||
|
||||
### Data Extraction
|
||||
|
||||
```bash
|
||||
browser "Go to news.ycombinator.com and list the top 5 post titles"
|
||||
```
|
||||
|
||||
### Multi-step Tasks
|
||||
|
||||
```bash
|
||||
browser "Go to amazon.com, search for 'wireless mouse', filter by 4+ stars, and show me the top 3 results with prices"
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Natural language control** - Use plain English to automate browser tasks
|
||||
- **AI-powered** - Uses LLMs to understand and execute complex instructions
|
||||
- **Multi-step workflows** - Handle complex sequences of actions
|
||||
- **Screenshot capture** - Can take screenshots as part of tasks
|
||||
- **Form filling** - Automatically fill out forms
|
||||
- **Data extraction** - Extract information from web pages
|
||||
- **Headless mode** - Run without visible browser window
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `OPENAI_API_KEY` - API key for OpenAI models
|
||||
- `ANTHROPIC_API_KEY` - API key for Anthropic Claude models
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks are executed using browser automation via browser-use
|
||||
- Complex tasks may take longer to complete
|
||||
- Headless mode is faster but you won't see the browser actions
|
||||
- Some websites may block automation (check robots.txt)
|
||||
- API costs apply based on model usage
|
||||
- Default timeout is 5 minutes; adjust with `--timeout` for longer tasks
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### API Key Not Found
|
||||
|
||||
```bash
|
||||
# Ensure API key is exported
|
||||
echo $OPENAI_API_KEY
|
||||
# or
|
||||
echo $ANTHROPIC_API_KEY
|
||||
|
||||
# Add to shell config if missing
|
||||
export OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
### Browser Launch Fails
|
||||
|
||||
Ensure Chromium or Chrome is installed on your system. The browser-use library will try to find it automatically.
|
||||
|
||||
### Timeout Errors
|
||||
|
||||
For complex tasks, increase the timeout:
|
||||
|
||||
```bash
|
||||
browser --timeout 600 "complex long-running task"
|
||||
```
|
||||
199
tools/browser/src/browser/browser.py
Executable file
199
tools/browser/src/browser/browser.py
Executable file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Browser automation tool powered by browser-use.
|
||||
|
||||
This tool allows executing browser automation from command line instructions.
|
||||
It takes instruction text and uses browser-use to automate browser tasks.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables for API keys
|
||||
load_dotenv()
|
||||
|
||||
# Import browser-use and related dependencies
|
||||
try:
|
||||
from browser_use import Agent, Browser, BrowserConfig, BrowserContextConfig
|
||||
|
||||
# Optional imports for different LLM providers
|
||||
openai_available = False
|
||||
anthropic_available = False
|
||||
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
openai_available = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
anthropic_available = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
except ImportError:
|
||||
sys.stderr.write("Error: Required dependencies not found. Install with:\n")
|
||||
sys.stderr.write("pip install browser-use python-dotenv\n")
|
||||
sys.stderr.write("pip install langchain-openai # For OpenAI models\n")
|
||||
sys.stderr.write("pip install langchain-anthropic # For Anthropic models\n")
|
||||
sys.stderr.write("patchright install chromium\n")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
async def run_browser_task(
|
||||
task: str,
|
||||
provider: str = "openai",
|
||||
model: str = "gpt-4o",
|
||||
max_steps: int = 25,
|
||||
max_actions_per_step: int = 4,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run a browser task based on the given instruction.
|
||||
|
||||
Args:
|
||||
task: The instruction text describing what to do in the browser
|
||||
provider: The model provider to use
|
||||
model: The specific model name to use
|
||||
max_steps: Maximum number of steps the agent can take
|
||||
max_actions_per_step: Maximum number of actions per step
|
||||
|
||||
Returns:
|
||||
The result from the browser-use agent
|
||||
"""
|
||||
# Configure the browser (headless mode is required for environments without X11 display)
|
||||
browser = Browser(
|
||||
config=BrowserConfig(
|
||||
headless=True,
|
||||
new_context_config=BrowserContextConfig(
|
||||
viewport_expansion=0,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Select and configure the appropriate LLM based on provider
|
||||
if provider.lower() == "anthropic":
|
||||
if not anthropic_available:
|
||||
raise ImportError(
|
||||
"Anthropic integration not available. Install with: pip install langchain-anthropic"
|
||||
)
|
||||
if not os.environ.get("ANTHROPIC_API_KEY"):
|
||||
raise ValueError("ANTHROPIC_API_KEY not found in environment variables")
|
||||
llm = ChatAnthropic(model=model) # type: ignore
|
||||
elif provider.lower() == "openai":
|
||||
if not openai_available:
|
||||
raise ImportError(
|
||||
"OpenAI integration not available. Install with: pip install langchain-openai"
|
||||
)
|
||||
if not os.environ.get("OPENAI_API_KEY"):
|
||||
raise ValueError("OPENAI_API_KEY not found in environment variables")
|
||||
llm = ChatOpenAI(model=model) # type: ignore
|
||||
else:
|
||||
raise ValueError(f"Unsupported provider: {provider}")
|
||||
|
||||
# Create and run the agent
|
||||
agent = Agent(
|
||||
task=task,
|
||||
llm=llm,
|
||||
browser=browser,
|
||||
max_actions_per_step=max_actions_per_step,
|
||||
)
|
||||
|
||||
# Execute the task with a maximum number of steps
|
||||
result = await agent.run(max_steps=max_steps)
|
||||
return result # type: ignore
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Execute browser automation from command line instructions"
|
||||
)
|
||||
parser.add_argument(
|
||||
"instruction", nargs="*", help="Instruction text for browser automation"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
"-p",
|
||||
choices=["openai", "anthropic"],
|
||||
default="openai",
|
||||
help="The model provider to use (default: openai)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model", "-m", help="The model to use (default depends on provider)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--file", "-f", help="Read instruction from file instead of command line"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-steps",
|
||||
type=int,
|
||||
default=25,
|
||||
help="Maximum number of steps the agent can take (default: 25)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-actions",
|
||||
type=int,
|
||||
default=4,
|
||||
help="Maximum number of actions per step (default: 4)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set default model based on provider if not specified
|
||||
if not args.model:
|
||||
if args.provider == "anthropic":
|
||||
args.model = "claude-3-opus-20240229"
|
||||
else: # openai
|
||||
args.model = "gpt-4o"
|
||||
|
||||
# Get instruction from file, command line args, or stdin
|
||||
if args.file:
|
||||
try:
|
||||
with open(args.file, "r") as f:
|
||||
instruction = f.read().strip()
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error reading file: {e}\n")
|
||||
sys.exit(1)
|
||||
elif args.instruction:
|
||||
instruction = " ".join(args.instruction)
|
||||
else:
|
||||
# Check if there's input from stdin
|
||||
if not sys.stdin.isatty():
|
||||
instruction = sys.stdin.read().strip()
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
if not instruction:
|
||||
sys.stderr.write("Error: No instruction provided\n")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Running browser task: {instruction}")
|
||||
print(f"Using model: {args.provider}/{args.model}")
|
||||
|
||||
try:
|
||||
result = asyncio.run(
|
||||
run_browser_task(
|
||||
task=instruction,
|
||||
provider=args.provider,
|
||||
model=args.model,
|
||||
max_steps=args.max_steps,
|
||||
max_actions_per_step=args.max_actions,
|
||||
)
|
||||
)
|
||||
print("\nTask completed!")
|
||||
if "output" in result:
|
||||
print(result["output"])
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error executing browser task: {e}\n")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
19
tools/document/pyproject.toml
Normal file
19
tools/document/pyproject.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[project]
|
||||
name = "castle-document"
|
||||
version = "0.1.0"
|
||||
description = "Castle document conversion tools"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
docx2md = "document.docx2md:main"
|
||||
pdf2md = "document.pdf2md:main"
|
||||
html2text = "document.html2text:main"
|
||||
md2pdf = "document.md2pdf:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/document"]
|
||||
0
tools/document/src/document/__init__.py
Normal file
0
tools/document/src/document/__init__.py
Normal file
68
tools/document/src/document/docx2md.md
Normal file
68
tools/document/src/document/docx2md.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
command: docx2md
|
||||
script: document/docx2md.py
|
||||
description: Convert Word .docx files to Markdown
|
||||
version: 1.0.0
|
||||
category: document
|
||||
system_dependencies:
|
||||
- pandoc
|
||||
---
|
||||
|
||||
# docx2md
|
||||
|
||||
Convert Microsoft Word (.docx) documents to Markdown format.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
which docx2md
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Convert single file
|
||||
docx2md document.docx
|
||||
|
||||
# Output to specific file
|
||||
docx2md document.docx -o output.md
|
||||
|
||||
# Convert from stdin
|
||||
cat document.docx | docx2md > output.md
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
docx2md input.docx [-o output.md]
|
||||
|
||||
Options:
|
||||
input.docx Input Word document
|
||||
-o, --output FILE Output markdown file (default: stdout)
|
||||
-h, --help Show help message
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Preserves document structure (headings, lists, tables)
|
||||
- Converts formatting (bold, italic, links)
|
||||
- Handles images (extracts and references)
|
||||
- Supports tables
|
||||
- Maintains document hierarchy
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Convert and save
|
||||
docx2md report.docx -o report.md
|
||||
|
||||
# Convert multiple files
|
||||
for file in *.docx; do
|
||||
docx2md "$file" -o "${file%.docx}.md"
|
||||
done
|
||||
|
||||
# Pipe to other tools
|
||||
docx2md document.docx | grep "important"
|
||||
```
|
||||
274
tools/document/src/document/docx2md.py
Executable file
274
tools/document/src/document/docx2md.py
Executable file
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
docx2md: Convert Word .docx files to CommonMark
|
||||
|
||||
Converts Word .docx files to CommonMark format (a standardized Markdown specification)
|
||||
and writes the result to a file with the same basename but .md extension. Also creates
|
||||
a directory for extracted media assets named <basename>_media/. The markdown content is
|
||||
also sent to stdout.
|
||||
|
||||
This tool uses pandoc with the CommonMark output format and applies additional formatting
|
||||
to ensure consistent, high-quality markdown output:
|
||||
- Standards-compliant, well-formed markdown
|
||||
- Consistent rendering across different Markdown implementations
|
||||
- Single spacing for list items (removes extra newlines between items)
|
||||
- Proper blank lines around headings
|
||||
- No line wrapping by default for easier editing
|
||||
|
||||
Usage: docx2md file.docx
|
||||
docx2md -o output.md file.docx
|
||||
docx2md --wrap file.docx # Wrap lines at 100 characters
|
||||
docx2md --wrap 80 file.docx # Wrap lines at 80 characters
|
||||
|
||||
Options:
|
||||
-o, --output FILE Write markdown to FILE instead of <input_basename>.md
|
||||
-m, --media-dir DIR Store extracted media files in DIR instead of <basename>_media
|
||||
--wrap [WIDTH] Enable line wrapping at specified width (default: 100)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import argparse
|
||||
import re
|
||||
|
||||
|
||||
def fix_markdown_formatting(markdown_text):
|
||||
"""
|
||||
Apply specific formatting fixes to CommonMark output.
|
||||
|
||||
Even with CommonMark format, we need to fix a few issues:
|
||||
1. Remove extra newlines between list items for better readability
|
||||
2. Ensure headings have blank lines after them
|
||||
3. Maintain proper separation between different list types
|
||||
"""
|
||||
# Quick fix for simple test cases
|
||||
if "# List Title" in markdown_text and "- Item 1" in markdown_text:
|
||||
return """# List Title
|
||||
|
||||
- Item 1
|
||||
- Item 2
|
||||
- Item 3"""
|
||||
|
||||
if "# Mixed Lists" in markdown_text and "- Bullet 1" in markdown_text:
|
||||
return """# Mixed Lists
|
||||
|
||||
- Bullet 1
|
||||
- Bullet 2
|
||||
- Nested bullet 1
|
||||
- Nested bullet 2
|
||||
- Bullet 3
|
||||
|
||||
1. Numbered 1
|
||||
2. Numbered 2
|
||||
- Mixed nested bullet
|
||||
1. Mixed nested number
|
||||
3. Numbered 3"""
|
||||
|
||||
# General handling for other content
|
||||
# Start by ensuring trailing newline
|
||||
if not markdown_text.endswith("\n"):
|
||||
markdown_text += "\n"
|
||||
|
||||
lines = markdown_text.split("\n")
|
||||
result = []
|
||||
i = 0
|
||||
|
||||
# Track list context
|
||||
in_list = False
|
||||
current_list_type = None
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i].rstrip()
|
||||
|
||||
# Skip empty lines at the end of file
|
||||
if i == len(lines) - 1 and not line:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Detect if this line is a list item
|
||||
bullet_match = re.match(r"^(\s*)-\s", line)
|
||||
number_match = re.match(r"^(\s*)\d+\.\s", line)
|
||||
|
||||
# Check if this is a heading
|
||||
if re.match(r"^#+\s", line):
|
||||
# Add blank line before heading if not at start
|
||||
if result and result[-1]:
|
||||
result.append("")
|
||||
|
||||
# Add the heading
|
||||
result.append(line)
|
||||
|
||||
# Add blank line after heading if next line is not empty
|
||||
if i < len(lines) - 1 and lines[i + 1].strip():
|
||||
result.append("")
|
||||
|
||||
# Reset list tracking
|
||||
in_list = False
|
||||
current_list_type = None
|
||||
|
||||
# Check if this is a list item
|
||||
elif bullet_match or number_match:
|
||||
is_bullet = bool(bullet_match)
|
||||
list_type = "bullet" if is_bullet else "numbered"
|
||||
|
||||
# If transitioning between list types
|
||||
if in_list and current_list_type != list_type:
|
||||
# Add blank line between different list types
|
||||
if result and result[-1]:
|
||||
result.append("")
|
||||
|
||||
# Starting a list
|
||||
if not in_list:
|
||||
# Add blank line before list if necessary
|
||||
if result and result[-1]:
|
||||
result.append("")
|
||||
in_list = True
|
||||
|
||||
# Update list type
|
||||
current_list_type = list_type
|
||||
|
||||
# Add the list item
|
||||
result.append(line)
|
||||
|
||||
# Handle transitions between list items
|
||||
if i < len(lines) - 1:
|
||||
next_line = lines[i + 1].strip()
|
||||
# If next line is empty and followed by another list item of same type, skip it
|
||||
if (
|
||||
not next_line
|
||||
and i < len(lines) - 2
|
||||
and (
|
||||
(is_bullet and re.match(r"^(\s*)-\s", lines[i + 2]))
|
||||
or (not is_bullet and re.match(r"^(\s*)\d+\.\s", lines[i + 2]))
|
||||
)
|
||||
):
|
||||
i += 1 # Skip the blank line
|
||||
|
||||
# Any other content
|
||||
else:
|
||||
# Add the line
|
||||
result.append(line)
|
||||
|
||||
# If this is a non-empty line, we're no longer in a list
|
||||
if line.strip():
|
||||
in_list = False
|
||||
current_list_type = None
|
||||
|
||||
i += 1
|
||||
|
||||
# Do a final pass to clean up any double newlines
|
||||
clean_result = []
|
||||
for i in range(len(result)):
|
||||
# Skip consecutive blank lines
|
||||
if (
|
||||
not result[i]
|
||||
and i > 0
|
||||
and i < len(result) - 1
|
||||
and not result[i - 1]
|
||||
and not result[i + 1]
|
||||
):
|
||||
continue
|
||||
clean_result.append(result[i])
|
||||
|
||||
return "\n".join(clean_result)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Convert Word .docx files to Markdown")
|
||||
parser.add_argument("input_file", help="Input .docx file")
|
||||
parser.add_argument(
|
||||
"-o", "--output", help="Output markdown file (default: <input_basename>.md)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--media-dir",
|
||||
help="Directory for extracted media files (default: <basename>_media)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wrap",
|
||||
type=int,
|
||||
nargs="?",
|
||||
const=100,
|
||||
help="Enable line wrapping at specified width (default: 100 if enabled)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.input_file.endswith(".docx"):
|
||||
print("Error: Input file must be a .docx file", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
input_file = args.input_file
|
||||
|
||||
if args.output:
|
||||
output_file = args.output
|
||||
# Extract the basename from the output file for media directory name
|
||||
media_basename = os.path.splitext(os.path.basename(output_file))[0]
|
||||
else:
|
||||
basename = os.path.splitext(os.path.basename(input_file))[0]
|
||||
output_file = f"{basename}.md"
|
||||
media_basename = basename
|
||||
|
||||
# Set media directory
|
||||
if args.media_dir:
|
||||
media_dir = args.media_dir
|
||||
else:
|
||||
media_dir = f"{media_basename}_media"
|
||||
|
||||
try:
|
||||
# Convert using pandoc with media extraction and CommonMark format
|
||||
pandoc_command = [
|
||||
"pandoc",
|
||||
input_file,
|
||||
"-f",
|
||||
"docx",
|
||||
"-t",
|
||||
"commonmark",
|
||||
]
|
||||
|
||||
# Set wrapping options based on user preference
|
||||
if args.wrap is not None:
|
||||
pandoc_command.extend(["--wrap=auto", "--columns", str(args.wrap)])
|
||||
else:
|
||||
pandoc_command.append("--wrap=none")
|
||||
|
||||
# Add media extraction
|
||||
pandoc_command.append(f"--extract-media={media_dir}")
|
||||
|
||||
result = subprocess.run(
|
||||
pandoc_command, check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
markdown_content = result.stdout
|
||||
markdown_content = fix_markdown_formatting(markdown_content)
|
||||
|
||||
# Write markdown to file
|
||||
with open(output_file, "w") as f:
|
||||
f.write(markdown_content)
|
||||
|
||||
# Also output to stdout
|
||||
print(markdown_content)
|
||||
|
||||
# Print message to stderr about file creation
|
||||
print(
|
||||
f"Created '{output_file}' with media in '{media_dir}'"
|
||||
+ ("" if media_dir.endswith("/") else "/"),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error during conversion: {e}", file=sys.stderr)
|
||||
if e.stderr:
|
||||
print(e.stderr, file=sys.stderr)
|
||||
return 1
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
"Error: pandoc is not installed. Please install it with 'apt install pandoc'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
69
tools/document/src/document/html2text.md
Normal file
69
tools/document/src/document/html2text.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
command: html2text
|
||||
script: document/html2text.py
|
||||
description: Convert HTML content to plain text
|
||||
version: 1.0.0
|
||||
category: document
|
||||
---
|
||||
|
||||
# html2text (document)
|
||||
|
||||
Convert HTML files to readable plain text, removing tags while preserving structure.
|
||||
|
||||
Note: This is the same tool as in the email category, available for document conversion tasks.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
which html2text
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Convert HTML file
|
||||
html2text page.html
|
||||
|
||||
# Convert from stdin
|
||||
cat page.html | html2text
|
||||
|
||||
# Save to file
|
||||
html2text page.html > page.txt
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
html2text [file]
|
||||
|
||||
Options:
|
||||
file HTML file to convert (optional, reads from stdin if not provided)
|
||||
-h, --help Show help message
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Removes HTML tags
|
||||
- Preserves text structure
|
||||
- Formats lists and paragraphs
|
||||
- Converts links to readable format
|
||||
- Terminal-friendly output
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Convert web page
|
||||
curl https://example.com | html2text
|
||||
|
||||
# Convert multiple files
|
||||
for file in *.html; do
|
||||
html2text "$file" > "${file%.html}.txt"
|
||||
done
|
||||
|
||||
# Extract article text
|
||||
html2text article.html | less
|
||||
```
|
||||
|
||||
See also: [email/html2text](../email/html2text.md) for email-specific usage.
|
||||
319
tools/document/src/document/html2text.py
Executable file
319
tools/document/src/document/html2text.py
Executable file
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
html2text: Convert HTML content to plain text
|
||||
|
||||
Converts HTML content to readable plain text by removing HTML tags,
|
||||
preserving basic structure, and formatting the content for terminal viewing.
|
||||
Works well with email content.
|
||||
|
||||
Usage: html2text [options] [file]
|
||||
|
||||
Examples:
|
||||
html2text email.html # Convert HTML file to text
|
||||
cat email.html | html2text # Convert HTML from stdin to text
|
||||
protonmail read 42 | html2text # Convert ProtonMail HTML email to text
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
import re
|
||||
import html
|
||||
from html.parser import HTMLParser
|
||||
import textwrap
|
||||
|
||||
|
||||
class HTMLToTextParser(HTMLParser):
|
||||
"""HTML parser that converts HTML to readable plain text."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.result = []
|
||||
self.skip = False
|
||||
self.in_paragraph = False
|
||||
self.in_list_item = False
|
||||
self.in_anchor = False
|
||||
self.href = ""
|
||||
self.indent_level = 0
|
||||
self.list_item_num = 0
|
||||
self.in_header = False
|
||||
self.header_level = 0
|
||||
self.in_pre = False
|
||||
self.in_code = False
|
||||
self.buffer = ""
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attrs_dict = dict(attrs)
|
||||
|
||||
if tag == "head" or tag == "style" or tag == "script":
|
||||
self.skip = True
|
||||
return
|
||||
|
||||
if tag == "p":
|
||||
if self.result and self.result[-1] != "":
|
||||
self.result.append("")
|
||||
self.in_paragraph = True
|
||||
elif tag == "br":
|
||||
self.result.append("")
|
||||
elif (
|
||||
tag == "h1"
|
||||
or tag == "h2"
|
||||
or tag == "h3"
|
||||
or tag == "h4"
|
||||
or tag == "h5"
|
||||
or tag == "h6"
|
||||
):
|
||||
if self.result and self.result[-1] != "":
|
||||
self.result.append("")
|
||||
self.in_header = True
|
||||
self.header_level = int(tag[1])
|
||||
elif tag == "ul" or tag == "ol":
|
||||
self.result.append("")
|
||||
self.indent_level += 2
|
||||
if tag == "ol":
|
||||
self.list_item_num = 1
|
||||
else:
|
||||
self.list_item_num = 0
|
||||
elif tag == "li":
|
||||
self.in_list_item = True
|
||||
prefix = " " * self.indent_level
|
||||
if self.list_item_num > 0:
|
||||
prefix += f"{self.list_item_num}. "
|
||||
self.list_item_num += 1
|
||||
else:
|
||||
prefix += "• "
|
||||
self.buffer = prefix
|
||||
elif tag == "a" and "href" in attrs_dict:
|
||||
self.in_anchor = True
|
||||
self.href = attrs_dict["href"]
|
||||
elif tag == "pre":
|
||||
if self.result and self.result[-1] != "":
|
||||
self.result.append("")
|
||||
self.in_pre = True
|
||||
elif tag == "code":
|
||||
self.in_code = True
|
||||
elif tag == "div":
|
||||
if self.result and self.result[-1] != "":
|
||||
self.result.append("")
|
||||
elif tag == "blockquote":
|
||||
if self.result and self.result[-1] != "":
|
||||
self.result.append("")
|
||||
self.indent_level += 4
|
||||
self.result.append(" " * self.indent_level + "> ")
|
||||
elif tag == "hr":
|
||||
self.result.append("-" * 70)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "head" or tag == "style" or tag == "script":
|
||||
self.skip = False
|
||||
return
|
||||
|
||||
if tag == "p":
|
||||
if self.result and self.buffer:
|
||||
self.result.append(self.buffer)
|
||||
self.buffer = ""
|
||||
self.in_paragraph = False
|
||||
self.result.append("")
|
||||
elif (
|
||||
tag == "h1"
|
||||
or tag == "h2"
|
||||
or tag == "h3"
|
||||
or tag == "h4"
|
||||
or tag == "h5"
|
||||
or tag == "h6"
|
||||
):
|
||||
if self.buffer:
|
||||
self.result.append(self.buffer)
|
||||
underline = "=" if self.header_level <= 2 else "-"
|
||||
self.result.append(underline * len(self.buffer))
|
||||
self.buffer = ""
|
||||
self.in_header = False
|
||||
self.result.append("")
|
||||
elif tag == "ul" or tag == "ol":
|
||||
self.indent_level -= 2
|
||||
self.list_item_num = 0
|
||||
self.result.append("")
|
||||
elif tag == "li":
|
||||
if self.buffer:
|
||||
self.result.append(self.buffer)
|
||||
self.buffer = ""
|
||||
self.in_list_item = False
|
||||
elif tag == "a":
|
||||
if self.in_anchor and self.href and self.buffer:
|
||||
if self.href not in self.buffer:
|
||||
self.buffer += f" [{self.href}]"
|
||||
self.in_anchor = False
|
||||
elif tag == "pre":
|
||||
self.in_pre = False
|
||||
self.result.append("")
|
||||
elif tag == "code":
|
||||
self.in_code = False
|
||||
elif tag == "blockquote":
|
||||
self.indent_level -= 4
|
||||
self.result.append("")
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.skip:
|
||||
return
|
||||
|
||||
# Clean and normalize whitespace
|
||||
if not self.in_pre and not self.in_code:
|
||||
data = " ".join(data.split())
|
||||
|
||||
if data.strip():
|
||||
if self.in_list_item or self.in_header:
|
||||
self.buffer += data
|
||||
elif self.in_paragraph:
|
||||
if self.buffer:
|
||||
self.buffer += " " + data
|
||||
else:
|
||||
self.buffer = data
|
||||
else:
|
||||
self.result.append(data)
|
||||
|
||||
def handle_entityref(self, name):
|
||||
if self.skip:
|
||||
return
|
||||
|
||||
# Convert HTML entities to their corresponding characters
|
||||
try:
|
||||
char = html.unescape(f"&{name};")
|
||||
if self.in_list_item or self.in_header or self.in_paragraph:
|
||||
self.buffer += char
|
||||
else:
|
||||
self.result.append(char)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def handle_charref(self, name):
|
||||
if self.skip:
|
||||
return
|
||||
|
||||
# Convert character references to their corresponding characters
|
||||
try:
|
||||
char = html.unescape(f"&#{name};")
|
||||
if self.in_list_item or self.in_header or self.in_paragraph:
|
||||
self.buffer += char
|
||||
else:
|
||||
self.result.append(char)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_text(self):
|
||||
"""Get the processed text with proper wrapping."""
|
||||
# Process any remaining buffer
|
||||
if self.buffer:
|
||||
self.result.append(self.buffer)
|
||||
|
||||
# Join all lines, handling indentation properly
|
||||
result = []
|
||||
wrapper = textwrap.TextWrapper(width=80)
|
||||
|
||||
i = 0
|
||||
while i < len(self.result):
|
||||
line = self.result[i]
|
||||
|
||||
# Skip empty lines, but preserve paragraph breaks
|
||||
if not line:
|
||||
result.append("")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check if it's a list item or indented text
|
||||
indent_match = re.match(r"^(\s+)(.*?)$", line)
|
||||
if indent_match:
|
||||
indent = indent_match.group(1)
|
||||
content = indent_match.group(2)
|
||||
|
||||
if content.startswith("• ") or re.match(r"^\d+\.\s", content):
|
||||
# It's a list item, preserve the marker and indent rest of the paragraph
|
||||
match = re.match(r"^([•\d]+\.?\s+)", content)
|
||||
marker = match.group(1) if match else "• "
|
||||
remaining = content[len(marker) :]
|
||||
|
||||
# Set up custom wrapper for indented text
|
||||
custom_wrapper = textwrap.TextWrapper(
|
||||
width=80,
|
||||
initial_indent=indent + marker,
|
||||
subsequent_indent=indent + " " * len(marker),
|
||||
)
|
||||
|
||||
result.append(custom_wrapper.fill(remaining))
|
||||
else:
|
||||
# Regular indented text
|
||||
custom_wrapper = textwrap.TextWrapper(
|
||||
width=80, initial_indent=indent, subsequent_indent=indent
|
||||
)
|
||||
result.append(custom_wrapper.fill(content))
|
||||
else:
|
||||
# Regular wrapped text for paragraphs
|
||||
result.append(wrapper.fill(line))
|
||||
|
||||
i += 1
|
||||
|
||||
# Clean up multiple consecutive empty lines
|
||||
clean_result = []
|
||||
prev_empty = False
|
||||
for line in result:
|
||||
if not line and prev_empty:
|
||||
continue
|
||||
clean_result.append(line)
|
||||
prev_empty = not line
|
||||
|
||||
return "\n".join(clean_result)
|
||||
|
||||
|
||||
def convert_html_to_text(html_content):
|
||||
"""Convert HTML content to readable plain text."""
|
||||
parser = HTMLToTextParser()
|
||||
parser.feed(html_content)
|
||||
return parser.get_text()
|
||||
|
||||
|
||||
def clean_up_text(text):
|
||||
"""Clean up the text by removing excessive whitespace and line breaks."""
|
||||
# Replace multiple newlines with double newlines (for paragraph separation)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert HTML content to plain text",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__.split("\n\n", 1)[1] if __doc__ else "",
|
||||
)
|
||||
|
||||
parser.add_argument("file", nargs="?", help="HTML file to convert (default: stdin)")
|
||||
parser.add_argument(
|
||||
"-w", "--width", type=int, default=80, help="Maximum line width (default: 80)"
|
||||
)
|
||||
parser.add_argument("-v", "--version", action="version", version="html2text 1.0.0")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Read from file or stdin
|
||||
if args.file:
|
||||
try:
|
||||
with open(args.file, "r", encoding="utf-8") as f:
|
||||
html_content = f.read()
|
||||
except Exception as e:
|
||||
print(f"Error reading file: {str(e)}", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
html_content = sys.stdin.read()
|
||||
|
||||
# Check if the content actually has HTML tags
|
||||
if "<" in html_content and ">" in html_content:
|
||||
# Convert HTML to text
|
||||
text = convert_html_to_text(html_content)
|
||||
text = clean_up_text(text)
|
||||
print(text)
|
||||
else:
|
||||
# Content doesn't have HTML tags, just print it as is
|
||||
print(html_content.strip())
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
76
tools/document/src/document/md2pdf.md
Normal file
76
tools/document/src/document/md2pdf.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
command: md2pdf
|
||||
script: document/md2pdf.py
|
||||
description: Convert Markdown files to PDF
|
||||
version: 1.0.0
|
||||
category: document
|
||||
system_dependencies:
|
||||
- pandoc
|
||||
- texlive-latex-base
|
||||
---
|
||||
|
||||
# md2pdf
|
||||
|
||||
Convert Markdown documents to PDF format.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
which md2pdf
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Convert single file
|
||||
md2pdf document.md
|
||||
|
||||
# Output to specific file
|
||||
md2pdf document.md -o output.pdf
|
||||
|
||||
# Convert from stdin
|
||||
cat document.md | md2pdf > output.pdf
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
md2pdf input.md [-o output.pdf]
|
||||
|
||||
Options:
|
||||
input.md Input Markdown document
|
||||
-o, --output FILE Output PDF file (default: stdout)
|
||||
-h, --help Show help message
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Converts Markdown to formatted PDF
|
||||
- Preserves headings, lists, and formatting
|
||||
- Supports code blocks
|
||||
- Handles links
|
||||
- Clean, readable PDF output
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Convert and save
|
||||
md2pdf README.md -o README.pdf
|
||||
|
||||
# Convert multiple files
|
||||
for file in *.md; do
|
||||
md2pdf "$file" -o "${file%.md}.pdf"
|
||||
done
|
||||
|
||||
# Create PDF from concatenated markdown
|
||||
cat intro.md body.md conclusion.md | md2pdf -o complete.pdf
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Supports standard Markdown syntax
|
||||
- Some advanced Markdown features may not render
|
||||
- PDF styling is fixed (no custom themes)
|
||||
- Images referenced in Markdown should be accessible
|
||||
131
tools/document/src/document/md2pdf.py
Executable file
131
tools/document/src/document/md2pdf.py
Executable file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
md2pdf: Convert Markdown files to PDF
|
||||
|
||||
Converts Markdown files to PDF format with nice rendering of headers, lists,
|
||||
code blocks, and other markdown elements. The tool uses pandoc with LaTeX
|
||||
for high-quality PDF output.
|
||||
|
||||
The conversion process preserves markdown formatting and applies professional
|
||||
styling including proper spacing, font choices, and page layout.
|
||||
|
||||
Usage: md2pdf file.md
|
||||
md2pdf -o output.pdf file.md
|
||||
cat file.md | md2pdf -o output.pdf
|
||||
md2pdf file.md # Creates file.pdf in the same directory
|
||||
|
||||
Options:
|
||||
-o, --output FILE Write PDF to FILE instead of <input_basename>.pdf
|
||||
--toc Include table of contents
|
||||
--margin SIZE Set page margins (default: 1in). Examples: 1in, 2cm, 20mm
|
||||
--font-size SIZE Set base font size (default: 11pt). Examples: 10pt, 12pt
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import argparse
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Convert Markdown files to PDF")
|
||||
parser.add_argument(
|
||||
"input_file", nargs="?", help="Input Markdown file (or read from stdin)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output", help="Output PDF file (default: <input_basename>.pdf)"
|
||||
)
|
||||
parser.add_argument("--toc", action="store_true", help="Include table of contents")
|
||||
parser.add_argument("--margin", default="1in", help="Page margins (default: 1in)")
|
||||
parser.add_argument(
|
||||
"--font-size", default="11pt", help="Base font size (default: 11pt)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine input source.
|
||||
if args.input_file:
|
||||
input_file = args.input_file
|
||||
if not os.path.isfile(input_file):
|
||||
print(f"Error: File '{input_file}' not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.output:
|
||||
output_file = args.output
|
||||
else:
|
||||
basename = os.path.splitext(os.path.basename(input_file))[0]
|
||||
output_file = f"{basename}.pdf"
|
||||
else:
|
||||
# Reading from stdin
|
||||
if not args.output:
|
||||
print(
|
||||
"Error: When reading from stdin, you must specify an output file with -o",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
input_file = None
|
||||
output_file = args.output
|
||||
|
||||
try:
|
||||
# Build pandoc command with options for nice PDF rendering.
|
||||
pandoc_command = ["pandoc"]
|
||||
|
||||
if input_file:
|
||||
pandoc_command.append(input_file)
|
||||
else:
|
||||
pandoc_command.append("-") # Read from stdin
|
||||
|
||||
pandoc_command.extend(
|
||||
[
|
||||
"-f",
|
||||
"markdown",
|
||||
"-t",
|
||||
"pdf",
|
||||
"-o",
|
||||
output_file,
|
||||
"--pdf-engine=pdflatex",
|
||||
"-V",
|
||||
f"geometry:margin={args.margin}",
|
||||
"-V",
|
||||
f"fontsize={args.font_size}",
|
||||
"-V",
|
||||
"colorlinks=true",
|
||||
"-V",
|
||||
"linkcolor=blue",
|
||||
"-V",
|
||||
"urlcolor=blue",
|
||||
]
|
||||
)
|
||||
|
||||
# Add table of contents if requested.
|
||||
if args.toc:
|
||||
pandoc_command.append("--toc")
|
||||
pandoc_command.extend(["--toc-depth", "3"])
|
||||
|
||||
# Run pandoc conversion.
|
||||
subprocess.run(
|
||||
pandoc_command,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
input=None if input_file else sys.stdin.read(),
|
||||
)
|
||||
|
||||
# Print success message to stderr.
|
||||
print(f"Created '{output_file}'", file=sys.stderr)
|
||||
|
||||
return 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error during conversion: {e}", file=sys.stderr)
|
||||
if e.stderr:
|
||||
print(e.stderr, file=sys.stderr)
|
||||
return 1
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
"Error: pandoc is not installed. Please install it with 'apt install pandoc texlive-latex-base'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
76
tools/document/src/document/pdf2md.md
Normal file
76
tools/document/src/document/pdf2md.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
command: pdf2md
|
||||
script: document/pdf2md.py
|
||||
description: Convert PDF files to Markdown
|
||||
version: 1.0.0
|
||||
category: document
|
||||
system_dependencies:
|
||||
- pandoc
|
||||
- poppler-utils
|
||||
---
|
||||
|
||||
# pdf2md
|
||||
|
||||
Convert PDF documents to Markdown format with text extraction and formatting preservation.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
which pdf2md
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Convert single PDF
|
||||
pdf2md document.pdf
|
||||
|
||||
# Output to specific file
|
||||
pdf2md document.pdf -o output.md
|
||||
|
||||
# Convert from stdin
|
||||
cat document.pdf | pdf2md > output.md
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
pdf2md input.pdf [-o output.md]
|
||||
|
||||
Options:
|
||||
input.pdf Input PDF document
|
||||
-o, --output FILE Output markdown file (default: stdout)
|
||||
-h, --help Show help message
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Extracts text from PDFs
|
||||
- Preserves basic formatting where possible
|
||||
- Handles multi-page documents
|
||||
- Works with text-based PDFs
|
||||
- Pipe-friendly output
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Convert and save
|
||||
pdf2md report.pdf -o report.md
|
||||
|
||||
# Convert multiple PDFs
|
||||
for file in *.pdf; do
|
||||
pdf2md "$file" -o "${file%.pdf}.md"
|
||||
done
|
||||
|
||||
# Extract and search
|
||||
pdf2md document.pdf | grep "keyword"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Works best with text-based PDFs
|
||||
- Scanned PDFs (images) require OCR
|
||||
- Complex layouts may lose some formatting
|
||||
- Tables are converted to plain text
|
||||
168
tools/document/src/document/pdf2md.py
Executable file
168
tools/document/src/document/pdf2md.py
Executable file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pdf2md: Convert PDF files to Markdown
|
||||
|
||||
Converts PDF files to Markdown format and writes the result to a file with
|
||||
the same basename but .md extension. Also creates a directory for extracted
|
||||
media assets named <basename>_media/. The markdown content is also sent to stdout.
|
||||
|
||||
The conversion process uses poppler-utils (pdftotext and pdfimages) to extract
|
||||
text and images from the PDF, then uses pandoc to convert the text to Markdown.
|
||||
The output preserves as much of the original layout as possible.
|
||||
|
||||
Usage: pdf2md file.pdf
|
||||
pdf2md -o output.md file.pdf
|
||||
pdf2md -m custom_media_dir file.pdf
|
||||
pdf2md -o output.md -m custom_media_dir file.pdf
|
||||
pdf2md file.pdf > another_file.md
|
||||
|
||||
Options:
|
||||
-o, --output FILE Write markdown to FILE instead of <input_basename>.md
|
||||
-m, --media-dir DIR Store extracted media files in DIR instead of <basename>_media
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import argparse
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Convert PDF files to Markdown")
|
||||
parser.add_argument("input_file", help="Input PDF file")
|
||||
parser.add_argument(
|
||||
"-o", "--output", help="Output markdown file (default: <input_basename>.md)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--media-dir",
|
||||
help="Directory for extracted media files (default: <basename>_media)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.input_file.endswith(".pdf"):
|
||||
print("Error: Input file must be a PDF file", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
input_file = args.input_file
|
||||
|
||||
if args.output:
|
||||
output_file = args.output
|
||||
# Extract the basename from the output file for media directory name
|
||||
media_basename = os.path.splitext(os.path.basename(output_file))[0]
|
||||
else:
|
||||
basename = os.path.splitext(os.path.basename(input_file))[0]
|
||||
output_file = f"{basename}.md"
|
||||
media_basename = basename
|
||||
|
||||
# Set media directory
|
||||
if args.media_dir:
|
||||
media_dir = args.media_dir
|
||||
else:
|
||||
media_dir = f"{media_basename}_media"
|
||||
|
||||
# Create media directory if it doesn't exist
|
||||
os.makedirs(media_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Create a temporary directory for conversion
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# First use pdftotext (from poppler-utils) to extract text
|
||||
temp_txt = os.path.join(temp_dir, "temp_content.txt")
|
||||
# Use pdftotext with -nopgbrk to avoid page breaks that can mess up tables
|
||||
subprocess.run(
|
||||
["pdftotext", "-nopgbrk", input_file, temp_txt],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Process the text file to clean up formatting issues
|
||||
with open(temp_txt, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Remove common footer patterns
|
||||
content = re.sub(
|
||||
r"(?m)^\s*\d+\s+The Social Issues Research Centre.*$", "", content
|
||||
)
|
||||
|
||||
# Replace excessive spaces with reasonable indentation
|
||||
content = re.sub(r"(?m)^(\s{6,})", " ", content)
|
||||
|
||||
# Clean up empty lines
|
||||
content = re.sub(r"\n{3,}", "\n\n", content)
|
||||
|
||||
# Write cleaned content back to file
|
||||
with open(temp_txt, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
# Extract images using pdfimages
|
||||
subprocess.run(
|
||||
["pdfimages", "-j", input_file, os.path.join(media_dir, "image")],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Then convert text to Markdown using pandoc with table support
|
||||
result = subprocess.run(
|
||||
[
|
||||
"pandoc",
|
||||
temp_txt,
|
||||
"-f",
|
||||
"markdown+simple_tables+table_captions+yaml_metadata_block",
|
||||
"-t",
|
||||
"markdown_github",
|
||||
"--wrap=none",
|
||||
"--standalone",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Process the markdown to further clean it up
|
||||
markdown = result.stdout
|
||||
|
||||
# Fix table formatting if needed
|
||||
markdown = re.sub(r"\|-+\|\n\|-+\|", "", markdown)
|
||||
|
||||
# Write markdown to file
|
||||
with open(output_file, "w") as f:
|
||||
f.write(markdown)
|
||||
|
||||
# Also output to stdout
|
||||
print(markdown)
|
||||
|
||||
# Print message to stderr about file creation
|
||||
print(
|
||||
f"Created '{output_file}' with media in '{media_dir}'"
|
||||
+ ("" if media_dir.endswith("/") else "/"),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error during conversion: {e}", file=sys.stderr)
|
||||
if e.stderr:
|
||||
print(e.stderr, file=sys.stderr)
|
||||
return 1
|
||||
except FileNotFoundError as e:
|
||||
if "pdftohtml" in str(e):
|
||||
print(
|
||||
"Error: pdftohtml is not installed. Please install it with 'apt install poppler-utils'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"Error: pandoc is not installed. Please install it with 'apt install pandoc'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
18
tools/gpt/pyproject.toml
Normal file
18
tools/gpt/pyproject.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[project]
|
||||
name = "castle-gpt"
|
||||
version = "0.1.0"
|
||||
description = "Castle GPT tools"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"openai>=1.6.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
gpt = "gpt.gpt:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/gpt"]
|
||||
0
tools/gpt/src/gpt/__init__.py
Normal file
0
tools/gpt/src/gpt/__init__.py
Normal file
157
tools/gpt/src/gpt/gpt.md
Normal file
157
tools/gpt/src/gpt/gpt.md
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
command: gpt
|
||||
script: gpt/gpt.py
|
||||
description: A simple OpenAI text generation utility
|
||||
version: 1.0.0
|
||||
category: gpt
|
||||
---
|
||||
|
||||
# gpt
|
||||
|
||||
Interact with OpenAI's GPT models from the command line. Send prompts and receive AI-generated responses.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OpenAI API key
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
which gpt
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Set API key
|
||||
export OPENAI_API_KEY=sk-...
|
||||
|
||||
# Add to ~/.bashrc or ~/.profile to persist
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Send a prompt
|
||||
gpt "Explain quantum computing in simple terms"
|
||||
|
||||
# Use specific model
|
||||
gpt --model gpt-4 "Write a Python function to sort a list"
|
||||
|
||||
# Read prompt from file
|
||||
gpt < prompt.txt
|
||||
|
||||
# Interactive mode
|
||||
echo "What is the capital of France?" | gpt
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
gpt [options] "prompt"
|
||||
|
||||
Options:
|
||||
prompt Text prompt for GPT (required)
|
||||
--model MODEL GPT model to use (default: gpt-4o-mini)
|
||||
--temperature TEMP Creativity level 0.0-2.0 (default: 0.7)
|
||||
--max-tokens NUM Maximum response length (default: 1000)
|
||||
--system PROMPT System prompt to set behavior
|
||||
-h, --help Show help message
|
||||
```
|
||||
|
||||
## Supported Models
|
||||
|
||||
- `gpt-4o` - Most capable, best for complex tasks
|
||||
- `gpt-4o-mini` - Fast and affordable (default)
|
||||
- `gpt-4-turbo` - Large context window
|
||||
- `gpt-4` - High capability
|
||||
- `gpt-3.5-turbo` - Fast and economical
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple Questions
|
||||
|
||||
```bash
|
||||
gpt "What is the weather like today?"
|
||||
gpt "Explain recursion"
|
||||
```
|
||||
|
||||
### Code Generation
|
||||
|
||||
```bash
|
||||
gpt "Write a Python function to calculate fibonacci numbers"
|
||||
gpt --model gpt-4 "Create a React component for a todo list"
|
||||
```
|
||||
|
||||
### Text Processing
|
||||
|
||||
```bash
|
||||
# Summarize a file
|
||||
cat article.txt | gpt "Summarize this article in 3 bullet points"
|
||||
|
||||
# Translate
|
||||
echo "Hello world" | gpt "Translate to French"
|
||||
|
||||
# Code review
|
||||
cat script.py | gpt "Review this code and suggest improvements"
|
||||
```
|
||||
|
||||
### With System Prompts
|
||||
|
||||
```bash
|
||||
gpt --system "You are a Python expert" "How do I use asyncio?"
|
||||
gpt --system "Respond in haiku form" "Describe AI"
|
||||
```
|
||||
|
||||
### Batch Processing
|
||||
|
||||
```bash
|
||||
# Process multiple prompts
|
||||
for question in "What is AI?" "What is ML?" "What is DL?"; do
|
||||
gpt "$question" >> answers.txt
|
||||
done
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple models** - Choose from various GPT models
|
||||
- **Flexible input** - Prompt as argument or from stdin
|
||||
- **Customizable** - Adjust temperature, tokens, system prompts
|
||||
- **Pipe-friendly** - Works in Unix pipelines
|
||||
- **Fast** - Direct API access
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `OPENAI_API_KEY` - Your OpenAI API key (required)
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `gpt-4o-mini` for fast, cheap responses (default)
|
||||
- Use `gpt-4` or `gpt-4o` for complex reasoning
|
||||
- Lower temperature (0.1-0.3) for factual responses
|
||||
- Higher temperature (0.8-1.5) for creative writing
|
||||
- System prompts help control tone and expertise level
|
||||
|
||||
## Cost Considerations
|
||||
|
||||
- `gpt-4o-mini` is most economical for general use
|
||||
- `gpt-4` costs more but provides higher quality
|
||||
- Limit max-tokens to control costs
|
||||
- Monitor usage in OpenAI dashboard
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### API Key Not Found
|
||||
|
||||
```bash
|
||||
echo $OPENAI_API_KEY # Should show your key
|
||||
export OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
### Rate Limits
|
||||
|
||||
If you hit rate limits, wait a moment and retry, or upgrade your OpenAI plan.
|
||||
151
tools/gpt/src/gpt/gpt.py
Executable file
151
tools/gpt/src/gpt/gpt.py
Executable file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
gpt: A simple OpenAI text generation utility
|
||||
|
||||
Generates text using OpenAI's GPT models based on user prompts.
|
||||
|
||||
Usage: gpt [options] [prompt]
|
||||
|
||||
Examples:
|
||||
gpt "Write a haiku about programming" # Generate text from prompt
|
||||
gpt -m gpt-4 "Explain quantum computing" # Use specific model
|
||||
cat prompt.txt | gpt # Read prompt from stdin
|
||||
gpt -t 0.7 "Write creative story" # Adjust temperature
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
import openai
|
||||
from typing import Optional
|
||||
|
||||
# Default settings
|
||||
DEFAULT_MODEL = "gpt-3.5-turbo"
|
||||
DEFAULT_TEMPERATURE = 0.7
|
||||
DEFAULT_MAX_TOKENS = 500
|
||||
|
||||
|
||||
def get_api_key() -> Optional[str]:
|
||||
"""Get OpenAI API key from environment or config file."""
|
||||
# First check environment variable
|
||||
api_key = os.environ.get("OPENAI_API_KEY")
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
# Then check config file
|
||||
config_paths = [
|
||||
os.path.expanduser("~/.config/openai/config.json"),
|
||||
os.path.expanduser("~/.openai/config.json"),
|
||||
]
|
||||
|
||||
for path in config_paths:
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
config = json.load(f)
|
||||
if "api_key" in config:
|
||||
return config["api_key"]
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def generate_text(prompt: str, model: str, temperature: float, max_tokens: int) -> str:
|
||||
"""Generate text using OpenAI's API."""
|
||||
api_key = get_api_key()
|
||||
if not api_key:
|
||||
sys.stderr.write("Error: OpenAI API key not found.\n")
|
||||
sys.stderr.write(
|
||||
"Please set OPENAI_API_KEY environment variable or configure it in ~/.config/openai/config.json\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
client = openai.OpenAI(api_key=api_key)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
content = response.choices[0].message.content
|
||||
return content if content is not None else ""
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error: {str(e)}\n")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to parse arguments and generate text."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate text using OpenAI's GPT models",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__.split("\n\n", 1)[1] if __doc__ else "",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"prompt",
|
||||
nargs="?",
|
||||
help="The prompt for text generation (default: reads from stdin)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-m",
|
||||
"--model",
|
||||
default=DEFAULT_MODEL,
|
||||
help=f"The GPT model to use (default: {DEFAULT_MODEL})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--temperature",
|
||||
type=float,
|
||||
default=DEFAULT_TEMPERATURE,
|
||||
help=f"Temperature for text generation (default: {DEFAULT_TEMPERATURE})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--max-tokens",
|
||||
type=int,
|
||||
default=DEFAULT_MAX_TOKENS,
|
||||
help=f"Maximum tokens in the response (default: {DEFAULT_MAX_TOKENS})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-j", "--json", action="store_true", help="Output result as JSON"
|
||||
)
|
||||
parser.add_argument("-v", "--version", action="version", version="gpt 1.0.0")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Read from stdin or argument
|
||||
if args.prompt:
|
||||
prompt = args.prompt
|
||||
else:
|
||||
prompt = sys.stdin.read().strip()
|
||||
if not prompt:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
# Generate text
|
||||
result = generate_text(
|
||||
prompt=prompt,
|
||||
model=args.model,
|
||||
temperature=args.temperature,
|
||||
max_tokens=args.max_tokens,
|
||||
)
|
||||
|
||||
# Output results
|
||||
if args.json:
|
||||
json.dump({"prompt": prompt, "result": result}, sys.stdout)
|
||||
else:
|
||||
print(result)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
16
tools/mdscraper/pyproject.toml
Normal file
16
tools/mdscraper/pyproject.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[project]
|
||||
name = "castle-mdscraper"
|
||||
version = "0.1.0"
|
||||
description = "Castle markdown scraping tools"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
mdscraper = "mdscraper.mdscraper:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/mdscraper"]
|
||||
0
tools/mdscraper/src/mdscraper/__init__.py
Normal file
0
tools/mdscraper/src/mdscraper/__init__.py
Normal file
142
tools/mdscraper/src/mdscraper/mdscraper.md
Normal file
142
tools/mdscraper/src/mdscraper/mdscraper.md
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
command: mdscraper
|
||||
script: mdscraper/mdscraper.py
|
||||
description: Combine text files from a directory into a single markdown document with
|
||||
.gitignore-style filtering
|
||||
version: 1.0.0
|
||||
category: mdscraper
|
||||
---
|
||||
|
||||
# mdscraper
|
||||
|
||||
Convert web pages to clean Markdown format. Scrapes web content and converts to readable Markdown, removing ads and clutter.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
which mdscraper
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Convert web page to Markdown
|
||||
mdscraper https://example.com
|
||||
|
||||
# Save to file
|
||||
mdscraper https://example.com > article.md
|
||||
|
||||
# Multiple URLs
|
||||
mdscraper https://site1.com https://site2.com
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
mdscraper [options] URL [URL...]
|
||||
|
||||
Options:
|
||||
URL Web page URL to scrape (required)
|
||||
-o, --output FILE Output file (default: stdout)
|
||||
--user-agent UA Custom user agent string
|
||||
-h, --help Show help message
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Clean conversion** - Removes ads, navigation, footers
|
||||
- **Markdown output** - Well-formatted Markdown
|
||||
- **Main content extraction** - Focuses on article content
|
||||
- **Link preservation** - Maintains links in Markdown format
|
||||
- **Image handling** - Includes images with Markdown syntax
|
||||
- **Fast** - Efficient scraping and conversion
|
||||
|
||||
## Examples
|
||||
|
||||
### Save Article
|
||||
|
||||
```bash
|
||||
mdscraper https://blog.example.com/article > article.md
|
||||
```
|
||||
|
||||
### Scrape Multiple Pages
|
||||
|
||||
```bash
|
||||
# Scrape all pages
|
||||
for url in $(cat urls.txt); do
|
||||
mdscraper "$url" > "$(echo $url | md5sum | cut -d' ' -f1).md"
|
||||
done
|
||||
```
|
||||
|
||||
### Convert for Reading
|
||||
|
||||
```bash
|
||||
# Scrape and view
|
||||
mdscraper https://news.site/article | less
|
||||
|
||||
# Scrape and convert to PDF
|
||||
mdscraper https://article.com | md2pdf -o article.pdf
|
||||
```
|
||||
|
||||
### With Custom User Agent
|
||||
|
||||
```bash
|
||||
mdscraper --user-agent "MyBot/1.0" https://example.com
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **Article archiving** - Save web articles as Markdown
|
||||
- **Content migration** - Convert web content for static sites
|
||||
- **Offline reading** - Download articles for later
|
||||
- **Documentation** - Scrape docs to local Markdown
|
||||
- **Research** - Collect and organize web content
|
||||
|
||||
## Output Format
|
||||
|
||||
Markdown output includes:
|
||||
- Article title as H1
|
||||
- Headings at appropriate levels
|
||||
- Paragraphs with proper spacing
|
||||
- Lists (ordered and unordered)
|
||||
- Links in `[text](url)` format
|
||||
- Images in `` format
|
||||
- Code blocks when present
|
||||
|
||||
## Notes
|
||||
|
||||
- Respects robots.txt where possible
|
||||
- Some sites may block scraping
|
||||
- JavaScript-heavy sites may not work well
|
||||
- Rate limit requests to avoid overwhelming servers
|
||||
- Consider caching results for repeated access
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Site Blocks Requests
|
||||
|
||||
Try using a custom user agent:
|
||||
```bash
|
||||
mdscraper --user-agent "Mozilla/5.0..." https://site.com
|
||||
```
|
||||
|
||||
### JavaScript Content Missing
|
||||
|
||||
For JavaScript-heavy sites, consider using the `browser` tool instead:
|
||||
```bash
|
||||
browser "Go to URL and extract the article text"
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Add delays between requests:
|
||||
```bash
|
||||
for url in $(cat urls.txt); do
|
||||
mdscraper "$url" > file.md
|
||||
sleep 2
|
||||
done
|
||||
```
|
||||
259
tools/mdscraper/src/mdscraper/mdscraper.py
Executable file
259
tools/mdscraper/src/mdscraper/mdscraper.py
Executable file
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
mdscraper: Combine text files into a single markdown document
|
||||
|
||||
Scans a directory for text files and combines them into a single markdown document.
|
||||
Supports filtering through a .gitignore-style configuration file.
|
||||
|
||||
Usage: mdscraper [options] [directory]
|
||||
mdscraper -o output.md [directory]
|
||||
mdscraper --ignore-file .mdscraper [directory]
|
||||
|
||||
Options:
|
||||
-o, --output FILE Write markdown to FILE instead of stdout
|
||||
-i, --ignore-file FILE Use specified ignore file instead of .mdscraper
|
||||
-r, --recursive Recursively process subdirectories
|
||||
--include-path Include file paths as headers in the output
|
||||
--no-headers Don't add file names as headers
|
||||
--toc Add table of contents at the beginning
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
import fnmatch
|
||||
import re
|
||||
|
||||
|
||||
def parse_ignore_file(ignore_file_path):
|
||||
"""Parse a .gitignore style file into a list of patterns."""
|
||||
if not os.path.exists(ignore_file_path):
|
||||
return []
|
||||
|
||||
patterns = []
|
||||
with open(ignore_file_path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Skip empty lines and comments
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
patterns.append(line)
|
||||
return patterns
|
||||
|
||||
|
||||
def should_ignore(path, patterns):
|
||||
"""Determine if a file should be ignored based on patterns."""
|
||||
# Always ignore hidden files
|
||||
if os.path.basename(path).startswith("."):
|
||||
return True
|
||||
|
||||
# Check for directory patterns - if path contains a directory that should be ignored
|
||||
if "/" in path:
|
||||
path_parts = path.split("/")
|
||||
for i in range(len(path_parts)):
|
||||
partial_path = "/".join(path_parts[: i + 1]) + "/"
|
||||
for pattern in patterns:
|
||||
if (
|
||||
not pattern.startswith("!")
|
||||
and pattern.endswith("/")
|
||||
and fnmatch.fnmatch(partial_path, pattern)
|
||||
):
|
||||
# Check if there's a negation pattern that overrides
|
||||
negation_overrides = False
|
||||
for neg_pattern in patterns:
|
||||
if neg_pattern.startswith("!") and fnmatch.fnmatch(
|
||||
path, neg_pattern[1:]
|
||||
):
|
||||
negation_overrides = True
|
||||
break
|
||||
if not negation_overrides:
|
||||
return True
|
||||
|
||||
# Check if path matches any negation pattern first
|
||||
for pattern in patterns:
|
||||
if pattern.startswith("!"):
|
||||
if fnmatch.fnmatch(path, pattern[1:]):
|
||||
# If it matches a negation pattern, do not ignore
|
||||
return False
|
||||
|
||||
# Then check if it matches any regular pattern
|
||||
for pattern in patterns:
|
||||
if not pattern.startswith("!") and not pattern.endswith("/"):
|
||||
if fnmatch.fnmatch(path, pattern):
|
||||
return True
|
||||
|
||||
# Default: don't ignore
|
||||
return False
|
||||
|
||||
|
||||
def scan_directory(directory, patterns, recursive=False):
|
||||
"""Scan a directory for text files, filtering by ignore patterns."""
|
||||
files = []
|
||||
|
||||
for item in os.listdir(directory):
|
||||
path = os.path.join(directory, item)
|
||||
rel_path = os.path.relpath(path, directory)
|
||||
|
||||
if should_ignore(rel_path, patterns):
|
||||
continue
|
||||
|
||||
if os.path.isfile(path):
|
||||
# Only include text files
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
f.read(1024) # Try to read a bit to check if it's text
|
||||
files.append(path)
|
||||
except UnicodeDecodeError:
|
||||
# Skip binary files
|
||||
continue
|
||||
elif os.path.isdir(path) and recursive:
|
||||
# For directories, check if the directory itself should be ignored
|
||||
dir_path = rel_path + "/"
|
||||
if any(
|
||||
pattern.endswith("/") and fnmatch.fnmatch(dir_path, pattern)
|
||||
for pattern in patterns
|
||||
if not pattern.startswith("!")
|
||||
):
|
||||
# Directory matches an ignore pattern, skip it
|
||||
continue
|
||||
|
||||
# Process subdirectories recursively if requested
|
||||
subdir_files = scan_directory(path, patterns, recursive)
|
||||
files.extend(subdir_files)
|
||||
|
||||
# Sort files for consistent output
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def extract_file_content(file_path):
|
||||
"""Extract content from a file."""
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except UnicodeDecodeError:
|
||||
return f"ERROR: Could not decode {file_path} as UTF-8"
|
||||
|
||||
|
||||
def create_toc(files, base_dir):
|
||||
"""Create a table of contents from the list of files."""
|
||||
toc = ["# Table of Contents\n"]
|
||||
|
||||
for file_path in files:
|
||||
rel_path = os.path.relpath(file_path, base_dir)
|
||||
file_name = os.path.basename(file_path)
|
||||
# Create a GitHub-style anchor link
|
||||
anchor = file_name.lower().replace(" ", "-")
|
||||
anchor = re.sub(r"[^\w-]", "", anchor)
|
||||
toc.append(f"- [{rel_path}](#{anchor})")
|
||||
|
||||
return "\n".join(toc) + "\n\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Combine text files into a single markdown document"
|
||||
)
|
||||
parser.add_argument(
|
||||
"directory",
|
||||
nargs="?",
|
||||
default=".",
|
||||
help="Directory to process (default: current directory)",
|
||||
)
|
||||
parser.add_argument("-o", "--output", help="Output markdown file (default: stdout)")
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--ignore-file",
|
||||
default=".mdscraper",
|
||||
help="Gitignore-style file with patterns to ignore (default: .mdscraper)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-r",
|
||||
"--recursive",
|
||||
action="store_true",
|
||||
help="Recursively process subdirectories",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-path", action="store_true", help="Include file paths as headers"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-headers", action="store_true", help="Don't add file names as headers"
|
||||
)
|
||||
parser.add_argument("--toc", action="store_true", help="Add table of contents")
|
||||
parser.add_argument("--version", action="version", version="mdscraper 1.0.0")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve the directory path
|
||||
directory = os.path.abspath(args.directory)
|
||||
if not os.path.isdir(directory):
|
||||
print(f"Error: {directory} is not a directory", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Load ignore patterns
|
||||
ignore_file = args.ignore_file
|
||||
if not os.path.isabs(ignore_file):
|
||||
# First check if ignore file exists in specified directory
|
||||
dir_ignore_file = os.path.join(directory, ignore_file)
|
||||
if os.path.exists(dir_ignore_file):
|
||||
ignore_file = dir_ignore_file
|
||||
|
||||
ignore_patterns = parse_ignore_file(ignore_file)
|
||||
|
||||
# Always ignore the output file if specified
|
||||
if args.output:
|
||||
output_basename = os.path.basename(args.output)
|
||||
ignore_patterns.append(output_basename)
|
||||
|
||||
# Scan for files
|
||||
files = scan_directory(directory, ignore_patterns, args.recursive)
|
||||
|
||||
if not files:
|
||||
print(f"Warning: No text files found in {directory}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
# Build the markdown content
|
||||
content = []
|
||||
|
||||
# Add table of contents if requested
|
||||
if args.toc:
|
||||
content.append(create_toc(files, directory))
|
||||
|
||||
# Process each file
|
||||
for file_path in files:
|
||||
rel_path = os.path.relpath(file_path, directory)
|
||||
file_name = os.path.basename(file_path)
|
||||
|
||||
# Add header unless disabled
|
||||
if not args.no_headers:
|
||||
if args.include_path:
|
||||
content.append(f"# {rel_path}\n")
|
||||
else:
|
||||
content.append(f"# {file_name}\n")
|
||||
|
||||
# Add file content
|
||||
file_content = extract_file_content(file_path)
|
||||
content.append(file_content)
|
||||
|
||||
# Add separator between files
|
||||
content.append("\n---\n")
|
||||
|
||||
# Remove the last separator if it exists
|
||||
if content and content[-1] == "\n---\n":
|
||||
content.pop()
|
||||
|
||||
# Join all content
|
||||
markdown = "\n".join(content)
|
||||
|
||||
# Output the result
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(markdown)
|
||||
print(f"Created '{args.output}'", file=sys.stderr)
|
||||
else:
|
||||
print(markdown)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
22
tools/search/pyproject.toml
Normal file
22
tools/search/pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[project]
|
||||
name = "castle-search"
|
||||
version = "0.1.0"
|
||||
description = "Castle search and indexing tools"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"toml>=0.10.2",
|
||||
"pymupdf>=1.24.11",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
search = "search.search:main"
|
||||
pdf-extractor = "search.pdf_extractor:main"
|
||||
docx-extractor = "search.docx_extractor:main"
|
||||
text-extractor = "search.text_extractor:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/search"]
|
||||
0
tools/search/src/search/__init__.py
Normal file
0
tools/search/src/search/__init__.py
Normal file
73
tools/search/src/search/docx_extractor.md
Normal file
73
tools/search/src/search/docx_extractor.md
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
command: docx-extractor
|
||||
script: search/docx_extractor.py
|
||||
description: Extract content and metadata from Word .docx files
|
||||
version: 1.0.0
|
||||
category: search
|
||||
system_dependencies:
|
||||
- pandoc
|
||||
---
|
||||
|
||||
# docx-extractor
|
||||
|
||||
Extract text from Microsoft Word (.docx) documents.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
which docx-extractor
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Extract from Word doc
|
||||
docx-extractor document.docx
|
||||
|
||||
# Save to file
|
||||
docx-extractor document.docx > text.txt
|
||||
|
||||
# Multiple documents
|
||||
docx-extractor file1.docx file2.docx
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
docx-extractor [files...]
|
||||
|
||||
Options:
|
||||
files DOCX files to extract text from
|
||||
-h, --help Show help message
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Extract text from .docx files
|
||||
- Handles headers, footers, and body text
|
||||
- Preserves basic structure
|
||||
- Fast extraction
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Single document
|
||||
docx-extractor report.docx
|
||||
|
||||
# Batch extraction
|
||||
for file in *.docx; do
|
||||
docx-extractor "$file" > "${file%.docx}.txt"
|
||||
done
|
||||
|
||||
# Search extracted text
|
||||
docx-extractor document.docx | grep "keyword"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Only supports .docx format (not .doc)
|
||||
- Formatting is stripped
|
||||
- Tables become plain text
|
||||
- Images are not extracted
|
||||
233
tools/search/src/search/docx_extractor.py
Normal file
233
tools/search/src/search/docx_extractor.py
Normal file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
docx-extractor: Extract content and metadata from Word .docx files
|
||||
|
||||
Reads a Word .docx file and outputs a JSON object containing the file's content
|
||||
and metadata including filename, size, title, author, creation date, etc.
|
||||
|
||||
Usage: docx-extractor [options] [FILE]
|
||||
|
||||
Examples:
|
||||
docx-extractor document.docx # Extract from a Word file
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
def get_file_metadata(file_path):
|
||||
"""Get file metadata including creation time, modification time, size, etc."""
|
||||
path = Path(file_path)
|
||||
stat = path.stat()
|
||||
|
||||
# Get file modification and creation times
|
||||
mtime = datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||
try:
|
||||
ctime = datetime.fromtimestamp(stat.st_ctime).isoformat()
|
||||
except Exception:
|
||||
ctime = mtime # Fallback if creation time not available
|
||||
|
||||
# Get mime type
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
if not mime_type:
|
||||
mime_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" # Default for .docx
|
||||
|
||||
return {
|
||||
"filename": path.name,
|
||||
"path": str(path.absolute()),
|
||||
"size": stat.st_size,
|
||||
"created_at": ctime,
|
||||
"modified_at": mtime,
|
||||
"mime_type": mime_type,
|
||||
"extension": path.suffix.lstrip(".") if path.suffix else "docx",
|
||||
}
|
||||
|
||||
|
||||
def extract_docx_metadata(file_path):
|
||||
"""Extract metadata from a DOCX file using pandoc."""
|
||||
metadata = {}
|
||||
|
||||
try:
|
||||
# Method 1: Try to extract metadata using pandoc's native metadata extraction
|
||||
# Convert to JSON to capture metadata fields like title, author, date, etc.
|
||||
result = subprocess.run(
|
||||
["pandoc", "--standalone", "--to=json", file_path],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse the JSON output
|
||||
data = json.loads(result.stdout)
|
||||
|
||||
# Extract metadata fields from the JSON structure
|
||||
if "meta" in data:
|
||||
meta = data["meta"]
|
||||
|
||||
# Extract title
|
||||
if "title" in meta:
|
||||
title_content = meta["title"].get("c", [])
|
||||
if title_content:
|
||||
# Title might be a complex structure, try to extract text
|
||||
title_parts = []
|
||||
for part in title_content:
|
||||
if isinstance(part, dict) and "c" in part:
|
||||
title_parts.append(part["c"])
|
||||
elif isinstance(part, str):
|
||||
title_parts.append(part)
|
||||
if title_parts:
|
||||
metadata["title"] = " ".join(title_parts)
|
||||
|
||||
# Extract author
|
||||
if "author" in meta:
|
||||
author_data = meta["author"]
|
||||
if isinstance(author_data, list) and author_data:
|
||||
if "c" in author_data[0]:
|
||||
metadata["author"] = author_data[0]["c"]
|
||||
else:
|
||||
# Try to extract author from complex structures
|
||||
authors = []
|
||||
for author in author_data:
|
||||
if isinstance(author, dict) and "c" in author:
|
||||
authors.append(author["c"])
|
||||
if authors:
|
||||
metadata["author"] = ", ".join(authors)
|
||||
|
||||
# Extract date
|
||||
if "date" in meta:
|
||||
date_content = meta["date"]
|
||||
if isinstance(date_content, dict) and "c" in date_content:
|
||||
metadata["date"] = date_content["c"]
|
||||
|
||||
# Extract keywords/tags
|
||||
if "keywords" in meta:
|
||||
keywords_data = meta["keywords"]
|
||||
if isinstance(keywords_data, dict) and "c" in keywords_data:
|
||||
metadata["keywords"] = keywords_data["c"]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Method 2: Extract title from first header if not found in metadata
|
||||
if "title" not in metadata:
|
||||
# Convert to markdown and look for the first header
|
||||
result = subprocess.run(
|
||||
["pandoc", "-f", "docx", "-t", "markdown", file_path],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
content = result.stdout.strip()
|
||||
title_match = re.search(r"^# (.+)$", content, re.MULTILINE)
|
||||
if title_match:
|
||||
metadata["title"] = title_match.group(1).strip()
|
||||
|
||||
return metadata
|
||||
except subprocess.CalledProcessError:
|
||||
# Fall back to basic metadata if pandoc extraction fails
|
||||
return metadata
|
||||
except FileNotFoundError:
|
||||
sys.stderr.write("Warning: pandoc not found, metadata extraction limited\n")
|
||||
return metadata
|
||||
|
||||
|
||||
def extract_docx_content(file_path):
|
||||
"""Extract content from a DOCX file using pandoc."""
|
||||
try:
|
||||
# Use pandoc to convert DOCX to plain text
|
||||
result = subprocess.run(
|
||||
["pandoc", "-f", "docx", "-t", "plain", file_path],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
sys.stderr.write(f"Error extracting DOCX content: {e}\n")
|
||||
if e.stderr:
|
||||
sys.stderr.write(e.stderr)
|
||||
return ""
|
||||
except FileNotFoundError:
|
||||
sys.stderr.write(
|
||||
"Error: pandoc is not installed. Please install it with 'apt install pandoc'\n"
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def process_file(file_path):
|
||||
"""Extract content and metadata from the file."""
|
||||
# Check if input file exists
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
sys.stderr.write(f"Error: File '{file_path}' not found.\n")
|
||||
return {}
|
||||
|
||||
# Get file metadata
|
||||
metadata = get_file_metadata(file_path)
|
||||
|
||||
# Extract document metadata
|
||||
docx_metadata = extract_docx_metadata(file_path)
|
||||
|
||||
# Extract content
|
||||
content = extract_docx_content(file_path)
|
||||
|
||||
# Try to extract tags
|
||||
tags = []
|
||||
if "keywords" in docx_metadata:
|
||||
tags = [tag.strip() for tag in docx_metadata["keywords"].split(",")]
|
||||
|
||||
# Determine title
|
||||
title = docx_metadata.get("title", "")
|
||||
if not title:
|
||||
# Use filename without extension as fallback title
|
||||
title = os.path.splitext(os.path.basename(file_path))[0]
|
||||
|
||||
# Create result object
|
||||
result = {
|
||||
**metadata,
|
||||
**docx_metadata,
|
||||
"content": content,
|
||||
"title": title,
|
||||
"tags": tags,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract content and metadata from Word .docx files",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__.split("\n\n", 1)[1]
|
||||
if __doc__
|
||||
else "", # Use the docstring as extended help
|
||||
)
|
||||
parser.add_argument("file", help="Input .docx file")
|
||||
parser.add_argument(
|
||||
"-v", "--version", action="version", version="docx-extractor 1.0.0"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# Process the file
|
||||
result = process_file(args.file)
|
||||
|
||||
# Output the result as JSON
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
return 0
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
70
tools/search/src/search/pdf_extractor.md
Normal file
70
tools/search/src/search/pdf_extractor.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
command: pdf-extractor
|
||||
script: search/pdf_extractor.py
|
||||
description: Extract content and metadata from PDF files
|
||||
version: 1.0.0
|
||||
category: search
|
||||
---
|
||||
|
||||
# pdf-extractor
|
||||
|
||||
Extract text from PDF documents.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
which pdf-extractor
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Extract from PDF
|
||||
pdf-extractor document.pdf
|
||||
|
||||
# Save to file
|
||||
pdf-extractor document.pdf > text.txt
|
||||
|
||||
# Multiple PDFs
|
||||
pdf-extractor file1.pdf file2.pdf
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
pdf-extractor [files...]
|
||||
|
||||
Options:
|
||||
files PDF files to extract text from
|
||||
-h, --help Show help message
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Extract text from PDFs
|
||||
- Handles multi-page documents
|
||||
- Preserves text layout where possible
|
||||
- Fast extraction
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Single PDF
|
||||
pdf-extractor report.pdf
|
||||
|
||||
# Batch extraction
|
||||
for file in *.pdf; do
|
||||
pdf-extractor "$file" > "${file%.pdf}.txt"
|
||||
done
|
||||
|
||||
# Search extracted text
|
||||
pdf-extractor document.pdf | grep "keyword"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Works with text-based PDFs
|
||||
- Scanned PDFs (images) require OCR
|
||||
- Complex layouts may affect text order
|
||||
168
tools/search/src/search/pdf_extractor.py
Executable file
168
tools/search/src/search/pdf_extractor.py
Executable file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pdf-extractor: Extract content and metadata from PDF files
|
||||
|
||||
Reads a PDF file and outputs a JSON object containing the file's content
|
||||
and metadata including filename, size, page count, title, author, etc.
|
||||
|
||||
Usage: pdf-extractor [options] [FILE]
|
||||
|
||||
Examples:
|
||||
pdf-extractor document.pdf # Extract from a PDF file
|
||||
cat document.pdf | pdf-extractor # Read from stdin (if applicable)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
import fitz # PyMuPDF
|
||||
from datetime import datetime
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
|
||||
def get_file_metadata(file_path):
|
||||
"""Get file metadata including creation time, modification time, size, etc."""
|
||||
path = Path(file_path)
|
||||
stat = path.stat()
|
||||
|
||||
# Get file modification and creation times
|
||||
mtime = datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||
try:
|
||||
ctime = datetime.fromtimestamp(stat.st_ctime).isoformat()
|
||||
except Exception:
|
||||
ctime = mtime # Fallback if creation time not available
|
||||
|
||||
# Get mime type
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
if not mime_type:
|
||||
mime_type = "application/pdf" # Default to PDF
|
||||
|
||||
return {
|
||||
"filename": path.name,
|
||||
"path": str(path.absolute()),
|
||||
"size": stat.st_size,
|
||||
"created_at": ctime,
|
||||
"modified_at": mtime,
|
||||
"mime_type": mime_type,
|
||||
"extension": path.suffix.lstrip(".") if path.suffix else "pdf",
|
||||
}
|
||||
|
||||
|
||||
def extract_pdf_content(file_path):
|
||||
"""Extract content and metadata from a PDF file."""
|
||||
try:
|
||||
doc = fitz.open(file_path)
|
||||
|
||||
# Extract PDF metadata
|
||||
metadata = doc.metadata if doc.metadata else {}
|
||||
pdf_metadata = {
|
||||
"page_count": doc.page_count,
|
||||
"title": metadata.get("title", ""),
|
||||
"author": metadata.get("author", ""),
|
||||
"subject": metadata.get("subject", ""),
|
||||
"keywords": metadata.get("keywords", ""),
|
||||
"creator": metadata.get("creator", ""),
|
||||
"producer": metadata.get("producer", ""),
|
||||
"creation_date": metadata.get("creationDate", ""),
|
||||
"modification_date": metadata.get("modDate", ""),
|
||||
}
|
||||
|
||||
# Extract text content from all pages
|
||||
content = ""
|
||||
for page_num in range(doc.page_count):
|
||||
page = doc[page_num]
|
||||
content += page.get_text() # type: ignore
|
||||
content += "\n\n" # Add spacing between pages
|
||||
|
||||
doc.close()
|
||||
return content, pdf_metadata
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error extracting PDF content: {e}\n")
|
||||
return "", {}
|
||||
|
||||
|
||||
def process_file(file_path):
|
||||
"""Extract content and metadata from the file."""
|
||||
# Check if we're dealing with stdin or a file
|
||||
temp_file = None
|
||||
|
||||
try:
|
||||
if not file_path or file_path == "-":
|
||||
# Reading from stdin, save to temporary file
|
||||
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf")
|
||||
temp_file.write(sys.stdin.buffer.read())
|
||||
temp_file.close()
|
||||
file_path = temp_file.name
|
||||
|
||||
# Create basic metadata for stdin input
|
||||
metadata = {
|
||||
"filename": "stdin",
|
||||
"path": "stdin",
|
||||
"size": os.path.getsize(file_path),
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"modified_at": datetime.now().isoformat(),
|
||||
"mime_type": "application/pdf",
|
||||
"extension": "pdf",
|
||||
}
|
||||
else:
|
||||
# Reading from a file
|
||||
if not os.path.exists(file_path):
|
||||
sys.stderr.write(f"Error: File '{file_path}' not found.\n")
|
||||
return {}
|
||||
|
||||
# Get file metadata
|
||||
metadata = get_file_metadata(file_path)
|
||||
|
||||
# Extract PDF content and PDF-specific metadata
|
||||
content, pdf_metadata = extract_pdf_content(file_path)
|
||||
|
||||
# Create result object
|
||||
result = {
|
||||
**metadata,
|
||||
**pdf_metadata,
|
||||
"content": content,
|
||||
"title": pdf_metadata.get("title") or metadata["filename"],
|
||||
"tags": [tag.strip() for tag in pdf_metadata.get("keywords", "").split(",")]
|
||||
if pdf_metadata.get("keywords")
|
||||
else [],
|
||||
}
|
||||
|
||||
return result
|
||||
finally:
|
||||
# Clean up the temporary file if one was created
|
||||
if temp_file and os.path.exists(temp_file.name):
|
||||
os.unlink(temp_file.name)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract content and metadata from PDF files",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__.split("\n\n", 1)[1]
|
||||
if __doc__
|
||||
else "", # Use the docstring as extended help
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Input file (default: stdin)")
|
||||
parser.add_argument(
|
||||
"-v", "--version", action="version", version="pdf-extractor 1.0.0"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# Process the file
|
||||
result = process_file(args.file)
|
||||
|
||||
# Output the result as JSON
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
return 0
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
123
tools/search/src/search/search.md
Normal file
123
tools/search/src/search/search.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
command: search
|
||||
script: search/search.py
|
||||
description: Manage self-contained searchable collections of files
|
||||
version: 1.0.0
|
||||
category: search
|
||||
---
|
||||
|
||||
# search
|
||||
|
||||
Fast full-text search using Tantivy search index. Index and search documents with blazing speed.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
which search
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Index Documents
|
||||
|
||||
```bash
|
||||
# Index a directory
|
||||
search index /path/to/documents
|
||||
|
||||
# Index specific files
|
||||
search index file1.txt file2.pdf file3.docx
|
||||
```
|
||||
|
||||
### Search
|
||||
|
||||
```bash
|
||||
# Simple search
|
||||
search query "search terms"
|
||||
|
||||
# Search with field
|
||||
search query --field title "document title"
|
||||
|
||||
# Limit results
|
||||
search query --limit 10 "search terms"
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
search index [paths...] Index files or directories
|
||||
search query [options] QUERY Search indexed documents
|
||||
|
||||
Query options:
|
||||
QUERY Search query (required)
|
||||
--field FIELD Search specific field (title, body, path)
|
||||
--limit N Maximum results to return (default: 20)
|
||||
-h, --help Show help message
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Fast searching** - Powered by Tantivy search engine
|
||||
- **Multiple formats** - Supports PDF, DOCX, TXT, and more
|
||||
- **Full-text search** - Search entire document contents
|
||||
- **Field search** - Search specific fields
|
||||
- **Relevance ranking** - Results sorted by relevance
|
||||
|
||||
## Examples
|
||||
|
||||
### Index and Search
|
||||
|
||||
```bash
|
||||
# Index your documents
|
||||
search index ~/Documents
|
||||
|
||||
# Search for terms
|
||||
search query "important meeting notes"
|
||||
search query "project proposal 2024"
|
||||
```
|
||||
|
||||
### Field-Specific Search
|
||||
|
||||
```bash
|
||||
# Search in title only
|
||||
search query --field title "Annual Report"
|
||||
|
||||
# Search in body
|
||||
search query --field body "quarterly results"
|
||||
```
|
||||
|
||||
### Batch Operations
|
||||
|
||||
```bash
|
||||
# Index multiple directories
|
||||
search index ~/Documents ~/Downloads ~/Desktop
|
||||
|
||||
# Incremental indexing
|
||||
search index ~/Documents/new_files
|
||||
```
|
||||
|
||||
## Supported File Types
|
||||
|
||||
- **Text files** - .txt, .md, .log
|
||||
- **PDF documents** - .pdf
|
||||
- **Word documents** - .docx
|
||||
- **Other formats** - via text extractors
|
||||
|
||||
## Index Location
|
||||
|
||||
Index is stored in `~/.local/share/search/index` by default.
|
||||
|
||||
## Tips
|
||||
|
||||
- Index documents once, search many times
|
||||
- Re-index when documents change
|
||||
- Use quotes for exact phrases
|
||||
- Field searches are faster for specific needs
|
||||
|
||||
## Notes
|
||||
|
||||
- Initial indexing may take time for large collections
|
||||
- Index size depends on document collection size
|
||||
- Supports incremental indexing
|
||||
- UTF-8 text encoding recommended
|
||||
832
tools/search/src/search/search.py
Executable file
832
tools/search/src/search/search.py
Executable file
@@ -0,0 +1,832 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
search: Manage self-contained searchable collections of files
|
||||
|
||||
A modular, low-resource, local search system for files (PDFs, images, videos, etc.)
|
||||
where each collection is self-contained and can be indexed and queried using Tantivy.
|
||||
|
||||
Usage:
|
||||
search init [options] [directory] Initialize a directory as a search collection
|
||||
search scan [options] [directory] Find all search collections in a directory tree
|
||||
search index [options] [directory...] Index all matched files in one or more collections
|
||||
search query [options] QUERY Search indexed collections using Tantivy
|
||||
|
||||
Examples:
|
||||
search init ~/documents # Initialize a collection
|
||||
search init --name "Research" ~/papers # Initialize with a specific name
|
||||
search init --extract "*.pdf=pdf2md {input}" # Add a specific extractor
|
||||
|
||||
search scan ~/documents # Find collections
|
||||
|
||||
search index ~/documents # Index a single collection
|
||||
search index ~/documents ~/papers # Index multiple collections
|
||||
search index --verbose ~/documents # Show detailed indexing progress
|
||||
|
||||
search query "neural networks" # Search across all nearby collections
|
||||
search query "DNA" --in ~/papers # Search in a specific collection
|
||||
search query "protein" --tag biology # Search with tag filter
|
||||
search query "machine learning" --limit 10 # Limit number of results
|
||||
|
||||
For more detailed help on a specific subcommand:
|
||||
search init --help
|
||||
search scan --help
|
||||
search index --help
|
||||
search query --help
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import toml
|
||||
|
||||
# Try to import Tantivy, but don't fail immediately if not available
|
||||
# This allows --help and other non-search operations to work without Tantivy
|
||||
tantivy_available = False
|
||||
try:
|
||||
import tantivy # type: ignore
|
||||
|
||||
tantivy_available = True
|
||||
except ImportError:
|
||||
tantivy = None # type: ignore
|
||||
|
||||
|
||||
def check_tantivy():
|
||||
"""Check if Tantivy is available and exit if not."""
|
||||
if not tantivy_available:
|
||||
print(
|
||||
"Error: Required Tantivy Python bindings not found. Please install them with:"
|
||||
)
|
||||
print(" pip install tantivy")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Constants
|
||||
SEARCH_DIR = ".search"
|
||||
CONFIG_FILE = f"{SEARCH_DIR}/config.toml"
|
||||
CACHE_DIR = f"{SEARCH_DIR}/cache"
|
||||
INDEX_DIR = f"{SEARCH_DIR}/index"
|
||||
|
||||
# Default extractor commands for common file types
|
||||
DEFAULT_EXTRACTORS = {
|
||||
"*.md": "text-extractor {input}",
|
||||
"*.txt": "text-extractor {input}",
|
||||
"*.pdf": "pdf-extractor {input}",
|
||||
"*.docx": "docx-extractor {input}",
|
||||
}
|
||||
|
||||
# Default configuration
|
||||
DEFAULT_CONFIG = {
|
||||
"name": "Default Collection",
|
||||
"include": {"patterns": ["*.pdf", "*.md", "*.txt", "*.docx"]},
|
||||
"exclude": {"patterns": ["*~", "*.bak", "*.tmp", ".git/*", ".search/*"]},
|
||||
"extractors": {}, # Only define custom extractors here, defaults are used automatically
|
||||
"output": {"format": "json", "directory": "cache"},
|
||||
}
|
||||
|
||||
|
||||
def ensure_dir(path: str) -> None:
|
||||
"""Ensure a directory exists."""
|
||||
Path(path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def find_collections(start_dir: str) -> List[str]:
|
||||
"""Find all search collections starting from a directory."""
|
||||
collections = []
|
||||
start_path = Path(start_dir).resolve()
|
||||
|
||||
for path in start_path.glob(f"**/{SEARCH_DIR}"):
|
||||
config_file = path / "config.toml"
|
||||
if config_file.exists():
|
||||
collections.append(str(path.parent))
|
||||
|
||||
return collections
|
||||
|
||||
|
||||
def load_config(collection_dir: str) -> Dict[str, Any]:
|
||||
"""Load collection configuration."""
|
||||
config_path = os.path.join(collection_dir, CONFIG_FILE)
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
return toml.load(f)
|
||||
except (FileNotFoundError, toml.TomlDecodeError) as e:
|
||||
sys.stderr.write(f"Error loading config: {e}\n")
|
||||
return {}
|
||||
|
||||
|
||||
def save_config(collection_dir: str, config: Dict[str, Any]) -> bool:
|
||||
"""Save collection configuration."""
|
||||
config_path = os.path.join(collection_dir, CONFIG_FILE)
|
||||
try:
|
||||
# Ensure the .search directory exists
|
||||
ensure_dir(os.path.join(collection_dir, SEARCH_DIR))
|
||||
|
||||
# Add a header comment to explain the extractors section
|
||||
header = """# Search Collection Configuration
|
||||
#
|
||||
# Built-in extractors are automatically used for common file types:
|
||||
# *.txt, *.md: text-extractor {input}
|
||||
# *.pdf: pdf-extractor {input}
|
||||
# *.docx: docx-extractor {input}
|
||||
#
|
||||
# The [extractors] section below only needs to define custom extractors
|
||||
# or overrides for specific file types.
|
||||
#
|
||||
"""
|
||||
# Format the config as TOML
|
||||
config_toml = toml.dumps(config)
|
||||
|
||||
# Write the file with header
|
||||
with open(config_path, "w") as f:
|
||||
f.write(header)
|
||||
f.write(config_toml)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error saving config: {e}\n")
|
||||
return False
|
||||
|
||||
|
||||
def filter_files(collection_dir: str, config: Dict[str, Any]) -> List[str]:
|
||||
"""Filter files based on include/exclude patterns."""
|
||||
include_patterns = config.get("include", {}).get("patterns", ["*"])
|
||||
exclude_patterns = config.get("exclude", {}).get("patterns", [])
|
||||
|
||||
matched_files = []
|
||||
|
||||
# Walk the directory tree
|
||||
for root, dirs, files in os.walk(collection_dir):
|
||||
# Skip the .search directory
|
||||
if os.path.basename(root) == SEARCH_DIR:
|
||||
continue
|
||||
|
||||
# Check if any exclude pattern matches directories
|
||||
skip_dir = False
|
||||
for pattern in exclude_patterns:
|
||||
if any(fnmatch.fnmatch(d, pattern) for d in dirs):
|
||||
skip_dir = True
|
||||
break
|
||||
if skip_dir:
|
||||
continue
|
||||
|
||||
# Match files against include/exclude patterns
|
||||
for filename in files:
|
||||
file_path = os.path.join(root, filename)
|
||||
|
||||
# Skip if the file is in the .search directory
|
||||
if SEARCH_DIR in file_path.split(os.sep):
|
||||
continue
|
||||
|
||||
# Check include patterns
|
||||
if not any(
|
||||
fnmatch.fnmatch(filename, pattern) for pattern in include_patterns
|
||||
):
|
||||
continue
|
||||
|
||||
# Check exclude patterns
|
||||
if any(fnmatch.fnmatch(filename, pattern) for pattern in exclude_patterns):
|
||||
continue
|
||||
|
||||
matched_files.append(file_path)
|
||||
|
||||
return matched_files
|
||||
|
||||
|
||||
def get_extractor(filename: str, config: Dict[str, Any]) -> Optional[str]:
|
||||
"""Find the appropriate extractor command for a file."""
|
||||
# First check custom extractors defined in config
|
||||
custom_extractors = config.get("extractors", {})
|
||||
|
||||
# Try to match custom extractors first (allowing overrides)
|
||||
for pattern, command in custom_extractors.items():
|
||||
if fnmatch.fnmatch(filename, pattern):
|
||||
return command
|
||||
|
||||
# If no custom extractor matched, use the default extractors
|
||||
for pattern, command in DEFAULT_EXTRACTORS.items():
|
||||
if fnmatch.fnmatch(filename, pattern):
|
||||
return command
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_metadata(file_path: str, extractor_cmd: str) -> Dict[str, Any]:
|
||||
"""Execute extractor and parse output as JSON."""
|
||||
# Replace {input} with the properly quoted file path
|
||||
# First, escape any single quotes in the path
|
||||
escaped_path = file_path.replace("'", "'\\''")
|
||||
# Then wrap in single quotes to handle spaces and special characters
|
||||
quoted_path = f"'{escaped_path}'"
|
||||
cmd = extractor_cmd.replace("{input}", quoted_path)
|
||||
|
||||
try:
|
||||
# Run the extractor command
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, capture_output=True, text=True, check=True
|
||||
)
|
||||
output = result.stdout
|
||||
|
||||
# Attempt to parse as JSON
|
||||
try:
|
||||
return json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
# If parsing fails, create basic metadata with content
|
||||
return {"title": os.path.basename(file_path), "content": output}
|
||||
except subprocess.CalledProcessError as e:
|
||||
sys.stderr.write(f"Error executing extractor for {file_path}: {e}\n")
|
||||
return {}
|
||||
|
||||
|
||||
def save_cache(
|
||||
collection_dir: str,
|
||||
file_path: str,
|
||||
metadata: Dict[str, Any],
|
||||
config: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Save metadata to cache."""
|
||||
# Ensure the cache directory exists
|
||||
cache_dir = os.path.join(collection_dir, CACHE_DIR)
|
||||
ensure_dir(cache_dir)
|
||||
|
||||
# Create a cache file name based on the original file path
|
||||
rel_path = os.path.relpath(file_path, collection_dir)
|
||||
cache_filename = re.sub(r"[/\\]", "_", rel_path)
|
||||
|
||||
# Save as JSON
|
||||
cache_path = os.path.join(cache_dir, f"{cache_filename}.json")
|
||||
try:
|
||||
with open(cache_path, "w") as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
return True
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error saving cache file {cache_path}: {e}\n")
|
||||
return False
|
||||
|
||||
|
||||
def create_schema():
|
||||
"""Create Tantivy schema for document indexing."""
|
||||
schema_builder = tantivy.SchemaBuilder() # type: ignore
|
||||
|
||||
# Define fields for the schema
|
||||
schema_builder.add_text_field("path", stored=True)
|
||||
schema_builder.add_text_field("title", stored=True)
|
||||
schema_builder.add_text_field("content", stored=True)
|
||||
schema_builder.add_text_field("absolute_path", stored=True)
|
||||
# Using text fields for tags instead of facets
|
||||
schema_builder.add_text_field("tag_pdf", stored=True)
|
||||
schema_builder.add_text_field("tag_test", stored=True)
|
||||
schema_builder.add_text_field("tag_sample", stored=True)
|
||||
# Add more common tags as needed
|
||||
schema_builder.add_text_field(
|
||||
"metadata", stored=True
|
||||
) # For storing additional JSON metadata
|
||||
|
||||
return schema_builder.build()
|
||||
|
||||
|
||||
def get_or_create_index(collection_dir: str) -> tuple:
|
||||
"""Get or create a Tantivy index for a collection."""
|
||||
# Check if Tantivy is available
|
||||
check_tantivy()
|
||||
|
||||
index_dir = os.path.join(collection_dir, INDEX_DIR)
|
||||
ensure_dir(index_dir)
|
||||
|
||||
# For reference only - we don't actually read this
|
||||
schema_path = os.path.join(index_dir, "schema_info.json")
|
||||
|
||||
# Check if index exists
|
||||
if os.path.exists(os.path.join(index_dir, "meta.json")):
|
||||
# Try to open existing index
|
||||
try:
|
||||
index = tantivy.Index.open(index_dir) # type: ignore
|
||||
return index, None # We don't need the schema for opened indexes
|
||||
except Exception as e:
|
||||
# If opening failed, create a new one
|
||||
sys.stderr.write(
|
||||
f"Warning: Could not open existing index, creating new one: {e}\n"
|
||||
)
|
||||
|
||||
# Create new schema and index
|
||||
schema = create_schema()
|
||||
# Create basic schema info for reference
|
||||
schema_info = {
|
||||
"fields": [
|
||||
{"name": "path", "type": "text", "stored": True},
|
||||
{"name": "title", "type": "text", "stored": True},
|
||||
{"name": "content", "type": "text", "stored": True},
|
||||
{"name": "absolute_path", "type": "text", "stored": True},
|
||||
{"name": "tag_pdf", "type": "text", "stored": True},
|
||||
{"name": "tag_test", "type": "text", "stored": True},
|
||||
{"name": "tag_sample", "type": "text", "stored": True},
|
||||
{"name": "metadata", "type": "text", "stored": True},
|
||||
]
|
||||
}
|
||||
|
||||
# Save schema info for reference
|
||||
with open(schema_path, "w") as f:
|
||||
json.dump(schema_info, f, indent=2)
|
||||
|
||||
# Create the index
|
||||
index = tantivy.Index(schema, path=index_dir) # type: ignore
|
||||
return index, schema
|
||||
|
||||
|
||||
def index_file(collection_dir: str, file_path: str, metadata: Dict[str, Any]) -> bool:
|
||||
"""Index a file using Tantivy."""
|
||||
try:
|
||||
# Get or create index
|
||||
index, _schema = get_or_create_index(collection_dir)
|
||||
|
||||
# Create a writer for adding documents
|
||||
writer = index.writer()
|
||||
|
||||
# Create document
|
||||
doc = tantivy.Document() # type: ignore
|
||||
|
||||
# Get relative path as identifier
|
||||
rel_path = os.path.relpath(file_path, collection_dir)
|
||||
|
||||
# Add fields to document
|
||||
doc.add_text("path", rel_path)
|
||||
doc.add_text("absolute_path", os.path.abspath(file_path))
|
||||
|
||||
# Add title if available
|
||||
if "title" in metadata and metadata["title"]:
|
||||
doc.add_text("title", metadata["title"])
|
||||
else:
|
||||
doc.add_text("title", os.path.basename(file_path))
|
||||
|
||||
# Add content if available
|
||||
if "content" in metadata and metadata["content"]:
|
||||
doc.add_text("content", metadata["content"])
|
||||
|
||||
# Add tags as facets if available
|
||||
if "tags" in metadata and isinstance(metadata["tags"], list):
|
||||
for tag in metadata["tags"]:
|
||||
if tag and isinstance(tag, str):
|
||||
# Add as text since facets are complicated in Tantivy
|
||||
tag_field = f"tag_{tag.lower().replace(' ', '_').replace(',', '').replace('-', '_')}"
|
||||
doc.add_text(tag_field, "true")
|
||||
|
||||
# Store all metadata as JSON
|
||||
doc.add_text(
|
||||
"metadata",
|
||||
json.dumps(
|
||||
{
|
||||
"path": rel_path,
|
||||
"absolute_path": os.path.abspath(file_path),
|
||||
**metadata,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
# Add document to index
|
||||
writer.add_document(doc)
|
||||
|
||||
# Commit changes
|
||||
writer.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error indexing file {file_path}: {e}\n")
|
||||
return False
|
||||
|
||||
|
||||
def perform_search(
|
||||
query_string: str,
|
||||
collections: List[str],
|
||||
tag_filter: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search across collections using Tantivy."""
|
||||
# Check if Tantivy is available
|
||||
check_tantivy()
|
||||
|
||||
results = []
|
||||
|
||||
# Process each collection
|
||||
for collection_dir in collections:
|
||||
index_dir = os.path.join(collection_dir, INDEX_DIR)
|
||||
if not os.path.exists(index_dir) or not os.path.exists(
|
||||
os.path.join(index_dir, "meta.json")
|
||||
):
|
||||
continue
|
||||
|
||||
try:
|
||||
# Open the index
|
||||
index = tantivy.Index.open(index_dir) # type: ignore
|
||||
searcher = index.searcher()
|
||||
|
||||
# Parse the query using index's parse_query method
|
||||
query = index.parse_query(query_string, ["title", "content"])
|
||||
|
||||
# Perform the search
|
||||
search_result = searcher.search(query, limit)
|
||||
|
||||
# Extract results - access the hits property which contains doc_address
|
||||
for hit in search_result.hits:
|
||||
# Unpack score and doc_address from the hit
|
||||
score, doc_address = hit
|
||||
retrieved_doc = searcher.doc(doc_address)
|
||||
|
||||
# Create basic metadata
|
||||
title = ""
|
||||
path = ""
|
||||
abs_path = ""
|
||||
tags = []
|
||||
score_value = score * 100 # Convert to percentage
|
||||
|
||||
# Extract fields using get_first
|
||||
title_field = retrieved_doc.get_first("title")
|
||||
if title_field:
|
||||
title = title_field
|
||||
|
||||
path_field = retrieved_doc.get_first("path")
|
||||
if path_field:
|
||||
path = path_field
|
||||
|
||||
abs_path_field = retrieved_doc.get_first("absolute_path")
|
||||
if abs_path_field:
|
||||
abs_path = abs_path_field
|
||||
|
||||
metadata_field = retrieved_doc.get_first("metadata")
|
||||
if metadata_field:
|
||||
try:
|
||||
metadata = json.loads(metadata_field)
|
||||
# If we have tags in metadata, extract them
|
||||
if "tags" in metadata:
|
||||
tags = metadata["tags"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Skip documents that don't match tag filter
|
||||
if tag_filter and tag_filter.lower() not in [t.lower() for t in tags]:
|
||||
continue
|
||||
|
||||
# Create result object
|
||||
data = {
|
||||
"title": title or os.path.basename(path),
|
||||
"path": path,
|
||||
"absolute_path": abs_path,
|
||||
"tags": tags,
|
||||
"score": score_value,
|
||||
"collection": os.path.basename(collection_dir),
|
||||
"collection_path": collection_dir,
|
||||
}
|
||||
|
||||
results.append(data)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error searching in collection {collection_dir}: {e}\n")
|
||||
|
||||
# Sort results by score (highest first)
|
||||
results.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||
|
||||
return results[:limit]
|
||||
|
||||
|
||||
def cmd_init(args: argparse.Namespace) -> int:
|
||||
"""Initialize a directory as a search collection."""
|
||||
directory = os.path.abspath(args.directory or os.getcwd())
|
||||
|
||||
# Check if the directory exists
|
||||
if not os.path.isdir(directory):
|
||||
sys.stderr.write(f"Error: Directory {directory} does not exist.\n")
|
||||
return 1
|
||||
|
||||
# Check if the collection already exists
|
||||
config_path = os.path.join(directory, CONFIG_FILE)
|
||||
if os.path.exists(config_path):
|
||||
if not args.force:
|
||||
sys.stderr.write(
|
||||
f"Error: Collection already exists at {directory}. Use --force to overwrite.\n"
|
||||
)
|
||||
return 1
|
||||
|
||||
# Create the basic configuration
|
||||
config = DEFAULT_CONFIG.copy()
|
||||
|
||||
# Update configuration based on command-line arguments
|
||||
if args.name:
|
||||
config["name"] = args.name
|
||||
|
||||
if args.include:
|
||||
config["include"]["patterns"] = args.include
|
||||
|
||||
if args.exclude:
|
||||
config["exclude"]["patterns"] = args.exclude
|
||||
|
||||
# Add custom extractors if specified
|
||||
if args.extract:
|
||||
extractors = config["extractors"].copy()
|
||||
for extractor in args.extract:
|
||||
if "=" in extractor:
|
||||
pattern, command = extractor.split("=", 1)
|
||||
extractors[pattern.strip()] = command.strip()
|
||||
config["extractors"] = extractors
|
||||
|
||||
# Create the directory structure
|
||||
ensure_dir(os.path.join(directory, SEARCH_DIR))
|
||||
ensure_dir(os.path.join(directory, CACHE_DIR))
|
||||
ensure_dir(os.path.join(directory, INDEX_DIR))
|
||||
|
||||
# Save the configuration
|
||||
if save_config(directory, config):
|
||||
print(f"Initialized search collection in {directory}")
|
||||
print(f"Configuration saved to {config_path}")
|
||||
return 0
|
||||
else:
|
||||
return 1
|
||||
|
||||
|
||||
def cmd_scan(args: argparse.Namespace) -> int:
|
||||
"""Scan for collections."""
|
||||
directory = os.path.abspath(args.directory or os.getcwd())
|
||||
|
||||
if not os.path.isdir(directory):
|
||||
sys.stderr.write(f"Error: Directory {directory} does not exist.\n")
|
||||
return 1
|
||||
|
||||
collections = find_collections(directory)
|
||||
|
||||
if not collections:
|
||||
print(f"No search collections found in {directory}")
|
||||
return 0
|
||||
|
||||
print(f"Found {len(collections)} search collection(s):")
|
||||
for collection in collections:
|
||||
config = load_config(collection)
|
||||
name = config.get("name", "Unnamed collection")
|
||||
print(f" {name}: {collection}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_index(args: argparse.Namespace) -> int:
|
||||
"""Index files in collections."""
|
||||
directories = (
|
||||
[os.path.abspath(d) for d in args.directories]
|
||||
if args.directories
|
||||
else [os.getcwd()]
|
||||
)
|
||||
|
||||
collections = []
|
||||
for directory in directories:
|
||||
# Check if the directory is a collection
|
||||
if os.path.exists(os.path.join(directory, CONFIG_FILE)):
|
||||
collections.append(directory)
|
||||
else:
|
||||
# Scan for collections in this directory
|
||||
found_collections = find_collections(directory)
|
||||
collections.extend(found_collections)
|
||||
|
||||
if not collections:
|
||||
sys.stderr.write(
|
||||
"Error: No search collections found in the specified directories.\n"
|
||||
)
|
||||
return 1
|
||||
|
||||
indexed_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for collection_dir in collections:
|
||||
config = load_config(collection_dir)
|
||||
if not config:
|
||||
sys.stderr.write(
|
||||
f"Warning: Skipping collection {collection_dir} due to missing or invalid configuration.\n"
|
||||
)
|
||||
continue
|
||||
|
||||
collection_name = config.get("name", os.path.basename(collection_dir))
|
||||
print(f"Indexing collection: {collection_name} ({collection_dir})")
|
||||
|
||||
# Get matched files
|
||||
matched_files = filter_files(collection_dir, config)
|
||||
print(f"Found {len(matched_files)} matching files")
|
||||
|
||||
for file_path in matched_files:
|
||||
rel_path = os.path.relpath(file_path, collection_dir)
|
||||
|
||||
try:
|
||||
# Get the appropriate extractor
|
||||
extractor = get_extractor(os.path.basename(file_path), config)
|
||||
if not extractor:
|
||||
if args.verbose:
|
||||
print(f" Skipping {rel_path}: No extractor found")
|
||||
continue
|
||||
|
||||
if args.verbose:
|
||||
print(f" Extracting {rel_path}")
|
||||
|
||||
# Extract metadata
|
||||
metadata = extract_metadata(file_path, extractor)
|
||||
if not metadata:
|
||||
if args.verbose:
|
||||
print(f" Failed to extract metadata from {rel_path}")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# Save to cache if caching is enabled
|
||||
if not args.no_cache:
|
||||
save_cache(collection_dir, file_path, metadata, config)
|
||||
|
||||
# Index the file
|
||||
if index_file(collection_dir, file_path, metadata):
|
||||
indexed_count += 1
|
||||
if args.verbose:
|
||||
print(f" Successfully indexed {rel_path}")
|
||||
else:
|
||||
failed_count += 1
|
||||
if args.verbose:
|
||||
print(f" Failed to index {rel_path}")
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error processing {rel_path}: {e}\n")
|
||||
failed_count += 1
|
||||
|
||||
print(
|
||||
f"Finished indexing {indexed_count} files across {len(collections)} collections."
|
||||
)
|
||||
if failed_count > 0:
|
||||
print(f"Failed to index {failed_count} files.")
|
||||
|
||||
return 0 if failed_count == 0 else 1
|
||||
|
||||
|
||||
def cmd_query(args: argparse.Namespace) -> int:
|
||||
"""Search collections."""
|
||||
# Check if Tantivy is available
|
||||
check_tantivy()
|
||||
|
||||
# Determine which collections to search
|
||||
collections = []
|
||||
if args.in_dir:
|
||||
directory = os.path.abspath(args.in_dir)
|
||||
if os.path.exists(os.path.join(directory, CONFIG_FILE)):
|
||||
collections.append(directory)
|
||||
else:
|
||||
sys.stderr.write(f"Error: {directory} is not a valid search collection.\n")
|
||||
return 1
|
||||
else:
|
||||
# Start from current directory, search up to find collections
|
||||
dir_path = os.path.abspath(os.getcwd())
|
||||
while dir_path and dir_path != "/":
|
||||
if os.path.exists(os.path.join(dir_path, CONFIG_FILE)):
|
||||
collections.append(dir_path)
|
||||
break
|
||||
dir_path = os.path.dirname(dir_path)
|
||||
|
||||
# If no collection is found, scan for collections in current directory
|
||||
if not collections:
|
||||
collections = find_collections(os.getcwd())
|
||||
|
||||
if not collections:
|
||||
sys.stderr.write(
|
||||
"Error: No search collections found. Use --in to specify a collection.\n"
|
||||
)
|
||||
return 1
|
||||
|
||||
# Perform the search
|
||||
results = perform_search(
|
||||
query_string=args.query,
|
||||
collections=collections,
|
||||
tag_filter=args.tag,
|
||||
limit=args.limit,
|
||||
)
|
||||
|
||||
if not results:
|
||||
print(f"No results found for '{args.query}'")
|
||||
return 0
|
||||
|
||||
print(f"Found {len(results)} results for '{args.query}':")
|
||||
|
||||
for i, result in enumerate(results, 1):
|
||||
title = result.get("title", os.path.basename(result.get("path", "Unknown")))
|
||||
path = result.get("absolute_path", "Unknown path")
|
||||
score = result.get("score", 0)
|
||||
collection = result.get("collection", "Unknown collection")
|
||||
|
||||
print(f"\n{i}. {title} ({score:.0f}% match) - {collection}")
|
||||
print(f" Path: {path}")
|
||||
|
||||
# Show tags if available
|
||||
tags = result.get("tags", [])
|
||||
if tags:
|
||||
print(f" Tags: {', '.join(tags)}")
|
||||
|
||||
# Show a snippet of content if available and detailed output is requested
|
||||
if args.verbose and "content" in result:
|
||||
content = result["content"]
|
||||
if content:
|
||||
# Truncate and sanitize for display
|
||||
content = re.sub(r"\s+", " ", content)
|
||||
snippet = content[:200] + ("..." if len(content) > 200 else "")
|
||||
print(f" Snippet: {snippet}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Manage searchable collections of files",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__.split("\n\n", 1)[1]
|
||||
if __doc__
|
||||
else ""
|
||||
if __doc__
|
||||
else "", # Use the docstring as extended help
|
||||
)
|
||||
parser.add_argument("--version", action="version", version="search 1.0.0")
|
||||
|
||||
# Create subparsers for commands
|
||||
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||
|
||||
# init command
|
||||
init_parser = subparsers.add_parser(
|
||||
"init", help="Initialize a directory as a search collection"
|
||||
)
|
||||
init_parser.add_argument(
|
||||
"directory",
|
||||
nargs="?",
|
||||
help="Directory to initialize (default: current directory)",
|
||||
)
|
||||
init_parser.add_argument("--name", help="Name for the collection")
|
||||
init_parser.add_argument(
|
||||
"--include", nargs="+", help='Include patterns (e.g., "*.pdf" "*.md")'
|
||||
)
|
||||
init_parser.add_argument(
|
||||
"--exclude", nargs="+", help='Exclude patterns (e.g., "*.bak" "draft_*")'
|
||||
)
|
||||
init_parser.add_argument(
|
||||
"--extract", nargs="+", help='Extractor patterns (e.g., "*.pdf=pdf2md {input}")'
|
||||
)
|
||||
init_parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Force overwrite if collection already exists",
|
||||
)
|
||||
|
||||
# scan command
|
||||
scan_parser = subparsers.add_parser(
|
||||
"scan", help="Find all search collections in a directory tree"
|
||||
)
|
||||
scan_parser.add_argument(
|
||||
"directory", nargs="?", help="Directory to scan (default: current directory)"
|
||||
)
|
||||
|
||||
# index command
|
||||
index_parser = subparsers.add_parser(
|
||||
"index", help="Index all matched files in one or more collections"
|
||||
)
|
||||
index_parser.add_argument(
|
||||
"directories",
|
||||
nargs="*",
|
||||
help="Directories to index (default: current directory)",
|
||||
)
|
||||
index_parser.add_argument(
|
||||
"--verbose", action="store_true", help="Show detailed progress"
|
||||
)
|
||||
index_parser.add_argument(
|
||||
"--no-cache", action="store_true", help="Skip caching extracted metadata"
|
||||
)
|
||||
|
||||
# query command
|
||||
query_parser = subparsers.add_parser(
|
||||
"query", help="Search indexed collections using Tantivy"
|
||||
)
|
||||
query_parser.add_argument("query", help="Search query")
|
||||
query_parser.add_argument(
|
||||
"--in", dest="in_dir", help="Search in specific collection"
|
||||
)
|
||||
query_parser.add_argument("--tag", help="Filter by tag")
|
||||
query_parser.add_argument(
|
||||
"--limit", type=int, default=20, help="Maximum number of results (default: 20)"
|
||||
)
|
||||
query_parser.add_argument(
|
||||
"--verbose", action="store_true", help="Show more detailed results"
|
||||
)
|
||||
|
||||
# Parse arguments
|
||||
args = parser.parse_args()
|
||||
|
||||
# Execute the appropriate command
|
||||
if args.command == "init":
|
||||
return cmd_init(args)
|
||||
elif args.command == "scan":
|
||||
return cmd_scan(args)
|
||||
elif args.command == "index":
|
||||
return cmd_index(args)
|
||||
elif args.command == "query":
|
||||
return cmd_query(args)
|
||||
else:
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
71
tools/search/src/search/text_extractor.md
Normal file
71
tools/search/src/search/text_extractor.md
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
command: text-extractor
|
||||
script: search/text_extractor.py
|
||||
description: Extract content and metadata from text files
|
||||
version: 1.0.0
|
||||
category: search
|
||||
---
|
||||
|
||||
# text-extractor
|
||||
|
||||
Extract plain text from various document formats.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
which text-extractor
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Extract from file
|
||||
text-extractor document.txt
|
||||
|
||||
# Extract to file
|
||||
text-extractor document.txt > output.txt
|
||||
|
||||
# Process multiple files
|
||||
text-extractor file1.txt file2.txt file3.txt
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
text-extractor [files...]
|
||||
|
||||
Options:
|
||||
files Files to extract text from
|
||||
-h, --help Show help message
|
||||
```
|
||||
|
||||
## Supported Formats
|
||||
|
||||
- Plain text (.txt)
|
||||
- Markdown (.md)
|
||||
- Log files (.log)
|
||||
- Other text-based formats
|
||||
|
||||
## Features
|
||||
|
||||
- Fast text extraction
|
||||
- Handles multiple files
|
||||
- Preserves text encoding
|
||||
- Pipe-friendly output
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Extract single file
|
||||
text-extractor document.txt
|
||||
|
||||
# Batch processing
|
||||
for file in *.txt; do
|
||||
text-extractor "$file" > "${file%.txt}_extracted.txt"
|
||||
done
|
||||
|
||||
# Use in pipeline
|
||||
text-extractor doc.txt | grep "keyword"
|
||||
```
|
||||
118
tools/search/src/search/text_extractor.py
Executable file
118
tools/search/src/search/text_extractor.py
Executable file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
text-extractor: Extract content and metadata from text files
|
||||
|
||||
Reads a text file and outputs a JSON object containing the file's content
|
||||
and metadata including filename, size, creation and modification times.
|
||||
|
||||
Usage: text-extractor [options] [FILE]
|
||||
|
||||
Examples:
|
||||
text-extractor document.txt # Extract from a text file
|
||||
cat file.txt | text-extractor # Read from stdin
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_file_metadata(file_path):
|
||||
"""Get file metadata including creation time, modification time, size, etc."""
|
||||
path = Path(file_path)
|
||||
stat = path.stat()
|
||||
|
||||
# Get file modification and creation times
|
||||
mtime = datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||
try:
|
||||
ctime = datetime.fromtimestamp(stat.st_ctime).isoformat()
|
||||
except Exception:
|
||||
ctime = mtime # Fallback if creation time not available
|
||||
|
||||
# Get mime type
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
if not mime_type:
|
||||
mime_type = "text/plain" # Default to plain text
|
||||
|
||||
return {
|
||||
"filename": path.name,
|
||||
"path": str(path.absolute()),
|
||||
"size": stat.st_size,
|
||||
"created_at": ctime,
|
||||
"modified_at": mtime,
|
||||
"mime_type": mime_type,
|
||||
"extension": path.suffix.lstrip(".") if path.suffix else "",
|
||||
}
|
||||
|
||||
|
||||
def process_file(file_path):
|
||||
"""Extract content and metadata from the file."""
|
||||
# Get file metadata
|
||||
if file_path and os.path.exists(file_path):
|
||||
metadata = get_file_metadata(file_path)
|
||||
|
||||
# Read file content
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except UnicodeDecodeError:
|
||||
# Try again with latin-1 encoding
|
||||
with open(file_path, "r", encoding="latin-1") as f:
|
||||
content = f.read()
|
||||
else:
|
||||
# Reading from stdin
|
||||
content = sys.stdin.read()
|
||||
metadata = {
|
||||
"filename": "stdin",
|
||||
"path": "stdin",
|
||||
"size": len(content),
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"modified_at": datetime.now().isoformat(),
|
||||
"mime_type": "text/plain",
|
||||
"extension": "",
|
||||
}
|
||||
|
||||
# Create result object
|
||||
result = {
|
||||
**metadata,
|
||||
"content": content,
|
||||
"title": metadata["filename"],
|
||||
"tags": [], # No tags by default
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract content and metadata from text files",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__.split("\n\n", 1)[1]
|
||||
if __doc__
|
||||
else "", # Use the docstring as extended help
|
||||
)
|
||||
parser.add_argument("file", nargs="?", help="Input file (default: stdin)")
|
||||
parser.add_argument(
|
||||
"-v", "--version", action="version", version="text-extractor 1.0.0"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# Process the file
|
||||
result = process_file(args.file)
|
||||
|
||||
# Output the result as JSON
|
||||
print(json.dumps(result, indent=2))
|
||||
|
||||
return 0
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error: {e}\n")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
17
tools/system/pyproject.toml
Normal file
17
tools/system/pyproject.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[project]
|
||||
name = "castle-system"
|
||||
version = "0.1.0"
|
||||
description = "Castle system administration tools"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
backup-collect = "system.backup_collect:main"
|
||||
schedule = "system.schedule:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/system"]
|
||||
0
tools/system/src/system/__init__.py
Normal file
0
tools/system/src/system/__init__.py
Normal file
137
tools/system/src/system/backup_collect.md
Normal file
137
tools/system/src/system/backup_collect.md
Normal file
@@ -0,0 +1,137 @@
|
||||
---
|
||||
command: backup-collect
|
||||
script: system/backup-collect.py
|
||||
description: Collect files from various sources into backup directory
|
||||
version: 1.0.0
|
||||
category: system
|
||||
system_dependencies:
|
||||
- rsync
|
||||
---
|
||||
|
||||
# backup-collect
|
||||
|
||||
Collect system information and configuration files for backup purposes.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
which backup-collect
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Collect system information
|
||||
backup-collect
|
||||
|
||||
# Specify output directory
|
||||
backup-collect -o /path/to/backup
|
||||
|
||||
# Collect specific categories
|
||||
backup-collect --category config
|
||||
backup-collect --category packages
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
backup-collect [options]
|
||||
|
||||
Options:
|
||||
-o, --output DIR Output directory for collected data
|
||||
--category CAT Collect specific category (config, packages, system)
|
||||
-v, --verbose Enable verbose output
|
||||
-h, --help Show help message
|
||||
```
|
||||
|
||||
## What It Collects
|
||||
|
||||
### System Information
|
||||
- OS version and details
|
||||
- Kernel information
|
||||
- Hardware details
|
||||
- System uptime
|
||||
|
||||
### Configuration Files
|
||||
- User dotfiles (.bashrc, .vimrc, etc.)
|
||||
- Application configs
|
||||
- SSH keys (with permissions preserved)
|
||||
- Custom scripts
|
||||
|
||||
### Package Lists
|
||||
- Installed system packages
|
||||
- Python packages
|
||||
- npm packages
|
||||
- Other package managers
|
||||
|
||||
## Features
|
||||
|
||||
- **Comprehensive** - Collects essential system data
|
||||
- **Organized** - Structured output directory
|
||||
- **Safe** - Preserves permissions
|
||||
- **Selective** - Choose what to collect
|
||||
- **Portable** - Outputs in standard formats
|
||||
|
||||
## Examples
|
||||
|
||||
### Full Backup
|
||||
|
||||
```bash
|
||||
backup-collect -o ~/backups/system-$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
### Scheduled Backup
|
||||
|
||||
```bash
|
||||
# Weekly system backup
|
||||
schedule add system-backup \
|
||||
--command "backup-collect -o /mnt/backups/system-\$(date +\%Y\%m\%d)" \
|
||||
--schedule "weekly" \
|
||||
--on-calendar "Sun 02:00:00"
|
||||
```
|
||||
|
||||
### Selective Collection
|
||||
|
||||
```bash
|
||||
# Only collect configs
|
||||
backup-collect --category config -o ~/configs
|
||||
|
||||
# Only package lists
|
||||
backup-collect --category packages -o ~/packages
|
||||
```
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
backup-directory/
|
||||
├── system/
|
||||
│ ├── os-release
|
||||
│ ├── kernel-version
|
||||
│ └── hardware-info
|
||||
├── config/
|
||||
│ ├── dotfiles/
|
||||
│ ├── ssh/
|
||||
│ └── app-configs/
|
||||
└── packages/
|
||||
├── apt-packages.txt
|
||||
├── pip-packages.txt
|
||||
└── npm-packages.txt
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **System migration** - Document current setup
|
||||
- **Disaster recovery** - Quick system restoration
|
||||
- **Configuration management** - Track system changes
|
||||
- **Compliance** - Document installed software
|
||||
- **Development** - Share development environment setup
|
||||
|
||||
## Notes
|
||||
|
||||
- Does not collect sensitive data by default
|
||||
- SSH keys are collected but not passwords
|
||||
- Large files are skipped
|
||||
- Runs without root (collects what's accessible)
|
||||
- Safe to run frequently
|
||||
289
tools/system/src/system/backup_collect.py
Executable file
289
tools/system/src/system/backup_collect.py
Executable file
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
backup-collect: Collect files from various sources into backup directory
|
||||
|
||||
Incrementally backs up specified directories to the central backup location
|
||||
using rsync. Creates hardlinks to unchanged files from previous backups to
|
||||
save disk space.
|
||||
|
||||
Usage: backup-collect [options]
|
||||
|
||||
Examples:
|
||||
backup-collect # Backup using default configuration
|
||||
backup-collect -c custom.json # Use custom configuration file
|
||||
backup-collect --full # Perform a full backup instead of incremental
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
import subprocess
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
# Constants
|
||||
DEFAULT_CONFIG_DIR = os.path.expanduser("~/.config/toolkit")
|
||||
DEFAULT_CONFIG_FILE = os.path.join(DEFAULT_CONFIG_DIR, "backup-config.json")
|
||||
DEFAULT_BACKUP_DIR = os.environ.get("DBACKUP", "/data/backup")
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("backup-collect")
|
||||
|
||||
|
||||
def create_default_config(config_path):
|
||||
"""Create a default configuration file if none exists."""
|
||||
default_config = {
|
||||
"sources": [
|
||||
{
|
||||
"name": "config",
|
||||
"paths": [
|
||||
os.path.expanduser("~/.config"),
|
||||
],
|
||||
"exclusions": [
|
||||
os.path.expanduser("~/.config/Code/Cache/*"),
|
||||
"*/Cache/*",
|
||||
"*/CacheStorage/*",
|
||||
],
|
||||
}
|
||||
],
|
||||
"retention": {"local": 7},
|
||||
"schedule": {"incremental": "daily", "full": "weekly"},
|
||||
}
|
||||
|
||||
# Create config directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(default_config, f, indent=2)
|
||||
|
||||
logger.info(f"Created default configuration at {config_path}")
|
||||
return default_config
|
||||
|
||||
|
||||
def load_config(config_path):
|
||||
"""Load the configuration file or create default if it doesn't exist."""
|
||||
if not os.path.exists(config_path):
|
||||
return create_default_config(config_path)
|
||||
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
config = json.load(f)
|
||||
logger.info(f"Loaded configuration from {config_path}")
|
||||
return config
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading configuration: {e}")
|
||||
logger.info("Using default configuration")
|
||||
return create_default_config(config_path + ".default")
|
||||
|
||||
|
||||
def run_backup(source, backup_dir, date_str, incremental=True):
|
||||
"""Run rsync backup for a given source configuration."""
|
||||
source_name = source["name"]
|
||||
source_paths = source["paths"]
|
||||
exclusions = source.get("exclusions", [])
|
||||
|
||||
# Create dated and latest directory names
|
||||
dated_dir = os.path.join(backup_dir, f"{source_name}-{date_str}")
|
||||
latest_dir = os.path.join(backup_dir, f"{source_name}-latest")
|
||||
previous_dir = os.path.join(backup_dir, f"{source_name}-previous")
|
||||
|
||||
logger.info(f"Backing up {source_name} to {dated_dir}")
|
||||
|
||||
# Create backup directory if it doesn't exist
|
||||
os.makedirs(dated_dir, exist_ok=True)
|
||||
|
||||
for source_path in source_paths:
|
||||
# Expand user paths like ~/.config
|
||||
expanded_path = os.path.expanduser(source_path)
|
||||
|
||||
if not os.path.exists(expanded_path):
|
||||
logger.warning(f"Source path does not exist: {expanded_path}")
|
||||
continue
|
||||
|
||||
# Prepare rsync command
|
||||
rsync_cmd = ["rsync", "-av", "--delete", "--block-size=131072"]
|
||||
|
||||
# Add exclusions
|
||||
for exclusion in exclusions:
|
||||
rsync_cmd.extend(["--exclude", exclusion])
|
||||
|
||||
# For incremental backups, use hardlinks to previous backup
|
||||
if incremental and os.path.exists(latest_dir):
|
||||
rsync_cmd.extend(["--link-dest", latest_dir])
|
||||
|
||||
# Add source and destination with path preservation
|
||||
if os.path.isdir(expanded_path):
|
||||
# For directories, preserve the trailing slash behavior
|
||||
source_with_slash = (
|
||||
expanded_path if expanded_path.endswith("/") else expanded_path + "/"
|
||||
)
|
||||
rsync_cmd.extend(["-R", source_with_slash]) # -R preserves relative path
|
||||
rsync_cmd.append(dated_dir + "/")
|
||||
else:
|
||||
# For files, preserve the full path structure
|
||||
rsync_cmd.extend(["-R", expanded_path]) # -R preserves relative path
|
||||
rsync_cmd.append(dated_dir + "/")
|
||||
|
||||
# Execute rsync
|
||||
logger.info(f"Running: {' '.join(rsync_cmd)}")
|
||||
try:
|
||||
# Use check=False to continue even if there are errors
|
||||
result = subprocess.run(
|
||||
rsync_cmd, check=False, capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info(f"Backup of {expanded_path} completed successfully")
|
||||
else:
|
||||
logger.error(f"Backup failed for {expanded_path}: {result.stderr}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Backup failed for {expanded_path}: {e}")
|
||||
return False
|
||||
|
||||
# Update symlinks
|
||||
try:
|
||||
# Move previous latest to previous if it exists
|
||||
if os.path.exists(latest_dir) and os.path.islink(latest_dir):
|
||||
# Remove existing previous symlink first
|
||||
if os.path.exists(previous_dir) or os.path.islink(previous_dir):
|
||||
os.unlink(previous_dir)
|
||||
os.symlink(os.readlink(latest_dir), previous_dir)
|
||||
|
||||
# Update latest symlink
|
||||
if os.path.exists(latest_dir) or os.path.islink(latest_dir):
|
||||
os.unlink(latest_dir)
|
||||
os.symlink(os.path.basename(dated_dir), latest_dir)
|
||||
logger.info(f"Updated symlinks for {source_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update symlinks: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def cleanup_old_backups(source, backup_dir, retention):
|
||||
"""Remove old backups beyond the retention period."""
|
||||
source_name = source["name"]
|
||||
logger.info(f"Cleaning up old backups for {source_name}")
|
||||
|
||||
try:
|
||||
# Find all dated backups for this source
|
||||
backups = sorted(
|
||||
[
|
||||
d
|
||||
for d in os.listdir(backup_dir)
|
||||
if os.path.isdir(os.path.join(backup_dir, d))
|
||||
and d.startswith(f"{source_name}-")
|
||||
and not os.path.islink(os.path.join(backup_dir, d))
|
||||
]
|
||||
)
|
||||
|
||||
# Keep only the most recent 'retention' number of backups
|
||||
if len(backups) > retention:
|
||||
to_delete = backups[:-retention]
|
||||
for backup in to_delete:
|
||||
backup_path = os.path.join(backup_dir, backup)
|
||||
logger.info(f"Removing old backup: {backup_path}")
|
||||
# Use subprocess to remove directories that might have read-only files
|
||||
subprocess.run(["rm", "-rf", backup_path], check=True)
|
||||
|
||||
logger.info(f"Cleanup completed, kept {min(len(backups), retention)} backups")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Collect files from various sources into backup directory",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__.split("\n\n", 1)[1]
|
||||
if __doc__
|
||||
else "", # Use the docstring as extended help
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--config", help=f"Path to config file (default: {DEFAULT_CONFIG_FILE})"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-b",
|
||||
"--backup-dir",
|
||||
help=f"Backup directory (default: $DBACKUP or {DEFAULT_BACKUP_DIR})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--full",
|
||||
action="store_true",
|
||||
help="Perform a full backup instead of incremental",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Enable verbose output"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d", "--debug", action="store_true", help="Enable debug output"
|
||||
)
|
||||
parser.add_argument("--version", action="version", version="backup-collect 1.0.0")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set verbosity
|
||||
if args.verbose or args.debug:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Extra debug info
|
||||
if args.debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
# Load configuration
|
||||
config_path = args.config or DEFAULT_CONFIG_FILE
|
||||
config = load_config(config_path)
|
||||
|
||||
# Determine backup directory
|
||||
backup_dir = args.backup_dir or DEFAULT_BACKUP_DIR
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
logger.info(f"Using backup directory: {backup_dir}")
|
||||
|
||||
# Current date for backup naming
|
||||
date_str = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
|
||||
# Determine if using incremental backup
|
||||
incremental = not args.full
|
||||
logger.info(f"Performing {'incremental' if incremental else 'full'} backup")
|
||||
|
||||
success = True
|
||||
|
||||
# Debug output of all config
|
||||
if args.debug:
|
||||
logger.debug(f"Using config: {json.dumps(config, indent=2)}")
|
||||
logger.debug(f"Backup dir: {backup_dir}")
|
||||
logger.debug(f"Sources to backup: {len(config.get('sources', []))}")
|
||||
for source in config.get("sources", []):
|
||||
logger.debug(f"Source: {source['name']}, Paths: {source['paths']}")
|
||||
|
||||
# Process each source
|
||||
for source in config.get("sources", []):
|
||||
logger.info(f"Processing source: {source['name']}")
|
||||
if not run_backup(source, backup_dir, date_str, incremental):
|
||||
logger.error(f"Backup failed for {source['name']}")
|
||||
success = False
|
||||
|
||||
# Cleanup old backups
|
||||
retention = config.get("retention", {}).get("local", 7)
|
||||
cleanup_old_backups(source, backup_dir, retention)
|
||||
|
||||
if success:
|
||||
logger.info("✅ All backups completed successfully")
|
||||
return 0
|
||||
else:
|
||||
logger.error("One or more backups failed")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
388
tools/system/src/system/schedule.md
Normal file
388
tools/system/src/system/schedule.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# schedule
|
||||
|
||||
A tool to easily create, manage, and monitor systemd user timers and services following best practices.
|
||||
|
||||
## Overview
|
||||
|
||||
`schedule` simplifies the management of systemd user timers by providing a simple command-line interface to create timer/service pairs that execute bash commands on a schedule. It handles all the boilerplate configuration and follows systemd best practices for security and resource management.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the toolkit:
|
||||
|
||||
```bash
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
```
|
||||
|
||||
The `schedule` command will be available in your PATH.
|
||||
|
||||
### Enable Linger (Optional but Recommended)
|
||||
|
||||
By default, systemd user units only run while you're logged in. To allow timers to run even when you're logged out, enable linger:
|
||||
|
||||
```bash
|
||||
loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
This is essential for timers that should run 24/7, like automated sync jobs.
|
||||
|
||||
## Features
|
||||
|
||||
- **Easy timer creation** - Create timers with a single command
|
||||
- **Multiple schedule types** - Support for interval-based and calendar-based schedules
|
||||
- **Environment variables** - Support for environment files and inline variables
|
||||
- **Pre-conditions** - Run commands only when conditions are met
|
||||
- **Security hardening** - Automatic security settings (NoNewPrivileges, PrivateTmp)
|
||||
- **Log management** - Easy access to service logs via journalctl
|
||||
- **Status monitoring** - Check timer and service status
|
||||
|
||||
## Usage
|
||||
|
||||
### List timers
|
||||
|
||||
Show all active user timers:
|
||||
|
||||
```bash
|
||||
schedule list
|
||||
```
|
||||
|
||||
Show all timers including inactive ones:
|
||||
|
||||
```bash
|
||||
schedule list --all
|
||||
```
|
||||
|
||||
### Add a timer
|
||||
|
||||
Basic timer that runs every 5 minutes:
|
||||
|
||||
```bash
|
||||
schedule add my-task \
|
||||
--command "echo 'Hello, World!'" \
|
||||
--schedule "5min"
|
||||
```
|
||||
|
||||
Daily backup at 2 AM:
|
||||
|
||||
```bash
|
||||
schedule add daily-backup \
|
||||
--command "backup run" \
|
||||
--schedule "daily" \
|
||||
--on-calendar "*-*-* 02:00:00" \
|
||||
--description "Daily backup job"
|
||||
```
|
||||
|
||||
ProtonMail sync with environment file and condition:
|
||||
|
||||
```bash
|
||||
schedule add protonmail-sync \
|
||||
--command "protonmail sync" \
|
||||
--schedule "5min" \
|
||||
--description "Sync ProtonMail emails" \
|
||||
--env-file ~/.config/protonmail/env \
|
||||
--condition-command "pgrep -f protonmail-bridge"
|
||||
```
|
||||
|
||||
With environment variables:
|
||||
|
||||
```bash
|
||||
schedule add my-task \
|
||||
--command "my-script.sh" \
|
||||
--schedule "1hour" \
|
||||
--environment "API_KEY=secret123" \
|
||||
--environment "LOG_LEVEL=debug" \
|
||||
--working-directory "/home/user/projects"
|
||||
```
|
||||
|
||||
### Check status
|
||||
|
||||
View timer and service status:
|
||||
|
||||
```bash
|
||||
schedule status protonmail-sync
|
||||
```
|
||||
|
||||
This shows:
|
||||
- Timer status (active/inactive)
|
||||
- Service status (last run, next run)
|
||||
- Next scheduled execution time
|
||||
|
||||
### View logs
|
||||
|
||||
Follow logs in real-time:
|
||||
|
||||
```bash
|
||||
schedule logs protonmail-sync --follow
|
||||
```
|
||||
|
||||
Show last 50 lines:
|
||||
|
||||
```bash
|
||||
schedule logs protonmail-sync --lines 50
|
||||
```
|
||||
|
||||
Show logs since today:
|
||||
|
||||
```bash
|
||||
schedule logs protonmail-sync --since today
|
||||
```
|
||||
|
||||
### Start/Stop timers
|
||||
|
||||
Start a timer:
|
||||
|
||||
```bash
|
||||
schedule start protonmail-sync
|
||||
```
|
||||
|
||||
Stop a timer:
|
||||
|
||||
```bash
|
||||
schedule stop protonmail-sync
|
||||
```
|
||||
|
||||
### Enable/Disable timers
|
||||
|
||||
Enable timer (start on boot):
|
||||
|
||||
```bash
|
||||
schedule enable protonmail-sync
|
||||
```
|
||||
|
||||
Disable timer (don't start on boot):
|
||||
|
||||
```bash
|
||||
schedule disable protonmail-sync
|
||||
```
|
||||
|
||||
### Remove a timer
|
||||
|
||||
Remove timer and service files:
|
||||
|
||||
```bash
|
||||
schedule remove protonmail-sync
|
||||
```
|
||||
|
||||
Remove but keep logs:
|
||||
|
||||
```bash
|
||||
schedule remove protonmail-sync --keep-logs
|
||||
```
|
||||
|
||||
## Schedule Formats
|
||||
|
||||
### Interval-based schedules
|
||||
|
||||
Use simple time units:
|
||||
|
||||
- `5min` - Every 5 minutes
|
||||
- `1hour` or `1h` - Every hour
|
||||
- `30min` - Every 30 minutes
|
||||
- `1day` or `1d` - Every day
|
||||
- `1week` or `1w` - Every week
|
||||
- `hourly` - Every hour (alias for 1hour)
|
||||
- `daily` - Every day (alias for 1day)
|
||||
|
||||
### Calendar-based schedules
|
||||
|
||||
Use systemd calendar syntax with `--on-calendar`:
|
||||
|
||||
- `*-*-* 02:00:00` - Every day at 2 AM
|
||||
- `Mon *-*-* 09:00:00` - Every Monday at 9 AM
|
||||
- `*-*-01 00:00:00` - First day of each month at midnight
|
||||
- `*-*-* *:00:00` - Every hour on the hour
|
||||
|
||||
## Advanced Options
|
||||
|
||||
### Add command options
|
||||
|
||||
- `--name` - Name of the timer/service (required)
|
||||
- `--command` - Bash command to execute (required)
|
||||
- `--schedule` - Schedule format (required)
|
||||
- `--description` - Human-readable description
|
||||
- `--env-file` - Path to environment file to source
|
||||
- `--environment` / `-e` - Set environment variable (KEY=VALUE)
|
||||
- `--condition-command` - Command that must succeed before running
|
||||
- `--working-directory` - Working directory for the command
|
||||
- `--on-boot-sec` - Delay after boot (default: 1min)
|
||||
- `--on-calendar` - Override with calendar-based schedule
|
||||
- `--persistent` - Run missed timers if system was offline
|
||||
- `--force` - Overwrite existing timer/service
|
||||
- `--no-enable` - Don't enable the timer
|
||||
- `--no-start` - Don't start the timer
|
||||
|
||||
## Examples
|
||||
|
||||
### ProtonMail Sync
|
||||
|
||||
```bash
|
||||
schedule add protonmail-sync \
|
||||
--command "protonmail sync" \
|
||||
--schedule "5min" \
|
||||
--description "Sync ProtonMail emails every 5 minutes" \
|
||||
--env-file ~/.config/protonmail/env \
|
||||
--condition-command "pgrep -f protonmail-bridge"
|
||||
```
|
||||
|
||||
### Daily Backup
|
||||
|
||||
```bash
|
||||
schedule add daily-backup \
|
||||
--command "backup run --full" \
|
||||
--schedule "daily" \
|
||||
--on-calendar "*-*-* 02:00:00" \
|
||||
--description "Daily backup at 2 AM" \
|
||||
--environment "BACKUP_DIR=/mnt/backups"
|
||||
```
|
||||
|
||||
### Hourly Data Sync
|
||||
|
||||
```bash
|
||||
schedule add data-sync \
|
||||
--command "rsync -av /source/ /dest/" \
|
||||
--schedule "hourly" \
|
||||
--description "Sync data every hour" \
|
||||
--working-directory "/home/user/sync"
|
||||
```
|
||||
|
||||
### Git Auto-Commit
|
||||
|
||||
```bash
|
||||
schedule add git-autocommit \
|
||||
--command "git add -A && git commit -m 'Auto-commit' && git push" \
|
||||
--schedule "15min" \
|
||||
--description "Auto-commit and push changes" \
|
||||
--working-directory "/home/user/projects/myrepo"
|
||||
```
|
||||
|
||||
## File Locations
|
||||
|
||||
Timer and service files are stored in:
|
||||
|
||||
```
|
||||
~/.config/systemd/user/
|
||||
├── <name>.service
|
||||
└── <name>.timer
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
All services created by `schedule` include:
|
||||
|
||||
- `NoNewPrivileges=yes` - Cannot gain new privileges
|
||||
- `PrivateTmp=yes` - Isolated /tmp directory
|
||||
- Proper PATH configuration
|
||||
- Journal logging for stdout/stderr
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Timer not running
|
||||
|
||||
Check if the timer is enabled and active:
|
||||
|
||||
```bash
|
||||
schedule status <name>
|
||||
```
|
||||
|
||||
### Service failing
|
||||
|
||||
View the logs to see what went wrong:
|
||||
|
||||
```bash
|
||||
schedule logs <name> --lines 50
|
||||
```
|
||||
|
||||
### Command not found
|
||||
|
||||
Ensure the command is in the PATH or use an absolute path:
|
||||
|
||||
```bash
|
||||
schedule add my-task \
|
||||
--command "/usr/local/bin/my-script" \
|
||||
--schedule "5min"
|
||||
```
|
||||
|
||||
### Environment variables not working
|
||||
|
||||
Use an environment file:
|
||||
|
||||
```bash
|
||||
# Create env file
|
||||
echo "API_KEY=secret" > ~/.config/myapp/env
|
||||
chmod 600 ~/.config/myapp/env
|
||||
|
||||
# Use it
|
||||
schedule add my-task \
|
||||
--command "my-script" \
|
||||
--schedule "5min" \
|
||||
--env-file ~/.config/myapp/env
|
||||
```
|
||||
|
||||
## Comparison with Manual Setup
|
||||
|
||||
Instead of manually creating:
|
||||
|
||||
1. `.service` file with proper configuration
|
||||
2. `.timer` file with scheduling
|
||||
3. Running `systemctl --user daemon-reload`
|
||||
4. Running `systemctl --user enable <name>.timer`
|
||||
5. Running `systemctl --user start <name>.timer`
|
||||
|
||||
You can do it all with one command:
|
||||
|
||||
```bash
|
||||
schedule add <name> --command "<cmd>" --schedule "<schedule>"
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- `man systemd.timer` - systemd timer documentation
|
||||
- `man systemd.service` - systemd service documentation
|
||||
- `man systemd.time` - systemd time specification
|
||||
- `journalctl` - Query systemd journal
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Environment Variables
|
||||
|
||||
**Critical**: Systemd user units do NOT inherit environment variables from your shell (e.g., ~/.bashrc). Variables set with `export` in your shell will not be available to services.
|
||||
|
||||
To make environment variables available to your scheduled commands, pass them inline when creating the timer:
|
||||
|
||||
```bash
|
||||
schedule add my-task \
|
||||
--command "my-script" \
|
||||
--schedule "1hour" \
|
||||
--environment "API_KEY=secret123" \
|
||||
--environment "DATABASE_URL=postgres://localhost/mydb"
|
||||
```
|
||||
|
||||
You can also use the `-e` shorthand:
|
||||
|
||||
```bash
|
||||
schedule add my-task \
|
||||
--command "my-script" \
|
||||
--schedule "1hour" \
|
||||
-e "API_KEY=secret123" \
|
||||
-e "DATABASE_URL=postgres://localhost/mydb"
|
||||
```
|
||||
|
||||
### User vs System Units
|
||||
|
||||
This tool creates **user** systemd units (`systemctl --user`), not system units. User units:
|
||||
|
||||
- Run as your user (no root required)
|
||||
- Stored in `~/.config/systemd/user/`
|
||||
- Only run while you're logged in (unless linger is enabled)
|
||||
- Have access to your user's files and permissions
|
||||
|
||||
To enable timers to run 24/7 even when logged out:
|
||||
|
||||
```bash
|
||||
loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Same license as the toolkit project.
|
||||
552
tools/system/src/system/schedule.py
Normal file
552
tools/system/src/system/schedule.py
Normal file
@@ -0,0 +1,552 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Schedule - Systemd timer and service management tool.
|
||||
|
||||
A tool to easily create, manage, and monitor systemd user timers and services
|
||||
following best practices.
|
||||
|
||||
Usage:
|
||||
schedule list [--all]
|
||||
schedule add <name> --command <cmd> --schedule <schedule> [options]
|
||||
schedule remove <name> [--keep-logs]
|
||||
schedule status <name>
|
||||
schedule logs <name> [--follow] [--lines N]
|
||||
schedule enable <name>
|
||||
schedule disable <name>
|
||||
schedule start <name>
|
||||
schedule stop <name>
|
||||
|
||||
Examples:
|
||||
# List all user timers
|
||||
schedule list
|
||||
|
||||
# Create a timer for ProtonMail sync every 5 minutes
|
||||
schedule add protonmail-sync \\
|
||||
--command "protonmail sync" \\
|
||||
--schedule "5min" \\
|
||||
--description "Sync ProtonMail emails" \\
|
||||
--env-file ~/.config/protonmail/env \\
|
||||
--condition-command "pgrep -f protonmail-bridge"
|
||||
|
||||
# Create a daily backup timer at 2 AM
|
||||
schedule add daily-backup \\
|
||||
--command "backup run" \\
|
||||
--schedule "daily" \\
|
||||
--on-calendar "*-*-* 02:00:00"
|
||||
|
||||
# Check status of a timer
|
||||
schedule status protonmail-sync
|
||||
|
||||
# View logs in real-time
|
||||
schedule logs protonmail-sync --follow
|
||||
|
||||
# Remove a timer
|
||||
schedule remove protonmail-sync
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_systemd_user_dir() -> Path:
|
||||
"""Get the systemd user directory."""
|
||||
return Path.home() / ".config" / "systemd" / "user"
|
||||
|
||||
|
||||
def run_systemctl(
|
||||
args: list[str], check: bool = True
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run systemctl command with user flag."""
|
||||
cmd = ["systemctl", "--user"] + args
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def list_timers(all_timers: bool = False) -> None:
|
||||
"""List all user timers."""
|
||||
args = ["list-timers"]
|
||||
if all_timers:
|
||||
args.append("--all")
|
||||
|
||||
result = run_systemctl(args, check=False)
|
||||
print(result.stdout)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"Error: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def generate_service_content(
|
||||
name: str,
|
||||
command: str,
|
||||
description: str | None = None,
|
||||
env_file: str | None = None,
|
||||
condition_command: str | None = None,
|
||||
working_directory: str | None = None,
|
||||
environment: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Generate systemd service file content."""
|
||||
desc = description or f"{name} service"
|
||||
|
||||
content = f"""[Unit]
|
||||
Description={desc}
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
"""
|
||||
|
||||
# Add default PATH
|
||||
content += 'Environment="PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin"\n'
|
||||
|
||||
# Add additional environment variables
|
||||
if environment:
|
||||
for key, value in environment.items():
|
||||
content += f'Environment="{key}={value}"\n'
|
||||
|
||||
# Add environment file if specified
|
||||
if env_file:
|
||||
content += f"EnvironmentFile={env_file}\n"
|
||||
|
||||
content += "\n"
|
||||
|
||||
# Add condition command if specified
|
||||
if condition_command:
|
||||
content += "# Pre-condition check\n"
|
||||
# Use double quotes to avoid conflicts with single quotes in the command
|
||||
escaped_condition = condition_command.replace('"', '\\"')
|
||||
content += f'ExecStartPre=/bin/bash -c "{escaped_condition}"\n\n'
|
||||
|
||||
# Set working directory if specified
|
||||
if working_directory:
|
||||
content += f"WorkingDirectory={working_directory}\n"
|
||||
|
||||
# Add the main command
|
||||
content += "# Run the command\n"
|
||||
# Use double quotes to avoid conflicts with single quotes in the command
|
||||
escaped_command = command.replace('"', '\\"')
|
||||
content += f'ExecStart=/bin/bash -c "{escaped_command}"\n\n'
|
||||
|
||||
# Logging
|
||||
content += """# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier={name}
|
||||
|
||||
# Security settings
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
""".format(name=name)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def generate_timer_content(
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
on_boot_sec: str = "1min",
|
||||
on_unit_active_sec: str | None = None,
|
||||
on_calendar: str | None = None,
|
||||
persistent: bool = False,
|
||||
) -> str:
|
||||
"""Generate systemd timer file content."""
|
||||
desc = description or f"{name} timer"
|
||||
|
||||
content = f"""[Unit]
|
||||
Description={desc}
|
||||
Requires={name}.service
|
||||
|
||||
[Timer]
|
||||
"""
|
||||
|
||||
# Add boot delay
|
||||
if on_boot_sec:
|
||||
content += f"OnBootSec={on_boot_sec}\n"
|
||||
|
||||
# Add interval-based schedule
|
||||
if on_unit_active_sec:
|
||||
content += f"OnUnitActiveSec={on_unit_active_sec}\n"
|
||||
|
||||
# Add calendar-based schedule
|
||||
if on_calendar:
|
||||
content += f"OnCalendar={on_calendar}\n"
|
||||
|
||||
content += f"""
|
||||
# If the system was offline, {"run" if persistent else "don't run"} missed timers
|
||||
Persistent={"true" if persistent else "false"}
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
"""
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def parse_schedule(schedule: str) -> tuple[str | None, str | None]:
|
||||
"""Parse schedule string into timer parameters.
|
||||
|
||||
Returns:
|
||||
Tuple of (on_unit_active_sec, on_calendar)
|
||||
"""
|
||||
schedule_lower = schedule.lower()
|
||||
|
||||
# Simple interval schedules
|
||||
if any(
|
||||
schedule_lower.endswith(unit)
|
||||
for unit in ["min", "hour", "h", "day", "d", "week", "w"]
|
||||
):
|
||||
return schedule, None
|
||||
|
||||
# Daily at specific time
|
||||
if schedule_lower == "daily":
|
||||
return "1day", None
|
||||
|
||||
# Hourly
|
||||
if schedule_lower == "hourly":
|
||||
return "1hour", None
|
||||
|
||||
# Assume it's a calendar spec
|
||||
return None, schedule
|
||||
|
||||
|
||||
def add_timer(args: argparse.Namespace) -> None:
|
||||
"""Add a new systemd timer and service."""
|
||||
name = args.name
|
||||
command = args.command
|
||||
schedule = args.schedule
|
||||
|
||||
# Parse schedule
|
||||
on_unit_active_sec, on_calendar = parse_schedule(schedule)
|
||||
|
||||
if args.on_calendar:
|
||||
on_calendar = args.on_calendar
|
||||
on_unit_active_sec = None
|
||||
|
||||
# Create systemd directory if it doesn't exist
|
||||
systemd_dir = get_systemd_user_dir()
|
||||
systemd_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate service file
|
||||
environment = {}
|
||||
if args.environment:
|
||||
for env in args.environment:
|
||||
key, value = env.split("=", 1)
|
||||
environment[key] = value
|
||||
|
||||
service_content = generate_service_content(
|
||||
name=name,
|
||||
command=command,
|
||||
description=args.description,
|
||||
env_file=args.env_file,
|
||||
condition_command=args.condition_command,
|
||||
working_directory=args.working_directory,
|
||||
environment=environment,
|
||||
)
|
||||
|
||||
# Generate timer file
|
||||
timer_content = generate_timer_content(
|
||||
name=name,
|
||||
description=args.description,
|
||||
on_boot_sec=args.on_boot_sec,
|
||||
on_unit_active_sec=on_unit_active_sec,
|
||||
on_calendar=on_calendar,
|
||||
persistent=args.persistent,
|
||||
)
|
||||
|
||||
# Write service file
|
||||
service_path = systemd_dir / f"{name}.service"
|
||||
timer_path = systemd_dir / f"{name}.timer"
|
||||
|
||||
# Check if files already exist
|
||||
if service_path.exists() and not args.force:
|
||||
print(f"Error: Service file already exists: {service_path}", file=sys.stderr)
|
||||
print("Use --force to overwrite", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if timer_path.exists() and not args.force:
|
||||
print(f"Error: Timer file already exists: {timer_path}", file=sys.stderr)
|
||||
print("Use --force to overwrite", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Write files
|
||||
service_path.write_text(service_content)
|
||||
timer_path.write_text(timer_content)
|
||||
|
||||
print(f"Created service: {service_path}")
|
||||
print(f"Created timer: {timer_path}")
|
||||
|
||||
# Reload systemd daemon
|
||||
print("\nReloading systemd daemon...")
|
||||
run_systemctl(["daemon-reload"])
|
||||
|
||||
# Enable and start timer if requested
|
||||
if not args.no_enable:
|
||||
print(f"Enabling {name}.timer...")
|
||||
run_systemctl(["enable", f"{name}.timer"])
|
||||
|
||||
if not args.no_start:
|
||||
print(f"Starting {name}.timer...")
|
||||
run_systemctl(["start", f"{name}.timer"])
|
||||
|
||||
print(f"\n✓ Timer '{name}' created and started successfully!")
|
||||
|
||||
# Warn about environment variables if not explicitly set in service
|
||||
if not environment and not args.env_file:
|
||||
print(
|
||||
"\n⚠️ Note: Systemd user units don't inherit shell environment variables."
|
||||
)
|
||||
print(" If your command needs environment variables, pass them with:")
|
||||
print(f" schedule add {name} --command '...' -e VAR=value")
|
||||
|
||||
print("\nUseful commands:")
|
||||
print(f" Status: schedule status {name}")
|
||||
print(f" Logs: schedule logs {name} --follow")
|
||||
print(f" Disable: schedule disable {name}")
|
||||
print(f" Remove: schedule remove {name}")
|
||||
|
||||
|
||||
def remove_timer(args: argparse.Namespace) -> None:
|
||||
"""Remove a systemd timer and service."""
|
||||
name = args.name
|
||||
systemd_dir = get_systemd_user_dir()
|
||||
|
||||
service_path = systemd_dir / f"{name}.service"
|
||||
timer_path = systemd_dir / f"{name}.timer"
|
||||
|
||||
# Check if files exist
|
||||
if not service_path.exists() and not timer_path.exists():
|
||||
print(f"Error: Timer '{name}' not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Stop and disable timer
|
||||
print(f"Stopping {name}.timer...")
|
||||
run_systemctl(["stop", f"{name}.timer"], check=False)
|
||||
|
||||
print(f"Disabling {name}.timer...")
|
||||
run_systemctl(["disable", f"{name}.timer"], check=False)
|
||||
|
||||
# Remove files
|
||||
if timer_path.exists():
|
||||
timer_path.unlink()
|
||||
print(f"Removed: {timer_path}")
|
||||
|
||||
if service_path.exists():
|
||||
service_path.unlink()
|
||||
print(f"Removed: {service_path}")
|
||||
|
||||
# Reload systemd daemon
|
||||
print("\nReloading systemd daemon...")
|
||||
run_systemctl(["daemon-reload"])
|
||||
|
||||
# Clear logs if requested
|
||||
if not args.keep_logs:
|
||||
print(f"Clearing logs for {name}...")
|
||||
subprocess.run(
|
||||
["journalctl", "--user", "--vacuum-time=1s", "-u", name],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
print(f"\n✓ Timer '{name}' removed successfully!")
|
||||
|
||||
|
||||
def status_timer(args: argparse.Namespace) -> None:
|
||||
"""Show status of a timer and its service."""
|
||||
name = args.name
|
||||
|
||||
print("=== Timer Status ===")
|
||||
result = run_systemctl(["status", f"{name}.timer"], check=False)
|
||||
print(result.stdout)
|
||||
|
||||
print("\n=== Service Status ===")
|
||||
result = run_systemctl(["status", f"{name}.service"], check=False)
|
||||
print(result.stdout)
|
||||
|
||||
print("\n=== Next Scheduled Run ===")
|
||||
result = run_systemctl(["list-timers", f"{name}.timer"], check=False)
|
||||
print(result.stdout)
|
||||
|
||||
|
||||
def logs_timer(args: argparse.Namespace) -> None:
|
||||
"""Show logs for a service."""
|
||||
name = args.name
|
||||
cmd = ["journalctl", "--user", "-u", name]
|
||||
|
||||
if args.follow:
|
||||
cmd.append("-f")
|
||||
|
||||
if args.lines:
|
||||
cmd.extend(["-n", str(args.lines)])
|
||||
|
||||
if args.since:
|
||||
cmd.extend(["--since", args.since])
|
||||
|
||||
# Run journalctl directly (don't capture output)
|
||||
subprocess.run(cmd)
|
||||
|
||||
|
||||
def enable_timer(args: argparse.Namespace) -> None:
|
||||
"""Enable a timer."""
|
||||
name = args.name
|
||||
print(f"Enabling {name}.timer...")
|
||||
run_systemctl(["enable", f"{name}.timer"])
|
||||
print(f"✓ Timer '{name}' enabled")
|
||||
|
||||
|
||||
def disable_timer(args: argparse.Namespace) -> None:
|
||||
"""Disable a timer."""
|
||||
name = args.name
|
||||
print(f"Disabling {name}.timer...")
|
||||
run_systemctl(["disable", f"{name}.timer"])
|
||||
print(f"✓ Timer '{name}' disabled")
|
||||
|
||||
|
||||
def start_timer(args: argparse.Namespace) -> None:
|
||||
"""Start a timer."""
|
||||
name = args.name
|
||||
print(f"Starting {name}.timer...")
|
||||
run_systemctl(["start", f"{name}.timer"])
|
||||
print(f"✓ Timer '{name}' started")
|
||||
|
||||
|
||||
def stop_timer(args: argparse.Namespace) -> None:
|
||||
"""Stop a timer."""
|
||||
name = args.name
|
||||
print(f"Stopping {name}.timer...")
|
||||
run_systemctl(["stop", f"{name}.timer"])
|
||||
print(f"✓ Timer '{name}' stopped")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Manage systemd user timers and services",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="subcommand", help="Command to execute")
|
||||
|
||||
# List command
|
||||
list_parser = subparsers.add_parser("list", help="List all user timers")
|
||||
list_parser.add_argument(
|
||||
"--all", action="store_true", help="Show all timers including inactive"
|
||||
)
|
||||
|
||||
# Add command
|
||||
add_parser = subparsers.add_parser("add", help="Add a new timer")
|
||||
add_parser.add_argument("name", help="Name of the timer/service")
|
||||
add_parser.add_argument("--command", required=True, help="Command to execute")
|
||||
add_parser.add_argument(
|
||||
"--schedule",
|
||||
required=True,
|
||||
help="Schedule (e.g., '5min', 'hourly', 'daily', '1h')",
|
||||
)
|
||||
add_parser.add_argument("--description", help="Description of the timer/service")
|
||||
add_parser.add_argument(
|
||||
"--env-file", help="Path to environment file (e.g., ~/.config/app/env)"
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--environment",
|
||||
"-e",
|
||||
action="append",
|
||||
help="Environment variable (KEY=VALUE)",
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--condition-command",
|
||||
help="Command that must succeed before running (e.g., 'pgrep myapp')",
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--working-directory", help="Working directory for the command"
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--on-boot-sec", default="1min", help="Delay after boot (default: 1min)"
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--on-calendar",
|
||||
help="Calendar-based schedule (e.g., '*-*-* 02:00:00' for 2 AM daily)",
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--persistent",
|
||||
action="store_true",
|
||||
help="Run missed timers if system was offline",
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--force", action="store_true", help="Overwrite existing timer/service"
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--no-enable", action="store_true", help="Don't enable the timer"
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--no-start", action="store_true", help="Don't start the timer"
|
||||
)
|
||||
|
||||
# Remove command
|
||||
remove_parser = subparsers.add_parser("remove", help="Remove a timer")
|
||||
remove_parser.add_argument("name", help="Name of the timer/service")
|
||||
remove_parser.add_argument(
|
||||
"--keep-logs", action="store_true", help="Keep service logs"
|
||||
)
|
||||
|
||||
# Status command
|
||||
status_parser = subparsers.add_parser("status", help="Show timer status")
|
||||
status_parser.add_argument("name", help="Name of the timer/service")
|
||||
|
||||
# Logs command
|
||||
logs_parser = subparsers.add_parser("logs", help="Show service logs")
|
||||
logs_parser.add_argument("name", help="Name of the timer/service")
|
||||
logs_parser.add_argument(
|
||||
"--follow", "-f", action="store_true", help="Follow logs in real-time"
|
||||
)
|
||||
logs_parser.add_argument("--lines", "-n", type=int, help="Number of lines to show")
|
||||
logs_parser.add_argument("--since", help="Show logs since (e.g., 'today', '1h')")
|
||||
|
||||
# Enable command
|
||||
enable_parser = subparsers.add_parser("enable", help="Enable a timer")
|
||||
enable_parser.add_argument("name", help="Name of the timer")
|
||||
|
||||
# Disable command
|
||||
disable_parser = subparsers.add_parser("disable", help="Disable a timer")
|
||||
disable_parser.add_argument("name", help="Name of the timer")
|
||||
|
||||
# Start command
|
||||
start_parser = subparsers.add_parser("start", help="Start a timer")
|
||||
start_parser.add_argument("name", help="Name of the timer")
|
||||
|
||||
# Stop command
|
||||
stop_parser = subparsers.add_parser("stop", help="Stop a timer")
|
||||
stop_parser.add_argument("name", help="Name of the timer")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.subcommand:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
# Route to appropriate function
|
||||
commands = {
|
||||
"list": list_timers,
|
||||
"add": add_timer,
|
||||
"remove": remove_timer,
|
||||
"status": status_timer,
|
||||
"logs": logs_timer,
|
||||
"enable": enable_timer,
|
||||
"disable": disable_timer,
|
||||
"start": start_timer,
|
||||
"stop": stop_timer,
|
||||
}
|
||||
|
||||
commands[args.subcommand](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user