Skill: indigas/claw-file-organizer (version 1.0.0)

--- name: file-organizer description: Automated file management — sorts downloads folder by type, renames files with consistent patterns, generates structured folder hierarchy, with undo/restore capability. Use when: (1) Downloads folder is chaotic, (2) Need to organize files by category, (3) Want consistent naming conventions, (4) Need to restore files to original state, (5) Want to batch-rename files --- # File Organizer Skill Automatically sort, rename, and organize files into structured folders. Reduces chaos in Downloads and any directory with scattered files. ## Quick Start ```bash # Install the skill npx clawhub install file-organizer # Organize your downloads folder "Organize my Downloads folder" # Organize a specific directory "Organize ~/Documents/projects" # Undo the last organization "Undo the last file organization" ``` ## Core Features ### 1. Automatic Sorting Files are categorized into standard folders: - **Images** (`images/`) — JPG, PNG, GIF, SVG, WEBP, BMP, TIFF, ICO - **Documents** (`documents/`) — PDF, DOC, DOCX, TXT, RTF, ODT, PPT, PPTX, XLS, XLSX, PPT, ODP - **Audio** (`audio/`) — MP3, WAV, FLAC, OGG, AAC, M4A - **Video** (`video/`) — MP4, MKV, AVI, MOV, WEBM, FLV - **Archives** (`archives/`) — ZIP, TAR, GZ, 7Z, RAR, BZ2, XZ - **Code** (`code/`) — PY, JS, TS, HTML, CSS, MD, JSON, YAML, SH, BAT - **Data** (`data/`) — CSV, JSON, XML, SQL, DB, SQLITE, DAT - **Executables** (`installers/`) — EXE, MSI, DMG, APP, PKG, DEB, RPM, APK - **Fonts** (`fonts/`) — TTF, OTF, WOFF, WOFF2, FON - **Other** (`other/`) — uncategorized files ### 2. Smart File Renaming - Extracts dates from filenames (YYYY-MM-DD patterns) and places them in naming - Converts spaces to underscores for consistency - Strips special characters (preserving hyphens in compound names) - Handles duplicate names by appending `_2`, `_3`, etc. - Preserves original extension case (lowercased for consistency) ### 3. Undo / Restore - Generates a `ORGANIZE_LOG.json` after each operation - Stores original path, new path, and timestamp for every move - `undo` command restores files to their exact original locations - Safe: only restores if files haven't been modified since ### 4. Preview Mode - `--dry-run` shows what would change without moving files - Reports counts by category and renamed files - No disk writes in dry-run mode ## Configuration Edit `config.yaml` to customize: ```yaml source_dirs: - ~/Downloads target_base: ~/organized-files auto_sort: true rename_pattern: "{name}_{date}" # or "none" to skip renaming max_file_size_mb: 500 # skip files larger than this exclude_patterns: - "*.tmp" - "*.swp" - ".DS_Store" - "Thumbs.db" log_file: ORGANIZE_LOG.json ``` ## File Type Patterns Full mapping is in `references/file-patterns.md`. You can add custom mappings: ```yaml custom_types: - name: "Design Assets" folders: "design/" extensions: [AI, PSD, SKETCH, FIG, INDD] ``` ## Safety - **Dry-run first**: Always review changes before committing - **Undo-safe**: Every move is logged; full restore possible - **Size limits**: Skip large files to avoid moving heavy media - **Exclude patterns**: Configurable file filters to skip unwanted types - **No data loss**: Only moves files; never deletes ## Integration - Works with any directory (Downloads, Documents, Desktop, project roots) - Integrates with cron-manager for periodic auto-organizing - Compatible with file-sync workflows - Output is compatible with cloud storage (consistent naming)

path: README.md
content: # File Organizer Skill

Automatically sort, rename, and organize files into structured folders.

## Features

- **Auto-sorting** — Files categorized into 10 folder types (images, documents, audio, video, archives, code, data, installers, fonts, other)
- **Smart renaming** — Extracts dates, removes special characters, handles duplicates
- **Undo/restore** — Full rollback capability via `ORGANIZE_LOG.json`
- **Dry-run mode** — Preview changes without touching files
- **Configurable** — Customize categories, patterns, exclusions, file size limits

