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,248 @@
1
+ """Configuration file writer with round-trip comment preservation and atomic writes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from pydantic import ValidationError
13
+ from ruamel.yaml import YAML
14
+ from ruamel.yaml.error import YAMLError
15
+
16
+ from ..models import ConfigError
17
+ from ..paths import _ensure_dir_0700, config_path
18
+ from .loader import _format_loc
19
+ from .schema import Config, RuleSpec
20
+ from .validate import validate
21
+
22
+ DEFAULT_CONFIG_EXAMPLE = """# yaml-language-server: $schema=./clutterxp.schema.json
23
+ # ClutterXP example configuration. Copy to ~/.config/clutterxp/config.yaml
24
+ # (or run `clutterxp init`). Every name, keyword and folder below is an EXAMPLE:
25
+ # nothing is built in. Edit, delete and add your own.
26
+
27
+ version: 1
28
+
29
+ settings:
30
+ collision: skip # skip | rename (what to do if the destination file name exists)
31
+ create_destinations: leaf # never | leaf | all
32
+ match: token # default keyword mode: token | prefix | substring
33
+ case_sensitive: false
34
+ skip_hidden: true
35
+ follow_symlinks: false
36
+ confirm_threshold: 10 # ask before moving more than this many files at once
37
+ settle_seconds: 3 # how long a file must be quiet before the watcher moves it
38
+ verify_copy: size # size | hash (only matters for cross-filesystem moves)
39
+ record_hash: false
40
+ history:
41
+ retention_days: 365
42
+ log_no_match: false
43
+ ignore: [] # extra patterns to ignore everywhere
44
+ allow_chain: false # keep false: destinations must not live inside watched folders
45
+
46
+ watch:
47
+ - path: ~/Downloads
48
+ recursive: false
49
+ settle_seconds: 3
50
+ rules: all # or a list of rule names that apply to this folder
51
+ initial_scan: false
52
+
53
+ rules:
54
+ - name: Invoices
55
+ priority: 20
56
+ keywords: [invoice, inv, bill, receipt]
57
+ exclude: [draft]
58
+ extensions: [pdf, png, jpg]
59
+ destination: ~/Documents/Invoices/{year}/{month}
60
+ """
61
+
62
+
63
+ def _load_yaml(path: Path) -> tuple[YAML, Any]:
64
+ """Load YAML in round-trip mode preserving comments."""
65
+ if not path.exists():
66
+ raise ConfigError(f"Config file not found: {path}")
67
+
68
+ yaml = YAML()
69
+ yaml.preserve_quotes = True
70
+ try:
71
+ with path.open("r", encoding="utf-8") as f:
72
+ doc = yaml.load(f)
73
+ except (YAMLError, OSError) as e:
74
+ raise ConfigError(f"Failed to load configuration '{path}': {e}") from e
75
+
76
+ if doc is None or not isinstance(doc, dict):
77
+ raise ConfigError(f"Config file '{path}' is not a valid YAML mapping")
78
+
79
+ return yaml, doc
80
+
81
+
82
+ def _validate_doc(doc: Any) -> None:
83
+ """Validate modified in-memory configuration document."""
84
+ try:
85
+ cfg = Config.model_validate(doc)
86
+ except ValidationError as ve:
87
+ err_msgs = tuple(f"{_format_loc(err['loc'])}: {err['msg']}" for err in ve.errors())
88
+ raise ConfigError("Validation failed for modified configuration", err_msgs) from ve
89
+
90
+ errors, _ = validate(cfg)
91
+ if errors:
92
+ raise ConfigError("Cross-validation failed for modified configuration", tuple(errors))
93
+
94
+
95
+ def _save_atomic(path: Path, doc: Any, yaml: YAML) -> None:
96
+ """Validate in memory, create .bak backup, and atomically replace file (0600 mode)."""
97
+ _validate_doc(doc)
98
+
99
+ # 1. Create .bak backup of the existing file
100
+ bak_path = path.with_name(path.name + ".bak")
101
+ if path.exists():
102
+ shutil.copy2(path, bak_path)
103
+ with contextlib.suppress(OSError):
104
+ bak_path.chmod(0o600)
105
+
106
+ # 2. Write to temp file in same directory, fsync, and atomic replace
107
+ _ensure_dir_0700(path.parent)
108
+ temp_fd, temp_path_str = tempfile.mkstemp(
109
+ dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
110
+ )
111
+ temp_path = Path(temp_path_str)
112
+ try:
113
+ with os.fdopen(temp_fd, "w", encoding="utf-8") as f:
114
+ yaml.dump(doc, f)
115
+ f.flush()
116
+ os.fsync(f.fileno())
117
+ temp_path.chmod(0o600)
118
+ temp_path.replace(path)
119
+ finally:
120
+ if temp_path.exists():
121
+ with contextlib.suppress(OSError):
122
+ temp_path.unlink()
123
+
124
+
125
+ def add_rule(path: Path, rule: dict[str, Any] | RuleSpec) -> None:
126
+ """Add a new rule to the configuration, validating and preserving comments."""
127
+ yaml, doc = _load_yaml(path)
128
+
129
+ rule_dict = rule.model_dump(exclude_unset=True) if isinstance(rule, RuleSpec) else dict(rule)
130
+
131
+ rule_name = str(rule_dict.get("name", ""))
132
+ if not rule_name:
133
+ raise ConfigError("Rule must have a non-empty name")
134
+
135
+ rules_list = doc.setdefault("rules", [])
136
+ for existing in rules_list:
137
+ if (
138
+ isinstance(existing, dict)
139
+ and existing.get("name", "").casefold() == rule_name.casefold()
140
+ ):
141
+ raise ConfigError(f"Rule with name '{rule_name}' already exists")
142
+
143
+ rules_list.append(rule_dict)
144
+ _save_atomic(path, doc, yaml)
145
+
146
+
147
+ def edit_rule(path: Path, rule_name: str, updates: dict[str, Any]) -> None:
148
+ """Edit an existing rule by name, validating and preserving comments."""
149
+ yaml, doc = _load_yaml(path)
150
+
151
+ rules_list = doc.get("rules", [])
152
+ target: dict[str, Any] | None = None
153
+ for r in rules_list:
154
+ if isinstance(r, dict) and r.get("name", "").casefold() == rule_name.casefold():
155
+ target = r
156
+ break
157
+
158
+ if target is None:
159
+ raise ConfigError(f"Rule '{rule_name}' not found")
160
+
161
+ # If updating the name, check for collisions
162
+ if "name" in updates:
163
+ new_name = str(updates["name"])
164
+ for r in rules_list:
165
+ if (
166
+ isinstance(r, dict)
167
+ and r is not target
168
+ and r.get("name", "").casefold() == new_name.casefold()
169
+ ):
170
+ raise ConfigError(f"Rule with name '{new_name}' already exists")
171
+
172
+ target.update(updates)
173
+ _save_atomic(path, doc, yaml)
174
+
175
+
176
+ def remove_rule(path: Path, rule_name: str) -> None:
177
+ """Remove a rule by name, validating and preserving comments."""
178
+ yaml, doc = _load_yaml(path)
179
+
180
+ rules_list = doc.get("rules", [])
181
+ target_idx = -1
182
+ for i, r in enumerate(rules_list):
183
+ if isinstance(r, dict) and r.get("name", "").casefold() == rule_name.casefold():
184
+ target_idx = i
185
+ break
186
+
187
+ if target_idx == -1:
188
+ raise ConfigError(f"Rule '{rule_name}' not found")
189
+
190
+ del rules_list[target_idx]
191
+ _save_atomic(path, doc, yaml)
192
+
193
+
194
+ def enable_rule(path: Path, rule_name: str) -> None:
195
+ """Enable a rule by name."""
196
+ edit_rule(path, rule_name, {"enabled": True})
197
+
198
+
199
+ def disable_rule(path: Path, rule_name: str) -> None:
200
+ """Disable a rule by name."""
201
+ edit_rule(path, rule_name, {"enabled": False})
202
+
203
+
204
+ def init_config(path: Path | None = None, *, minimal: bool = False, force: bool = False) -> Path:
205
+ """Initialize a configuration file with 0600 permissions.
206
+
207
+ Refuses to overwrite existing config unless force is True.
208
+ """
209
+ target = path if path is not None else config_path()
210
+ if target.exists() and not force:
211
+ raise FileExistsError(
212
+ f"Config file already exists at '{target}'. Pass force=True to overwrite."
213
+ )
214
+
215
+ _ensure_dir_0700(target.parent)
216
+
217
+ if minimal:
218
+ content = (
219
+ "# yaml-language-server: $schema=./clutterxp.schema.json\n"
220
+ "version: 1\n\n"
221
+ "watch:\n"
222
+ " - path: ~/Downloads\n"
223
+ )
224
+ else:
225
+ # Search for config.example.yaml in parents
226
+ example_path = Path(__file__).resolve().parents[3] / "config.example.yaml"
227
+ if example_path.exists():
228
+ content = example_path.read_text(encoding="utf-8")
229
+ else:
230
+ content = DEFAULT_CONFIG_EXAMPLE
231
+
232
+ temp_fd, temp_path_str = tempfile.mkstemp(
233
+ dir=target.parent, prefix=f".{target.name}.", suffix=".tmp"
234
+ )
235
+ temp_path = Path(temp_path_str)
236
+ try:
237
+ with os.fdopen(temp_fd, "w", encoding="utf-8") as f:
238
+ f.write(content)
239
+ f.flush()
240
+ os.fsync(f.fileno())
241
+ temp_path.chmod(0o600)
242
+ temp_path.replace(target)
243
+ finally:
244
+ if temp_path.exists():
245
+ with contextlib.suppress(OSError):
246
+ temp_path.unlink()
247
+
248
+ return target
@@ -0,0 +1,76 @@
1
+ # yaml-language-server: $schema=./clutterxp.schema.json
2
+ # ClutterXP example configuration. Copy to ~/.config/clutterxp/config.yaml
3
+ # (or run `clutterxp init`). Every name, keyword and folder below is an EXAMPLE:
4
+ # nothing is built in. Edit, delete and add your own.
5
+
6
+ version: 1
7
+
8
+ settings:
9
+ collision: skip # skip | rename (what to do if the destination file name exists)
10
+ create_destinations: leaf # never | leaf | all
11
+ match: token # default keyword mode: token | prefix | substring
12
+ case_sensitive: false
13
+ skip_hidden: true
14
+ follow_symlinks: false
15
+ confirm_threshold: 10 # ask before moving more than this many files at once
16
+ settle_seconds: 3 # how long a file must be quiet before the watcher moves it
17
+ verify_copy: size # size | hash (only matters for cross-filesystem moves)
18
+ record_hash: false
19
+ history:
20
+ retention_days: 365
21
+ log_no_match: false
22
+ ignore: [] # extra patterns to ignore everywhere
23
+ allow_chain: false # keep false: destinations must not live inside watched folders
24
+
25
+ # Folders to watch (and process with `clutterxp organize` when no path is given).
26
+ # ANY folder works; Downloads is just an example.
27
+ watch:
28
+ - path: ~/Downloads
29
+ recursive: false
30
+ settle_seconds: 3
31
+ rules: all # or a list of rule names that apply to this folder
32
+ initial_scan: false
33
+ - path: ~/Desktop
34
+ recursive: false
35
+ rules: [Screenshots, Invoices]
36
+ ignore: ["*.log"]
37
+
38
+ rules:
39
+ - name: ProjectAlpha
40
+ priority: 10 # lower number wins
41
+ keywords: [alpha, pa, alpha-ctf] # short aliases like "pa" match whole tokens only
42
+ destination: ~/Projects/ProjectAlpha
43
+ collision: rename
44
+
45
+ - name: Invoices
46
+ priority: 20
47
+ keywords: [invoice, inv, bill, receipt]
48
+ exclude: [draft]
49
+ extensions: [pdf, png, jpg]
50
+ destination: ~/Documents/Invoices/{year}/{month}
51
+
52
+ - name: College
53
+ priority: 30
54
+ keywords: [assignment, semester, lab, lecture]
55
+ destination: ~/College
56
+
57
+ - name: Screenshots
58
+ priority: 40
59
+ keywords: ["regex:^screenshot[ _-]"]
60
+ extensions: [png]
61
+ destination: ~/Pictures/Screenshots/{mtime:%Y-%m}
62
+
63
+ - name: Installers
64
+ priority: 50
65
+ extensions: [deb, appimage, run, rpm]
66
+ conditions:
67
+ min_size: 1MB
68
+ destination: ~/Software/Installers
69
+
70
+ - name: OldArchives
71
+ priority: 90
72
+ extensions: [zip, tar.gz, 7z]
73
+ conditions:
74
+ older_than: 30d
75
+ destination: ~/Archive/{year}
76
+ collision: rename
@@ -0,0 +1,22 @@
1
+ """Duplicate detection: multi-stage pipeline, hash cache, and name normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .detector import (
6
+ DuplicateDetectorImpl,
7
+ compute_full_hash,
8
+ compute_partial_hash,
9
+ )
10
+ from .hashcache import HashCache
11
+ from .namenorm import normalize_stem
12
+ from .report import groups_to_csv_rows, groups_to_json
13
+
14
+ __all__ = [
15
+ "DuplicateDetectorImpl",
16
+ "HashCache",
17
+ "compute_full_hash",
18
+ "compute_partial_hash",
19
+ "groups_to_csv_rows",
20
+ "groups_to_json",
21
+ "normalize_stem",
22
+ ]