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
|
@@ -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
|
+
]
|