clutterxp 1.1.0__py3-none-any.whl
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.
- clutterxp/__init__.py +4 -0
- clutterxp/cli/__init__.py +7 -0
- clutterxp/cli/app.py +251 -0
- clutterxp/cli/cmd_config.py +250 -0
- clutterxp/cli/cmd_doctor.py +449 -0
- clutterxp/cli/cmd_duplicates.py +253 -0
- clutterxp/cli/cmd_gui.py +22 -0
- clutterxp/cli/cmd_history.py +233 -0
- clutterxp/cli/cmd_init.py +76 -0
- clutterxp/cli/cmd_organize.py +226 -0
- clutterxp/cli/cmd_rules.py +540 -0
- clutterxp/cli/cmd_scan.py +109 -0
- clutterxp/cli/cmd_service.py +130 -0
- clutterxp/cli/cmd_undo.py +180 -0
- clutterxp/cli/cmd_watch.py +147 -0
- clutterxp/cli/errors.py +166 -0
- clutterxp/cli/render.py +390 -0
- clutterxp/config/__init__.py +40 -0
- clutterxp/config/jsonschema.py +12 -0
- clutterxp/config/loader.py +212 -0
- clutterxp/config/placeholders.py +79 -0
- clutterxp/config/runtime.py +30 -0
- clutterxp/config/schema.py +166 -0
- clutterxp/config/validate.py +226 -0
- clutterxp/config/writer.py +248 -0
- clutterxp/config.example.yaml +76 -0
- clutterxp/duplicates/__init__.py +22 -0
- clutterxp/duplicates/detector.py +438 -0
- clutterxp/duplicates/hashcache.py +149 -0
- clutterxp/duplicates/namenorm.py +45 -0
- clutterxp/duplicates/quarantine.py +196 -0
- clutterxp/duplicates/report.py +110 -0
- clutterxp/gui/__init__.py +1 -0
- clutterxp/gui/api.py +494 -0
- clutterxp/gui/app.py +205 -0
- clutterxp/gui/models_qt.py +544 -0
- clutterxp/gui/views/__init__.py +21 -0
- clutterxp/gui/views/dashboard.py +253 -0
- clutterxp/gui/views/duplicates.py +404 -0
- clutterxp/gui/views/folders.py +354 -0
- clutterxp/gui/views/history.py +281 -0
- clutterxp/gui/views/preview.py +277 -0
- clutterxp/gui/views/rules.py +527 -0
- clutterxp/gui/views/settings.py +172 -0
- clutterxp/gui/workers.py +147 -0
- clutterxp/history/__init__.py +23 -0
- clutterxp/history/db.py +174 -0
- clutterxp/history/pathcodec.py +42 -0
- clutterxp/history/recovery.py +318 -0
- clutterxp/history/schema.sql +43 -0
- clutterxp/history/service.py +493 -0
- clutterxp/history/undo.py +373 -0
- clutterxp/interfaces.py +164 -0
- clutterxp/locking.py +113 -0
- clutterxp/logging_setup.py +73 -0
- clutterxp/models.py +266 -0
- clutterxp/mover/__init__.py +26 -0
- clutterxp/mover/collision.py +46 -0
- clutterxp/mover/fsops.py +191 -0
- clutterxp/mover/safe_move.py +741 -0
- clutterxp/organizer.py +388 -0
- clutterxp/packaging/clutterxp.service +15 -0
- clutterxp/paths.py +112 -0
- clutterxp/planner.py +339 -0
- clutterxp/py.typed +0 -0
- clutterxp/rules/__init__.py +16 -0
- clutterxp/rules/engine.py +374 -0
- clutterxp/rules/matcher.py +222 -0
- clutterxp/rules/template.py +171 -0
- clutterxp/rules/tokenizer.py +118 -0
- clutterxp/safety.py +111 -0
- clutterxp/scanner.py +196 -0
- clutterxp/service_install.py +208 -0
- clutterxp/units.py +59 -0
- clutterxp/watch/__init__.py +37 -0
- clutterxp/watch/debounce.py +84 -0
- clutterxp/watch/events.py +259 -0
- clutterxp/watch/ignore.py +77 -0
- clutterxp/watch/service.py +662 -0
- clutterxp/watch/settle.py +170 -0
- clutterxp-1.1.0.dist-info/METADATA +149 -0
- clutterxp-1.1.0.dist-info/RECORD +84 -0
- clutterxp-1.1.0.dist-info/WHEEL +4 -0
- clutterxp-1.1.0.dist-info/entry_points.txt +6 -0
clutterxp/cli/cmd_gui.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Command handler for `clutterxp gui`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def gui_command(ctx: typer.Context) -> None:
|
|
12
|
+
"""Launch the graphical interface (v1.1 feature)."""
|
|
13
|
+
try:
|
|
14
|
+
importlib.import_module("PySide6")
|
|
15
|
+
gui_app = importlib.import_module("clutterxp.gui.app")
|
|
16
|
+
gui_app.main()
|
|
17
|
+
except (ImportError, ModuleNotFoundError, AttributeError):
|
|
18
|
+
sys.stderr.write('Install GUI support with: pip install "clutterxp[gui]"\n')
|
|
19
|
+
raise typer.Exit(code=1) from None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["gui_command"]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Command handlers for `clutterxp history` subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import TYPE_CHECKING, Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from clutterxp import paths
|
|
12
|
+
from clutterxp.cli.errors import handle_errors
|
|
13
|
+
from clutterxp.cli.render import (
|
|
14
|
+
get_console,
|
|
15
|
+
print_json,
|
|
16
|
+
render_history_table,
|
|
17
|
+
safe_str,
|
|
18
|
+
)
|
|
19
|
+
from clutterxp.history.service import HistoryService, op_to_dict
|
|
20
|
+
from clutterxp.models import OpStatus
|
|
21
|
+
from clutterxp.units import parse_duration_seconds
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from clutterxp.cli.app import GlobalOptions
|
|
25
|
+
|
|
26
|
+
history_app = typer.Typer(
|
|
27
|
+
help="Inspect and maintain file operation history",
|
|
28
|
+
invoke_without_command=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@history_app.callback(invoke_without_command=True)
|
|
33
|
+
def history_list_command(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
limit: Annotated[
|
|
36
|
+
int,
|
|
37
|
+
typer.Option("--limit", "-l", help="Maximum records to return"),
|
|
38
|
+
] = 50,
|
|
39
|
+
batch: Annotated[
|
|
40
|
+
int | None,
|
|
41
|
+
typer.Option("--batch", "-b", help="Filter by batch ID"),
|
|
42
|
+
] = None,
|
|
43
|
+
status: Annotated[
|
|
44
|
+
str | None,
|
|
45
|
+
typer.Option(
|
|
46
|
+
"--status",
|
|
47
|
+
"-s",
|
|
48
|
+
help="Filter by status (done, undone, failed, pending)",
|
|
49
|
+
),
|
|
50
|
+
] = None,
|
|
51
|
+
since: Annotated[
|
|
52
|
+
str | None,
|
|
53
|
+
typer.Option(
|
|
54
|
+
"--since",
|
|
55
|
+
help="Filter operations since duration (e.g. '7d') or ISO timestamp",
|
|
56
|
+
),
|
|
57
|
+
] = None,
|
|
58
|
+
rule: Annotated[
|
|
59
|
+
str | None,
|
|
60
|
+
typer.Option("--rule", help="Filter by rule name"),
|
|
61
|
+
] = None,
|
|
62
|
+
path: Annotated[
|
|
63
|
+
str | None,
|
|
64
|
+
typer.Option("--path", help="Filter by path substring"),
|
|
65
|
+
] = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""List file operations from the history journal."""
|
|
68
|
+
if ctx.invoked_subcommand is not None:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
from clutterxp.cli.app import get_globals
|
|
72
|
+
|
|
73
|
+
opts: GlobalOptions = get_globals(ctx)
|
|
74
|
+
db_path = paths.history_db_path()
|
|
75
|
+
|
|
76
|
+
with handle_errors(json_mode=opts.json_mode, command_name="history"):
|
|
77
|
+
history = HistoryService(db_path)
|
|
78
|
+
|
|
79
|
+
op_status = None
|
|
80
|
+
if status:
|
|
81
|
+
try:
|
|
82
|
+
op_status = OpStatus(status.lower())
|
|
83
|
+
except ValueError:
|
|
84
|
+
sys.stderr.write(
|
|
85
|
+
f"error: Invalid status '{status}'. Choices: done, undone, failed, pending\n"
|
|
86
|
+
)
|
|
87
|
+
raise typer.Exit(code=2) from None
|
|
88
|
+
|
|
89
|
+
since_iso = None
|
|
90
|
+
if since:
|
|
91
|
+
try:
|
|
92
|
+
secs = parse_duration_seconds(since)
|
|
93
|
+
dt = datetime.now(timezone.utc) - timedelta(seconds=secs)
|
|
94
|
+
since_iso = dt.isoformat()
|
|
95
|
+
except Exception:
|
|
96
|
+
since_iso = since
|
|
97
|
+
|
|
98
|
+
records = history.list_ops(
|
|
99
|
+
batch_id=batch,
|
|
100
|
+
status=op_status,
|
|
101
|
+
limit=limit,
|
|
102
|
+
since_iso=since_iso,
|
|
103
|
+
rule_name=rule,
|
|
104
|
+
path_contains=path,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if opts.json_mode:
|
|
108
|
+
ops_json = [op_to_dict(r) for r in records]
|
|
109
|
+
print_json({"schema_version": 1, "operations": ops_json})
|
|
110
|
+
else:
|
|
111
|
+
if not records:
|
|
112
|
+
get_console(no_color=opts.no_color).print("No history operations found.")
|
|
113
|
+
else:
|
|
114
|
+
render_history_table(records, no_color=opts.no_color)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@history_app.command("show")
|
|
118
|
+
def history_show_command(
|
|
119
|
+
ctx: typer.Context,
|
|
120
|
+
op_id: Annotated[int, typer.Argument(help="Operation ID to inspect")],
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Show details of a specific operation and its related events."""
|
|
123
|
+
from clutterxp.cli.app import get_globals
|
|
124
|
+
|
|
125
|
+
opts: GlobalOptions = get_globals(ctx)
|
|
126
|
+
db_path = paths.history_db_path()
|
|
127
|
+
|
|
128
|
+
with handle_errors(json_mode=opts.json_mode, command_name="history_show"):
|
|
129
|
+
history = HistoryService(db_path)
|
|
130
|
+
op = history.get_op(op_id)
|
|
131
|
+
if op is None:
|
|
132
|
+
sys.stderr.write(f"error: Operation ID {op_id} not found in history\n")
|
|
133
|
+
raise typer.Exit(code=2)
|
|
134
|
+
|
|
135
|
+
events = history.event_list(op_id=op_id)
|
|
136
|
+
|
|
137
|
+
if opts.json_mode:
|
|
138
|
+
print_json(
|
|
139
|
+
{
|
|
140
|
+
"schema_version": 1,
|
|
141
|
+
"command": "history_show",
|
|
142
|
+
"operation": op_to_dict(op),
|
|
143
|
+
"events": events,
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
console = get_console(no_color=opts.no_color)
|
|
148
|
+
console.print(f"[bold]Operation #{op.id}[/bold]")
|
|
149
|
+
console.print(f" Batch: {op.batch_id}")
|
|
150
|
+
console.print(f" Status: {op.status.value}")
|
|
151
|
+
console.print(f" Rule: {op.rule_name or '(none)'}")
|
|
152
|
+
console.print(f" Strategy: {op.strategy.value if op.strategy else '(none)'}")
|
|
153
|
+
console.print(f" Source: {safe_str(op.src_path)}")
|
|
154
|
+
console.print(f" Destination: {safe_str(op.dst_path or '(none)')}")
|
|
155
|
+
console.print(f" Size: {op.size} bytes")
|
|
156
|
+
console.print(f" SHA-256: {op.sha256 or '(none)'}")
|
|
157
|
+
console.print(f" Created: {op.created_at}")
|
|
158
|
+
console.print(f" Finished: {op.finished_at or '(none)'}")
|
|
159
|
+
console.print(f" Undone: {op.undone_at or '(none)'}")
|
|
160
|
+
if op.error:
|
|
161
|
+
console.print(f" Error: {op.error}")
|
|
162
|
+
if events:
|
|
163
|
+
console.print(f"\n[bold]Events ({len(events)}):[/bold]")
|
|
164
|
+
for ev in events:
|
|
165
|
+
lvl = ev.get("level", "info").upper()
|
|
166
|
+
ts = ev.get("timestamp")
|
|
167
|
+
msg = ev.get("message")
|
|
168
|
+
console.print(f" [{lvl}] {ts}: {msg}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@history_app.command("prune")
|
|
172
|
+
def history_prune_command(
|
|
173
|
+
ctx: typer.Context,
|
|
174
|
+
older_than: Annotated[
|
|
175
|
+
str,
|
|
176
|
+
typer.Option(
|
|
177
|
+
"--older-than",
|
|
178
|
+
help="Prune operations older than duration (e.g. 180d)",
|
|
179
|
+
),
|
|
180
|
+
] = "180d",
|
|
181
|
+
yes: Annotated[
|
|
182
|
+
bool,
|
|
183
|
+
typer.Option(
|
|
184
|
+
"--yes",
|
|
185
|
+
"-y",
|
|
186
|
+
help="Skip confirmation prompt",
|
|
187
|
+
),
|
|
188
|
+
] = False,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Prune history operations older than a cutoff (deletes rows only, keeps pending ops)."""
|
|
191
|
+
from clutterxp.cli.app import get_globals
|
|
192
|
+
|
|
193
|
+
opts: GlobalOptions = get_globals(ctx)
|
|
194
|
+
db_path = paths.history_db_path()
|
|
195
|
+
|
|
196
|
+
with handle_errors(json_mode=opts.json_mode, command_name="history_prune"):
|
|
197
|
+
history = HistoryService(db_path)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
secs = parse_duration_seconds(older_than)
|
|
201
|
+
days = max(1, secs // 86400)
|
|
202
|
+
except Exception:
|
|
203
|
+
try:
|
|
204
|
+
days = int(older_than.rstrip("dD"))
|
|
205
|
+
except ValueError:
|
|
206
|
+
sys.stderr.write(f"error: Invalid duration '{older_than}'. Expected e.g. '180d'\n")
|
|
207
|
+
raise typer.Exit(code=2) from None
|
|
208
|
+
|
|
209
|
+
if (
|
|
210
|
+
not yes
|
|
211
|
+
and not opts.json_mode
|
|
212
|
+
and not typer.confirm(f"Prune history records older than {days} days?", default=False)
|
|
213
|
+
):
|
|
214
|
+
get_console(no_color=opts.no_color).print("Aborted.")
|
|
215
|
+
raise typer.Exit(code=0)
|
|
216
|
+
|
|
217
|
+
pruned = history.prune(days)
|
|
218
|
+
|
|
219
|
+
if opts.json_mode:
|
|
220
|
+
print_json(
|
|
221
|
+
{
|
|
222
|
+
"schema_version": 1,
|
|
223
|
+
"command": "history_prune",
|
|
224
|
+
"older_than_days": days,
|
|
225
|
+
"pruned_rows": pruned,
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
console = get_console(no_color=opts.no_color)
|
|
230
|
+
console.print(f"Pruned {pruned} history operations older than {days} days.")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
__all__ = ["history_app"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Command handler for `clutterxp init`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from clutterxp import paths
|
|
12
|
+
from clutterxp.cli.errors import handle_errors
|
|
13
|
+
from clutterxp.cli.render import get_console, print_json, safe_str
|
|
14
|
+
from clutterxp.config import writer
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from clutterxp.cli.app import GlobalOptions
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def init_command(
|
|
21
|
+
ctx: typer.Context,
|
|
22
|
+
minimal: Annotated[
|
|
23
|
+
bool,
|
|
24
|
+
typer.Option(
|
|
25
|
+
"--minimal",
|
|
26
|
+
help="Write minimal configuration with Downloads watch only",
|
|
27
|
+
),
|
|
28
|
+
] = False,
|
|
29
|
+
force: Annotated[
|
|
30
|
+
bool,
|
|
31
|
+
typer.Option(
|
|
32
|
+
"--force",
|
|
33
|
+
"-f",
|
|
34
|
+
help="Overwrite existing configuration file",
|
|
35
|
+
),
|
|
36
|
+
] = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Initialize a commented config.yaml file with 0600 permissions."""
|
|
39
|
+
from clutterxp.cli.app import get_globals
|
|
40
|
+
|
|
41
|
+
opts: GlobalOptions = get_globals(ctx)
|
|
42
|
+
target = opts.config if opts.config is not None else paths.config_path()
|
|
43
|
+
|
|
44
|
+
with handle_errors(json_mode=opts.json_mode, command_name="init"):
|
|
45
|
+
if target.exists() and not force:
|
|
46
|
+
msg = f"Config file already exists at '{target}'. Pass --force to overwrite."
|
|
47
|
+
if opts.json_mode:
|
|
48
|
+
print_json(
|
|
49
|
+
{
|
|
50
|
+
"schema_version": 1,
|
|
51
|
+
"command": "init",
|
|
52
|
+
"error": msg,
|
|
53
|
+
"code": 2,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
sys.stderr.write(f"error: {msg}\n")
|
|
58
|
+
raise typer.Exit(code=2)
|
|
59
|
+
|
|
60
|
+
created_path: Path = writer.init_config(target, minimal=minimal, force=force)
|
|
61
|
+
|
|
62
|
+
if opts.json_mode:
|
|
63
|
+
print_json(
|
|
64
|
+
{
|
|
65
|
+
"schema_version": 1,
|
|
66
|
+
"command": "init",
|
|
67
|
+
"path": safe_str(created_path),
|
|
68
|
+
"status": "created",
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
console = get_console(no_color=opts.no_color)
|
|
73
|
+
console.print(f"Created configuration at {safe_str(created_path)} (mode 0600)")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = ["init_command"]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Command handler for `clutterxp organize`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Annotated, Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from clutterxp.cli.errors import handle_errors
|
|
12
|
+
from clutterxp.cli.render import (
|
|
13
|
+
get_console,
|
|
14
|
+
print_json,
|
|
15
|
+
render_organize_summary,
|
|
16
|
+
render_plan,
|
|
17
|
+
safe_str,
|
|
18
|
+
)
|
|
19
|
+
from clutterxp.models import (
|
|
20
|
+
ErrorCode,
|
|
21
|
+
Plan,
|
|
22
|
+
PlannedAction,
|
|
23
|
+
PlanStatus,
|
|
24
|
+
SafetyRefusal,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from clutterxp.cli.app import GlobalOptions
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def organize_command(
|
|
32
|
+
ctx: typer.Context,
|
|
33
|
+
paths: Annotated[
|
|
34
|
+
list[Path] | None,
|
|
35
|
+
typer.Argument(
|
|
36
|
+
help="Folders or files to organize (defaults to configured watch folders)",
|
|
37
|
+
),
|
|
38
|
+
] = None,
|
|
39
|
+
dry_run: Annotated[
|
|
40
|
+
bool,
|
|
41
|
+
typer.Option(
|
|
42
|
+
"-n",
|
|
43
|
+
"--dry-run",
|
|
44
|
+
help="Preview actions without moving any files",
|
|
45
|
+
),
|
|
46
|
+
] = False,
|
|
47
|
+
yes: Annotated[
|
|
48
|
+
bool,
|
|
49
|
+
typer.Option(
|
|
50
|
+
"-y",
|
|
51
|
+
"--yes",
|
|
52
|
+
help="Skip confirmation prompt for moves exceeding confirm_threshold",
|
|
53
|
+
),
|
|
54
|
+
] = False,
|
|
55
|
+
recursive: Annotated[
|
|
56
|
+
bool,
|
|
57
|
+
typer.Option(
|
|
58
|
+
"--recursive",
|
|
59
|
+
"-r",
|
|
60
|
+
help="Scan directories recursively",
|
|
61
|
+
),
|
|
62
|
+
] = False,
|
|
63
|
+
rule: Annotated[
|
|
64
|
+
list[str] | None,
|
|
65
|
+
typer.Option(
|
|
66
|
+
"--rule",
|
|
67
|
+
help="Limit execution to named rules (repeatable)",
|
|
68
|
+
),
|
|
69
|
+
] = None,
|
|
70
|
+
limit: Annotated[
|
|
71
|
+
int | None,
|
|
72
|
+
typer.Option(
|
|
73
|
+
"--limit",
|
|
74
|
+
"-l",
|
|
75
|
+
help="Cap maximum number of files to move in this run",
|
|
76
|
+
),
|
|
77
|
+
] = None,
|
|
78
|
+
strict: Annotated[
|
|
79
|
+
bool,
|
|
80
|
+
typer.Option(
|
|
81
|
+
"--strict",
|
|
82
|
+
help="Exit with code 3 if any file is ambiguous or failed",
|
|
83
|
+
),
|
|
84
|
+
] = False,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Sort files into destinations according to configured rules."""
|
|
87
|
+
from clutterxp.cli.app import build_organizer, get_globals
|
|
88
|
+
|
|
89
|
+
opts: GlobalOptions = get_globals(ctx)
|
|
90
|
+
|
|
91
|
+
with handle_errors(json_mode=opts.json_mode, command_name="organize"):
|
|
92
|
+
organizer = build_organizer(opts.config)
|
|
93
|
+
plan = organizer.plan(
|
|
94
|
+
paths=paths if paths else None,
|
|
95
|
+
recursive=recursive,
|
|
96
|
+
only_rules=rule if rule else None,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Apply limit if specified
|
|
100
|
+
if limit is not None and limit >= 0:
|
|
101
|
+
move_count = 0
|
|
102
|
+
limited_actions: list[PlannedAction] = []
|
|
103
|
+
for a in plan.actions:
|
|
104
|
+
if a.status in (PlanStatus.WILL_MOVE, PlanStatus.WILL_MOVE_RENAMED):
|
|
105
|
+
if move_count < limit:
|
|
106
|
+
move_count += 1
|
|
107
|
+
limited_actions.append(a)
|
|
108
|
+
else:
|
|
109
|
+
limited_actions.append(
|
|
110
|
+
PlannedAction(
|
|
111
|
+
file=a.file,
|
|
112
|
+
status=PlanStatus.IGNORED,
|
|
113
|
+
decision=a.decision,
|
|
114
|
+
code=ErrorCode.NONE,
|
|
115
|
+
message="Skipped due to --limit",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
limited_actions.append(a)
|
|
120
|
+
plan = Plan(folders=plan.folders, actions=tuple(limited_actions))
|
|
121
|
+
|
|
122
|
+
moves_planned = plan.count(PlanStatus.WILL_MOVE) + plan.count(PlanStatus.WILL_MOVE_RENAMED)
|
|
123
|
+
ambig_count = plan.count(PlanStatus.AMBIGUOUS)
|
|
124
|
+
|
|
125
|
+
# Print plan unless in quiet mode
|
|
126
|
+
if not opts.quiet and not opts.json_mode:
|
|
127
|
+
render_plan(
|
|
128
|
+
plan,
|
|
129
|
+
dry_run=dry_run,
|
|
130
|
+
no_color=opts.no_color,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Dry run stops here
|
|
134
|
+
if dry_run:
|
|
135
|
+
if opts.json_mode:
|
|
136
|
+
actions_json = _build_actions_json(plan)
|
|
137
|
+
print_json(
|
|
138
|
+
{
|
|
139
|
+
"schema_version": 1,
|
|
140
|
+
"command": "organize",
|
|
141
|
+
"dry_run": True,
|
|
142
|
+
"batch_id": None,
|
|
143
|
+
"summary": {
|
|
144
|
+
"moved": 0,
|
|
145
|
+
"unmatched": plan.count(PlanStatus.NO_MATCH)
|
|
146
|
+
+ plan.count(PlanStatus.IGNORED),
|
|
147
|
+
"ambiguous": ambig_count,
|
|
148
|
+
"blocked": plan.count(PlanStatus.BLOCKED),
|
|
149
|
+
"failed": 0,
|
|
150
|
+
},
|
|
151
|
+
"actions": actions_json,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
if strict and ambig_count > 0:
|
|
155
|
+
raise typer.Exit(code=3)
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# Check confirmation threshold
|
|
159
|
+
threshold = organizer.config.settings.confirm_threshold
|
|
160
|
+
if organizer.needs_confirmation(plan) and not yes:
|
|
161
|
+
if not sys.stdin.isatty():
|
|
162
|
+
raise SafetyRefusal(
|
|
163
|
+
f"Refused: confirmation required to move {moves_planned} files "
|
|
164
|
+
f"(confirm_threshold={threshold}) in non-interactive session. "
|
|
165
|
+
"Pass --yes to confirm."
|
|
166
|
+
)
|
|
167
|
+
if not typer.confirm(f"Move {moves_planned} files?", default=True):
|
|
168
|
+
get_console(no_color=opts.no_color).print("Aborted.")
|
|
169
|
+
raise typer.Exit(code=0)
|
|
170
|
+
|
|
171
|
+
# Apply plan
|
|
172
|
+
batch_result = organizer.apply(plan, command="organize")
|
|
173
|
+
|
|
174
|
+
if opts.json_mode:
|
|
175
|
+
actions_json = _build_actions_json(plan)
|
|
176
|
+
print_json(
|
|
177
|
+
{
|
|
178
|
+
"schema_version": 1,
|
|
179
|
+
"command": "organize",
|
|
180
|
+
"dry_run": False,
|
|
181
|
+
"batch_id": batch_result.batch_id,
|
|
182
|
+
"summary": {
|
|
183
|
+
"moved": batch_result.moved,
|
|
184
|
+
"unmatched": plan.count(PlanStatus.NO_MATCH)
|
|
185
|
+
+ plan.count(PlanStatus.IGNORED),
|
|
186
|
+
"ambiguous": ambig_count,
|
|
187
|
+
"blocked": batch_result.blocked,
|
|
188
|
+
"failed": batch_result.failed,
|
|
189
|
+
},
|
|
190
|
+
"actions": actions_json,
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
render_organize_summary(plan, batch_result, no_color=opts.no_color)
|
|
195
|
+
|
|
196
|
+
# Exit code: 3 on partial failure or strict with ambiguity
|
|
197
|
+
if batch_result.blocked > 0 or batch_result.failed > 0:
|
|
198
|
+
raise typer.Exit(code=3)
|
|
199
|
+
if strict and ambig_count > 0:
|
|
200
|
+
raise typer.Exit(code=3)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _build_actions_json(plan: Plan) -> list[dict[str, Any]]:
|
|
204
|
+
"""Build list of action dictionaries for JSON output."""
|
|
205
|
+
actions_json: list[dict[str, Any]] = []
|
|
206
|
+
for a in plan.actions:
|
|
207
|
+
status_str = a.status.value
|
|
208
|
+
act_data: dict[str, Any] = {
|
|
209
|
+
"status": status_str,
|
|
210
|
+
"src": safe_str(a.file.path),
|
|
211
|
+
}
|
|
212
|
+
if a.dest_path is not None:
|
|
213
|
+
act_data["dst"] = safe_str(a.dest_path)
|
|
214
|
+
if a.decision.hit is not None:
|
|
215
|
+
act_data["rule"] = a.decision.hit.rule_name
|
|
216
|
+
act_data["matched_by"] = a.decision.hit.matched_by
|
|
217
|
+
if a.decision.candidates:
|
|
218
|
+
act_data["candidates"] = [c.rule_name for c in a.decision.candidates]
|
|
219
|
+
if a.status == PlanStatus.BLOCKED:
|
|
220
|
+
act_data["code"] = a.code.value
|
|
221
|
+
act_data["message"] = a.message or a.code.value
|
|
222
|
+
actions_json.append(act_data)
|
|
223
|
+
return actions_json
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
__all__ = ["organize_command"]
|