## Quick Start

```bash
# Organize downloads
python3 scripts/organize.py ~/Downloads

# Preview changes first
python3 scripts/organize.py ~/Downloads --dry-run

# Undo last organization
python3 scripts/organize.py --undo

# Output as JSON
python3 scripts/organize.py ~/Downloads --json
```

## Configuration

Edit `config.yaml`:

```yaml
source_dirs:
  - ~/Downloads
target_base: ~/organized-files
rename_pattern: "{name}_{date}"  # or "none"
max_file_size_mb: 500
exclude_patterns:
  - "*.tmp"
  - ".DS_Store"
```

## File Categories

| Folder | Extensions |
|--------|-----------|
| images | jpg, png, gif, svg, webp, bmp, tiff, ico |
| documents | pdf, doc, docx, txt, rtf, odt, ppt, xls |
| audio | mp3, wav, flac, ogg, aac, m4a |
| video | mp4, mkv, avi, mov, webm, flv |
| archives | zip, tar, gz, 7z, rar, bz2, xz |
| code | py, js, ts, html, css, md, json, yaml |
| data | csv, xml, db, sqlite, dat, log |
| installers | exe, msi, dmg, app, pkg, deb, rpm, apk |
| fonts | ttf, otf, woff, woff2, fon |
| other | uncategorized files |

## License

MIT


path: config.yaml
content: source_dirs:
  - ~/Downloads

target_base: ~/organized-files
auto_sort: true
rename_pattern: "{name}_{date}"
max_file_size_mb: 500
exclude_patterns:
  - "*.tmp"
  - "*.swp"
  - ".DS_Store"
  - "Thumbs.db"
log_file: ORGANIZE_LOG.json


path: demo_output.md
content: # File Organizer Demo Output

## Source: ~/Downloads (before)

```
downloads/
├── photo.jpg
├── report 2025-03-15.pdf
├── song.mp3
├── video.mp4
├── archive.zip
├── script.py
├── data.csv
├── installer.exe
├── font.ttf
├── unknown.xyz
├── .DS_Store
├── temp.tmp
└── backup.tar.gz
```

## After Running: `python3 scripts/organize.py ~/Downloads`

### Directory Structure (after)

```
organized-files/
├── images/
│   └── photo.jpg
├── documents/
│   └── report_2025-03-15.pdf
├── audio/
│   └── song.mp3
├── video/
│   └── video.mp4
├── archives/
│   ├── archive.zip
│   └── backup.tar.gz
├── code/
│   └── script.py
├── data/
│   └── data.csv
├── installers/
│   └── installer.exe
├── fonts/
│   └── font.ttf
└── other/
    └── unknown.xyz
```

Excluded (not moved):
- `.DS_Store` — in exclude_patterns
- `temp.tmp` — in exclude_patterns

### Rename Behavior

| Original | Result | Reason |
|----------|--------|--------|
| `report 2025-03-15.pdf` | `report_2025-03-15.pdf` | Date extracted, spaces → underscores |
| `photo.jpg` | `photo.jpg` | No changes needed |

### Undo

Running `python3 scripts/organize.py --undo` restores all files to `~/Downloads`.

### Dry Run

`python3 scripts/organize.py ~/Downloads --dry-run` previews:
```
[DRY RUN] photo.jpg → images/photo.jpg
[DRY RUN] report 2025-03-15.pdf → documents/report_2025-03-15.pdf
[DRY RUN] song.mp3 → audio/song.mp3
[DRY RUN] video.mp4 → video/video.mp4
[DRY RUN] archive.zip → archives/archive.zip
[DRY RUN] script.py → code/script.py
[DRY RUN] data.csv → data/data.csv
[DRY RUN] installer.exe → installers/installer.exe
[DRY RUN] font.ttf → fonts/font.ttf
[DRY RUN] unknown.xyz → other/unknown.xyz
[DRY RUN] backup.tar.gz → archives/backup.tar.gz
```


