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,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