aboutsummaryrefslogtreecommitdiff
path: root/sorter
diff options
context:
space:
mode:
Diffstat (limited to 'sorter')
-rw-r--r--sorter/readme.md210
-rw-r--r--sorter/rules.toml137
-rw-r--r--sorter/sort_downloads.py310
-rwxr-xr-xsorter/sort_downloads.sh60
4 files changed, 0 insertions, 717 deletions
diff --git a/sorter/readme.md b/sorter/readme.md
deleted file mode 100644
index e81c411..0000000
--- a/sorter/readme.md
+++ /dev/null
@@ -1,210 +0,0 @@
-# Downloads Sorter
-
-A Python script that automatically sorts files from your `~/Downloads` folder into organised subdirectories based on filename keywords and — more importantly — the **actual text content** of each file.
-
-Built for Debian Linux, Polish and English documents, Python 3.11+.
-
----
-
-## How it works
-
-When you run the script it does the following:
-
-1. Scans every supported file in `~/Downloads` (not subdirectories)
-2. Extracts the full text content from each file
-3. Checks that text against the keyword rules in `rules.toml`
-4. Falls back to checking the filename if no content match is found
-5. Shows you a **dry-run preview** of where each file would go
-6. Asks for confirmation before moving anything
-
-Content always beats filename — so a file called `scan001.pdf` that contains an Arc of Asia invoice will correctly go to `Work/ARC`, even though the filename gives no hint.
-
-### Supported file types
-
-`.pdf` `.docx` `.doc` `.txt` `.xlsx` `.xls` `.md` `.odt`
-
-You can add more in `rules.toml` (see below).
-
----
-
-## Files
-
-```
-sorter/
-├── sort_downloads.sh # Run this — installs deps, then calls the Python script
-├── sort_downloads.py # The actual logic
-└── rules.toml # Your rules — edit this to add keywords and categories
-```
-
-Keep all three files in the same directory.
-
----
-
-## Daily use
-
-```bash
-cd ~/.local/bin/sorter
-./sort_downloads.sh
-```
-
-That's it. The script will show a preview, then ask:
-
-```
-Proceed with moving files? [y/N]
-```
-
-Type `y` to move, or just press Enter to cancel without touching anything.
-
-### Useful flags
-
-```bash
-# Skip the confirmation prompt and move immediately
-./sort_downloads.sh --yes
-
-# Preview rules without scanning any files
-./sort_downloads.sh --list-rules
-
-# Sort a different folder instead of ~/Downloads
-./sort_downloads.sh --dir /path/to/folder
-
-# Use a different rules file
-./sort_downloads.sh --config /path/to/other-rules.toml
-```
-
-### Output folders
-
-Subfolders are created automatically inside `~/Downloads` the first time a file routes there. Based on the default rules you will get:
-
-```
-~/Downloads/
-├── Work/
-│ ├── ARC/
-│ └── LKIT/
-├── AKW/
-└── Other/
-```
-
-If two files would land on the same destination path, the script appends `_1`, `_2` etc. rather than overwriting.
-
----
-
-## How to extend it
-
-Everything is controlled by `rules.toml`. You never need to touch the Python script to add new keywords or categories.
-
-### Adding a keyword to an existing category
-
-Open `rules.toml` and add a line to the relevant list:
-
-```toml
-[[categories]]
-name = "Work/ARC"
-content_keywords = [
- "arc of asia",
- "9571181577",
- "your new keyword here", # ← add here
-]
-filename_keywords = [
- "arc",
- "new_filename_hint", # ← or here
-]
-```
-
-`content_keywords` are matched against the full extracted text of the file.
-`filename_keywords` are matched against the filename only, and only used if no content match was found first.
-
-Both are case-insensitive and partial — `"kasprzak"` will match `"Łukasz Kasprzak International Trade"`.
-
-### Adding a new category
-
-Append a new `[[categories]]` block anywhere in the list:
-
-```toml
-[[categories]]
-name = "Finance/Banking"
-description = "Bank statements and account exports"
-content_keywords = [
- "revolut",
- "account statement",
- "wyciąg bankowy",
- "mbank",
-]
-filename_keywords = [
- "revolut",
- "statement",
- "wyciag",
-]
-```
-
-The folder path (`Finance/Banking`, `Personal/Tax`, or any depth you like) will be created automatically inside `~/Downloads`.
-
-**Order matters** — categories are checked top to bottom and the first match wins. Put more specific categories above broader ones.
-
-### Adding a new file extension
-
-Add it to `supported_extensions` in `rules.toml`:
-
-```toml
-supported_extensions = [
- ".pdf",
- ".docx",
- ".txt",
- # ... existing entries ...
- ".log", # plain text — works out of the box
- ".csv", # plain text — works out of the box
-]
-```
-
-Plain text formats (`.log`, `.csv`, `.json`, `.xml`, `.ini`, `.conf`) work immediately with no code changes. Binary formats that need a dedicated parser (e.g. `.pptx`, `.ods`) would require a small addition to `sort_downloads.py`.
-
-### Changing the catch-all folder
-
-Files that match no category go here:
-
-```toml
-fallback_folder = "Other"
-```
-
-Change it to anything you like, e.g. `"Unsorted"` or `"Inbox"`.
-
----
-
-## Keyword tips
-
-- **NIP / REGON / KRS numbers** are the most reliable content keywords — they are unique per company and appear on every invoice and document
-- Put **broad keywords** (like `"invoice"`) lower in the list so they don't accidentally catch documents that should match a more specific category above
-- If a bank statement matches the wrong category because a supplier's address appears as a payee, move that supplier's address out of `content_keywords` and rely on the NIP/REGON instead
-- Polish characters work fine in both content and filename keywords (`ł`, `ó`, `ą`, `ś`, `ź`, etc.)
-- Run `./sort_downloads.sh --list-rules` after editing to confirm your changes loaded correctly
-
----
-
-## Dependencies
-
-| Library | Purpose | Installed by |
-|---|---|---|
-| `pdfplumber` | Read text from PDF files | `sort_downloads.sh` automatically |
-| `python-docx` | Read text from .docx/.doc files | `sort_downloads.sh` automatically |
-| `openpyxl` | Read text from .xlsx/.xls files | `sort_downloads.sh` automatically |
-| `odfpy` | Read text from .odt files | `sort_downloads.sh` automatically |
-| `tomllib` | Parse rules.toml | Built into Python 3.11+ — nothing to install |
-
-The bash wrapper (`sort_downloads.sh`) checks for and installs any missing libraries automatically on each run using `pip install --break-system-packages`.
-
-**Python 3.11 or newer is required.** On Debian 12+ this is the default.
-
----
-
-## Troubleshooting
-
-**A file landed in the wrong folder**
-Run `--list-rules` to check what keywords are loaded. The preview also shows the match reason in brackets, e.g. `[content: 'wiercany 60a']` — use this to identify which keyword caused the misroute and either remove it or move a more specific category above it in `rules.toml`.
-
-**A file ended up in Other**
-The script found no matching keyword in the file's content or filename. Open the file, find a unique phrase or number, and add it as a `content_keyword` to the appropriate category.
-
-**PDF content is not being read**
-Check that `pdfplumber` is installed (`pip show pdfplumber`). Some PDFs are image-only scans with no embedded text — these cannot be read without OCR, which is not currently supported.
-
-**TOML syntax error on startup**
-TOML is strict about quoting — all strings must be in double quotes. Numbers like NIP/REGON must also be quoted (`"9571181577"`, not `9571181577`) to be treated as text for matching.
diff --git a/sorter/rules.toml b/sorter/rules.toml
deleted file mode 100644
index ae1cbfd..0000000
--- a/sorter/rules.toml
+++ /dev/null
@@ -1,137 +0,0 @@
-# ─────────────────────────────────────────────────────────────────────────────
-# Downloads Sorter — Rules Configuration (TOML)
-#
-# PRIORITY: content_keywords > filename_keywords
-# File contents are scanned first; filename is used as fallback.
-#
-# FOLDER PATHS: relative to your Downloads directory.
-# Use / for subfolders, e.g. "Work/ARC"
-#
-# KEYWORD TIPS:
-# - Case-insensitive ("arc" matches "ARC", "Arc", "arc")
-# - Partial match ("kasprzak" matches "Łukasz Kasprzak International Trade")
-# - Always quote strings — avoids TOML type surprises with numbers
-# - NIP/REGON/KRS numbers are the most reliable content keywords
-# - Polish characters work fine (ł, ó, ą, ś, ź, etc.)
-# - Categories are checked in order — first match wins
-# ─────────────────────────────────────────────────────────────────────────────
-
-# Catch-all folder for files that match no category
-fallback_folder = "Other"
-
-# File types to scan (add or remove extensions as needed)
-supported_extensions = [
- ".pdf",
- ".docx",
- ".doc",
- ".txt",
- ".xlsx",
- ".xls",
- ".md",
- ".odt",
- ".sh",
- ".py",
- ".js",
- ".ts",
- ".html",
- ".css",
- ".json",
- ".yaml",
- ".toml",
-]
-
-# ── Categories ────────────────────────────────────────────────────────────────
-# Each [[categories]] block defines one destination folder.
-# Add as many blocks as you need.
-
-[[categories]]
-name = "Work/ARC"
-description = "Arc of Asia Sp. z o.o. — documents, invoices, correspondence"
-content_keywords = [
- "arc of asia",
- "arc of asia spółka z ograniczoną odpowiedzialnością",
- "arc of asia spolka z ograniczona odpowiedzialnoscia",
- "aleja grunwaldzka 56",
- "9571181577", # NIP
- "540356138", # REGON
- "0001140839", # KRS
- "pl957118157700000", # EORI
-]
-filename_keywords = [
- "arc",
- "aoa",
- "arc_of_asia",
-]
-
-[[categories]]
-name = "Work/LKIT"
-description = "Łukasz Kasprzak International Trade — documents, invoices, correspondence"
-content_keywords = [
- "łukasz kasprzak international trade",
- "lukasz kasprzak international trade",
- "8181739189", # NIP
- "540804571", # REGON
-]
-filename_keywords = [
- "lkit",
- "kasprzak",
-]
-
-[[categories]]
-name = "AKW"
-description = "Akademia Katolicka w Warszawie"
-content_keywords = [
- "akademia katolicka w warszawie",
- "akademia katolicka",
-]
-filename_keywords = [
- "akw",
-]
-
-[[categories]]
-name = "Code"
-description = "Scripts and source code files"
-filename_keywords = [".sh", ".py", ".js", ".ts", ".html", ".css", ".json", ".yaml", ".toml"]
-# ── Add more categories below ─────────────────────────────────────────────────
-
-# [[categories]]
-# name = "Finance/Banking"
-# description = "Bank statements, Revolut, account history"
-# content_keywords = [
-# "revolut",
-# "account statement",
-# "wyciąg bankowy",
-# ]
-# filename_keywords = [
-# "revolut",
-# "statement",
-# "wyciag",
-# ]
-
-# [[categories]]
-# name = "Finance/Invoices"
-# description = "VAT invoices and receipts"
-# content_keywords = [
-# "faktura vat",
-# "invoice",
-# "23% vat",
-# ]
-# filename_keywords = [
-# "faktura",
-# "invoice",
-# ]
-
-# [[categories]]
-# name = "Personal/Tax"
-# description = "Tax documents and PIT forms"
-# content_keywords = [
-# "pit-37",
-# "pit-36",
-# "urząd skarbowy",
-# "zeznanie podatkowe",
-# ]
-# filename_keywords = [
-# "pit",
-# "podatek",
-# "tax",
-# ]
diff --git a/sorter/sort_downloads.py b/sorter/sort_downloads.py
deleted file mode 100644
index 1ced5e3..0000000
--- a/sorter/sort_downloads.py
+++ /dev/null
@@ -1,310 +0,0 @@
-#!/usr/bin/env python3
-"""
-Downloads Folder Sorter
-Rules are loaded from rules.toml — edit that file to add keywords/categories.
-Content match takes priority over filename match.
-Requires Python 3.11+ (uses built-in tomllib).
-"""
-
-import sys
-import shutil
-import tomllib
-import argparse
-from pathlib import Path
-
-# ── Optional deps (graceful fallback) ────────────────────────────────────────
-try:
- import pdfplumber
- HAS_PDF = True
-except ImportError:
- HAS_PDF = False
-
-try:
- from docx import Document
- HAS_DOCX = True
-except ImportError:
- HAS_DOCX = False
-
-try:
- import openpyxl
- HAS_XLSX = True
-except ImportError:
- HAS_XLSX = False
-
-try:
- from odf import teletype
- from odf.opendocument import load as odf_load
- HAS_ODT = True
-except ImportError:
- HAS_ODT = False
-
-# ── Config loading ────────────────────────────────────────────────────────────
-
-def load_config(config_path: Path) -> dict:
- if not config_path.exists():
- print(f"Error: Config file not found: {config_path}")
- print("Make sure rules.toml is in the same folder as this script.")
- sys.exit(1)
- with open(config_path, "rb") as f:
- config = tomllib.load(f)
- return config
-
-def validate_config(config: dict):
- if "categories" not in config or not config["categories"]:
- print("Error: 'categories' is missing or empty in rules.toml")
- sys.exit(1)
- for cat in config["categories"]:
- if "name" not in cat:
- print("Error: A category in rules.toml is missing a 'name' field.")
- sys.exit(1)
-
-# ── Text extraction ───────────────────────────────────────────────────────────
-
-def extract_text_pdf(path: Path) -> str:
- if not HAS_PDF:
- return ""
- try:
- with pdfplumber.open(path) as pdf:
- return "\n".join(page.extract_text() or "" for page in pdf.pages)
- except Exception:
- return ""
-
-def extract_text_docx(path: Path) -> str:
- if not HAS_DOCX:
- return ""
- try:
- doc = Document(path)
- return "\n".join(p.text for p in doc.paragraphs)
- except Exception:
- return ""
-
-def extract_text_xlsx(path: Path) -> str:
- if not HAS_XLSX:
- return ""
- try:
- wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
- parts = []
- for ws in wb.worksheets:
- for row in ws.iter_rows(values_only=True):
- parts.append(" ".join(str(c) for c in row if c is not None))
- return "\n".join(parts)
- except Exception:
- return ""
-
-def extract_text_odt(path: Path) -> str:
- if not HAS_ODT:
- return ""
- try:
- doc = odf_load(path)
- return teletype.extractText(doc.text)
- except Exception:
- return ""
-
-def extract_text_plain(path: Path) -> str:
- for enc in ("utf-8", "utf-16", "latin-1"):
- try:
- return path.read_text(encoding=enc)
- except Exception:
- continue
- return ""
-
-def extract_text(path: Path) -> str:
- ext = path.suffix.lower()
- if ext == ".pdf":
- return extract_text_pdf(path)
- elif ext in (".docx", ".doc"):
- return extract_text_docx(path)
- elif ext in (".xlsx", ".xls"):
- return extract_text_xlsx(path)
- elif ext == ".odt":
- return extract_text_odt(path)
- elif ext in (".txt", ".md"):
- return extract_text_plain(path)
- return ""
-
-# ── Classification ────────────────────────────────────────────────────────────
-
-def classify(text: str, filename: str, categories: list, fallback: str) -> tuple:
- """
- Returns (destination_folder, match_reason).
- Content keywords checked first, then filename keywords.
- Categories are evaluated in order — first match wins.
- """
- text_low = text.lower()
- name_low = filename.lower()
-
- for cat in categories:
- for kw in cat.get("content_keywords", []):
- if str(kw).lower() in text_low:
- return cat["name"], f"content: '{kw}'"
-
- for cat in categories:
- for kw in cat.get("filename_keywords", []):
- if str(kw).lower() in name_low:
- return cat["name"], f"filename: '{kw}'"
-
- return fallback, "no match"
-
-# ── Core ──────────────────────────────────────────────────────────────────────
-
-def collect_files(downloads: Path, extensions: set) -> list:
- files = []
- for entry in downloads.iterdir():
- if entry.is_file() and entry.suffix.lower() in extensions:
- files.append(entry)
- return sorted(files)
-
-def plan_moves(files: list, downloads: Path, config: dict) -> list:
- categories = config["categories"]
- fallback = config.get("fallback_folder", "Other")
- moves = []
- for f in files:
- print(f" Scanning: {f.name} ...", end=" ", flush=True)
- text = extract_text(f)
- dest_folder, reason = classify(text, f.name, categories, fallback)
- dest_path = downloads / dest_folder / f.name
- moves.append((f, dest_path, reason))
- print(f"-> {dest_folder}/ ({reason})")
- return moves
-
-def print_plan(moves: list, downloads: Path):
- print("\n" + "=" * 70)
- print(" DRY-RUN PREVIEW")
- print("=" * 70)
- col_w = max(len(src.name) for src, _, _ in moves) + 2
- for src, dst, reason in moves:
- rel = dst.relative_to(downloads)
- print(f" {src.name:<{col_w}} -> {rel} [{reason}]")
- print("=" * 70)
-
-def execute_moves(moves: list, downloads: Path) -> list:
- errors = []
- for src, dst, _ in moves:
- try:
- dst.parent.mkdir(parents=True, exist_ok=True)
- final_dst = dst
- counter = 1
- while final_dst.exists():
- final_dst = dst.with_stem(f"{dst.stem}_{counter}")
- counter += 1
- shutil.move(str(src), str(final_dst))
- print(f" OK {src.name} -> {final_dst.relative_to(downloads)}")
- except Exception as e:
- errors.append((src, e))
- print(f" ERR {src.name} -> {e}")
- return errors
-
-def check_deps() -> list:
- missing = []
- if not HAS_PDF:
- missing.append("pdfplumber")
- if not HAS_DOCX:
- missing.append("python-docx")
- if not HAS_XLSX:
- missing.append("openpyxl")
- if not HAS_ODT:
- missing.append("odfpy")
- return missing
-
-# ── Entry point ───────────────────────────────────────────────────────────────
-
-def main():
- if sys.version_info < (3, 11):
- print("Error: Python 3.11 or newer is required (for built-in tomllib).")
- print(f" You have Python {sys.version}")
- sys.exit(1)
-
- parser = argparse.ArgumentParser(
- description="Sort ~/downloads using rules defined in rules.toml"
- )
- parser.add_argument(
- "--dir",
- default=str(Path.home() / "downloads"),
- help="Path to Downloads folder (default: ~/Downloads)",
- )
- parser.add_argument(
- "--config",
- default=None,
- help="Path to rules.toml (default: same folder as this script)",
- )
- parser.add_argument(
- "--yes", "-y",
- action="store_true",
- help="Skip confirmation prompt and move immediately",
- )
- parser.add_argument(
- "--list-rules",
- action="store_true",
- help="Print loaded categories and keywords, then exit",
- )
- args = parser.parse_args()
-
- script_dir = Path(__file__).parent
- config_path = Path(args.config) if args.config else script_dir / "rules.toml"
- config = load_config(config_path)
- validate_config(config)
-
- extensions = set(config.get("supported_extensions", [
- ".pdf", ".docx", ".doc", ".txt", ".xlsx", ".xls", ".md", ".odt"
- ".sh", ".py", ".js", ".ts", ".html", ".css", ".json", ".yaml", ".toml"
- ]))
-
- if args.list_rules:
- print(f"\nConfig: {config_path}\n")
- for cat in config["categories"]:
- print(f" [folder] {cat['name']}")
- if cat.get("description"):
- print(f" {cat['description']}")
- if cat.get("content_keywords"):
- kws = ", ".join(str(k) for k in cat["content_keywords"])
- print(f" content : {kws}")
- if cat.get("filename_keywords"):
- kws = ", ".join(str(k) for k in cat["filename_keywords"])
- print(f" filename: {kws}")
- print()
- print(f" [folder] {config.get('fallback_folder', 'Other')} (catch-all)\n")
- return
-
- downloads = Path(args.dir).expanduser().resolve()
- if not downloads.is_dir():
- print(f"Error: '{downloads}' is not a directory.")
- sys.exit(1)
-
- missing = check_deps()
- if missing:
- print("Warning: some content-extraction libraries are missing.")
- print(" Install them for full content scanning:")
- for m in missing:
- print(f" pip install {m}")
- print()
-
- print(f"Scanning: {downloads}")
- print(f"Rules: {config_path}\n")
-
- files = collect_files(downloads, extensions)
- if not files:
- print("No supported files found.")
- sys.exit(0)
-
- print(f"Found {len(files)} file(s). Classifying...\n")
- moves = plan_moves(files, downloads, config)
-
- print_plan(moves, downloads)
-
- if not args.yes:
- answer = input("\nProceed with moving files? [y/N] ").strip().lower()
- if answer != "y":
- print("Aborted. No files were moved.")
- sys.exit(0)
-
- print("\nMoving files...\n")
- errors = execute_moves(moves, downloads)
-
- print()
- if errors:
- print(f"Done with {len(errors)} error(s).")
- else:
- print("All files moved successfully.")
-
-if __name__ == "__main__":
- main()
diff --git a/sorter/sort_downloads.sh b/sorter/sort_downloads.sh
deleted file mode 100755
index d2023a8..0000000
--- a/sorter/sort_downloads.sh
+++ /dev/null
@@ -1,60 +0,0 @@
-#!/usr/bin/env bash
-# sort_downloads.sh — wrapper for sort_downloads.py
-# Installs missing Python deps, then runs the sorter.
-
-set -euo pipefail
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-PYTHON_SCRIPT="$SCRIPT_DIR/sort_downloads.py"
-
-# ── Colour helpers ────────────────────────────────────────────────────────────
-GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
-info() { echo -e "${GREEN}[sort]${NC} $*"; }
-warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
-error() { echo -e "${RED}[error]${NC} $*" >&2; }
-
-# ── Check Python 3.11+ ────────────────────────────────────────────────────────
-if ! command -v python3 &>/dev/null; then
- error "python3 not found. Install it with: sudo apt install python3"
- exit 1
-fi
-
-PYVER=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
-PYMAJ=$(python3 -c "import sys; print(sys.version_info.major)")
-PYMIN=$(python3 -c "import sys; print(sys.version_info.minor)")
-
-if [[ "$PYMAJ" -lt 3 ]] || [[ "$PYMAJ" -eq 3 && "$PYMIN" -lt 11 ]]; then
- error "Python 3.11+ required for built-in tomllib. You have Python $PYVER."
- exit 1
-fi
-
-info "Using Python $(python3 --version) — tomllib built-in, no extra config deps needed."
-
-# ── Install pip if missing ────────────────────────────────────────────────────
-if ! python3 -m pip --version &>/dev/null; then
- warn "pip not found — installing..."
- sudo apt-get install -y python3-pip
-fi
-
-# ── Install content-extraction libraries ─────────────────────────────────────
-# Note: pyyaml is no longer needed — TOML is built into Python 3.11+
-declare -A DEPS=(
- [pdfplumber]="pdfplumber"
- [docx]="python-docx"
- [openpyxl]="openpyxl"
- [odf]="odfpy"
-)
-
-for import_name in "${!DEPS[@]}"; do
- pip_name="${DEPS[$import_name]}"
- if ! python3 -c "import $import_name" &>/dev/null 2>&1; then
- info "Installing $pip_name..."
- python3 -m pip install --quiet "$pip_name" --break-system-packages
- fi
-done
-
-info "All dependencies ready."
-echo ""
-
-# ── Run the sorter ────────────────────────────────────────────────────────────
-exec python3 "$PYTHON_SCRIPT" "$@"