Add delete and ignore actions to Duplicate Courses
Delete permanently removes a course from disk (confirmed with the full path first) and cleans up any Hidden/Next Up/Recently Viewed references to it. Ignore marks a specific pair as confirmed-not- duplicates so it stops resurfacing in scans, without suppressing either course's other matches; ignored pairs are listed and reversible under "Ignored matches," and now travel with backup/restore. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+138
-2
@@ -12,6 +12,7 @@ import re
|
||||
import sys
|
||||
import argparse
|
||||
import io
|
||||
import itertools
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -1450,6 +1451,47 @@ def _propose_destination(item_name: str, categories: List[Dict[str, Any]],
|
||||
}
|
||||
|
||||
|
||||
IGNORED_DUPLICATES_FILE = os.path.join(DATA_DIR, 'ignored_duplicates.json')
|
||||
|
||||
|
||||
def get_ignored_duplicate_pairs() -> Set[Tuple[str, str]]:
|
||||
"""Pairs of course paths the user has confirmed are NOT duplicates of
|
||||
each other, despite matching the tokenizer - excluded from future
|
||||
duplicate scans (see _find_duplicate_courses) so a wrong call doesn't
|
||||
keep resurfacing. Each pair is stored path-order-independent."""
|
||||
try:
|
||||
if os.path.exists(IGNORED_DUPLICATES_FILE):
|
||||
with open(IGNORED_DUPLICATES_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
return {tuple(sorted(pair)) for pair in data if isinstance(pair, list) and len(pair) == 2}
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"Could not load ignored duplicates: {e}")
|
||||
return set()
|
||||
|
||||
|
||||
def _save_ignored_duplicate_pairs(pairs: Set[Tuple[str, str]]) -> None:
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
with open(IGNORED_DUPLICATES_FILE, 'w') as f:
|
||||
json.dump([list(p) for p in sorted(pairs)], f, indent=2)
|
||||
|
||||
|
||||
def add_ignored_duplicate_pairs(pairs: List[Tuple[str, str]]) -> None:
|
||||
"""Mark one or more course-path pairs as confirmed-not-duplicates."""
|
||||
existing = get_ignored_duplicate_pairs()
|
||||
existing |= {tuple(sorted(p)) for p in pairs}
|
||||
_save_ignored_duplicate_pairs(existing)
|
||||
|
||||
|
||||
def remove_ignored_duplicate_pair(path_a: str, path_b: str) -> None:
|
||||
"""Undo add_ignored_duplicate_pairs for one pair - lets a wrong ignore
|
||||
call be reversed, matching the reversible-curation pattern hidden
|
||||
paths/Next Up already follow elsewhere in the app."""
|
||||
existing = get_ignored_duplicate_pairs()
|
||||
existing.discard(tuple(sorted((path_a, path_b))))
|
||||
_save_ignored_duplicate_pairs(existing)
|
||||
|
||||
|
||||
def _find_duplicate_courses(min_similarity: float = 0.6) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Group courses across the whole library (Unsorted included, so a
|
||||
@@ -1464,9 +1506,12 @@ def _find_duplicate_courses(min_similarity: float = 0.6) -> List[Dict[str, Any]]
|
||||
Pairwise Jaccard similarity (shared tokens / all tokens) at or above
|
||||
min_similarity gets union-found into groups, so if A matches B and B
|
||||
matches C, all three land in one group instead of two overlapping
|
||||
pairs. Nothing here touches disk - it's read-only, for the user to
|
||||
review and hide/rename/move manually.
|
||||
pairs - except any pair the user has explicitly marked as not-actually-
|
||||
duplicates (get_ignored_duplicate_pairs), which never unions regardless
|
||||
of how similar the names look. Nothing here touches disk - it's
|
||||
read-only, for the user to review and hide/delete/rename/move manually.
|
||||
"""
|
||||
ignored_pairs = get_ignored_duplicate_pairs()
|
||||
entries = []
|
||||
for course in get_all_course_dirs():
|
||||
tokens = _tokenize(course.name)
|
||||
@@ -1490,6 +1535,8 @@ def _find_duplicate_courses(min_similarity: float = 0.6) -> List[Dict[str, Any]]
|
||||
pair_similarity: Dict[Tuple[int, int], float] = {}
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
if tuple(sorted((entries[i]['path'], entries[j]['path']))) in ignored_pairs:
|
||||
continue
|
||||
a, b = entries[i]['tokens'], entries[j]['tokens']
|
||||
union_tokens = a | b
|
||||
if not union_tokens:
|
||||
@@ -3053,6 +3100,94 @@ def duplicate_courses_api():
|
||||
return jsonify({'groups': groups, 'course_count': len(get_all_course_dirs())})
|
||||
|
||||
|
||||
@app.route('/api/library/duplicates/ignore', methods=['POST'])
|
||||
def ignore_duplicate_group_api():
|
||||
"""Mark every pairwise combination within a reviewed duplicate group as
|
||||
confirmed-not-duplicates, so the same match doesn't resurface on the
|
||||
next scan."""
|
||||
data = request.json or {}
|
||||
paths = data.get('paths') or []
|
||||
if not isinstance(paths, list) or len(paths) < 2:
|
||||
return jsonify({'success': False, 'error': 'Need at least 2 paths'}), 400
|
||||
|
||||
pairs = list(itertools.combinations(sorted(set(paths)), 2))
|
||||
add_ignored_duplicate_pairs(pairs)
|
||||
return jsonify({'success': True, 'ignored_pairs': len(pairs)})
|
||||
|
||||
|
||||
@app.route('/api/library/duplicates/ignored')
|
||||
def list_ignored_duplicates_api():
|
||||
"""Every pair currently excluded from duplicate scans, for a 'currently
|
||||
ignored' review list - the same reversible-curation pattern hidden
|
||||
paths already use."""
|
||||
result = []
|
||||
for path_a, path_b in sorted(get_ignored_duplicate_pairs()):
|
||||
result.append({
|
||||
'a': {'path': path_a, 'name': os.path.basename(path_a.rstrip(os.sep)) or path_a},
|
||||
'b': {'path': path_b, 'name': os.path.basename(path_b.rstrip(os.sep)) or path_b},
|
||||
})
|
||||
return jsonify({'ignored': result})
|
||||
|
||||
|
||||
@app.route('/api/library/duplicates/ignore', methods=['DELETE'])
|
||||
def restore_ignored_duplicate_api():
|
||||
"""Un-ignore one pair, so it's eligible to show up in duplicate scans again."""
|
||||
data = request.json or {}
|
||||
path_a = data.get('a', '')
|
||||
path_b = data.get('b', '')
|
||||
if not path_a or not path_b:
|
||||
return jsonify({'success': False, 'error': 'Missing pair'}), 400
|
||||
remove_ignored_duplicate_pair(path_a, path_b)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/library/delete', methods=['POST'])
|
||||
def delete_library_item_api():
|
||||
"""
|
||||
Permanently delete a course/folder from disk - the one truly
|
||||
destructive action in this app, so it's scoped tightly: the path must
|
||||
be inside the library root and can't be the root itself. The client is
|
||||
expected to have already confirmed with the user (Duplicate Courses is
|
||||
the only caller today). Also clears any hidden/Next-Up/Recently-Viewed
|
||||
references to the deleted path, the same cleanup Clean Up Stale
|
||||
References would otherwise have to do after the fact.
|
||||
"""
|
||||
data = request.json or {}
|
||||
path = (data.get('path') or '').strip()
|
||||
if not path:
|
||||
return jsonify({'success': False, 'error': 'Missing path'}), 400
|
||||
|
||||
library_root = os.path.abspath(get_library_root())
|
||||
target_abs = os.path.abspath(path)
|
||||
|
||||
if not (target_abs == library_root or target_abs.startswith(library_root + os.sep)):
|
||||
return jsonify({'success': False, 'error': 'Path is outside the library'}), 403
|
||||
if target_abs == library_root:
|
||||
return jsonify({'success': False, 'error': 'Cannot delete the library root itself'}), 400
|
||||
if not os.path.isdir(target_abs):
|
||||
return jsonify({'success': False, 'error': 'No longer exists - already deleted?'}), 404
|
||||
|
||||
try:
|
||||
shutil.rmtree(target_abs)
|
||||
except OSError as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
invalidate_cache()
|
||||
set_path_hidden(target_abs, False)
|
||||
set_path_queued(target_abs, False)
|
||||
remove_recent_views_for_path(target_abs)
|
||||
|
||||
global current_course
|
||||
active_course_reset = False
|
||||
if current_course is not None:
|
||||
course_abs = os.path.abspath(current_course.path)
|
||||
if course_abs == target_abs or course_abs.startswith(target_abs + os.sep):
|
||||
current_course = None
|
||||
active_course_reset = True
|
||||
|
||||
return jsonify({'success': True, 'active_course_reset': active_course_reset})
|
||||
|
||||
|
||||
@app.route('/api/library/stale-references')
|
||||
def stale_references_api():
|
||||
"""Entries in hidden_paths/next_up/recent_views pointing at paths no
|
||||
@@ -3432,6 +3567,7 @@ BACKUP_ROOT_FILES = {
|
||||
'next_up.json': NEXT_UP_FILE,
|
||||
'recent_views.json': RECENT_VIEWS_FILE,
|
||||
'outline_config.json': OUTLINE_CONFIG_FILE,
|
||||
'ignored_duplicates.json': IGNORED_DUPLICATES_FILE,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user