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,438 @@
|
|
|
1
|
+
"""Multi-stage duplicate detector implementation.
|
|
2
|
+
|
|
3
|
+
Implements the staged pipeline:
|
|
4
|
+
0. Enumerate (metadata only)
|
|
5
|
+
1. Group by size
|
|
6
|
+
2. Hardlink check
|
|
7
|
+
3. Partial hash (64 KiB head + 64 KiB tail + size)
|
|
8
|
+
4. Full streaming hash (1 MiB chunks)
|
|
9
|
+
Potential duplicates from normalized stem and ext.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import fnmatch
|
|
15
|
+
import hashlib
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import stat
|
|
19
|
+
import threading
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from clutterxp.models import DuplicateGroup, FileInfo, ProgressCb
|
|
26
|
+
|
|
27
|
+
from .hashcache import HashCache
|
|
28
|
+
from .namenorm import normalize_stem
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
PARTIAL_CHUNK_SIZE = 64 * 1024 # 64 KiB
|
|
33
|
+
FULL_CHUNK_SIZE = 1024 * 1024 # 1 MiB
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def compute_partial_hash(first_64k: bytes, last_64k: bytes, size: int) -> str:
|
|
37
|
+
"""Compute SHA-256 of first 64KiB + last 64KiB + size (8-byte little-endian)."""
|
|
38
|
+
hasher = hashlib.sha256()
|
|
39
|
+
hasher.update(first_64k)
|
|
40
|
+
hasher.update(last_64k)
|
|
41
|
+
hasher.update(size.to_bytes(8, "little"))
|
|
42
|
+
return hasher.hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def compute_full_hash(
|
|
46
|
+
path: Path,
|
|
47
|
+
size: int,
|
|
48
|
+
*,
|
|
49
|
+
cancel: threading.Event | None = None,
|
|
50
|
+
on_chunk: Callable[[int], None] | None = None,
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Stream full file in 1MiB chunks and compute SHA-256."""
|
|
53
|
+
hasher = hashlib.sha256()
|
|
54
|
+
with path.open("rb") as fp:
|
|
55
|
+
while True:
|
|
56
|
+
if cancel is not None and cancel.is_set():
|
|
57
|
+
raise InterruptedError("Scan cancelled")
|
|
58
|
+
chunk = fp.read(FULL_CHUNK_SIZE)
|
|
59
|
+
if not chunk:
|
|
60
|
+
break
|
|
61
|
+
hasher.update(chunk)
|
|
62
|
+
if on_chunk is not None:
|
|
63
|
+
on_chunk(len(chunk))
|
|
64
|
+
return hasher.hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DuplicateDetectorImpl:
|
|
68
|
+
"""Implementation of the DuplicateDetector protocol."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
cache: HashCache | None = None,
|
|
73
|
+
*,
|
|
74
|
+
cancel: threading.Event | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
self.cache = cache
|
|
77
|
+
self.cancel = cancel
|
|
78
|
+
self.last_scanned_files: int = 0
|
|
79
|
+
self.last_scanned_bytes: int = 0
|
|
80
|
+
|
|
81
|
+
def scan(
|
|
82
|
+
self,
|
|
83
|
+
folders: Sequence[Path],
|
|
84
|
+
*,
|
|
85
|
+
recursive: bool = True,
|
|
86
|
+
min_size: int = 1,
|
|
87
|
+
exclude: Sequence[str] = (),
|
|
88
|
+
jobs: int = 1,
|
|
89
|
+
potential: bool = True,
|
|
90
|
+
progress: ProgressCb | None = None,
|
|
91
|
+
) -> Sequence[DuplicateGroup]:
|
|
92
|
+
"""Scan folders for duplicate files."""
|
|
93
|
+
self.last_scanned_files = 0
|
|
94
|
+
self.last_scanned_bytes = 0
|
|
95
|
+
|
|
96
|
+
# Stage 0: Enumerate
|
|
97
|
+
scanned_files = self._stage0_enumerate(
|
|
98
|
+
folders, recursive=recursive, min_size=min_size, exclude=exclude
|
|
99
|
+
)
|
|
100
|
+
self.last_scanned_files = len(scanned_files)
|
|
101
|
+
self.last_scanned_bytes = sum(f.size for f in scanned_files)
|
|
102
|
+
|
|
103
|
+
if self.cancel is not None and self.cancel.is_set():
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
# Stage 1: Group by size
|
|
107
|
+
size_groups = self._stage1_group_by_size(scanned_files)
|
|
108
|
+
|
|
109
|
+
# Stage 2: Hardlink check
|
|
110
|
+
surviving_after_stage2, hardlink_groups = self._stage2_hardlinks(size_groups)
|
|
111
|
+
|
|
112
|
+
if self.cancel is not None and self.cancel.is_set():
|
|
113
|
+
return hardlink_groups
|
|
114
|
+
|
|
115
|
+
# Stage 3: Partial hash
|
|
116
|
+
surviving_after_stage3 = self._stage3_partial_hash(surviving_after_stage2)
|
|
117
|
+
|
|
118
|
+
if self.cancel is not None and self.cancel.is_set():
|
|
119
|
+
return hardlink_groups
|
|
120
|
+
|
|
121
|
+
# Stage 4: Full hash
|
|
122
|
+
exact_groups = self._stage4_full_hash(surviving_after_stage3, jobs=jobs, progress=progress)
|
|
123
|
+
|
|
124
|
+
# Potential duplicates: computed from remaining files not in exact or hardlink groups
|
|
125
|
+
potential_groups: list[DuplicateGroup] = []
|
|
126
|
+
if potential:
|
|
127
|
+
exact_paths = {f.path for g in exact_groups for f in g.files}
|
|
128
|
+
hardlink_paths = {f.path for g in hardlink_groups for f in g.files}
|
|
129
|
+
claimed_paths = exact_paths | hardlink_paths
|
|
130
|
+
|
|
131
|
+
remaining = [f for f in scanned_files if f.path not in claimed_paths]
|
|
132
|
+
potential_groups = self._compute_potential_groups(remaining)
|
|
133
|
+
|
|
134
|
+
return [*exact_groups, *hardlink_groups, *potential_groups]
|
|
135
|
+
|
|
136
|
+
def _stage0_enumerate(
|
|
137
|
+
self,
|
|
138
|
+
folders: Sequence[Path],
|
|
139
|
+
*,
|
|
140
|
+
recursive: bool,
|
|
141
|
+
min_size: int,
|
|
142
|
+
exclude: Sequence[str],
|
|
143
|
+
) -> list[FileInfo]:
|
|
144
|
+
"""Enumerate files applying excludes, min_size, and symlink skipping."""
|
|
145
|
+
collected: list[FileInfo] = []
|
|
146
|
+
seen_paths: set[Path] = set()
|
|
147
|
+
|
|
148
|
+
for folder in folders:
|
|
149
|
+
if self.cancel is not None and self.cancel.is_set():
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
folder_path = folder.resolve() if folder.is_symlink() else folder
|
|
153
|
+
if not folder_path.exists() or not folder_path.is_dir():
|
|
154
|
+
logger.warning("Folder %s does not exist or is not a directory", folder)
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
self._walk_folder(
|
|
158
|
+
folder_path,
|
|
159
|
+
recursive=recursive,
|
|
160
|
+
min_size=min_size,
|
|
161
|
+
exclude=exclude,
|
|
162
|
+
collected=collected,
|
|
163
|
+
seen_paths=seen_paths,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return collected
|
|
167
|
+
|
|
168
|
+
def _walk_folder(
|
|
169
|
+
self,
|
|
170
|
+
current_dir: Path,
|
|
171
|
+
*,
|
|
172
|
+
recursive: bool,
|
|
173
|
+
min_size: int,
|
|
174
|
+
exclude: Sequence[str],
|
|
175
|
+
collected: list[FileInfo],
|
|
176
|
+
seen_paths: set[Path],
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Walk directory recursively or flat, handling errors gracefully (S15)."""
|
|
179
|
+
try:
|
|
180
|
+
entries = list(os.scandir(current_dir))
|
|
181
|
+
except OSError as err:
|
|
182
|
+
logger.warning("Cannot read directory %s: %s", current_dir, err)
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
for entry in entries:
|
|
186
|
+
if self.cancel is not None and self.cancel.is_set():
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
# Skip symlinks (follow_symlinks default False)
|
|
191
|
+
if entry.is_symlink():
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
if entry.is_dir(follow_symlinks=False):
|
|
195
|
+
if recursive:
|
|
196
|
+
self._walk_folder(
|
|
197
|
+
Path(entry.path),
|
|
198
|
+
recursive=recursive,
|
|
199
|
+
min_size=min_size,
|
|
200
|
+
exclude=exclude,
|
|
201
|
+
collected=collected,
|
|
202
|
+
seen_paths=seen_paths,
|
|
203
|
+
)
|
|
204
|
+
elif entry.is_file(follow_symlinks=False):
|
|
205
|
+
if any(fnmatch.fnmatch(entry.name, pat) for pat in exclude):
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
st = entry.stat(follow_symlinks=False)
|
|
209
|
+
if st.st_size < min_size:
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
p = Path(entry.path)
|
|
213
|
+
try:
|
|
214
|
+
resolved_path = p.resolve()
|
|
215
|
+
except OSError:
|
|
216
|
+
resolved_path = p
|
|
217
|
+
|
|
218
|
+
if resolved_path in seen_paths:
|
|
219
|
+
continue
|
|
220
|
+
seen_paths.add(resolved_path)
|
|
221
|
+
|
|
222
|
+
suffix = p.suffix
|
|
223
|
+
fi = FileInfo(
|
|
224
|
+
path=p,
|
|
225
|
+
name=p.name,
|
|
226
|
+
stem=p.stem,
|
|
227
|
+
ext=suffix[1:].lower() if suffix else "",
|
|
228
|
+
size=st.st_size,
|
|
229
|
+
mtime_ns=st.st_mtime_ns,
|
|
230
|
+
inode=st.st_ino,
|
|
231
|
+
device=st.st_dev,
|
|
232
|
+
is_regular=stat.S_ISREG(st.st_mode),
|
|
233
|
+
is_symlink=stat.S_ISLNK(st.st_mode),
|
|
234
|
+
)
|
|
235
|
+
collected.append(fi)
|
|
236
|
+
except OSError as err:
|
|
237
|
+
logger.warning("Error processing %s: %s", entry.path, err)
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
def _stage1_group_by_size(self, files: list[FileInfo]) -> dict[int, list[FileInfo]]:
|
|
241
|
+
"""Group files by size and drop singletons."""
|
|
242
|
+
by_size: dict[int, list[FileInfo]] = defaultdict(list)
|
|
243
|
+
for f in files:
|
|
244
|
+
by_size[f.size].append(f)
|
|
245
|
+
return {size: flist for size, flist in by_size.items() if len(flist) >= 2}
|
|
246
|
+
|
|
247
|
+
def _stage2_hardlinks(
|
|
248
|
+
self, size_groups: dict[int, list[FileInfo]]
|
|
249
|
+
) -> tuple[dict[int, list[FileInfo]], list[DuplicateGroup]]:
|
|
250
|
+
"""Identify hardlink sets within each size group and exclude them from further steps."""
|
|
251
|
+
hardlink_groups: list[DuplicateGroup] = []
|
|
252
|
+
surviving_groups: dict[int, list[FileInfo]] = {}
|
|
253
|
+
|
|
254
|
+
for size, flist in size_groups.items():
|
|
255
|
+
by_ino: dict[tuple[int, int], list[FileInfo]] = defaultdict(list)
|
|
256
|
+
for f in flist:
|
|
257
|
+
by_ino[(f.device, f.inode)].append(f)
|
|
258
|
+
|
|
259
|
+
remaining_in_size: list[FileInfo] = []
|
|
260
|
+
for files_with_ino in by_ino.values():
|
|
261
|
+
if len(files_with_ino) >= 2:
|
|
262
|
+
# Sort oldest first by mtime_ns, tie-break by path
|
|
263
|
+
sorted_files = sorted(files_with_ino, key=lambda x: (x.mtime_ns, str(x.path)))
|
|
264
|
+
hardlink_groups.append(
|
|
265
|
+
DuplicateGroup(
|
|
266
|
+
kind="hardlink",
|
|
267
|
+
files=tuple(sorted_files),
|
|
268
|
+
sha256=None,
|
|
269
|
+
wasted_bytes=0,
|
|
270
|
+
note="",
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
remaining_in_size.extend(files_with_ino)
|
|
275
|
+
|
|
276
|
+
if len(remaining_in_size) >= 2:
|
|
277
|
+
surviving_groups[size] = remaining_in_size
|
|
278
|
+
|
|
279
|
+
return surviving_groups, hardlink_groups
|
|
280
|
+
|
|
281
|
+
def _stage3_partial_hash(
|
|
282
|
+
self, size_groups: dict[int, list[FileInfo]]
|
|
283
|
+
) -> dict[str, list[FileInfo]]:
|
|
284
|
+
"""Read 64KiB head + 64KiB tail + size and group by partial SHA-256."""
|
|
285
|
+
by_partial: dict[str, list[FileInfo]] = defaultdict(list)
|
|
286
|
+
|
|
287
|
+
for flist in size_groups.values():
|
|
288
|
+
for f in flist:
|
|
289
|
+
if self.cancel is not None and self.cancel.is_set():
|
|
290
|
+
break
|
|
291
|
+
|
|
292
|
+
partial_hash = self._get_or_compute_partial_hash(f)
|
|
293
|
+
if partial_hash is not None:
|
|
294
|
+
by_partial[partial_hash].append(f)
|
|
295
|
+
|
|
296
|
+
return {ph: flist for ph, flist in by_partial.items() if len(flist) >= 2}
|
|
297
|
+
|
|
298
|
+
def _get_or_compute_partial_hash(self, f: FileInfo) -> str | None:
|
|
299
|
+
"""Lookup or compute partial hash for a file."""
|
|
300
|
+
if self.cache is not None:
|
|
301
|
+
cached_partial, _ = self.cache.get(f.device, f.inode, f.size, f.mtime_ns)
|
|
302
|
+
if cached_partial is not None:
|
|
303
|
+
return cached_partial
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
with f.path.open("rb") as fp:
|
|
307
|
+
first_64k = fp.read(PARTIAL_CHUNK_SIZE)
|
|
308
|
+
if f.size > PARTIAL_CHUNK_SIZE:
|
|
309
|
+
fp.seek(f.size - PARTIAL_CHUNK_SIZE)
|
|
310
|
+
last_64k = fp.read(PARTIAL_CHUNK_SIZE)
|
|
311
|
+
else:
|
|
312
|
+
last_64k = b""
|
|
313
|
+
|
|
314
|
+
ph = compute_partial_hash(first_64k, last_64k, f.size)
|
|
315
|
+
if self.cache is not None:
|
|
316
|
+
self.cache.put(f.device, f.inode, f.size, f.mtime_ns, ph, None)
|
|
317
|
+
return ph
|
|
318
|
+
except OSError as err:
|
|
319
|
+
logger.warning("Could not read %s for partial hash: %s", f.path, err)
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
def _stage4_full_hash(
|
|
323
|
+
self,
|
|
324
|
+
partial_groups: dict[str, list[FileInfo]],
|
|
325
|
+
*,
|
|
326
|
+
jobs: int,
|
|
327
|
+
progress: ProgressCb | None,
|
|
328
|
+
) -> list[DuplicateGroup]:
|
|
329
|
+
"""Compute full streaming SHA-256 using ThreadPoolExecutor."""
|
|
330
|
+
# Flatten all files that need full hash check
|
|
331
|
+
all_candidates: list[tuple[FileInfo, str]] = []
|
|
332
|
+
for ph, flist in partial_groups.items():
|
|
333
|
+
for f in flist:
|
|
334
|
+
all_candidates.append((f, ph))
|
|
335
|
+
|
|
336
|
+
if not all_candidates:
|
|
337
|
+
return []
|
|
338
|
+
|
|
339
|
+
bytes_total = sum(f.size for f, _ in all_candidates)
|
|
340
|
+
bytes_done = 0
|
|
341
|
+
progress_lock = threading.Lock()
|
|
342
|
+
|
|
343
|
+
def on_chunk(n: int) -> None:
|
|
344
|
+
if progress is not None:
|
|
345
|
+
nonlocal bytes_done
|
|
346
|
+
with progress_lock:
|
|
347
|
+
bytes_done += n
|
|
348
|
+
progress(min(bytes_done, bytes_total), bytes_total)
|
|
349
|
+
|
|
350
|
+
def hash_worker(item: tuple[FileInfo, str]) -> tuple[FileInfo, str] | None:
|
|
351
|
+
f, ph = item
|
|
352
|
+
if self.cancel is not None and self.cancel.is_set():
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
# Check cache
|
|
356
|
+
if self.cache is not None:
|
|
357
|
+
cached_partial, cached_full = self.cache.get(f.device, f.inode, f.size, f.mtime_ns)
|
|
358
|
+
if cached_full is not None and cached_partial == ph:
|
|
359
|
+
on_chunk(f.size)
|
|
360
|
+
return (f, cached_full)
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
full_hash = compute_full_hash(f.path, f.size, cancel=self.cancel, on_chunk=on_chunk)
|
|
364
|
+
except OSError as err:
|
|
365
|
+
logger.warning("Could not read %s for full hash: %s", f.path, err)
|
|
366
|
+
return None
|
|
367
|
+
except InterruptedError:
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
# Re-stat check after hashing: if file was modified or vanished, skip it
|
|
371
|
+
try:
|
|
372
|
+
st = f.path.lstat()
|
|
373
|
+
except OSError as err:
|
|
374
|
+
logger.warning("File %s vanished during scan: %s", f.path, err)
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
if st.st_size != f.size or st.st_mtime_ns != f.mtime_ns:
|
|
378
|
+
logger.warning("File %s changed during scan; skipping", f.path)
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
# Update cache with both partial and full hash
|
|
382
|
+
if self.cache is not None:
|
|
383
|
+
self.cache.put(f.device, f.inode, f.size, f.mtime_ns, ph, full_hash)
|
|
384
|
+
|
|
385
|
+
return (f, full_hash)
|
|
386
|
+
|
|
387
|
+
results: list[tuple[FileInfo, str]] = []
|
|
388
|
+
worker_count = max(1, jobs)
|
|
389
|
+
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
|
390
|
+
for res in executor.map(hash_worker, all_candidates):
|
|
391
|
+
if res is not None:
|
|
392
|
+
results.append(res)
|
|
393
|
+
|
|
394
|
+
by_full_hash: dict[str, list[FileInfo]] = defaultdict(list)
|
|
395
|
+
for f, fh in results:
|
|
396
|
+
by_full_hash[fh].append(f)
|
|
397
|
+
|
|
398
|
+
exact_groups: list[DuplicateGroup] = []
|
|
399
|
+
for fh, flist in by_full_hash.items():
|
|
400
|
+
if len(flist) >= 2:
|
|
401
|
+
# Oldest first by mtime_ns, tie-break by path
|
|
402
|
+
sorted_files = sorted(flist, key=lambda x: (x.mtime_ns, str(x.path)))
|
|
403
|
+
wasted = (len(sorted_files) - 1) * sorted_files[0].size
|
|
404
|
+
exact_groups.append(
|
|
405
|
+
DuplicateGroup(
|
|
406
|
+
kind="exact",
|
|
407
|
+
files=tuple(sorted_files),
|
|
408
|
+
sha256=fh,
|
|
409
|
+
wasted_bytes=wasted,
|
|
410
|
+
note="",
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
return exact_groups
|
|
415
|
+
|
|
416
|
+
def _compute_potential_groups(self, remaining_files: list[FileInfo]) -> list[DuplicateGroup]:
|
|
417
|
+
"""Group remaining files by normalized stem and extension."""
|
|
418
|
+
by_norm: dict[tuple[str, str], list[FileInfo]] = defaultdict(list)
|
|
419
|
+
for f in remaining_files:
|
|
420
|
+
norm_stem = normalize_stem(f.stem)
|
|
421
|
+
by_norm[(norm_stem, f.ext)].append(f)
|
|
422
|
+
|
|
423
|
+
potential_groups: list[DuplicateGroup] = []
|
|
424
|
+
for (norm_stem, ext), flist in by_norm.items():
|
|
425
|
+
if len(flist) >= 2:
|
|
426
|
+
sorted_files = sorted(flist, key=lambda x: (x.mtime_ns, str(x.path)))
|
|
427
|
+
note = f"same normalized name '{norm_stem}', ext '{ext}', content differs"
|
|
428
|
+
potential_groups.append(
|
|
429
|
+
DuplicateGroup(
|
|
430
|
+
kind="potential",
|
|
431
|
+
files=tuple(sorted_files),
|
|
432
|
+
sha256=None,
|
|
433
|
+
wasted_bytes=0,
|
|
434
|
+
note=note,
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
return potential_groups
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""SQLite-based hash cache for duplicate detection.
|
|
2
|
+
|
|
3
|
+
Persists partial and full SHA-256 hashes keyed by (dev, ino, size, mtime_ns).
|
|
4
|
+
Thread-safe with thread-local connections and WAL mode.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sqlite3
|
|
13
|
+
import threading
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class HashCache:
|
|
21
|
+
"""Thread-safe SQLite hash cache for file duplicate detection."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, cache_path: Path) -> None:
|
|
24
|
+
"""Creates cache.db at cache_path with 0600 perms. One connection per thread."""
|
|
25
|
+
self._cache_path = cache_path.resolve()
|
|
26
|
+
self._local = threading.local()
|
|
27
|
+
self._write_lock = threading.RLock()
|
|
28
|
+
self._all_conns: set[sqlite3.Connection] = set()
|
|
29
|
+
|
|
30
|
+
# Ensure parent directory exists with 0700 permissions
|
|
31
|
+
self._cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
with contextlib.suppress(OSError):
|
|
33
|
+
self._cache_path.parent.chmod(0o700)
|
|
34
|
+
|
|
35
|
+
# Create file with 0600 permissions if it doesn't exist
|
|
36
|
+
if not self._cache_path.exists():
|
|
37
|
+
fd = os.open(self._cache_path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
38
|
+
os.close(fd)
|
|
39
|
+
else:
|
|
40
|
+
with contextlib.suppress(OSError):
|
|
41
|
+
self._cache_path.chmod(0o600)
|
|
42
|
+
|
|
43
|
+
# Initialize schema
|
|
44
|
+
conn = self._get_conn()
|
|
45
|
+
with self._write_lock:
|
|
46
|
+
conn.execute(
|
|
47
|
+
"""
|
|
48
|
+
CREATE TABLE IF NOT EXISTS hashes (
|
|
49
|
+
dev INTEGER NOT NULL,
|
|
50
|
+
ino INTEGER NOT NULL,
|
|
51
|
+
size INTEGER NOT NULL,
|
|
52
|
+
mtime_ns INTEGER NOT NULL,
|
|
53
|
+
partial_hash TEXT,
|
|
54
|
+
full_hash TEXT,
|
|
55
|
+
hashed_at TEXT NOT NULL,
|
|
56
|
+
PRIMARY KEY (dev, ino, size, mtime_ns)
|
|
57
|
+
);
|
|
58
|
+
"""
|
|
59
|
+
)
|
|
60
|
+
conn.commit()
|
|
61
|
+
|
|
62
|
+
# Enforce 0600 permissions after database initialization
|
|
63
|
+
with contextlib.suppress(OSError):
|
|
64
|
+
self._cache_path.chmod(0o600)
|
|
65
|
+
|
|
66
|
+
def _get_conn(self) -> sqlite3.Connection:
|
|
67
|
+
"""Get or initialize thread-local sqlite3 connection."""
|
|
68
|
+
conn: sqlite3.Connection | None = getattr(self._local, "conn", None)
|
|
69
|
+
if conn is None:
|
|
70
|
+
conn = sqlite3.connect(self._cache_path, timeout=30.0, check_same_thread=False)
|
|
71
|
+
conn.execute("PRAGMA journal_mode=WAL;")
|
|
72
|
+
conn.execute("PRAGMA synchronous=NORMAL;")
|
|
73
|
+
self._local.conn = conn
|
|
74
|
+
with self._write_lock:
|
|
75
|
+
self._all_conns.add(conn)
|
|
76
|
+
return conn
|
|
77
|
+
|
|
78
|
+
def get(self, dev: int, ino: int, size: int, mtime_ns: int) -> tuple[str | None, str | None]:
|
|
79
|
+
"""Returns (partial_hash, full_hash) or (None, None) if not cached."""
|
|
80
|
+
conn = self._get_conn()
|
|
81
|
+
cur = conn.cursor()
|
|
82
|
+
cur.execute(
|
|
83
|
+
"""
|
|
84
|
+
SELECT partial_hash, full_hash
|
|
85
|
+
FROM hashes
|
|
86
|
+
WHERE dev = ? AND ino = ? AND size = ? AND mtime_ns = ?
|
|
87
|
+
""",
|
|
88
|
+
(dev, ino, size, mtime_ns),
|
|
89
|
+
)
|
|
90
|
+
row = cur.fetchone()
|
|
91
|
+
if row is None:
|
|
92
|
+
return (None, None)
|
|
93
|
+
return (row[0], row[1])
|
|
94
|
+
|
|
95
|
+
def put(
|
|
96
|
+
self,
|
|
97
|
+
dev: int,
|
|
98
|
+
ino: int,
|
|
99
|
+
size: int,
|
|
100
|
+
mtime_ns: int,
|
|
101
|
+
partial_hash: str | None,
|
|
102
|
+
full_hash: str | None,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Insert or update cached hashes for a file."""
|
|
105
|
+
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
106
|
+
with self._write_lock:
|
|
107
|
+
conn = self._get_conn()
|
|
108
|
+
conn.execute(
|
|
109
|
+
"""
|
|
110
|
+
INSERT INTO hashes (dev, ino, size, mtime_ns, partial_hash, full_hash, hashed_at)
|
|
111
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
112
|
+
ON CONFLICT(dev, ino, size, mtime_ns) DO UPDATE SET
|
|
113
|
+
partial_hash = COALESCE(excluded.partial_hash, hashes.partial_hash),
|
|
114
|
+
full_hash = COALESCE(excluded.full_hash, hashes.full_hash),
|
|
115
|
+
hashed_at = excluded.hashed_at
|
|
116
|
+
""",
|
|
117
|
+
(dev, ino, size, mtime_ns, partial_hash, full_hash, now_iso),
|
|
118
|
+
)
|
|
119
|
+
conn.commit()
|
|
120
|
+
|
|
121
|
+
def prune(self, older_than_days: int = 90) -> int:
|
|
122
|
+
"""Delete rows not seen for N days. Returns count."""
|
|
123
|
+
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
|
|
124
|
+
cutoff_iso = cutoff.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
125
|
+
with self._write_lock:
|
|
126
|
+
conn = self._get_conn()
|
|
127
|
+
cur = conn.cursor()
|
|
128
|
+
cur.execute("DELETE FROM hashes WHERE hashed_at < ?", (cutoff_iso,))
|
|
129
|
+
count = cur.rowcount
|
|
130
|
+
conn.commit()
|
|
131
|
+
return max(0, count)
|
|
132
|
+
|
|
133
|
+
def clear(self) -> None:
|
|
134
|
+
"""Delete all rows."""
|
|
135
|
+
with self._write_lock:
|
|
136
|
+
conn = self._get_conn()
|
|
137
|
+
conn.execute("DELETE FROM hashes")
|
|
138
|
+
conn.commit()
|
|
139
|
+
|
|
140
|
+
def close(self) -> None:
|
|
141
|
+
"""Close all connections opened by threads."""
|
|
142
|
+
with self._write_lock:
|
|
143
|
+
for conn in list(self._all_conns):
|
|
144
|
+
try:
|
|
145
|
+
conn.close()
|
|
146
|
+
except Exception as err:
|
|
147
|
+
logger.debug("Error closing connection: %s", err)
|
|
148
|
+
self._all_conns.clear()
|
|
149
|
+
self._local.conn = None
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Filename normalization for potential-duplicate grouping.
|
|
2
|
+
|
|
3
|
+
Strips only the closed list of numeric and copy patterns from docs/07 section 3.
|
|
4
|
+
Does not perform fuzzy matching.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
# Closed list of copy-pattern suffixes to strip (from docs/07 section 3):
|
|
12
|
+
# ' (1)', '(2)', ' - copy', ' copy', '_copy', '-copy', '_1', '-1'
|
|
13
|
+
# Applied to the STEM only, not the extension.
|
|
14
|
+
# 'report2.pdf' stays 'report2' (digit NOT part of copy pattern unless space/dash before)
|
|
15
|
+
COPY_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
16
|
+
re.compile(r"\s+\(\d+\)$"),
|
|
17
|
+
re.compile(r"\(\d+\)$"),
|
|
18
|
+
re.compile(r"\s+-\s+copy$"),
|
|
19
|
+
re.compile(r"\s+copy$"),
|
|
20
|
+
re.compile(r"_copy$"),
|
|
21
|
+
re.compile(r"-copy$"),
|
|
22
|
+
re.compile(r"_\d+$"),
|
|
23
|
+
re.compile(r"-\d+$"),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_stem(stem: str) -> str:
|
|
28
|
+
"""Casefold and strip copy markers. Only the patterns in the closed list.
|
|
29
|
+
|
|
30
|
+
Idempotent: strips until no more patterns match.
|
|
31
|
+
"""
|
|
32
|
+
stem = stem.casefold()
|
|
33
|
+
while True:
|
|
34
|
+
matched = False
|
|
35
|
+
for pat in COPY_PATTERNS:
|
|
36
|
+
m = pat.search(stem)
|
|
37
|
+
if m is not None:
|
|
38
|
+
new_stem = stem[: m.start()]
|
|
39
|
+
if new_stem: # do not strip down to empty string
|
|
40
|
+
stem = new_stem
|
|
41
|
+
matched = True
|
|
42
|
+
break
|
|
43
|
+
if not matched:
|
|
44
|
+
break
|
|
45
|
+
return stem
|