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.
Files changed (84) hide show
  1. clutterxp/__init__.py +4 -0
  2. clutterxp/cli/__init__.py +7 -0
  3. clutterxp/cli/app.py +251 -0
  4. clutterxp/cli/cmd_config.py +250 -0
  5. clutterxp/cli/cmd_doctor.py +449 -0
  6. clutterxp/cli/cmd_duplicates.py +253 -0
  7. clutterxp/cli/cmd_gui.py +22 -0
  8. clutterxp/cli/cmd_history.py +233 -0
  9. clutterxp/cli/cmd_init.py +76 -0
  10. clutterxp/cli/cmd_organize.py +226 -0
  11. clutterxp/cli/cmd_rules.py +540 -0
  12. clutterxp/cli/cmd_scan.py +109 -0
  13. clutterxp/cli/cmd_service.py +130 -0
  14. clutterxp/cli/cmd_undo.py +180 -0
  15. clutterxp/cli/cmd_watch.py +147 -0
  16. clutterxp/cli/errors.py +166 -0
  17. clutterxp/cli/render.py +390 -0
  18. clutterxp/config/__init__.py +40 -0
  19. clutterxp/config/jsonschema.py +12 -0
  20. clutterxp/config/loader.py +212 -0
  21. clutterxp/config/placeholders.py +79 -0
  22. clutterxp/config/runtime.py +30 -0
  23. clutterxp/config/schema.py +166 -0
  24. clutterxp/config/validate.py +226 -0
  25. clutterxp/config/writer.py +248 -0
  26. clutterxp/config.example.yaml +76 -0
  27. clutterxp/duplicates/__init__.py +22 -0
  28. clutterxp/duplicates/detector.py +438 -0
  29. clutterxp/duplicates/hashcache.py +149 -0
  30. clutterxp/duplicates/namenorm.py +45 -0
  31. clutterxp/duplicates/quarantine.py +196 -0
  32. clutterxp/duplicates/report.py +110 -0
  33. clutterxp/gui/__init__.py +1 -0
  34. clutterxp/gui/api.py +494 -0
  35. clutterxp/gui/app.py +205 -0
  36. clutterxp/gui/models_qt.py +544 -0
  37. clutterxp/gui/views/__init__.py +21 -0
  38. clutterxp/gui/views/dashboard.py +253 -0
  39. clutterxp/gui/views/duplicates.py +404 -0
  40. clutterxp/gui/views/folders.py +354 -0
  41. clutterxp/gui/views/history.py +281 -0
  42. clutterxp/gui/views/preview.py +277 -0
  43. clutterxp/gui/views/rules.py +527 -0
  44. clutterxp/gui/views/settings.py +172 -0
  45. clutterxp/gui/workers.py +147 -0
  46. clutterxp/history/__init__.py +23 -0
  47. clutterxp/history/db.py +174 -0
  48. clutterxp/history/pathcodec.py +42 -0
  49. clutterxp/history/recovery.py +318 -0
  50. clutterxp/history/schema.sql +43 -0
  51. clutterxp/history/service.py +493 -0
  52. clutterxp/history/undo.py +373 -0
  53. clutterxp/interfaces.py +164 -0
  54. clutterxp/locking.py +113 -0
  55. clutterxp/logging_setup.py +73 -0
  56. clutterxp/models.py +266 -0
  57. clutterxp/mover/__init__.py +26 -0
  58. clutterxp/mover/collision.py +46 -0
  59. clutterxp/mover/fsops.py +191 -0
  60. clutterxp/mover/safe_move.py +741 -0
  61. clutterxp/organizer.py +388 -0
  62. clutterxp/packaging/clutterxp.service +15 -0
  63. clutterxp/paths.py +112 -0
  64. clutterxp/planner.py +339 -0
  65. clutterxp/py.typed +0 -0
  66. clutterxp/rules/__init__.py +16 -0
  67. clutterxp/rules/engine.py +374 -0
  68. clutterxp/rules/matcher.py +222 -0
  69. clutterxp/rules/template.py +171 -0
  70. clutterxp/rules/tokenizer.py +118 -0
  71. clutterxp/safety.py +111 -0
  72. clutterxp/scanner.py +196 -0
  73. clutterxp/service_install.py +208 -0
  74. clutterxp/units.py +59 -0
  75. clutterxp/watch/__init__.py +37 -0
  76. clutterxp/watch/debounce.py +84 -0
  77. clutterxp/watch/events.py +259 -0
  78. clutterxp/watch/ignore.py +77 -0
  79. clutterxp/watch/service.py +662 -0
  80. clutterxp/watch/settle.py +170 -0
  81. clutterxp-1.1.0.dist-info/METADATA +149 -0
  82. clutterxp-1.1.0.dist-info/RECORD +84 -0
  83. clutterxp-1.1.0.dist-info/WHEEL +4 -0
  84. clutterxp-1.1.0.dist-info/entry_points.txt +6 -0
@@ -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"]