path: references/file-patterns.md
content: # File Type Patterns Reference

## Built-in Categories

### Images
- **JPG/JPEG** — Standard photo format
- **PNG** — Lossless compression, transparency support
- **GIF** — Animated or static graphics
- **SVG** — Scalable vector graphics
- **WEBP** — Modern web format
- **BMP** — Bitmap images
- **TIFF/TIF** — High-quality archival format
- **ICO** — Icons

### Documents
- **PDF** — Portable document format
- **DOC/DOCX** — Microsoft Word
- **TXT** — Plain text
- **RTF** — Rich text format
- **ODT** — OpenDocument text
- **PPT/PPTX** — Microsoft PowerPoint
- **XLS/XLSX** — Microsoft Excel
- **ODP** — OpenDocument presentation
- **ODG** — OpenDocument graphics
- **TEX/TEXX** — LaTeX source
- **EPUB** — E-book format

### Audio
- **MP3** — Standard compressed audio
- **WAV** — Uncompressed audio
- **FLAC** — Lossless compressed
- **OGG** — Ogg Vorbis
- **AAC** — Advanced Audio Coding
- **M4A** — Apple AAC
- **WMA** — Windows Media Audio
- **AIFF** — Apple Audio Interchange

### Video
- **MP4** — Standard video format
- **MKV** — Matroska (supports subtitles)
- **AVI** — Video for Windows
- **MOV** — QuickTime format
- **WEBM** — Web-oriented video
- **FLV** — Flash Video
- **WMV** — Windows Media Video
- **M4V** — Apple video format
- **3GP** — Mobile video format

### Archives
- **ZIP** — Standard archive
- **TAR** — Unix tape archive
- **GZ** — Gzip compressed
- **7Z** — 7-Zip format
- **RAR** — WinRAR archive
- **BZ2** — Bzip2 compressed
- **XZ** — Lempel-Ziv compressed
- **TGZ** — Gzip-compressed tar
- **ZST** — Zstandard archive

### Code
- **Python** — py
- **JavaScript** — js, jsx
- **TypeScript** — ts, tsx
- **HTML/CSS** — html, css, scss
- **Markdown** — md
- **Data** — json, yaml, yml
- **Shell** — sh, bash
- **Batch** — bat, ps1
- **Ruby** — rb
- **Go** — go
- **Rust** — rs
- **Java** — java
- **C/C++** — c, cpp, h
- **Swift** — swift
- **Kotlin** — kt
- **PHP** — php
- **SQL** — sql

### Data
- **CSV** — Comma-separated values
- **XML** — Extensible Markup Language
- **DB/SQLITE** — SQLite database
- **DAT** — Generic data file
- **LOG** — Log files

### Installers
- **Windows** — exe, msi
- **macOS** — dmg, app, pkg
- **Linux** — deb, rpm
- **Android** — apk
- **Disk** — iso, img

### Fonts
- **TTF** — TrueType Font
- **OTF** — OpenType Font
- **WOFF/WOFF2** — Web fonts
- **FON** — Windows font
- **EOT** — Embedded OpenType

## Custom Categories

Add custom mappings in config.yaml:

```yaml
custom_types:
  - name: "Design Assets"
    folders: "design/"
    extensions: [AI, PSD, SKETCH, FIG, INDD]
  - name: "Development Tools"
    folders: "dev-tools/"
    extensions: [VSCODE, SUBPROJECT]
```

## Naming Convention Options

| Pattern | Result |
|---------|--------|
| `{name}_{date}` | `report_2025-03-15.pdf` |
| `{name}` | `report.pdf` |
| `none` | `report.pdf` (no date, no change) |

## Safety Features

- Files larger than `max_file_size_mb` are skipped
- Exclude patterns prevent unwanted files from moving
- Undo log preserves every move for full rollback
- Dry-run mode previews without touching files
- Duplicate names get `_2`, `_3` suffixes


path: scripts/organize.py
content: #!/usr/bin/env python3
"""
File Organizer — sorts files into categorized folders with rename, undo, and dry-run support.
"""

