refactor-cli 0.1.2__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/.gitignore +4 -0
- refactor_cli-0.2.0/PKG-INFO +107 -0
- refactor_cli-0.2.0/README.md +95 -0
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/pyproject.toml +1 -1
- refactor_cli-0.2.0/refactor/cli.py +291 -0
- refactor_cli-0.2.0/refactor/config.py +77 -0
- refactor_cli-0.2.0/refactor/context_store.py +48 -0
- refactor_cli-0.2.0/refactor/gemini.py +103 -0
- refactor_cli-0.2.0/refactor/hardware.py +86 -0
- refactor_cli-0.2.0/refactor/image_scanner.py +39 -0
- refactor_cli-0.2.0/refactor/imagga.py +186 -0
- refactor_cli-0.2.0/refactor/organizer.py +111 -0
- refactor_cli-0.2.0/refactor/setup.py +175 -0
- refactor_cli-0.2.0/refactor/shim.py +73 -0
- refactor_cli-0.2.0/refactor_cli.egg-info/PKG-INFO +107 -0
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor_cli.egg-info/SOURCES.txt +11 -0
- refactor_cli-0.2.0/refactor_cli.egg-info/requires.txt +3 -0
- refactor_cli-0.2.0/refactor_cli.egg-info/scm_file_list.json +26 -0
- refactor_cli-0.2.0/refactor_cli.egg-info/scm_version.json +8 -0
- refactor_cli-0.1.2/PKG-INFO +0 -11
- refactor_cli-0.1.2/README.md +0 -1
- refactor_cli-0.1.2/refactor/cli.py +0 -56
- refactor_cli-0.1.2/refactor_cli.egg-info/PKG-INFO +0 -11
- refactor_cli-0.1.2/refactor_cli.egg-info/requires.txt +0 -1
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/.github/workflows/publish.yml +0 -0
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/.refactorignore +0 -0
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/LICENSE +0 -0
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor/__init__.py +0 -0
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor/ignore.py +0 -0
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor/metadata.py +0 -0
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor/scanner.py +0 -0
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor/tree.py +0 -0
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor/validation.py +0 -0
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor_cli.egg-info/dependency_links.txt +0 -0
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor_cli.egg-info/entry_points.txt +0 -0
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/refactor_cli.egg-info/top_level.txt +0 -0
- {refactor_cli-0.1.2 → refactor_cli-0.2.0}/setup.cfg +0 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: refactor-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: CLI tool to organize files using AI
|
|
5
|
+
Requires-Python: >=3.8
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: typer
|
|
9
|
+
Requires-Dist: requests
|
|
10
|
+
Requires-Dist: python-dotenv
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# refactor.ai
|
|
14
|
+
|
|
15
|
+
`refactor.ai` is a Python CLI for inspecting local directories before automating file organization. The package is published as [`refactor-cli`](https://pypi.org/project/refactor-cli/) and targets Python 3.8+.
|
|
16
|
+
|
|
17
|
+
## Current scope
|
|
18
|
+
|
|
19
|
+
The CLI entry point in [refactor/cli.py](refactor/cli.py) provides:
|
|
20
|
+
|
|
21
|
+
- `version` — prints the installed package version.
|
|
22
|
+
- `tree` — prints a recursive directory tree through [refactor/tree.py](refactor/tree.py).
|
|
23
|
+
- `scan` — collects basic file and directory metadata through [refactor/scanner.py](refactor/scanner.py) and [refactor/metadata.py](refactor/metadata.py).
|
|
24
|
+
- `setup` — configures a multimodal backend (`hosted` or `local`), persisted to `~/.refactor/config.json`.
|
|
25
|
+
- `get-image-context` — obtains Imagga V3 tags/captions, asks Gemini to choose a
|
|
26
|
+
destination category, and writes JSON context.
|
|
27
|
+
- `refactor-images` — automatically refreshes image context, then moves images
|
|
28
|
+
into Gemini-selected existing or new category folders (`--copy` is available
|
|
29
|
+
when the originals must be retained).
|
|
30
|
+
- Path checks through [refactor/validation.py](refactor/validation.py), including existence, directory type, and read access.
|
|
31
|
+
- Optional filename exclusions through [refactor/ignore.py](refactor/ignore.py).
|
|
32
|
+
|
|
33
|
+
On first CLI run, users are prompted to run setup. They can rerun setup anytime with `refactor setup` or edit `~/.refactor/config.json` directly.
|
|
34
|
+
|
|
35
|
+
## Setup workflow
|
|
36
|
+
|
|
37
|
+
After installing `refactor-cli`, the first CLI run prompts:
|
|
38
|
+
|
|
39
|
+
1. **Hosted model (bring your own API keys)**
|
|
40
|
+
- Prompts for `IMAGGA_API_KEY`, `IMAGGA_API_SECRET`, and `GEMINI_API_KEY`
|
|
41
|
+
- Saves credentials to `~/.refactor/.env` with restricted file permissions
|
|
42
|
+
2. **Local multimodal recommendation (stub)**
|
|
43
|
+
- Detects basic hardware (CPU, memory, NVIDIA GPU/VRAM if available)
|
|
44
|
+
- Recommends an OSS multimodal model and records it in config
|
|
45
|
+
- Uses a local shim stub today (full local runtime integration is a follow-up)
|
|
46
|
+
|
|
47
|
+
For the hosted workflow, local images are uploaded to Imagga and classified with
|
|
48
|
+
the V3 `/tags` endpoint using `model=light` and `include_caption=true`. The
|
|
49
|
+
resulting tags/caption and the destination root's current folders are sent to
|
|
50
|
+
Gemini. Gemini selects one safe category name per image, reusing a suitable
|
|
51
|
+
folder where possible and naming a new category otherwise. The organizer creates
|
|
52
|
+
that directory as needed. The shim layer provides one interface for image
|
|
53
|
+
classification, regardless of hosted/local backend, so future modalities can
|
|
54
|
+
reuse the same abstraction.
|
|
55
|
+
|
|
56
|
+
Run `refactor refactor-images ./incoming --output-dir ./organized` to generate
|
|
57
|
+
fresh context and move the images. Add `--dry-run` to inspect destinations or
|
|
58
|
+
`--copy` to retain the originals.
|
|
59
|
+
|
|
60
|
+
## Techniques used
|
|
61
|
+
|
|
62
|
+
- **Recursive traversal with `pathlib`** — [refactor/scanner.py](refactor/scanner.py) uses [`Path.rglob()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.rglob) to discover nested paths. [refactor/tree.py](refactor/tree.py) recursively walks directory entries to render a text tree.
|
|
63
|
+
|
|
64
|
+
- **Filesystem metadata from stat data** — [refactor/metadata.py](refactor/metadata.py) reads [`Path.stat()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.stat) and combines it with path properties such as `name`, `suffix`, and `is_dir()`.
|
|
65
|
+
|
|
66
|
+
- **Glob-based ignore matching** — [refactor/ignore.py](refactor/ignore.py) loads patterns from an optional `.refactorignore` file and evaluates them with Python’s [`fnmatch`](https://docs.python.org/3/library/fnmatch.html). This keeps ignore rules lightweight while supporting familiar wildcard patterns.
|
|
67
|
+
|
|
68
|
+
- **Fail-fast input validation** — [refactor/validation.py](refactor/validation.py) validates paths before traversal and checks read permissions with [`os.access()`](https://docs.python.org/3/library/os.html#os.access). CLI commands convert validation failures into clear non-zero exits.
|
|
69
|
+
|
|
70
|
+
- **Declarative command registration** — [refactor/cli.py](refactor/cli.py) uses decorators to register commands on a Typer application. The package exports the `refactor` command through the console-script entry point in [pyproject.toml](pyproject.toml).
|
|
71
|
+
|
|
72
|
+
- **Versioning from Git tags** — [pyproject.toml](pyproject.toml) delegates package version discovery to [`setuptools-scm`](https://setuptools-scm.readthedocs.io/). Release versions can come from repository tags instead of being duplicated in source files.
|
|
73
|
+
|
|
74
|
+
## Technologies and libraries
|
|
75
|
+
|
|
76
|
+
- [Typer](https://typer.tiangolo.com/) — CLI framework built around Python type hints. It provides command dispatch, help output, and exit handling.
|
|
77
|
+
- [setuptools](https://setuptools.pypa.io/) — package build backend.
|
|
78
|
+
- [setuptools-scm](https://setuptools-scm.readthedocs.io/) — derives the distribution version from source-control metadata.
|
|
79
|
+
- [pathlib](https://docs.python.org/3/library/pathlib.html) — standard-library object model for portable path operations.
|
|
80
|
+
- [importlib.metadata](https://docs.python.org/3/library/importlib.metadata.html) — reads the installed package version at runtime.
|
|
81
|
+
|
|
82
|
+
The project currently has no web UI, image assets, or bundled fonts.
|
|
83
|
+
|
|
84
|
+
## Project structure
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
.
|
|
88
|
+
├── LICENSE
|
|
89
|
+
├── README.md
|
|
90
|
+
├── pyproject.toml
|
|
91
|
+
└── refactor/
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- [refactor/](refactor/) contains the CLI and filesystem logic.
|
|
95
|
+
- [pyproject.toml](pyproject.toml) defines packaging metadata, the `refactor` command, supported Python version, and build configuration.
|
|
96
|
+
- [LICENSE](LICENSE) contains the Apache License 2.0 terms.
|
|
97
|
+
|
|
98
|
+
## Next steps
|
|
99
|
+
|
|
100
|
+
The package metadata describes the broader goal as AI-assisted file organization. The next development work can build on the current inspection layer:
|
|
101
|
+
|
|
102
|
+
- Add an AI integration that proposes organization decisions from scanned metadata.
|
|
103
|
+
- Add a review step so users can inspect proposed changes before any write operation.
|
|
104
|
+
- Implement explicit rename and move operations with dry-run support.
|
|
105
|
+
- Expand ignore handling beyond filename-only matching where needed.
|
|
106
|
+
- Add automated tests for traversal, ignore patterns, validation, and planned write operations.
|
|
107
|
+
- Provide structured scan output for scripts and other tooling.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# refactor.ai
|
|
2
|
+
|
|
3
|
+
`refactor.ai` is a Python CLI for inspecting local directories before automating file organization. The package is published as [`refactor-cli`](https://pypi.org/project/refactor-cli/) and targets Python 3.8+.
|
|
4
|
+
|
|
5
|
+
## Current scope
|
|
6
|
+
|
|
7
|
+
The CLI entry point in [refactor/cli.py](refactor/cli.py) provides:
|
|
8
|
+
|
|
9
|
+
- `version` — prints the installed package version.
|
|
10
|
+
- `tree` — prints a recursive directory tree through [refactor/tree.py](refactor/tree.py).
|
|
11
|
+
- `scan` — collects basic file and directory metadata through [refactor/scanner.py](refactor/scanner.py) and [refactor/metadata.py](refactor/metadata.py).
|
|
12
|
+
- `setup` — configures a multimodal backend (`hosted` or `local`), persisted to `~/.refactor/config.json`.
|
|
13
|
+
- `get-image-context` — obtains Imagga V3 tags/captions, asks Gemini to choose a
|
|
14
|
+
destination category, and writes JSON context.
|
|
15
|
+
- `refactor-images` — automatically refreshes image context, then moves images
|
|
16
|
+
into Gemini-selected existing or new category folders (`--copy` is available
|
|
17
|
+
when the originals must be retained).
|
|
18
|
+
- Path checks through [refactor/validation.py](refactor/validation.py), including existence, directory type, and read access.
|
|
19
|
+
- Optional filename exclusions through [refactor/ignore.py](refactor/ignore.py).
|
|
20
|
+
|
|
21
|
+
On first CLI run, users are prompted to run setup. They can rerun setup anytime with `refactor setup` or edit `~/.refactor/config.json` directly.
|
|
22
|
+
|
|
23
|
+
## Setup workflow
|
|
24
|
+
|
|
25
|
+
After installing `refactor-cli`, the first CLI run prompts:
|
|
26
|
+
|
|
27
|
+
1. **Hosted model (bring your own API keys)**
|
|
28
|
+
- Prompts for `IMAGGA_API_KEY`, `IMAGGA_API_SECRET`, and `GEMINI_API_KEY`
|
|
29
|
+
- Saves credentials to `~/.refactor/.env` with restricted file permissions
|
|
30
|
+
2. **Local multimodal recommendation (stub)**
|
|
31
|
+
- Detects basic hardware (CPU, memory, NVIDIA GPU/VRAM if available)
|
|
32
|
+
- Recommends an OSS multimodal model and records it in config
|
|
33
|
+
- Uses a local shim stub today (full local runtime integration is a follow-up)
|
|
34
|
+
|
|
35
|
+
For the hosted workflow, local images are uploaded to Imagga and classified with
|
|
36
|
+
the V3 `/tags` endpoint using `model=light` and `include_caption=true`. The
|
|
37
|
+
resulting tags/caption and the destination root's current folders are sent to
|
|
38
|
+
Gemini. Gemini selects one safe category name per image, reusing a suitable
|
|
39
|
+
folder where possible and naming a new category otherwise. The organizer creates
|
|
40
|
+
that directory as needed. The shim layer provides one interface for image
|
|
41
|
+
classification, regardless of hosted/local backend, so future modalities can
|
|
42
|
+
reuse the same abstraction.
|
|
43
|
+
|
|
44
|
+
Run `refactor refactor-images ./incoming --output-dir ./organized` to generate
|
|
45
|
+
fresh context and move the images. Add `--dry-run` to inspect destinations or
|
|
46
|
+
`--copy` to retain the originals.
|
|
47
|
+
|
|
48
|
+
## Techniques used
|
|
49
|
+
|
|
50
|
+
- **Recursive traversal with `pathlib`** — [refactor/scanner.py](refactor/scanner.py) uses [`Path.rglob()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.rglob) to discover nested paths. [refactor/tree.py](refactor/tree.py) recursively walks directory entries to render a text tree.
|
|
51
|
+
|
|
52
|
+
- **Filesystem metadata from stat data** — [refactor/metadata.py](refactor/metadata.py) reads [`Path.stat()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.stat) and combines it with path properties such as `name`, `suffix`, and `is_dir()`.
|
|
53
|
+
|
|
54
|
+
- **Glob-based ignore matching** — [refactor/ignore.py](refactor/ignore.py) loads patterns from an optional `.refactorignore` file and evaluates them with Python’s [`fnmatch`](https://docs.python.org/3/library/fnmatch.html). This keeps ignore rules lightweight while supporting familiar wildcard patterns.
|
|
55
|
+
|
|
56
|
+
- **Fail-fast input validation** — [refactor/validation.py](refactor/validation.py) validates paths before traversal and checks read permissions with [`os.access()`](https://docs.python.org/3/library/os.html#os.access). CLI commands convert validation failures into clear non-zero exits.
|
|
57
|
+
|
|
58
|
+
- **Declarative command registration** — [refactor/cli.py](refactor/cli.py) uses decorators to register commands on a Typer application. The package exports the `refactor` command through the console-script entry point in [pyproject.toml](pyproject.toml).
|
|
59
|
+
|
|
60
|
+
- **Versioning from Git tags** — [pyproject.toml](pyproject.toml) delegates package version discovery to [`setuptools-scm`](https://setuptools-scm.readthedocs.io/). Release versions can come from repository tags instead of being duplicated in source files.
|
|
61
|
+
|
|
62
|
+
## Technologies and libraries
|
|
63
|
+
|
|
64
|
+
- [Typer](https://typer.tiangolo.com/) — CLI framework built around Python type hints. It provides command dispatch, help output, and exit handling.
|
|
65
|
+
- [setuptools](https://setuptools.pypa.io/) — package build backend.
|
|
66
|
+
- [setuptools-scm](https://setuptools-scm.readthedocs.io/) — derives the distribution version from source-control metadata.
|
|
67
|
+
- [pathlib](https://docs.python.org/3/library/pathlib.html) — standard-library object model for portable path operations.
|
|
68
|
+
- [importlib.metadata](https://docs.python.org/3/library/importlib.metadata.html) — reads the installed package version at runtime.
|
|
69
|
+
|
|
70
|
+
The project currently has no web UI, image assets, or bundled fonts.
|
|
71
|
+
|
|
72
|
+
## Project structure
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
.
|
|
76
|
+
├── LICENSE
|
|
77
|
+
├── README.md
|
|
78
|
+
├── pyproject.toml
|
|
79
|
+
└── refactor/
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- [refactor/](refactor/) contains the CLI and filesystem logic.
|
|
83
|
+
- [pyproject.toml](pyproject.toml) defines packaging metadata, the `refactor` command, supported Python version, and build configuration.
|
|
84
|
+
- [LICENSE](LICENSE) contains the Apache License 2.0 terms.
|
|
85
|
+
|
|
86
|
+
## Next steps
|
|
87
|
+
|
|
88
|
+
The package metadata describes the broader goal as AI-assisted file organization. The next development work can build on the current inspection layer:
|
|
89
|
+
|
|
90
|
+
- Add an AI integration that proposes organization decisions from scanned metadata.
|
|
91
|
+
- Add a review step so users can inspect proposed changes before any write operation.
|
|
92
|
+
- Implement explicit rename and move operations with dry-run support.
|
|
93
|
+
- Expand ignore handling beyond filename-only matching where needed.
|
|
94
|
+
- Add automated tests for traversal, ignore patterns, validation, and planned write operations.
|
|
95
|
+
- Provide structured scan output for scripts and other tooling.
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from importlib.metadata import version as get_version
|
|
3
|
+
|
|
4
|
+
from refactor.context_store import DEFAULT_CACHE_FILE, save_context
|
|
5
|
+
from refactor.ignore import load_ignore_patterns
|
|
6
|
+
from refactor.image_scanner import scan_images
|
|
7
|
+
from refactor.metadata import get_metadata
|
|
8
|
+
from refactor.organizer import (
|
|
9
|
+
execute_moves,
|
|
10
|
+
managed_category_dirs,
|
|
11
|
+
plan_moves,
|
|
12
|
+
save_managed_category_dirs,
|
|
13
|
+
)
|
|
14
|
+
from refactor.scanner import scan_directory
|
|
15
|
+
from refactor.setup import command_setup, ensure_backend_configured, maybe_run_first_time_setup
|
|
16
|
+
from refactor.shim import build_image_classifier, choose_category_folders
|
|
17
|
+
from refactor.tree import print_tree
|
|
18
|
+
from refactor.validation import validate_path
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(no_args_is_help=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.callback()
|
|
24
|
+
def bootstrap() -> None:
|
|
25
|
+
maybe_run_first_time_setup()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command()
|
|
29
|
+
def version() -> None:
|
|
30
|
+
"""Print version information"""
|
|
31
|
+
print(get_version("refactor-cli"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def setup() -> None:
|
|
36
|
+
"""Configure hosted or local multimodal backend."""
|
|
37
|
+
command_setup()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def tree(path: str = ".") -> None:
|
|
42
|
+
"""Display folder structure of given path"""
|
|
43
|
+
try:
|
|
44
|
+
validate_path(path)
|
|
45
|
+
print_tree(path)
|
|
46
|
+
except ValueError as e:
|
|
47
|
+
typer.echo(f"Error: {e}")
|
|
48
|
+
raise typer.Exit(code=1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def scan(path: str = ".") -> None:
|
|
53
|
+
"""Scan directory and display metadata"""
|
|
54
|
+
try:
|
|
55
|
+
validate_path(path)
|
|
56
|
+
patterns = load_ignore_patterns()
|
|
57
|
+
items = scan_directory(path, patterns)
|
|
58
|
+
|
|
59
|
+
for item in items:
|
|
60
|
+
print(get_metadata(item))
|
|
61
|
+
|
|
62
|
+
except ValueError as e:
|
|
63
|
+
typer.echo(f"Error: {e}")
|
|
64
|
+
raise typer.Exit(code=1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command(name="get-image-context")
|
|
68
|
+
def get_image_context(
|
|
69
|
+
path: str = typer.Argument(".", help="Directory to scan for images"),
|
|
70
|
+
output: str = typer.Option(
|
|
71
|
+
".refactor_image_context.json",
|
|
72
|
+
"--output",
|
|
73
|
+
"-o",
|
|
74
|
+
help="Path to write the JSON context cache",
|
|
75
|
+
),
|
|
76
|
+
min_confidence: float = typer.Option(
|
|
77
|
+
40.0,
|
|
78
|
+
"--min-confidence",
|
|
79
|
+
help="Minimum tag confidence to include (hosted backend only)",
|
|
80
|
+
),
|
|
81
|
+
verbose: bool = typer.Option(
|
|
82
|
+
False,
|
|
83
|
+
"--verbose",
|
|
84
|
+
"-v",
|
|
85
|
+
help="Print per-image tag results to stdout",
|
|
86
|
+
),
|
|
87
|
+
category_root: str = typer.Option(
|
|
88
|
+
".",
|
|
89
|
+
"--category-root",
|
|
90
|
+
help="Directory whose existing folders Gemini may reuse",
|
|
91
|
+
),
|
|
92
|
+
write_context: bool = typer.Option(
|
|
93
|
+
True,
|
|
94
|
+
"--write-context/--no-write-context",
|
|
95
|
+
hidden=True,
|
|
96
|
+
),
|
|
97
|
+
) -> list:
|
|
98
|
+
"""
|
|
99
|
+
Scan directory images using configured backend shim and save
|
|
100
|
+
tags/captions to JSON context cache.
|
|
101
|
+
"""
|
|
102
|
+
from pathlib import Path
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
validate_path(path)
|
|
106
|
+
if not Path(category_root).exists() or not Path(category_root).is_dir():
|
|
107
|
+
raise ValueError(f"Category root is not a directory: {category_root}")
|
|
108
|
+
config = ensure_backend_configured()
|
|
109
|
+
except (ValueError, EnvironmentError) as e:
|
|
110
|
+
typer.echo(f"Error: {e}", err=True)
|
|
111
|
+
raise typer.Exit(code=1)
|
|
112
|
+
|
|
113
|
+
patterns = load_ignore_patterns()
|
|
114
|
+
source_root = Path(path).resolve()
|
|
115
|
+
destination_root = Path(category_root).resolve()
|
|
116
|
+
excluded_dirs = (
|
|
117
|
+
managed_category_dirs(destination_root)
|
|
118
|
+
if source_root == destination_root else set()
|
|
119
|
+
)
|
|
120
|
+
images = scan_images(path, patterns, excluded_dirs)
|
|
121
|
+
|
|
122
|
+
if not images:
|
|
123
|
+
typer.echo("No image files found in the specified directory.")
|
|
124
|
+
raise typer.Exit(code=0)
|
|
125
|
+
|
|
126
|
+
typer.echo(f"Found {len(images)} image(s) in '{path}'")
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
classifier = build_image_classifier(config, min_confidence=min_confidence)
|
|
130
|
+
except EnvironmentError as e:
|
|
131
|
+
typer.echo(f"Error: {e}", err=True)
|
|
132
|
+
raise typer.Exit(code=1)
|
|
133
|
+
|
|
134
|
+
results = []
|
|
135
|
+
failed = 0
|
|
136
|
+
|
|
137
|
+
with typer.progressbar(images, label="Analysing images") as progress:
|
|
138
|
+
for img_path in progress:
|
|
139
|
+
try:
|
|
140
|
+
ctx = classifier.classify_image(img_path)
|
|
141
|
+
results.append(ctx)
|
|
142
|
+
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
typer.echo(f"\n [!] Skipping {img_path.name}: {exc}", err=True)
|
|
145
|
+
failed += 1
|
|
146
|
+
|
|
147
|
+
if results:
|
|
148
|
+
try:
|
|
149
|
+
results = choose_category_folders(config, results, Path(category_root))
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
# Folder selection is an essential part of the hosted workflow; do
|
|
152
|
+
# not silently fall back to an Imagga top-tag directory.
|
|
153
|
+
typer.echo(f"Error: Gemini folder selection failed: {exc}", err=True)
|
|
154
|
+
raise typer.Exit(code=1)
|
|
155
|
+
|
|
156
|
+
if verbose:
|
|
157
|
+
for ctx in results:
|
|
158
|
+
typer.echo(f"\n {Path(ctx.file_path).name}")
|
|
159
|
+
typer.echo(f" Folder : {ctx.primary_folder}")
|
|
160
|
+
typer.echo(f" Tags : {', '.join(ctx.tags[:5])}")
|
|
161
|
+
typer.echo(f" Caption : {ctx.caption}")
|
|
162
|
+
|
|
163
|
+
output_path = Path(output)
|
|
164
|
+
if write_context:
|
|
165
|
+
save_context(results, path, output_path)
|
|
166
|
+
|
|
167
|
+
typer.echo(f"\n{'─' * 52}")
|
|
168
|
+
typer.echo(f" Processed : {len(results)} image(s)")
|
|
169
|
+
if failed:
|
|
170
|
+
typer.echo(f" Failed : {failed} image(s)")
|
|
171
|
+
if write_context:
|
|
172
|
+
typer.echo(f" Cache : {output_path.resolve()}")
|
|
173
|
+
typer.echo(f"{'─' * 52}")
|
|
174
|
+
|
|
175
|
+
if results:
|
|
176
|
+
typer.echo(f"\n{'Filename':<30} {'Folder':<20} {'Top tags'}")
|
|
177
|
+
typer.echo("─" * 80)
|
|
178
|
+
for ctx in results:
|
|
179
|
+
fname = Path(ctx.file_path).name
|
|
180
|
+
top_tags = ", ".join(ctx.tags[:3])
|
|
181
|
+
typer.echo(f" {fname:<28} {ctx.primary_folder:<20} {top_tags}")
|
|
182
|
+
|
|
183
|
+
return results
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.command(name="refactor-images")
|
|
187
|
+
def refactor_images(
|
|
188
|
+
path: str = typer.Argument(".", help="Directory containing images to organize"),
|
|
189
|
+
context: str = typer.Option(
|
|
190
|
+
".refactor_image_context.json",
|
|
191
|
+
"--context",
|
|
192
|
+
"-c",
|
|
193
|
+
help="Path to context cache (defaults to the source directory)",
|
|
194
|
+
),
|
|
195
|
+
output_dir: str = typer.Option(
|
|
196
|
+
".",
|
|
197
|
+
"--output-dir",
|
|
198
|
+
"-d",
|
|
199
|
+
help="Root directory where tag-named folders will be created",
|
|
200
|
+
),
|
|
201
|
+
dry_run: bool = typer.Option(
|
|
202
|
+
False,
|
|
203
|
+
"--dry-run",
|
|
204
|
+
help="Print the planned moves without executing them",
|
|
205
|
+
),
|
|
206
|
+
move: bool = typer.Option(
|
|
207
|
+
True,
|
|
208
|
+
"--move/--copy",
|
|
209
|
+
help="Move files into categories. Use --copy to preserve source files.",
|
|
210
|
+
),
|
|
211
|
+
min_confidence: float = typer.Option(
|
|
212
|
+
40.0,
|
|
213
|
+
"--min-confidence",
|
|
214
|
+
help="Minimum Imagga tag confidence to retain",
|
|
215
|
+
),
|
|
216
|
+
) -> None:
|
|
217
|
+
"""
|
|
218
|
+
Generate fresh Imagga and Gemini context, then organise images into
|
|
219
|
+
Gemini-selected subdirectories. The context cache is refreshed on every run.
|
|
220
|
+
|
|
221
|
+
By default, images are MOVED. Use --copy to preserve source files.
|
|
222
|
+
"""
|
|
223
|
+
from pathlib import Path
|
|
224
|
+
try:
|
|
225
|
+
validate_path(path)
|
|
226
|
+
out = Path(output_dir)
|
|
227
|
+
if not out.exists() or not out.is_dir():
|
|
228
|
+
raise ValueError(f"Output directory is not a directory: {output_dir}")
|
|
229
|
+
except ValueError as e:
|
|
230
|
+
typer.echo(f"Error: {e}", err=True)
|
|
231
|
+
raise typer.Exit(code=1)
|
|
232
|
+
|
|
233
|
+
context_path = Path(context)
|
|
234
|
+
if context == DEFAULT_CACHE_FILE:
|
|
235
|
+
context_path = Path(path) / DEFAULT_CACHE_FILE
|
|
236
|
+
|
|
237
|
+
excluded = managed_category_dirs(out) if Path(path).resolve() == out.resolve() else set()
|
|
238
|
+
if excluded:
|
|
239
|
+
typer.echo(f"Skipping {len(excluded)} folder(s) created by an earlier Refactor run.")
|
|
240
|
+
|
|
241
|
+
typer.echo("Generating image context with Imagga and Gemini...")
|
|
242
|
+
contexts = get_image_context(
|
|
243
|
+
path=path,
|
|
244
|
+
output=str(context_path),
|
|
245
|
+
min_confidence=min_confidence,
|
|
246
|
+
verbose=False,
|
|
247
|
+
category_root=output_dir,
|
|
248
|
+
write_context=not dry_run,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if not contexts:
|
|
252
|
+
typer.echo("Context cache is empty. Nothing to organize.")
|
|
253
|
+
raise typer.Exit(code=0)
|
|
254
|
+
|
|
255
|
+
moves = plan_moves(contexts, out)
|
|
256
|
+
|
|
257
|
+
action_word = "Move" if move else "Copy"
|
|
258
|
+
|
|
259
|
+
if dry_run:
|
|
260
|
+
verb_plural = "moves" if move else "copies"
|
|
261
|
+
verb_past = "moved" if move else "copied"
|
|
262
|
+
typer.echo(f"\n[Dry Run] Planned {verb_plural} ({len(moves)} files):\n")
|
|
263
|
+
typer.echo(f" {'Source':<40} {'Destination'}")
|
|
264
|
+
typer.echo(" " + "─" * 76)
|
|
265
|
+
for src, dst in moves:
|
|
266
|
+
typer.echo(f" {src.name:<40} {dst}")
|
|
267
|
+
typer.echo(f"\n No files were {verb_past}.")
|
|
268
|
+
raise typer.Exit(code=0)
|
|
269
|
+
|
|
270
|
+
folders = {dst.parent.name for _, dst in moves}
|
|
271
|
+
typer.echo(f"\nWill {action_word.lower()} {len(moves)} image(s) into {len(folders)} folder(s):")
|
|
272
|
+
for folder in sorted(folders):
|
|
273
|
+
count = sum(1 for _, dst in moves if dst.parent.name == folder)
|
|
274
|
+
typer.echo(f" {out / folder} ({count} file(s))")
|
|
275
|
+
|
|
276
|
+
summary = execute_moves(moves, copy=not move)
|
|
277
|
+
if not summary["failed"]:
|
|
278
|
+
save_managed_category_dirs(out, folders)
|
|
279
|
+
|
|
280
|
+
typer.echo(f"\n{'─' * 52}")
|
|
281
|
+
count = summary["moved"] if move else summary["copied"]
|
|
282
|
+
verb_past = "moved" if move else "copied"
|
|
283
|
+
typer.echo(f" {verb_past.capitalize():<8}: {count} file(s)")
|
|
284
|
+
if summary["failed"]:
|
|
285
|
+
typer.echo(f" Failed : {summary['failed']} file(s)")
|
|
286
|
+
typer.echo(f" Output : {out.resolve()}")
|
|
287
|
+
typer.echo(f"{'─' * 52}")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
app()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
CONFIG_DIR = Path.home() / ".refactor"
|
|
8
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
9
|
+
ENV_FILE = CONFIG_DIR / ".env"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _ensure_config_dir() -> None:
|
|
13
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def default_config() -> dict[str, Any]:
|
|
17
|
+
return {
|
|
18
|
+
"version": 1,
|
|
19
|
+
"backend": "unconfigured",
|
|
20
|
+
"setup_skipped": False,
|
|
21
|
+
"hosted": {
|
|
22
|
+
"provider": "imagga_gemini",
|
|
23
|
+
"imagga_api_key_env": "IMAGGA_API_KEY",
|
|
24
|
+
"imagga_api_secret_env": "IMAGGA_API_SECRET",
|
|
25
|
+
"gemini_api_key_env": "GEMINI_API_KEY",
|
|
26
|
+
},
|
|
27
|
+
"local": {
|
|
28
|
+
"runtime": "ollama",
|
|
29
|
+
"recommended_model": "",
|
|
30
|
+
"status": "stub",
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def config_exists() -> bool:
|
|
36
|
+
return CONFIG_FILE.exists()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_config() -> dict[str, Any]:
|
|
40
|
+
if not CONFIG_FILE.exists():
|
|
41
|
+
return default_config()
|
|
42
|
+
|
|
43
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
44
|
+
payload = json.load(f)
|
|
45
|
+
|
|
46
|
+
merged = default_config()
|
|
47
|
+
merged.update(payload)
|
|
48
|
+
return merged
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def save_config(config: dict[str, Any]) -> None:
|
|
52
|
+
_ensure_config_dir()
|
|
53
|
+
|
|
54
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
55
|
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_env_vars(values: dict[str, str]) -> None:
|
|
59
|
+
_ensure_config_dir()
|
|
60
|
+
|
|
61
|
+
existing: dict[str, str] = {}
|
|
62
|
+
if ENV_FILE.exists():
|
|
63
|
+
for line in ENV_FILE.read_text(encoding="utf-8").splitlines():
|
|
64
|
+
if "=" not in line or line.strip().startswith("#"):
|
|
65
|
+
continue
|
|
66
|
+
key, value = line.split("=", 1)
|
|
67
|
+
existing[key.strip()] = value.strip()
|
|
68
|
+
|
|
69
|
+
existing.update(values)
|
|
70
|
+
|
|
71
|
+
lines = [f"{k}={v}" for k, v in existing.items()]
|
|
72
|
+
ENV_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
ENV_FILE.chmod(0o600)
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
refactor/context_store.py
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Manages reading and writing the JSON context cache produced by get-image-context.
|
|
5
|
+
|
|
6
|
+
Default cache filename: .refactor_image_context.json
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import asdict
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from refactor.imagga import ImageContext
|
|
16
|
+
|
|
17
|
+
DEFAULT_CACHE_FILE = ".refactor_image_context.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def save_context(
|
|
21
|
+
results: list[ImageContext],
|
|
22
|
+
source_dir: str,
|
|
23
|
+
output_path: Path,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Serialise a list of ImageContext objects to a JSON cache file."""
|
|
26
|
+
payload = {
|
|
27
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
28
|
+
"source_dir": str(Path(source_dir).resolve()),
|
|
29
|
+
"images": [asdict(ctx) for ctx in results],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
34
|
+
json.dump(payload, f, indent=2, ensure_ascii=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_context(input_path: Path) -> list[ImageContext]:
|
|
38
|
+
"""Load ImageContext objects from a JSON cache file."""
|
|
39
|
+
if not input_path.exists():
|
|
40
|
+
raise FileNotFoundError(
|
|
41
|
+
f"Context cache not found: {input_path}\n"
|
|
42
|
+
"Run 'refactor get-image-context <path>' first."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
with open(input_path, "r", encoding="utf-8") as f:
|
|
46
|
+
payload = json.load(f)
|
|
47
|
+
|
|
48
|
+
return [ImageContext(**img) for img in payload.get("images", [])]
|