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
clutterxp/organizer.py ADDED
@@ -0,0 +1,388 @@
1
+ """Organizer facade coordinating Scanner, RuleEngine, Planner, Mover, and History.
2
+
3
+ Implements Organizer Protocol from clutterxp.interfaces.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from collections.abc import Iterable, Sequence
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from clutterxp import paths, safety
14
+ from clutterxp.config import loader
15
+ from clutterxp.config.runtime import RuntimeConfig
16
+ from clutterxp.interfaces import History, Mover, Organizer, RuleEngine
17
+ from clutterxp.locking import fs_lock
18
+ from clutterxp.models import (
19
+ BatchResult,
20
+ ErrorCode,
21
+ FileInfo,
22
+ MoveResult,
23
+ Plan,
24
+ PlannedAction,
25
+ PlanStatus,
26
+ ProgressCb,
27
+ UndoOutcome,
28
+ )
29
+ from clutterxp.planner import build_plan
30
+ from clutterxp.rules import build_engine
31
+ from clutterxp.scanner import scan
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ def needs_confirmation(plan: Plan, threshold: int = 10) -> bool:
37
+ """Return True if plan moves at least threshold files (or if threshold == 0 and files > 0)."""
38
+ moves = plan.count(PlanStatus.WILL_MOVE) + plan.count(PlanStatus.WILL_MOVE_RENAMED)
39
+ if moves == 0:
40
+ return False
41
+ if threshold == 0:
42
+ return True
43
+ return moves >= threshold
44
+
45
+
46
+ class OrganizerImpl(Organizer):
47
+ """Facade implementation wires together RuleEngine, Mover, History and Planner."""
48
+
49
+ def __init__(
50
+ self,
51
+ config: RuntimeConfig,
52
+ engine: RuleEngine,
53
+ mover: Mover,
54
+ history: History,
55
+ *,
56
+ undo_service: Any = None,
57
+ fs_lock_path: Path | None = None,
58
+ ) -> None:
59
+ self.config: RuntimeConfig = config
60
+ self.engine: RuleEngine = engine
61
+ self.mover: Mover = mover
62
+ self.history: History = history
63
+ self.undo_service: Any = undo_service
64
+ self._fs_lock_path: Path | None = fs_lock_path
65
+
66
+ @property
67
+ def fs_lock_path(self) -> Path:
68
+ """Filesystem lock path."""
69
+ if self._fs_lock_path is not None:
70
+ return self._fs_lock_path
71
+ return paths.fs_lock_path()
72
+
73
+ def plan(
74
+ self,
75
+ paths: Iterable[Path] | None = None,
76
+ *,
77
+ recursive: bool | None = None,
78
+ only_rules: Sequence[str] | None = None,
79
+ ) -> Plan:
80
+ """Scan specified folders (or watch_entries) and build a dry-run execution Plan."""
81
+ all_actions: list[PlannedAction] = []
82
+ scanned_folders: list[Path] = []
83
+
84
+ if only_rules is not None:
85
+ target_names = {r.casefold() for r in only_rules}
86
+ active_rules = [r for r in self.config.rules if r.name.casefold() in target_names]
87
+ plan_engine: RuleEngine = build_engine(
88
+ active_rules,
89
+ self.config.settings,
90
+ watch=list(self.config.watch_entries),
91
+ )
92
+ else:
93
+ plan_engine = self.engine
94
+
95
+ if paths is None:
96
+ for entry in self.config.watch_entries:
97
+ folder_p = Path(entry.path)
98
+ rec = entry.recursive if recursive is None else recursive
99
+ safety.check_source_root(folder_p, recursive=rec)
100
+ scanned_folders.append(folder_p)
101
+ infos = scan(
102
+ folder_p,
103
+ recursive=rec,
104
+ settings=self.config.settings,
105
+ ignore_patterns=entry.ignore,
106
+ )
107
+ folder_plan = build_plan(
108
+ infos,
109
+ plan_engine,
110
+ self.config,
111
+ fs_check=True,
112
+ folder=folder_p,
113
+ )
114
+ all_actions.extend(folder_plan.actions)
115
+ else:
116
+ for p in paths:
117
+ folder_p = Path(p)
118
+ if folder_p.is_file():
119
+ safety.check_source_root(folder_p.parent, recursive=False)
120
+ scanned_folders.append(folder_p.parent)
121
+ infos_iter = iter([FileInfo.from_path(folder_p)])
122
+ folder_plan = build_plan(
123
+ infos_iter,
124
+ plan_engine,
125
+ self.config,
126
+ fs_check=True,
127
+ folder=folder_p.parent,
128
+ )
129
+ all_actions.extend(folder_plan.actions)
130
+ else:
131
+ rec = False if recursive is None else recursive
132
+ safety.check_source_root(folder_p, recursive=rec)
133
+ scanned_folders.append(folder_p)
134
+ infos = scan(
135
+ folder_p,
136
+ recursive=rec,
137
+ settings=self.config.settings,
138
+ ignore_patterns=(),
139
+ )
140
+ folder_plan = build_plan(
141
+ infos,
142
+ plan_engine,
143
+ self.config,
144
+ fs_check=True,
145
+ folder=folder_p,
146
+ )
147
+ all_actions.extend(folder_plan.actions)
148
+
149
+ return Plan(folders=tuple(scanned_folders), actions=tuple(all_actions))
150
+
151
+ def apply(
152
+ self,
153
+ plan: Plan,
154
+ *,
155
+ command: str = "organize",
156
+ progress: ProgressCb | None = None,
157
+ ) -> BatchResult:
158
+ """Execute plan actions under journal-first protocol and fs lock."""
159
+ first_folder = plan.folders[0] if plan.folders else None
160
+ batch_id = self.history.begin_batch(command, folder=first_folder)
161
+
162
+ moved = 0
163
+ skipped = 0
164
+ blocked = 0
165
+ failed = 0
166
+ results: list[MoveResult] = []
167
+
168
+ for action in plan.actions:
169
+ if action.status in (PlanStatus.WILL_MOVE, PlanStatus.WILL_MOVE_RENAMED):
170
+ if action.decision.hit is None or action.dest_path is None:
171
+ failed += 1
172
+ res = MoveResult(
173
+ ok=False,
174
+ src=action.file.path,
175
+ dst=None,
176
+ strategy=None,
177
+ code=ErrorCode.UNKNOWN,
178
+ message="Missing rule hit or destination path for planned move",
179
+ )
180
+ results.append(res)
181
+ continue
182
+
183
+ with fs_lock(self.fs_lock_path):
184
+ # TOCTOU check: re-verify source file exists and is unchanged
185
+ try:
186
+ st = action.file.path.lstat()
187
+ except FileNotFoundError:
188
+ failed += 1
189
+ res = MoveResult(
190
+ ok=False,
191
+ src=action.file.path,
192
+ dst=None,
193
+ strategy=None,
194
+ code=ErrorCode.SRC_VANISHED,
195
+ message=f"Source file {action.file.path} vanished since plan",
196
+ )
197
+ results.append(res)
198
+ self.history.record_event(
199
+ "error",
200
+ res.message,
201
+ batch_id=batch_id,
202
+ path=action.file.path,
203
+ )
204
+ continue
205
+ except OSError as e:
206
+ failed += 1
207
+ res = MoveResult(
208
+ ok=False,
209
+ src=action.file.path,
210
+ dst=None,
211
+ strategy=None,
212
+ code=ErrorCode.PERMISSION,
213
+ message=str(e),
214
+ )
215
+ results.append(res)
216
+ self.history.record_event(
217
+ "error",
218
+ res.message,
219
+ batch_id=batch_id,
220
+ path=action.file.path,
221
+ )
222
+ continue
223
+
224
+ if st.st_size != action.file.size or st.st_mtime_ns != action.file.mtime_ns:
225
+ failed += 1
226
+ res = MoveResult(
227
+ ok=False,
228
+ src=action.file.path,
229
+ dst=None,
230
+ strategy=None,
231
+ code=ErrorCode.SRC_CHANGED,
232
+ message=f"Source file {action.file.path} changed since plan",
233
+ )
234
+ results.append(res)
235
+ self.history.record_event(
236
+ "error",
237
+ res.message,
238
+ batch_id=batch_id,
239
+ path=action.file.path,
240
+ )
241
+ continue
242
+
243
+ # Invariant S5: Journal durable pending row before move
244
+ op_id = self.history.begin_op(
245
+ batch_id,
246
+ action.file.path,
247
+ action.decision.hit.rule_name,
248
+ action.file,
249
+ )
250
+
251
+ result = self.mover.move_file(
252
+ action.file.path,
253
+ action.dest_path.parent,
254
+ new_name=action.dest_path.name,
255
+ collision=action.decision.hit.collision,
256
+ create=action.decision.hit.create,
257
+ static_root=action.decision.hit.static_root,
258
+ expect_size=action.file.size,
259
+ expect_mtime_ns=action.file.mtime_ns,
260
+ progress=progress,
261
+ )
262
+
263
+ self.history.finish_op(op_id, result)
264
+ results.append(result)
265
+
266
+ if result.ok:
267
+ moved += 1
268
+ else:
269
+ if result.code == ErrorCode.COLLISION:
270
+ blocked += 1
271
+ else:
272
+ failed += 1
273
+
274
+ elif action.status == PlanStatus.AMBIGUOUS:
275
+ skipped += 1
276
+ self.history.record_event(
277
+ "skip_ambiguous",
278
+ f"File '{action.file.path}' is ambiguous: {action.message}",
279
+ batch_id=batch_id,
280
+ path=action.file.path,
281
+ )
282
+ elif action.status == PlanStatus.BLOCKED:
283
+ blocked += 1
284
+ self.history.record_event(
285
+ "skip_blocked",
286
+ f"File '{action.file.path}' is blocked ({action.code.value}): {action.message}",
287
+ batch_id=batch_id,
288
+ path=action.file.path,
289
+ )
290
+ elif action.status == PlanStatus.NO_MATCH:
291
+ skipped += 1
292
+ if self.config.settings.history.log_no_match:
293
+ self.history.record_event(
294
+ "skip_no_match",
295
+ f"File '{action.file.path}' matched no rules",
296
+ batch_id=batch_id,
297
+ path=action.file.path,
298
+ )
299
+ elif action.status == PlanStatus.IGNORED:
300
+ skipped += 1
301
+
302
+ return BatchResult(
303
+ batch_id=batch_id,
304
+ moved=moved,
305
+ skipped=skipped,
306
+ blocked=blocked,
307
+ failed=failed,
308
+ results=tuple(results),
309
+ )
310
+
311
+ def process_file(
312
+ self, path: Path, *, folder: Path | None = None, command: str = "watch"
313
+ ) -> BatchResult:
314
+ """Single-file processing used by watcher: decide -> plan -> apply as a batch of 1."""
315
+ p = Path(path)
316
+ if not p.exists():
317
+ return BatchResult(
318
+ batch_id=None,
319
+ moved=0,
320
+ skipped=0,
321
+ blocked=0,
322
+ failed=1,
323
+ results=(
324
+ MoveResult(
325
+ ok=False,
326
+ src=p,
327
+ dst=None,
328
+ strategy=None,
329
+ code=ErrorCode.SRC_VANISHED,
330
+ message=f"File {p} does not exist",
331
+ ),
332
+ ),
333
+ )
334
+
335
+ info = FileInfo.from_path(p)
336
+ plan_folder = folder if folder is not None else p.parent
337
+ plan = build_plan([info], self.engine, self.config, fs_check=True, folder=plan_folder)
338
+ return self.apply(plan, command=command)
339
+
340
+ def undo(
341
+ self,
342
+ *,
343
+ op_id: int | None = None,
344
+ batch_id: int | None = None,
345
+ dry_run: bool = False,
346
+ on_conflict: str = "skip",
347
+ allow_modified: bool = False,
348
+ ) -> Sequence[UndoOutcome]:
349
+ """Delegate undo to the injected UndoService."""
350
+ if self.undo_service is None:
351
+ raise NotImplementedError("UndoService not configured")
352
+
353
+ outcome: Sequence[UndoOutcome] = self.undo_service.undo(
354
+ op_id=op_id,
355
+ batch_id=batch_id,
356
+ dry_run=dry_run,
357
+ on_conflict=on_conflict,
358
+ allow_modified=allow_modified,
359
+ )
360
+ return outcome
361
+
362
+ def reload_config(self) -> None:
363
+ """Reload configuration from disk and atomically update engine if valid."""
364
+ try:
365
+ new_config = loader.load(self.config.config_path)
366
+ if new_config.is_valid:
367
+ new_engine = build_engine(
368
+ list(new_config.rules),
369
+ new_config.settings,
370
+ watch=list(new_config.watch_entries),
371
+ )
372
+ self.config = new_config
373
+ self.engine = new_engine
374
+ logger.info("Config successfully reloaded from %s", self.config.config_path)
375
+ else:
376
+ logger.error(
377
+ "Config reload rejected due to validation errors: %s",
378
+ ", ".join(new_config.errors),
379
+ )
380
+ except Exception as e:
381
+ logger.error("Config reload failed with exception: %s", e)
382
+
383
+ def needs_confirmation(self, plan: Plan) -> bool:
384
+ """Check if plan requires confirmation against confirm_threshold."""
385
+ return needs_confirmation(plan, self.config.settings.confirm_threshold)
386
+
387
+
388
+ __all__ = ["OrganizerImpl", "needs_confirmation"]
@@ -0,0 +1,15 @@
1
+ [Unit]
2
+ Description=ClutterXP folder watcher (automatic file sorting)
3
+ Documentation=https://example.invalid/clutterxp
4
+ After=default.target
5
+
6
+ [Service]
7
+ Type=simple
8
+ # `clutterxp service install` replaces the path below with the real executable path.
9
+ ExecStart=%h/.local/bin/clutterxp watch --no-color
10
+ Restart=on-failure
11
+ RestartSec=5
12
+ NoNewPrivileges=yes
13
+
14
+ [Install]
15
+ WantedBy=default.target
clutterxp/paths.py ADDED
@@ -0,0 +1,112 @@
1
+ """Path resolution, directory creation with 0700 permissions, and path expansion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+
10
+ from .models import ConfigError
11
+
12
+
13
+ def _ensure_dir_0700(path: Path) -> Path:
14
+ """Ensure directory exists with 0700 permissions."""
15
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
16
+ with contextlib.suppress(OSError):
17
+ path.chmod(0o700)
18
+ return path
19
+
20
+
21
+ def config_path() -> Path:
22
+ """Path to config.yaml (honors CLUTTERXP_CONFIG, default ~/.config/clutterxp/config.yaml)."""
23
+ if "CLUTTERXP_CONFIG" in os.environ:
24
+ return Path(os.environ["CLUTTERXP_CONFIG"])
25
+ return Path.home() / ".config" / "clutterxp" / "config.yaml"
26
+
27
+
28
+ def state_dir() -> Path:
29
+ """Path to state directory (honors CLUTTERXP_STATE_DIR, default ~/.local/state/clutterxp/).
30
+
31
+ Created with 0700 permissions when accessed.
32
+ """
33
+ if "CLUTTERXP_STATE_DIR" in os.environ:
34
+ return _ensure_dir_0700(Path(os.environ["CLUTTERXP_STATE_DIR"]))
35
+ return _ensure_dir_0700(Path.home() / ".local" / "state" / "clutterxp")
36
+
37
+
38
+ def cache_dir() -> Path:
39
+ """Path to cache directory (honors CLUTTERXP_CACHE_DIR, default ~/.cache/clutterxp/).
40
+
41
+ Created with 0700 permissions when accessed.
42
+ """
43
+ if "CLUTTERXP_CACHE_DIR" in os.environ:
44
+ return _ensure_dir_0700(Path(os.environ["CLUTTERXP_CACHE_DIR"]))
45
+ return _ensure_dir_0700(Path.home() / ".cache" / "clutterxp")
46
+
47
+
48
+ def history_db_path() -> Path:
49
+ """Path to history SQLite database (~/.local/state/clutterxp/history.db)."""
50
+ return state_dir() / "history.db"
51
+
52
+
53
+ def log_path() -> Path:
54
+ """Path to log file (~/.local/state/clutterxp/clutterxp.log)."""
55
+ return state_dir() / "clutterxp.log"
56
+
57
+
58
+ def fs_lock_path() -> Path:
59
+ """Path to filesystem operation lock (~/.local/state/clutterxp/fs.lock)."""
60
+ return state_dir() / "fs.lock"
61
+
62
+
63
+ def watch_lock_path() -> Path:
64
+ """Path to watch daemon lock (~/.local/state/clutterxp/watch.lock)."""
65
+ return state_dir() / "watch.lock"
66
+
67
+
68
+ def cache_db_path() -> Path:
69
+ """Path to hash cache database (~/.cache/clutterxp/cache.db)."""
70
+ return cache_dir() / "cache.db"
71
+
72
+
73
+ def expand(path_str: str, *, environ: dict[str, str] | None = None) -> Path:
74
+ """Expand ~ and $VAR / ${VAR} in path_str.
75
+
76
+ Raises ConfigError on unresolved or invalid environment variables.
77
+ Does NOT touch template placeholders like {year} or {month}.
78
+ """
79
+ env = os.environ if environ is None else environ
80
+
81
+ # Tilde expansion
82
+ if environ is not None and "HOME" in environ:
83
+ if path_str == "~":
84
+ path_str = environ["HOME"]
85
+ elif path_str.startswith("~/"):
86
+ path_str = environ["HOME"] + path_str[1:]
87
+ elif path_str.startswith("~"):
88
+ path_str = str(Path(path_str).expanduser())
89
+ else:
90
+ path_str = str(Path(path_str).expanduser())
91
+
92
+ # Check for unclosed variable braces
93
+ if re.search(r"\$\{[^}]*$", path_str):
94
+ raise ConfigError(f"Unclosed variable reference in path: '{path_str}'")
95
+
96
+ if re.search(r"\$\{\s*\}", path_str):
97
+ raise ConfigError(f"Empty variable reference in path: '{path_str}'")
98
+
99
+ var_pattern = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))")
100
+
101
+ def _sub(match: re.Match[str]) -> str:
102
+ name = match.group(1) or match.group(2)
103
+ if name not in env:
104
+ raise ConfigError(f"Unresolved environment variable: ${name}")
105
+ return env[name]
106
+
107
+ result = var_pattern.sub(_sub, path_str)
108
+
109
+ if "${" in result:
110
+ raise ConfigError(f"Unresolved or invalid variable reference in: '{path_str}'")
111
+
112
+ return Path(result)