import argparse
import json
import os
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path


DEFAULT_CONFIG = {
    "source_dirs": ["~/Downloads"],
    "target_base": "~/organized-files",
    "auto_sort": True,
    "rename_pattern": "{name}_{date}",
    "max_file_size_mb": 500,
    "exclude_patterns": ["*.tmp", "*.swp", ".DS_Store", "Thumbs.db"],
    "log_file": "ORGANIZE_LOG.json",
}

# File type to folder mapping
FILE_CATEGORIES = {
    "images": ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "tiff", "tif", "ico", "heic"],
    "documents": ["pdf", "doc", "docx", "txt", "rtf", "odt", "ppt", "pptx", "xls", "xlsx", "odp", "odg", "tex", "epub"],
    "audio": ["mp3", "wav", "flac", "ogg", "aac", "m4a", "wma", "aiff"],
    "video": ["mp4", "mkv", "avi", "mov", "webm", "flv", "wmv", "m4v", "3gp"],
    "archives": ["zip", "tar", "gz", "7z", "rar", "bz2", "xz", "tgz", "zst"],
    "code": ["py", "js", "ts", "jsx", "tsx", "html", "css", "scss", "md", "json", "yaml", "yml", "sh", "bat", "ps1", "rb", "go", "rs", "java", "c", "cpp", "h", "swift", "kt", "php", "sql"],
    "data": ["csv", "xml", "db", "sqlite", "sqlite3", "dat", "log"],
    "installers": ["exe", "msi", "dmg", "app", "pkg", "deb", "rpm", "apk", "iso", "img"],
    "fonts": ["ttf", "otf", "woff", "woff2", "fon", "eot"],
}

CATEGORIES = {v.lower(): k for k, vals in FILE_CATEGORIES.items() for v in vals}


def load_config(config_path="config.yaml"):
    """Load config from YAML or fall back to defaults."""
    try:
        import yaml
        with open(config_path) as f:
            return yaml.safe_load(f) or DEFAULT_CONFIG
    except ImportError:
        # Minimal YAML parser fallback
        pass
    except FileNotFoundError:
        pass

    cfg = DEFAULT_CONFIG.copy()
    if os.path.exists(config_path):
        # Simple key: value parsing for basic YAML
        with open(config_path) as f:
            for line in f:
                line = line.strip()
                if ":" in line and not line.startswith("#"):
                    key, val = line.split(":", 1)
                    key = key.strip()
                    val = val.strip()
                    if val.startswith("[") and val.endswith("]"):
                        items = [i.strip().strip('"').strip("'") for i in val[1:-1].split(",") if i.strip()]
                        cfg[key] = items
                    elif val.startswith('"') and val.endswith('"'):
                        cfg[key] = val[1:-1]
                    elif val.startswith("'") and val.endswith("'"):
                        cfg[key] = val[1:-1]
    return cfg


def expand_paths(paths):
    """Expand ~ and relative paths to absolute."""
    result = []
    for p in paths:
        p = os.path.expanduser(p)
        if not os.path.isabs(p):
            p = os.path.abspath(p)
        result.append(p)
    return result


def glob_match(pattern, filename):
    """Simple glob matching (supports *, ? patterns)."""
    regex = "^" + pattern.replace(".", r"\.").replace("*", ".*").replace("?", ".") + "$"
    return bool(re.match(regex, filename, re.IGNORECASE))


def should_exclude(filename, exclude_patterns):
    """Check if file matches any exclude pattern."""
    for pat in exclude_patterns:
        if glob_match(pat, filename):
            return True
    return False


def extract_date(filename):
    """Extract YYYY-MM-DD date from filename. Returns tuple (y, m, d) or None."""
    matches = re.findall(r"(\d{4})[-_](\d{1,2})[-_](\d{1,2})", filename)
    if matches:
        y, m, d = matches[0]
        return (y, f"{int(m):02d}", f"{int(d):02d}")
    # Try month-day-year (validate: month <= 12, day <= 31)
    matches = re.findall(r"(\d{1,2})[-_](\d{1,2})[-_](\d{4})", filename)
    if matches:
        a, b, y = matches[0]
        a, b = int(a), int(b)
        if a > 12:
            # Likely day-first: swap
            a, b = b, a
        return (y, f"{a:02d}", f"{b:02d}")
    return None

def extract_date_str(filename):
    """Extract YYYY-MM-DD date string from filename."""
    d = extract_date(filename)
    return "-".join(d) if d else None

def strip_existing_date(filename):
    """Remove date patterns from the stem of a filename."""
    p = Path(filename)
    stem = p.stem
    # Remove YYYY-MM-DD or YYYY_MM_DD patterns
    stem = re.sub(r"\d{4}[_-]\d{1,2}[_-]\d{1,2}", "", stem)
    stem = re.sub(r"\d{1,2}[_-]\d{1,2}[_-]\d{4}", "", stem)
    # Clean up double underscores/dashes left behind
    stem = re.sub(r"_+", "_", stem)
    stem = re.sub(r"-+", "-", stem)
    stem = stem.strip("_-")
    return stem if stem else p.stem


def sanitize_name(name, date=None, pattern="{name}_{date}"):
    """Create a clean filename from source."""
    p = Path(name)
    ext = p.suffix.lower() if p.suffix else ""
    base = p.stem

    if date and pattern != "none":
        # Strip existing date from stem to avoid duplication
        base = strip_existing_date(name)

    # Remove spaces
    base = base.replace(" ", "_")
    # Remove special chars (keep hyphens, underscores, alphanumeric)
    base = re.sub(r"[^\w\-]", "", base)
    # Collapse multiple underscores/dashes
    base = re.sub(r"_+", "_", base).strip("_-")
    if not base:
        base = "unnamed"

    if pattern == "none":
        return f"{base}{ext}"

    if date:
        return f"{base}_{date}{ext}"
    return f"{base}{ext}"


def get_category(filename):
    """Determine file category based on extension."""
    ext = Path(filename).suffix.lower().lstrip(".")
    return CATEGORIES.get(ext, "other")


def organize_directory(source_dir, target_base, config, dry_run=False):
    """Organize files in a directory."""
    source = os.path.abspath(source_dir)
    target = os.path.abspath(target_base)

    if not os.path.isdir(source):
        print(f"Error: Source directory not found: {source_dir}")
        return None

    exclude = config.get("exclude_patterns", ["*.tmp", "*.swp", ".DS_Store", "Thumbs.db"])
    max_size = config.get("max_file_size_mb", 500) * 1024 * 1024
    rename_pat = config.get("rename_pattern", "{name}_{date}")
    log_file = config.get("log_file", "ORGANIZE_LOG.json")

    moves = []
    errors = []
    counts = {}

    os.makedirs(target, exist_ok=True)

    for filename in os.listdir(source):
        filepath = os.path.join(source, filename)

        # Skip directories
        if not os.path.isfile(filepath):
            continue

        # Skip exclude patterns
        if should_exclude(filename, exclude):
            continue

        # Skip large files
        try:
            size = os.path.getsize(filepath)
            if size > max_size:
                errors.append({"file": filename, "reason": f"Too large ({size // 1024 // 1024}MB)"})
                continue
        except OSError as e:
            errors.append({"file": filename, "reason": str(e)})
            continue

        # Get category
        ext = Path(filename).suffix.lower()
        if not ext:
            continue  # No extension, skip

        category = get_category(filename)
        counts[category] = counts.get(category, 0) + 1

        # Build new path
        cat_dir = os.path.join(target, category)
        date_str = extract_date_str(filename)
        new_name = sanitize_name(filename, date_str, rename_pat)
        new_path = os.path.join(cat_dir, new_name)

        # Handle duplicates
        if os.path.exists(new_path):
            base = Path(new_name).stem
            suffix = Path(new_name).suffix
            counter = 2
            while os.path.exists(new_path := os.path.join(cat_dir, f"{base}_{counter}{suffix}")):
                counter += 1
            new_path = os.path.join(cat_dir, f"{base}_{counter}{suffix}")

        if dry_run:
            print(f"  [DRY RUN] {filename} → {category}/{new_name.lstrip('.')}")
        else:
            os.makedirs(cat_dir, exist_ok=True)
            try:
                shutil.move(filepath, new_path)
                moves.append({
                    "original": filepath,
                    "new": new_path,
                    "category": category,
                    "date": datetime.now().isoformat(),
                })
            except OSError as e:
                errors.append({"file": filename, "reason": str(e)})

    return {
        "moves": moves,
        "counts": counts,
        "errors": errors,
        "timestamp": datetime.now().isoformat(),
        "source": source,
        "target": target,
        "dry_run": dry_run,
    }


def undo_operation(log_file_path, dry_run=False):
    """Undo the last organization operation from log."""
    if not os.path.exists(log_file_path):
        print(f"Error: No log file found: {log_file_path}")
        return

    with open(log_file_path) as f:
        log = json.load(f)

    # Get latest operation
    if isinstance(log, list):
        ops = log
    else:
        ops = [log]

    latest = ops[-1] if ops else None
    if not latest:
        print("No operations to undo.")
        return

    moves = latest.get("moves", [])
    if not moves:
        print("No moves to undo.")
        return

    restored = 0
    for move in moves:
        orig = move["original"]
        new = move["new"]
        if os.path.exists(new):
            if not dry_run:
                os.makedirs(os.path.dirname(orig), exist_ok=True)
                shutil.move(new, orig)
            restored += 1
            print(f"  Restored: {Path(new).name} → {Path(orig).name}")
        else:
            print(f"  Skipped (not found): {os.path.basename(new)}")

    if not dry_run:
        # Remove category dirs if empty
        cats = set(m.get("category", "") for m in moves)
        for cat in cats:
            cat_dir = os.path.join(latest.get("target", ""), cat)
            if os.path.isdir(cat_dir) and not os.listdir(cat_dir):
                os.rmdir(cat_dir)

    print(f"Restored {restored} file(s).")
    return {"restored": restored, "timestamp": datetime.now().isoformat()}


def main():
    parser = argparse.ArgumentParser(description="Organize files into categorized folders")
    parser.add_argument("source", nargs="?", default="~/Downloads", help="Source directory")
    parser.add_argument("--target", default="~/organized-files", help="Target directory")
    parser.add_argument("--config", default="config.yaml", help="Config file path")
    parser.add_argument("--dry-run", action="store_true", help="Preview without moving files")
    parser.add_argument("--undo", action="store_true", help="Undo last operation")
    parser.add_argument("--undo-log", default="ORGANIZE_LOG.json", help="Undo log file path")
    parser.add_argument("--json", action="store_true", help="Output as JSON")

    args = parser.parse_args()
    config = load_config(args.config)

    if args.undo:
        result = undo_operation(args.undo_log, args.dry_run)
        if result and args.json:
            print(json.dumps(result, indent=2))
        elif not args.json:
            pass  # undo prints its own output
        return

    source_dirs = config.get("source_dirs", [args.source])
    target_base = args.target or config.get("target_base", "~/organized-files")

    # Expand ~
    source_dirs = expand_paths(source_dirs)
    target_base = expand_paths([target_base])[0]

    all_counts = {}
    all_moves = []
    all_errors = []

    for source in source_dirs:
        result = organize_directory(source, target_base, config, args.dry_run)
        if result:
            all_counts.update(result["counts"])
            all_moves.extend(result["moves"])
            all_errors.extend(result["errors"])

    # Log the operation (unless dry-run)
    if not args.dry_run and all_moves:
        log_path = os.path.join(os.path.abspath(target_base), args.undo_log)
        log_dir = os.path.dirname(log_path)
        if log_dir:
            os.makedirs(log_dir, exist_ok=True)

        # Load existing log and append
        existing = []
        if os.path.exists(log_path):
            try:
                with open(log_path) as f:
                    existing = json.load(f)
                if isinstance(existing, dict):
                    existing = [existing]
            except json.JSONDecodeError:
                existing = []

        operation = {
            "timestamp": datetime.now().isoformat(),
            "source": source_dirs,
            "target": target_base,
            "moves": all_moves,
            "counts": all_counts,
        }
        existing.append(operation)
        with open(log_path, "w") as f:
            json.dump(existing, f, indent=2)

    # Output
    output = {
        "timestamp": datetime.now().isoformat(),
        "dry_run": args.dry_run,
        "counts": all_counts,
        "total_moves": len(all_moves),
        "errors": all_errors,
    }

    if args.json:
        print(json.dumps(output, indent=2))
    else:
        print(f"\n{'DRY RUN' if args.dry_run else 'DONE'} — {len(source_dirs)} source(s)")
        for cat, count in sorted(all_counts.items()):
            print(f"  {cat:15s} {count:4d} files")
        if all_errors:
            print(f"\n  Errors ({len(all_errors)}):")
            for e in all_errors[:5]:
                print(f"    - {e['file']}: {e['reason']}")
            if len(all_errors) > 5:
                print(f"    ... and {len(all_errors) - 5} more")
        if not args.dry_run and all_moves:
            print(f"\n  Logged to: {os.path.join(target_base, args.undo_log)}")


if __name__ == "__main__":
    main()


path: test/test_organize.py
content: #!/usr/bin/env python3
"""Tests for file-organizer."""

import json
import os
import shutil
import sys
import tempfile
import unittest
from pathlib import Path

# Add scripts to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
import organize


class TestCategories(unittest.TestCase):
    def test_image_extensions(self):
        self.assertEqual(organize.get_category("photo.jpg"), "images")
        self.assertEqual(organize.get_category("icon.PNG"), "images")
        self.assertEqual(organize.get_category("diagram.svg"), "images")
        self.assertEqual(organize.get_category("animation.webp"), "images")

    def test_document_extensions(self):
        self.assertEqual(organize.get_category("report.pdf"), "documents")
        self.assertEqual(organize.get_category("notes.txt"), "documents")
        self.assertEqual(organize.get_category("presentation.pptx"), "documents")

    def test_audio_extensions(self):
        self.assertEqual(organize.get_category("song.mp3"), "audio")
        self.assertEqual(organize.get_category("track.flac"), "audio")

    def test_video_extensions(self):
        self.assertEqual(organize.get_category("clip.mp4"), "video")
        self.assertEqual(organize.get_category("movie.mkv"), "video")

    def test_archive_extensions(self):
        self.assertEqual(organize.get_category("backup.zip"), "archives")
        self.assertEqual(organize.get_category("source.tar.gz"), "archives")

    def test_code_extensions(self):
        self.assertEqual(organize.get_category("script.py"), "code")
        self.assertEqual(organize.get_category("page.html"), "code")

    def test_data_extensions(self):
        self.assertEqual(organize.get_category("data.csv"), "data")

    def test_other(self):
        self.assertEqual(organize.get_category("unknown.xyz"), "other")
        self.assertEqual(organize.get_category("no_extension"), "other")


class TestRename(unittest.TestCase):
    def test_sanitize_basic(self):
        result = organize.sanitize_name("My Document.pdf", None, "{name}_{date}")
        self.assertEqual(result, "My_Document.pdf")

    def test_sanitize_with_date(self):
        result = organize.sanitize_name("report 2025-03-15.pdf", "2025-03-15", "{name}_{date}")
        self.assertEqual(result, "report_2025-03-15.pdf")

    def test_sanitize_no_pattern(self):
        result = organize.sanitize_name("file with spaces.txt", None, "none")
        self.assertEqual(result, "file_with_spaces.txt")

    def test_sanitize_special_chars(self):
        result = organize.sanitize_name("file (v2).pdf", None, "none")
        self.assertEqual(result, "file_v2.pdf")

    def test_extract_date(self):
        self.assertEqual(organize.extract_date("report_2025-03-15.pdf"), ("2025", "03", "15"))
        self.assertEqual(organize.extract_date("doc_v1.0.txt"), None)

    def test_extract_date_dmy(self):
        result = organize.extract_date("report_15_03_2025.pdf")
        self.assertEqual(result, ("2025", "03", "15"))

    def test_extract_date_str(self):
        self.assertEqual(organize.extract_date_str("report_2025-03-15.pdf"), "2025-03-15")
        self.assertEqual(organize.extract_date_str("doc_v1.0.txt"), None)


class TestExclude(unittest.TestCase):
    def test_tmp_excluded(self):
        self.assertTrue(organize.should_exclude("test.tmp", ["*.tmp"]))

    def test_txt_not_excluded(self):
        self.assertFalse(organize.should_exclude("notes.txt", ["*.tmp"]))

    def test_ds_store_excluded(self):
        self.assertTrue(organize.should_exclude(".DS_Store", [".DS_Store"]))


class TestOrganizeDryRun(unittest.TestCase):
    def test_dry_run_no_moves(self):
        """Dry run should not move any files."""
        with tempfile.TemporaryDirectory() as tmpdir:
            # Create test files
            test_files = [
                "photo.jpg",
                "report.pdf",
                "song.mp3",
            ]
            for f in test_files:
                Path(tmpdir, f).touch()

            result = organize.organize_directory(tmpdir, tmpdir, organize.DEFAULT_CONFIG, dry_run=True)
            self.assertIsNotNone(result)
            self.assertEqual(len(result["moves"]), 0)
            self.assertEqual(result["counts"]["images"], 1)
            self.assertEqual(result["counts"]["documents"], 1)
            self.assertEqual(result["counts"]["audio"], 1)

            # Verify files still in original location
            for f in test_files:
                self.assertTrue(os.path.exists(os.path.join(tmpdir, f)))


class TestUndo(unittest.TestCase):
    def test_undo_restores_files(self):
        """Test that undo restores files to original location."""
        with tempfile.TemporaryDirectory() as tmpdir:
            # Setup
            source = os.path.join(tmpdir, "downloads")
            target = os.path.join(tmpdir, "organized")
            log_file = os.path.join(target, "ORGANIZE_LOG.json")
            os.makedirs(source)
            os.makedirs(target)

            # Create a test file
            test_file = os.path.join(source, "photo.jpg")
            Path(test_file).touch()

            # Organize (move)
            result = organize.organize_directory(source, target, organize.DEFAULT_CONFIG)
            self.assertEqual(len(result["moves"]), 1)
            new_path = result["moves"][0]["new"]
            self.assertTrue(os.path.exists(new_path))
            self.assertFalse(os.path.exists(test_file))

            # Save log for undo
            with open(log_file, "w") as f:
                json.dump([result], f)

            # Undo
            undo_result = organize.undo_operation(log_file)
            self.assertEqual(undo_result["restored"], 1)

            # Verify restored
            self.assertTrue(os.path.exists(test_file))


class TestIntegration(unittest.TestCase):
    def test_full_workflow(self):
        """End-to-end test: create files, organize, verify structure."""
        with tempfile.TemporaryDirectory() as tmpdir:
            source = os.path.join(tmpdir, "downloads")
            target = os.path.join(tmpdir, "organized")
            os.makedirs(source)

            # Create test files across categories
            test_files = [
                ("photo.jpg", "images"),
                ("report.pdf", "documents"),
                ("song.mp3", "audio"),
                ("video.mp4", "video"),
                ("archive.zip", "archives"),
                ("script.py", "code"),
                ("data.csv", "data"),
            ]
            for fname, _ in test_files:
                Path(source, fname).touch()

            config = organize.DEFAULT_CONFIG.copy()
            config["rename_pattern"] = "none"

            result = organize.organize_directory(source, target, config, dry_run=False)
            self.assertEqual(result["counts"], {cat: 1 for _, cat in test_files})
            self.assertEqual(len(result["moves"]), len(test_files))

            # Verify folder structure
            for _, expected_cat in test_files:
                cat_dir = os.path.join(target, expected_cat)
                self.assertTrue(os.path.isdir(cat_dir))


if __name__ == "__main__":
    unittest.main(verbosity=2)


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)