Tighten Sort Unsorted matching; consolidate file management tools

Fix Sort Unsorted picking a wrong destination on words that are common
clutter across many categories (a creator's name, generic marketing
filler) by ranking matches on a folder's own deliberate name over
incidental sibling-course words, and downweighting borrowed words that
recur across many categories. Add a folder-picker/create-new/rename UI
to override any proposal.

Turn /unsorted into a general File Management page: move Refresh
Library, Bulk Rename, and Manage Library (hide/rename) here from
Settings, add a Move action to Manage Library using the same picker,
and add Duplicate Courses and Clean Up Stale References tools. Add a
footer link to the page from the dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 10:29:55 -04:00
co-authored by Claude Sonnet 5
parent 4eda493e00
commit 9ff8c70311
5 changed files with 1325 additions and 535 deletions
+43 -10
View File
@@ -88,19 +88,52 @@ silently stay blank instead of erroring.
- Cover art: uses a manually-placed `cover`/`folder`/`thumbnail`/`thumb`/
`poster` image if a course has one, otherwise auto-generates one via
`ffmpeg` from a frame of the course's first video
- Hide courses/folders from the browser without touching anything on disk;
bulk-select to hide or queue several at once
- Bulk rename across course/folder names (find & replace)
- **Sort Unsorted** ([/unsorted](templates/unsorted.html), linked from
Settings): drop new/incoming courses into an `Unsorted` folder at the
library root, then scan proposes a destination for each one by keyword
- **File Management** ([/unsorted](templates/unsorted.html), linked from
Settings and from a footer link on the dashboard): a dedicated page for
everything that touches files on disk, since it needs more room than a
Settings card and warrants review before anything actually changes.
- *Sort Unsorted*: drop new/incoming courses into an `Unsorted` folder at
the library root, then scan proposes a destination for each one by keyword
overlap against the *existing* category tree - read fresh from disk every
scan, so it adapts to whatever folders you actually have rather than any
hardcoded subject list. Three outcomes per item: a confident match to an
existing folder, a suggested new subfolder under a broader category match,
or "needs review" (unchecked by default) when nothing overlaps at all.
Every destination is editable before applying, and nothing on disk moves
until you review and hit Apply.
Every row has a folder-picker dropdown (listing every real category in the
library, plus "+ Create new folder…" to type a brand-new path) so a wrong
or low-confidence guess is one click to correct, and a name field to
rename the course's folder in the same move. Nothing on disk moves until
you review and hit Apply. Matching ignores common noise (e-learning
platform names, release/distribution-group tags, dates) via a stopword
list, and beyond that treats a match against a folder's own deliberate
name as always stronger evidence than a word only borrowed from a
sibling course's title - and among those borrowed words, downweights
ones that recur across many categories (a prolific creator's name, etc.)
so they can't outvote a genuinely specific word just by sharing more of
them.
- *Refresh Library*: manually bypasses the 5-minute filesystem-scan cache,
for when files were added/removed directly on disk.
- *Bulk Rename*: find & replace across every course/folder name in the
library at once, with a per-match preview and the ability to drop
individual matches before applying.
- *Manage Library*: hide courses/folders from the browser without
touching anything on disk (hiding a folder hides everything inside it;
bulk-select elsewhere to hide or queue several at once), rename a
course/folder in place, or **move** one anywhere else in the library via
the same folder-picker/create-new-folder UI Sort Unsorted uses - handy
for correcting a bad auto-sort later or just reorganizing.
- *Duplicate Courses*: scans every course in the library (Unsorted
included) for names that look like the same thing filed twice, using the
same tokenizer as Sort Unsorted so a platform-name or release-tag
difference doesn't hide a real duplicate. Groups by similarity (union of
any two courses over the threshold, transitively) rather than showing
raw pairs; each course in a group gets a one-click Hide.
- *Clean Up Stale References*: finds entries in the hidden-paths list,
Next Up queue, or Recently Viewed history that point at a path no longer
on disk - normally from renaming/moving/deleting a course directly on
the NAS instead of through the app (doing it through the app already
keeps these in sync). Review individually or clear them all at once;
course files themselves are never touched.
**Dashboard**
- Library-wide stats (courses, lessons completed, time watched, time
@@ -138,8 +171,8 @@ silently stay blank instead of erroring.
actual color distance), or a fully custom accent color, corner radius,
and card style
- Font, text size, page width, and spacing density
- Default library path override + manual "Refresh Library" (bypasses the
5-minute filesystem-scan cache)
- Default library path override (Refresh Library and Bulk Rename live on the
File Management page - see above)
**Backup & integrations**
- One-click backup export and restore: settings, hidden-path choices, the
+373 -37
View File
@@ -1009,10 +1009,11 @@ def rebase_library_path(old_abs: str, new_abs: str) -> None:
"""
After a directory in the library is renamed/moved on disk, rewrite any
stored absolute paths that pointed inside it (hidden_paths.json,
recent_views.json's course_path) so curation and Recently Viewed don't
silently go stale. Lesson-level progress needs no rebasing - it lives
inside the directory itself, keyed by paths relative to it, so it moves
with the rename automatically.
next_up.json, recent_views.json's course_path) so curation, the Next
Up queue, and Recently Viewed don't silently go stale. Lesson-level
progress needs no rebasing - it lives inside the directory itself,
keyed by paths relative to it, so it moves with the rename
automatically.
"""
hidden = get_hidden_paths()
rebased_hidden = [_rebase_prefix(p, old_abs, new_abs) for p in hidden]
@@ -1021,6 +1022,11 @@ def rebase_library_path(old_abs: str, new_abs: str) -> None:
with open(HIDDEN_PATHS_FILE, 'w') as f:
json.dump(sorted(set(rebased_hidden)), f, indent=2)
queued = get_next_up_paths()
rebased_queue = [_rebase_prefix(p, old_abs, new_abs) for p in queued]
if rebased_queue != queued:
_save_next_up_paths(rebased_queue)
views = get_recent_views()
changed = False
for entry in views:
@@ -1200,6 +1206,29 @@ _TOKEN_STOPWORDS = {
'a', 'an', 'and', 'for', 'from', 'in', 'is', 'of', 'on', 'or', 'the', 'to', 'with',
'course', 'courses', 'training', 'class', 'complete', 'full', 'guide', 'tutorial',
'part', 'vol', 'volume', 'edition',
# Skill-level/marketing filler - describes the packaging, not the
# subject, same spirit as 'complete'/'guide'/'tutorial' above.
'advanced', 'intermediate', 'beginner', 'beginners', 'basics', 'basic',
'essentials', 'fundamentals', 'mastering', 'master', 'crash', 'level', 'levels',
# E-learning platforms/marketplaces - whichever site a course was bought
# or ripped from says nothing about its subject, and it shows up in
# almost every course name from that source.
'udemy', 'pluralsight', 'lynda', 'linkedin', 'linkedinlearning', 'coursera',
'skillshare', 'packt', 'packtpub', 'oreilly', 'edx', 'educative', 'egghead',
'frontendmasters', 'codecademy', 'udacity', 'skillsoft', 'cbtnuggets', 'cbt',
'itprotv', 'cybrary', 'tutsplus', 'masterclass', 'treehouse', 'datacamp',
# Scene/distribution-group tags and rip metadata that piggyback on
# downloaded course names - never a subject, always noise.
'bookware', 'tutsnode', 'ftu', 'ftuforum', 'freecoursesonline', 'freetutorials',
'coursedrive', 'downtem', 'glodls', 'garr', 'ridware', 'illiterate', 'zh',
'nfo', 'readnfo', 'repack', 'reup', 'reupload', 'repost', 'release', 'released',
'rip', 'x264', 'x265', 'www', 'com', 'net', 'org', 'info',
# "Freshness" marketing words - describe the release, not the subject.
'update', 'updated', 'new', 'newest', 'latest', 'final', 'retail',
# Calendar words - dates say nothing about what a course is about.
'january', 'february', 'march', 'april', 'june', 'july', 'august', 'september',
'october', 'november', 'december', 'jan', 'feb', 'mar', 'apr', 'jun', 'jul',
'aug', 'sep', 'sept', 'oct', 'nov', 'dec',
}
@@ -1236,16 +1265,26 @@ def _tokenize_ordered(name: str) -> List[str]:
def _build_category_index() -> List[Dict[str, Any]]:
"""
Every category/subcategory folder currently in the library (e.g. IT,
IT/AWS) as a possible destination, each with keyword tokens from its
own path components plus the titles of any courses already filed
directly under it - that second part lets a folder match on subjects
its name alone doesn't mention (an "IT/AWS" folder full of courses
titled "...Solutions Architect..." now also matches on "solutions" and
"architect"), without hardcoding any subject list. Stops recursing
into a folder once it reads as a course itself (_looks_like_course,
the same rule the Library browser uses), so individual courses never
show up as if they were categories to file things under. The Unsorted
folder itself is excluded - it's the source, never a valid destination.
IT/AWS) as a possible destination, each with keyword tokens split into
two tiers of trust:
- 'path_tokens': from the folder's own path components - a deliberate
name the user chose, so a match here is always taken at face value.
- 'bonus_tokens': from the titles of courses already filed directly
under it - lets a folder match on subjects its name alone doesn't
mention (an "IT/AWS" folder full of courses titled "...Solutions
Architect..." now also matches on "solutions" and "architect"),
without hardcoding any subject list. Since these come from
whatever the courses happen to be named, they're incidental rather
than deliberate - _propose_destination downweights them by how
many *different* categories share the same bonus word, so a word
that turns out to be common clutter (recurring across many
categories' course titles) can't outweigh a specific match
elsewhere just by sharing more of it.
Stops recursing into a folder once it reads as a course itself
(_looks_like_course, the same rule the Library browser uses), so
individual courses never show up as if they were categories to file
things under. The Unsorted folder itself is excluded - it's the
source, never a valid destination.
"""
library_root = Path(get_library_root())
try:
@@ -1271,20 +1310,23 @@ def _build_category_index() -> List[Dict[str, Any]]:
if _looks_like_course(entry):
continue
new_parts = path_parts + [entry.name]
tokens: Set[str] = set()
path_tokens: Set[str] = set()
for part in new_parts:
tokens |= _tokenize(part)
path_tokens |= _tokenize(part)
bonus_tokens: Set[str] = set()
try:
for child in entry.iterdir():
if child.is_dir() and not child.name.startswith('.') and _looks_like_course(child):
tokens |= _tokenize(child.name)
bonus_tokens |= _tokenize(child.name)
except (PermissionError, OSError):
pass
bonus_tokens -= path_tokens
categories.append({
'path': str(entry),
'relative': '/'.join(new_parts),
'depth': len(new_parts),
'tokens': tokens,
'path_tokens': path_tokens,
'bonus_tokens': bonus_tokens,
})
walk(entry, new_parts)
@@ -1292,6 +1334,20 @@ def _build_category_index() -> List[Dict[str, Any]]:
return categories
def _bonus_token_frequency(categories: List[Dict[str, Any]]) -> Dict[str, int]:
"""How many distinct categories each bonus token shows up in, across
the whole library - used to tell a word that's actually specific to one
place ("cloudformation") from one that's just common clutter recurring
in course titles everywhere ("advanced", a creator's name, etc.), so
the latter can be downweighted instead of needing to be individually
named as a stopword ahead of time."""
freq: Dict[str, int] = {}
for cat in categories:
for token in cat['bonus_tokens']:
freq[token] = freq.get(token, 0) + 1
return freq
def _list_unsorted_items() -> List[Path]:
"""Direct children of the Unsorted folder - each one is a course
waiting to be filed into the real category tree."""
@@ -1304,7 +1360,8 @@ def _list_unsorted_items() -> List[Path]:
)
def _propose_destination(item_name: str, categories: List[Dict[str, Any]]) -> Dict[str, Any]:
def _propose_destination(item_name: str, categories: List[Dict[str, Any]],
bonus_df: Dict[str, int]) -> Dict[str, Any]:
"""
Best-effort destination for one Unsorted item, by keyword overlap
against the existing category tree. Three outcomes:
@@ -1314,11 +1371,26 @@ def _propose_destination(item_name: str, categories: List[Dict[str, Any]]) -> Di
named from whichever of the item's own keywords the category
didn't already cover.
- 'manual': nothing matches with any confidence - needs a human.
"Confident" here means either a fairly specific existing folder (2+
levels deep, e.g. IT/AWS) matched on even one shared keyword, or a
shallower folder matched on 2+ - a single shared word with a bare
top-level category ("IT") isn't enough to drop something straight
into it; that's exactly the case a new subfolder proposal is for.
Categories are ranked by evidence quality, not raw shared-word count -
a match on a *path* token (the folder's own deliberate name, e.g. the
"AWS" in IT/AWS) always outranks any number of *bonus* tokens (words
only borrowed from a sibling course's title - see
_build_category_index), since a folder's own name is deliberate and a
sibling course's title is incidental. Among bonus-only matches, the
single least-diluted word wins rather than the total - a bonus word
shared by only one category counts fully, one recurring across N
categories counts for 1/N - so several incidental, recurring words (a
prolific creator's name, a repeated marketing phrase, etc. showing up
across many categories' course titles) can't outvote one genuinely
specific word just by there being more of them, and don't need to be
individually named as stopwords ahead of time to be discounted.
"Confident" then means the best match is either a path-token hit or an
essentially-unique bonus word (not diluted across other categories),
AND it's either at a fairly specific existing folder (2+ levels deep,
e.g. IT/AWS) or corroborated by a second shared word - one weak,
common word alone isn't enough to drop something straight into a bare
top-level category ("IT"); that's exactly the case a new subfolder
proposal is for.
"""
item_tokens_ordered = _tokenize_ordered(item_name)
item_tokens = set(item_tokens_ordered)
@@ -1328,26 +1400,36 @@ def _propose_destination(item_name: str, categories: List[Dict[str, Any]]) -> Di
scored = []
for cat in categories:
overlap = item_tokens & cat['tokens']
if overlap:
scored.append((len(overlap), cat['depth'], cat, overlap))
overlap = item_tokens & (cat['path_tokens'] | cat['bonus_tokens'])
if not overlap:
continue
path_hits = overlap & cat['path_tokens']
bonus_overlap = overlap - cat['path_tokens']
bonus_best = max((1.0 / bonus_df.get(t, 1) for t in bonus_overlap), default=0.0)
scored.append({
'cat': cat, 'overlap': overlap,
'path_hits': len(path_hits), 'bonus_best': bonus_best, 'count': len(overlap),
})
if not scored:
return {'type': 'manual', 'destination': None,
'reason': "No existing folder shares any keywords with this name."}
scored.sort(key=lambda s: (s[0], s[1]), reverse=True)
best_count, best_depth, best_cat, best_overlap = scored[0]
overlap_words = ', '.join(w for w in item_tokens_ordered if w in best_overlap)
scored.sort(key=lambda s: (s['path_hits'], s['bonus_best'], s['count'], s['cat']['depth']), reverse=True)
best = scored[0]
best_cat = best['cat']
overlap_words = ', '.join(w for w in item_tokens_ordered if w in best['overlap'])
best_all_tokens = best_cat['path_tokens'] | best_cat['bonus_tokens']
if best_depth >= 2 or best_count >= 2:
is_strong_match = best['path_hits'] >= 1 or best['bonus_best'] >= 1.0
if is_strong_match and (best_cat['depth'] >= 2 or best['count'] >= 2):
return {
'type': 'existing',
'destination': best_cat['relative'],
'reason': f'Matches existing folder "{best_cat["relative"]}" on: {overlap_words}',
}
leftover = item_tokens - best_cat['tokens']
leftover = item_tokens - best_all_tokens
if leftover:
# preserve the item's own word order rather than the set's
# arbitrary one, so "Docker Compose Basics" suggests "Compose
@@ -1368,6 +1450,110 @@ def _propose_destination(item_name: str, categories: List[Dict[str, Any]]) -> Di
}
def _find_duplicate_courses(min_similarity: float = 0.6) -> List[Dict[str, Any]]:
"""
Group courses across the whole library (Unsorted included, so a
just-dropped item that duplicates something already filed gets caught
too) whose names look like the same thing filed twice - a re-download
that landed in a different category, or a course that's both sorted
and still sitting in Unsorted. Reuses Sort Unsorted's own
tokenizer/stopword list, so platform names, release-group tags, and
dates are already ignored - "AWS Certified Solutions Architect" and
"AWS.Certified.Solutions.Architect.2024.BOOKWARE-XYZ" tokenize down to
the same core words despite looking nothing alike as strings.
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.
"""
entries = []
for course in get_all_course_dirs():
tokens = _tokenize(course.name)
if tokens:
entries.append({'path': str(course), 'name': course.name, 'tokens': tokens})
n = len(entries)
parent = list(range(n))
def find(i: int) -> int:
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
def union(i: int, j: int) -> None:
ri, rj = find(i), find(j)
if ri != rj:
parent[ri] = rj
pair_similarity: Dict[Tuple[int, int], float] = {}
for i in range(n):
for j in range(i + 1, n):
a, b = entries[i]['tokens'], entries[j]['tokens']
union_tokens = a | b
if not union_tokens:
continue
similarity = len(a & b) / len(union_tokens)
if similarity >= min_similarity:
pair_similarity[(i, j)] = similarity
union(i, j)
clusters: Dict[int, List[int]] = {}
for i in range(n):
clusters.setdefault(find(i), []).append(i)
groups = []
for members in clusters.values():
if len(members) < 2:
continue
best_similarity = max(
(sim for (i, j), sim in pair_similarity.items() if i in members and j in members),
default=0.0
)
groups.append({
'courses': sorted(
({'path': entries[i]['path'], 'name': entries[i]['name']} for i in members),
key=lambda c: c['name'].lower()
),
'similarity': round(best_similarity, 2),
})
groups.sort(key=lambda g: g['similarity'], reverse=True)
return groups
def _find_stale_references() -> Dict[str, List[Dict[str, str]]]:
"""
Entries in hidden_paths.json / next_up.json / recent_views.json that
point at a path no longer on disk - typically because a course was
renamed, moved, or deleted directly on the NAS rather than through the
app (a rename/move made through the app already rebases these, see
rebase_library_path). Read-only; nothing is removed until
/api/library/stale-references/clean is called with the exact items
reviewed here.
"""
stale: Dict[str, List[Dict[str, str]]] = {'hidden': [], 'next_up': [], 'recent_views': []}
for path in get_hidden_paths():
if not os.path.isdir(path):
stale['hidden'].append({'path': path, 'name': os.path.basename(path.rstrip(os.sep)) or path})
for path in get_next_up_paths():
if not os.path.isdir(path):
stale['next_up'].append({'path': path, 'name': os.path.basename(path.rstrip(os.sep)) or path})
seen: Set[str] = set()
for entry in get_recent_views():
path = entry.get('course_path', '')
if path and path not in seen and not os.path.isdir(path):
seen.add(path)
name = entry.get('course_name') or os.path.basename(path.rstrip(os.sep)) or path
stale['recent_views'].append({'path': path, 'name': name})
return stale
def _probe_media_duration_seconds(file_path: Path) -> Optional[float]:
"""Read a single media file's duration via ffprobe. None if ffprobe is
missing, the file isn't readable, or the output can't be parsed."""
@@ -2045,6 +2231,20 @@ def get_recent_views() -> List[Dict[str, Any]]:
return []
def remove_recent_views_for_path(path: str) -> List[Dict[str, Any]]:
"""Drop every Recently Viewed entry for one course path - there can be
several, one per lesson. Used to clear a stale reference left behind
when a course was deleted/moved outside the app."""
entries = get_recent_views()
normalized = os.path.abspath(path)
remaining = [e for e in entries if e.get('course_path') != normalized]
if remaining != entries:
os.makedirs(DATA_DIR, exist_ok=True)
with open(RECENT_VIEWS_FILE, 'w') as f:
json.dump(remaining, f, indent=2)
return remaining
def _read_lesson_progress_entry(course_path: str, lesson_path: str) -> Dict[str, Any]:
"""
Read a single lesson's progress entry directly from its course's own
@@ -2681,16 +2881,20 @@ def scan_unsorted_api():
items = _list_unsorted_items()
categories = _build_category_index()
bonus_df = _bonus_token_frequency(categories)
results = []
for item in items:
proposal = _propose_destination(item.name, categories)
proposal = _propose_destination(item.name, categories, bonus_df)
results.append({'name': item.name, 'path': str(item), **proposal})
category_paths = sorted((c['relative'] for c in categories), key=str.lower)
return jsonify({
'items': results,
'unsorted_exists': True,
'unsorted_path': unsorted_root,
'category_count': len(categories),
'categories': category_paths,
})
@@ -2698,8 +2902,10 @@ def scan_unsorted_api():
def apply_unsorted_api():
"""
Move a batch of Unsorted items to their (possibly user-edited)
destinations, creating new subfolders as needed. Every source must
actually be inside the Unsorted folder and every destination must
destinations, creating new subfolders as needed, and optionally renaming
each item's folder in the same step (move + rename is one filesystem
operation either way, so there's no reason to make it two). Every source
must actually be inside the Unsorted folder and every destination must
stay inside the library root and outside Unsorted itself - guards
against a hand-edited destination path escaping the library, the same
zip-slip-style check used for backup restore.
@@ -2738,9 +2944,16 @@ def apply_unsorted_api():
errors.append({'source': source, 'error': 'Destination cannot be inside Unsorted'})
continue
final_path = os.path.join(dest_dir, os.path.basename(source_abs))
final_name = ((move or {}).get('name') or '').strip()
if not final_name:
final_name = os.path.basename(source_abs)
if os.sep in final_name or (os.altsep and os.altsep in final_name) or final_name in ('.', '..'):
errors.append({'source': source, 'error': f'"{final_name}" is not a valid folder name'})
continue
final_path = os.path.join(dest_dir, final_name)
if os.path.exists(final_path):
errors.append({'source': source, 'error': f'"{os.path.basename(source_abs)}" already exists at that destination'})
errors.append({'source': source, 'error': f'"{final_name}" already exists at that destination'})
continue
try:
@@ -2754,6 +2967,129 @@ def apply_unsorted_api():
return jsonify({'success': True, 'moved': len(moved), 'errors': errors})
@app.route('/api/library/categories')
def library_categories_api():
"""Every category/subcategory folder in the library, for destination
pickers - Sort Unsorted's own picker gets this from /api/unsorted/scan
already; this is the same list for callers (like Manage Library's move
action) that need it without depending on an Unsorted-folder scan."""
categories = _build_category_index()
return jsonify({'categories': sorted((c['relative'] for c in categories), key=str.lower)})
@app.route('/api/library/move', methods=['POST'])
def move_library_item_api():
"""
Move (and optionally rename) any course or folder already in the
library to a different location - the general-purpose version of
/api/unsorted/apply, for reorganizing something already filed rather
than just-arrived. Same source/destination-inside-library-root safety
checks, plus one apply_unsorted_api doesn't need: the destination
can't be inside the source's own subtree, since here the source can
itself be a category folder with children, not just a leaf course.
"""
data = request.json or {}
source = (data.get('source') or '').strip()
destination_relative = (data.get('destination') or '').strip().strip('/')
final_name = (data.get('name') or '').strip()
if not source or not destination_relative:
return jsonify({'success': False, 'error': 'Missing source or destination'}), 400
library_root = os.path.abspath(get_library_root())
source_abs = os.path.abspath(source)
if not (source_abs == library_root or source_abs.startswith(library_root + os.sep)):
return jsonify({'success': False, 'error': 'Source is outside the library'}), 403
if source_abs == library_root:
return jsonify({'success': False, 'error': 'Cannot move the library root itself'}), 400
if not os.path.isdir(source_abs):
return jsonify({'success': False, 'error': 'Source no longer exists'}), 404
dest_dir = os.path.abspath(os.path.join(library_root, destination_relative))
if not (dest_dir == library_root or dest_dir.startswith(library_root + os.sep)):
return jsonify({'success': False, 'error': 'Destination is outside the library'}), 403
if dest_dir == source_abs or dest_dir.startswith(source_abs + os.sep):
return jsonify({'success': False, 'error': 'Cannot move a folder into itself'}), 400
if not final_name:
final_name = os.path.basename(source_abs)
if os.sep in final_name or (os.altsep and os.altsep in final_name) or final_name in ('.', '..'):
return jsonify({'success': False, 'error': f'"{final_name}" is not a valid folder name'}), 400
final_path = os.path.join(dest_dir, final_name)
if final_path == source_abs:
return jsonify({'success': False, 'error': 'Already there'}), 400
if os.path.exists(final_path):
return jsonify({'success': False, 'error': f'"{final_name}" already exists at that destination'}), 409
try:
os.makedirs(dest_dir, exist_ok=True)
shutil.move(source_abs, final_path)
except OSError as e:
return jsonify({'success': False, 'error': str(e)}), 500
invalidate_cache()
rebase_library_path(source_abs, final_path)
global current_course
active_course_reset = False
if current_course is not None:
course_abs = os.path.abspath(current_course.path)
if course_abs == source_abs or course_abs.startswith(source_abs + os.sep):
current_course = None
active_course_reset = True
return jsonify({'success': True, 'new_path': final_path, 'active_course_reset': active_course_reset})
@app.route('/api/library/duplicates')
def duplicate_courses_api():
"""Scan for courses that look like copies of each other by name (see
_find_duplicate_courses). Not run automatically like Sort Unsorted's
scan - it's O(n^2) over every course in the library, so it's a manual
trigger instead."""
groups = _find_duplicate_courses()
return jsonify({'groups': groups, 'course_count': len(get_all_course_dirs())})
@app.route('/api/library/stale-references')
def stale_references_api():
"""Entries in hidden_paths/next_up/recent_views pointing at paths no
longer on disk (see _find_stale_references). Read-only - nothing is
removed until /api/library/stale-references/clean is called."""
return jsonify(_find_stale_references())
@app.route('/api/library/stale-references/clean', methods=['POST'])
def clean_stale_references_api():
"""Remove specific stale entries the client reviewed via the scan
above. Re-checks each path still doesn't exist before removing it, in
case it became valid again between scan and apply."""
data = request.json or {}
items = data.get('items') or []
if not isinstance(items, list) or not items:
return jsonify({'success': False, 'error': 'No items provided'}), 400
removed = 0
for item in items:
category = (item or {}).get('category')
path = (item or {}).get('path')
if not path or os.path.isdir(path):
continue
if category == 'hidden':
set_path_hidden(path, False)
removed += 1
elif category == 'next_up':
set_path_queued(path, False)
removed += 1
elif category == 'recent_views':
remove_recent_views_for_path(path)
removed += 1
return jsonify({'success': True, 'removed': removed})
# Single-process personal app, so a plain dict + background thread is
# enough state for the duration prewarm - no job queue needed. Guarded by
# 'running' so a second click while one's in flight just reports progress
+19
View File
@@ -197,6 +197,21 @@
transform: translateY(0);
}
.page-footer-link {
text-align: center;
padding: 24px 0 8px;
}
.page-footer-link a {
color: var(--text-muted);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85em;
}
.page-footer-link a:hover {
color: var(--accent);
}
.bottom-tab-bar {
position: fixed;
left: 0;
@@ -1313,6 +1328,10 @@
</div>
{% endif %}
{% endif %}
<div class="page-footer-link">
<a href="/unsorted">{{ icons.icon('folder', 14) }} File Management</a>
</div>
</div>
<nav class="bottom-tab-bar">
+6 -466
View File
@@ -221,101 +221,6 @@
padding: 5px 12px;
font-size: 0.85em;
}
.curate-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius);
background: var(--bg-tertiary);
margin-bottom: 5px;
cursor: pointer;
}
.curate-row:hover {
background: var(--bg-tertiary-hover);
}
.curate-row.is-hidden {
opacity: 0.55;
}
.curate-row-name {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.curate-row-name span.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.curate-toggle {
font-size: 1.1em;
width: 18px;
text-align: center;
flex-shrink: 0;
}
.curate-children {
margin-left: 22px;
margin-top: 4px;
display: none;
}
.curate-children.expanded {
display: block;
}
.curate-empty-hint {
color: var(--text-muted);
font-size: 0.9em;
padding: 6px 0;
}
.curate-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.curate-rename-input {
flex: 1;
min-width: 0;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 4px 8px;
font-size: 0.95em;
font-family: var(--font-family);
}
.curate-row-status {
flex-basis: 100%;
font-size: 0.8em;
color: var(--error);
}
.bulk-rename-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius);
background: var(--bg-tertiary);
margin-bottom: 5px;
}
.bulk-rename-row-names {
flex: 1;
min-width: 0;
overflow-wrap: break-word;
word-break: break-word;
}
.bulk-rename-old {
color: var(--text-muted);
font-size: 0.9em;
text-decoration: line-through;
}
.bulk-rename-new {
color: var(--text-primary);
}
#save-status {
font-size: 0.9em;
color: var(--success);
@@ -448,11 +353,6 @@
<button class="btn" onclick="saveLibraryPath()">Save</button>
</div>
<span id="library-path-status" style="font-size: 0.85em; min-height: 1.2em;"></span>
<div style="display: flex; align-items: center; gap: 10px;">
<button class="btn btn-secondary btn-sm" onclick="refreshLibraryCache()">{{ icons.icon('refresh', 14) }} Refresh Library</button>
<span id="library-refresh-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
</div>
<span class="setting-desc">Courses are cached briefly for speed - use this after adding or removing files directly on disk if you don't want to wait a few minutes for it to notice.</span>
<div style="display: flex; align-items: center; gap: 10px; margin-top: 10px;">
<button class="btn btn-secondary btn-sm" id="prewarm-durations-btn" onclick="startDurationPrewarm()">{{ icons.icon('refresh', 14) }} Precompute Lengths &amp; Cover Art</button>
@@ -463,50 +363,14 @@
</div>
<div class="card">
<h2>Sort Unsorted Courses</h2>
<h2>File Management</h2>
<p class="setting-desc" style="margin-bottom: 12px;">
Drop new courses in an "Unsorted" folder at the root of your library, then use this to
propose where each one belongs based on your existing folder structure - nothing moves
until you review and confirm on its own page.
Sort new courses out of an "Unsorted" folder at the root of your library, refresh the
library cache, bulk-rename or move things around, hide courses, check for duplicates, and
clean up stale references - all on its own page, with room to review before anything
changes on disk.
</p>
<a class="btn btn-secondary" href="/unsorted">{{ icons.icon('folder', 14) }} Open Sorter</a>
</div>
<div class="card">
<h2>Manage Library</h2>
<p class="setting-desc" style="margin-bottom: 12px;">
Hide courses or whole folders from the Library browser without touching anything on disk
(hiding a folder hides everything inside it), or rename a course/folder directly - no need
to go to the NAS.
</p>
<div id="hidden-list-wrap" style="margin-bottom: 15px; display: none;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Currently hidden</div>
<div id="hidden-list"></div>
</div>
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Browse</div>
<div id="curate-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
<div id="curate-tree"></div>
</div>
<div class="card">
<h2>Bulk Rename</h2>
<p class="setting-desc" style="margin-bottom: 12px;">
Find and replace text across every course/folder name in the library at once - handy for
stripping a release-group suffix off a batch of downloads. Nothing changes until you apply.
</p>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px;">
<input type="text" id="bulk-rename-find" class="hex-input"
style="flex: 1; min-width: 140px; width: auto; font-family: var(--font-family);"
placeholder="Find (e.g. .BOOKWARE-LERNSTUF)">
<input type="text" id="bulk-rename-replace" class="hex-input"
style="flex: 1; min-width: 140px; width: auto; font-family: var(--font-family);"
placeholder="Replace with (blank to remove)">
<button class="btn" onclick="previewBulkRename()">Preview</button>
</div>
<div id="bulk-rename-status" style="font-size: 0.85em; min-height: 1.2em; margin-bottom: 8px;"></div>
<div id="bulk-rename-preview"></div>
<a class="btn btn-secondary" href="/unsorted">{{ icons.icon('folder', 14) }} Open File Management</a>
</div>
<div class="card">
@@ -627,10 +491,6 @@
// built dynamically here are kept in sync with the same path data
// by hand.
const ICON_SVGS = {
ban: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"></line></svg>',
'graduation-cap': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path></svg>',
folder: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg>',
pencil: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>',
check: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>',
};
@@ -682,309 +542,6 @@
}
});
// ---- Library curation: hide/show courses & folders ----
function loadHiddenList() {
fetch('/api/hidden-paths')
.then(r => r.json())
.then(data => {
const wrap = document.getElementById('hidden-list-wrap');
const list = document.getElementById('hidden-list');
const items = data.hidden_paths || [];
if (items.length === 0) {
wrap.style.display = 'none';
return;
}
wrap.style.display = 'block';
list.innerHTML = items.map(item => `
<div class="curate-row">
<div class="curate-row-name">
<span>${ICON_SVGS.ban}</span>
<span class="name">${item.name}</span>
</div>
<button class="btn btn-secondary btn-sm" onclick="toggleHidden('${item.path.replace(/'/g, "\\'")}', false)">Show</button>
</div>
`).join('');
})
.catch(() => {});
}
function loadCurateLevel(path, container, isRoot) {
const url = path ? `/library/manage?path=${encodeURIComponent(path)}` : '/library/manage';
fetch(url)
.then(r => r.json())
.then(data => {
if (isRoot) {
document.getElementById('curate-path-bar').textContent = `Browsing ${data.library_path}`;
}
renderCurateLevel(data, container);
})
.catch(() => {
container.innerHTML = '<p style="color:var(--error);">Could not reach the library scanner.</p>';
});
}
function renderCurateLevel(data, container) {
if (!data.items || data.items.length === 0) {
const reason = (data.errors && data.errors.length) ? data.errors.join(' ') : 'Nothing here.';
container.innerHTML = `<div class="curate-empty-hint">${reason}</div>`;
return;
}
container.innerHTML = data.items.map(item => {
const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'");
const icon = item.type === 'course' ? ICON_SVGS['graduation-cap'] : ICON_SVGS.folder;
const hiddenClass = item.hidden ? 'is-hidden' : '';
const renameBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startRename(this, '${safePath}', '${safeName}')" title="Rename">${ICON_SVGS.pencil}</button>`;
const toggleBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); toggleHidden('${safePath}', ${!item.hidden}, this)">${item.hidden ? 'Show' : 'Hide'}</button>`;
const actions = `<div class="curate-actions">${renameBtn}${toggleBtn}</div>`;
if (item.type === 'course') {
return `
<div class="curate-row ${hiddenClass}">
<div class="curate-row-name">
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${actions}
</div>
`;
}
return `
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
<div class="curate-row-name">
<span class="curate-toggle"></span>
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${actions}
</div>
<div class="curate-children" data-loaded="false"></div>
`;
}).join('');
}
function toggleCurateDir(rowEl, path) {
const content = rowEl.nextElementSibling;
const toggleIcon = rowEl.querySelector('.curate-toggle');
if (!content || !content.classList.contains('curate-children')) return;
if (content.classList.contains('expanded')) {
content.classList.remove('expanded');
if (toggleIcon) toggleIcon.textContent = '▶';
return;
}
content.classList.add('expanded');
if (toggleIcon) toggleIcon.textContent = '▼';
if (content.dataset.loaded === 'true') return;
content.innerHTML = '<div class="curate-empty-hint">Loading...</div>';
content.dataset.loaded = 'true';
content.dataset.path = path;
loadCurateLevel(path, content, false);
}
function toggleHidden(path, hidden, btnEl) {
fetch('/api/hidden-paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, hidden: hidden })
})
.then(r => r.json())
.then(data => {
if (data.success) {
loadHiddenList();
// Refresh just the level this toggle happened in, rather
// than collapsing the whole tree back to root.
const container = btnEl ? (btnEl.closest('.curate-children') || document.getElementById('curate-tree'))
: document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
}
})
.catch(() => {});
}
// ---- Library curation: rename a course/folder in place on disk ----
function startRename(btnEl, path, currentName) {
const row = btnEl.closest('.curate-row');
const nameSpan = row ? row.querySelector('.curate-row-name .name') : null;
if (!row || !nameSpan) return;
let settled = false;
const input = document.createElement('input');
input.type = 'text';
input.className = 'curate-rename-input';
input.value = currentName;
input.onclick = (e) => e.stopPropagation();
nameSpan.replaceWith(input);
input.focus();
input.select();
function cancel() {
if (settled) return;
settled = true;
input.replaceWith(nameSpan);
}
function commit() {
if (settled) return;
const newName = input.value.trim();
if (!newName || newName === currentName) {
cancel();
return;
}
settled = true;
submitRename(btnEl, path, newName, row, nameSpan, input);
}
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
});
input.addEventListener('blur', commit);
}
function submitRename(btnEl, path, newName, row, nameSpan, input) {
fetch('/api/rename-path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, new_name: newName })
})
.then(r => r.json().then(data => ({ ok: r.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
input.replaceWith(nameSpan); // nameSpan still shows the original, unchanged name
showRowError(row, data.error || 'Rename failed');
return;
}
if (data.active_course_reset) {
alert('That was your active course - reload the main page to pick it up under its new name.');
}
loadHiddenList();
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
})
.catch(() => {
input.replaceWith(nameSpan);
showRowError(row, 'Could not reach the server');
});
}
function showRowError(row, message) {
let status = row.querySelector('.curate-row-status');
if (!status) {
status = document.createElement('div');
status.className = 'curate-row-status';
row.appendChild(status);
}
status.textContent = message;
}
loadHiddenList();
loadCurateLevel(null, document.getElementById('curate-tree'), true);
// ---- Bulk rename: find/replace across every course/folder name ----
let bulkRenameMatches = [];
function previewBulkRename() {
const pattern = document.getElementById('bulk-rename-find').value;
const replacement = document.getElementById('bulk-rename-replace').value;
const status = document.getElementById('bulk-rename-status');
const preview = document.getElementById('bulk-rename-preview');
preview.innerHTML = '';
if (!pattern) {
status.style.color = 'var(--error)';
status.textContent = 'Enter text to find first.';
return;
}
status.style.color = 'var(--text-muted)';
status.textContent = 'Searching...';
fetch(`/api/bulk-rename/preview?pattern=${encodeURIComponent(pattern)}&replacement=${encodeURIComponent(replacement)}`)
.then(r => r.json())
.then(data => {
bulkRenameMatches = data.matches || [];
renderBulkRenamePreview();
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
function renderBulkRenamePreview() {
const status = document.getElementById('bulk-rename-status');
const preview = document.getElementById('bulk-rename-preview');
if (bulkRenameMatches.length === 0) {
status.style.color = 'var(--text-muted)';
status.textContent = 'No matches.';
preview.innerHTML = '';
return;
}
status.textContent = '';
const rows = bulkRenameMatches.map((m, i) => `
<div class="bulk-rename-row">
<div class="bulk-rename-row-names">
<div class="bulk-rename-old">${m.old_name}</div>
<div class="bulk-rename-new">→ ${m.new_name}</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="removeBulkRenameMatch(${i})">Remove</button>
</div>
`).join('');
const count = bulkRenameMatches.length;
preview.innerHTML = rows +
`<button class="btn" style="margin-top: 10px;" onclick="applyBulkRename()">Apply ${count} Rename${count === 1 ? '' : 's'}</button>`;
}
function removeBulkRenameMatch(index) {
bulkRenameMatches.splice(index, 1);
renderBulkRenamePreview();
}
function applyBulkRename() {
if (!confirm(`Rename ${bulkRenameMatches.length} item(s)? This changes real folders on disk.`)) return;
const status = document.getElementById('bulk-rename-status');
status.style.color = 'var(--text-muted)';
status.textContent = 'Applying...';
fetch('/api/bulk-rename/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: bulkRenameMatches })
})
.then(r => r.json())
.then(data => {
const results = data.results || [];
const succeeded = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success);
const activeCourseReset = results.some(r => r.active_course_reset);
status.style.color = failed.length ? 'var(--error)' : 'var(--success)';
let message = `${succeeded} renamed`;
if (failed.length) {
message += `, ${failed.length} failed: ` +
failed.map(f => `${f.old_name} (${f.error})`).join('; ');
} else {
message += '.';
}
if (activeCourseReset) {
message += ' Your active course was renamed - reload the main page to pick it up under its new name.';
}
status.textContent = message;
bulkRenameMatches = [];
document.getElementById('bulk-rename-preview').innerHTML = '';
document.getElementById('bulk-rename-find').value = '';
document.getElementById('bulk-rename-replace').value = '';
loadHiddenList();
loadCurateLevel(null, document.getElementById('curate-tree'), true);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
function saveLibraryPath() {
const input = document.getElementById('library_path');
const status = document.getElementById('library-path-status');
@@ -1010,23 +567,6 @@
});
}
function refreshLibraryCache() {
const status = document.getElementById('library-refresh-status');
status.style.color = 'var(--text-muted)';
status.textContent = 'Refreshing…';
fetch('/api/library/refresh', { method: 'POST' })
.then(r => r.json())
.then(data => {
status.style.color = data.success ? 'var(--success)' : 'var(--error)';
status.innerHTML = data.success ? (ICON_SVGS.check + ' Refreshed') : 'Could not refresh';
if (data.success) setTimeout(() => { status.textContent = ''; }, 3000);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server';
});
}
function updatePrewarmStatus(data) {
const btn = document.getElementById('prewarm-durations-btn');
const status = document.getElementById('prewarm-durations-status');
+884 -22
View File
@@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sort Unsorted - OfflineU</title>
<title>File Management - OfflineU</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#007acc">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
@@ -103,6 +103,179 @@
padding: 20px 25px;
margin-bottom: 20px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 1.15em;
color: var(--text-primary);
margin: 34px 0 6px;
}
.section-title:first-of-type { margin-top: 0; }
.section-desc {
color: var(--text-muted);
font-size: 0.92em;
margin: 0 0 14px;
}
.text-input {
flex: 1;
min-width: 160px;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 8px 10px;
font-size: 0.9em;
font-family: var(--font-family);
}
.bulk-rename-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius);
background: var(--bg-tertiary);
margin-bottom: 5px;
}
.bulk-rename-row-names {
flex: 1;
min-width: 0;
overflow-wrap: break-word;
word-break: break-word;
}
.bulk-rename-old {
color: var(--text-muted);
font-size: 0.9em;
text-decoration: line-through;
}
.bulk-rename-new {
color: var(--text-primary);
}
.curate-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius);
background: var(--bg-tertiary);
margin-bottom: 5px;
cursor: pointer;
}
.curate-row:hover {
background: var(--bg-tertiary-hover);
}
.curate-row.is-hidden {
opacity: 0.55;
}
.curate-row-name {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.curate-row-name span.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.curate-toggle {
font-size: 1.1em;
width: 18px;
text-align: center;
flex-shrink: 0;
}
.curate-children {
margin-left: 22px;
margin-top: 4px;
display: none;
}
.curate-children.expanded {
display: block;
}
.curate-empty-hint {
color: var(--text-muted);
font-size: 0.9em;
padding: 6px 0;
}
.curate-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.curate-rename-input {
flex: 1;
min-width: 0;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 4px 8px;
font-size: 0.95em;
font-family: var(--font-family);
}
.curate-row-status {
flex-basis: 100%;
font-size: 0.8em;
color: var(--error);
}
.curate-move-panel {
background: var(--bg-tertiary);
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 12px 14px;
margin: -3px 0 8px 0;
}
.curate-move-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.curate-move-status {
font-size: 0.82em;
margin-top: 6px;
min-height: 1.1em;
}
.dup-group {
background: var(--bg-tertiary);
border-radius: var(--radius);
padding: 12px 14px;
margin-bottom: 10px;
}
.dup-group-header {
font-size: 0.78em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
color: var(--accent);
margin-bottom: 8px;
}
.dup-course-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 6px 0;
border-top: 1px solid var(--border-color);
}
.dup-course-row:first-of-type {
border-top: none;
}
.dup-course-name {
font-weight: 600;
}
.dup-course-path {
flex: 1;
min-width: 160px;
color: var(--text-muted);
font-size: 0.8em;
overflow-wrap: break-word;
word-break: break-word;
}
.toolbar {
display: flex;
align-items: center;
@@ -188,15 +361,27 @@
color: var(--text-muted);
margin-top: 4px;
}
.sort-row-dest {
.sort-row-fields {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 240px;
flex-wrap: wrap;
gap: 14px;
margin-top: 10px;
}
.sort-row-dest input {
.sort-field {
flex: 1;
min-width: 200px;
display: flex;
flex-direction: column;
gap: 5px;
}
.sort-field label {
font-size: 0.72em;
text-transform: uppercase;
letter-spacing: 0.3px;
color: var(--text-muted);
}
.sort-field input,
.sort-field select {
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border-color);
@@ -204,9 +389,18 @@
padding: 8px 10px;
font-size: 0.9em;
font-family: var(--font-family);
width: 100%;
}
.sort-row-dest input:invalid,
.sort-row-dest input.empty {
.dest-picker {
display: flex;
align-items: center;
gap: 8px;
}
.dest-picker select { flex: 1; }
.dest-new-input {
margin-top: 6px;
}
.dest-new-input.empty {
border-color: var(--error);
}
.action-bar {
@@ -269,8 +463,11 @@
<div class="page-content">
<div class="container">
<h1>{{ icons.icon('folder', 24) }} Sort Unsorted</h1>
<p class="subtitle">Propose a destination for every course sitting in the <strong>Unsorted</strong> folder, based on your existing category structure. Nothing moves until you apply it.</p>
<h1>{{ icons.icon('folder', 24) }} File Management</h1>
<p class="subtitle">Sort new courses, refresh the library cache, bulk-rename, hide/move/rename anything already filed, check for duplicates, and clean up stale references. Nothing changes on disk until you review and apply it.</p>
<h2 class="section-title">{{ icons.icon('folder', 18) }} Sort Unsorted</h2>
<p class="section-desc">Propose a destination for every course sitting in the <strong>Unsorted</strong> folder, based on your existing category structure.</p>
<div class="card">
<div class="toolbar">
@@ -295,6 +492,60 @@
</div>
<div id="empty-state" class="empty-hint" style="display: none;"></div>
<h2 class="section-title">{{ icons.icon('refresh', 18) }} Refresh Library</h2>
<p class="section-desc">Courses are cached briefly for speed - use this after adding or removing files directly on disk if you don't want to wait a few minutes for it to notice.</p>
<div class="card">
<div class="toolbar">
<button class="btn btn-secondary" id="refresh-library-btn" onclick="refreshLibraryCache()">{{ icons.icon('refresh', 14) }} Refresh Library</button>
<span id="library-refresh-status" style="font-size: 0.85em; color: var(--text-muted);"></span>
</div>
</div>
<h2 class="section-title">{{ icons.icon('pencil', 18) }} Bulk Rename</h2>
<p class="section-desc">Find and replace text across every course/folder name in the library at once - handy for stripping a release-group suffix off a batch of downloads.</p>
<div class="card">
<div class="toolbar" style="margin-bottom: 10px;">
<input type="text" id="bulk-rename-find" class="text-input" placeholder="Find (e.g. .BOOKWARE-LERNSTUF)">
<input type="text" id="bulk-rename-replace" class="text-input" placeholder="Replace with (blank to remove)">
<button class="btn" onclick="previewBulkRename()">Preview</button>
</div>
<div id="bulk-rename-status" style="font-size: 0.85em; min-height: 1.2em; margin-bottom: 8px;"></div>
<div id="bulk-rename-preview"></div>
</div>
<h2 class="section-title">{{ icons.icon('folder', 18) }} Manage Library</h2>
<p class="section-desc">Hide courses or whole folders from the Library browser without touching anything on disk (hiding a folder hides everything inside it), or rename/move a course or folder directly - no need to go to the NAS.</p>
<div class="card">
<div id="hidden-list-wrap" style="margin-bottom: 15px; display: none;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Currently hidden</div>
<div id="hidden-list"></div>
</div>
<div style="font-weight: 600; margin-bottom: 8px; font-size: 0.9em; color: var(--text-muted);">Browse</div>
<div id="curate-path-bar" style="color: var(--text-muted); font-size: 13px; margin-bottom: 8px;"></div>
<div id="curate-tree"></div>
</div>
<h2 class="section-title">{{ icons.icon('clipboard', 18) }} Duplicate Courses</h2>
<p class="section-desc">Scan for courses that look like the same thing filed twice - a re-download that landed in a different folder, or something that's both sorted and still sitting in Unsorted. Matching reuses the same tokenizer as Sort Unsorted, so platform names and release-group tags don't throw it off.</p>
<div class="card">
<div class="toolbar">
<button class="btn btn-secondary" id="dup-scan-btn" onclick="scanDuplicates()">{{ icons.icon('refresh', 14) }} Scan for Duplicates</button>
<span id="dup-status"></span>
</div>
<div id="dup-results"></div>
</div>
<h2 class="section-title">{{ icons.icon('trash', 18) }} Clean Up Stale References</h2>
<p class="section-desc">Renaming, moving, or deleting a course directly on the NAS (instead of through this app) can leave behind references in Hidden, Next Up, or Recently Viewed that point at nothing. Review and remove them here - course files on disk are never touched.</p>
<div class="card">
<div class="toolbar">
<button class="btn btn-secondary" id="stale-scan-btn" onclick="scanStaleReferences()">{{ icons.icon('refresh', 14) }} Scan for Stale References</button>
<span id="stale-status"></span>
</div>
<div id="stale-results"></div>
</div>
</div>
</div>
@@ -319,9 +570,15 @@
// template literals can't call the Jinja macro.
const ICON_SVGS = {
folder: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg>',
check: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>',
ban: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><line x1="5.6" y1="5.6" x2="18.4" y2="18.4"></line></svg>',
'graduation-cap': '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 9 12 4 2 9l10 5 10-5Z"></path><path d="M6 11v5c0 1.4 2.7 2.5 6 2.5s6-1.1 6-2.5v-5"></path></svg>',
pencil: '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>',
};
let lastItems = [];
let lastCategories = [];
const NEW_FOLDER_VALUE = '__new__';
function badgeLabel(type) {
if (type === 'existing') return 'Existing folder';
@@ -336,11 +593,36 @@
return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function categoryOptionsHtml() {
return lastCategories.map(path =>
`<option value="${escapeAttr(path)}">${escapeHtml(path.split('/').join(' / '))}</option>`
).join('');
}
// Shared across Sort Unsorted's own picker and Manage Library's Move
// panel, so both work the same regardless of whether the Unsorted
// folder exists (Sort Unsorted's own scan skips categories entirely
// when there's nothing to sort).
function loadCategories() {
return fetch('/api/library/categories')
.then(r => r.json())
.then(data => { lastCategories = data.categories || []; })
.catch(() => {});
}
function renderRows(items) {
const container = document.getElementById('sort-rows');
const optionsHtml = categoryOptionsHtml();
container.innerHTML = items.map((item, idx) => {
const checked = item.type !== 'manual' ? 'checked' : '';
const destValue = item.destination || '';
// An 'existing' proposal always names a real folder already in
// lastCategories, so it's the only case the <select> can point
// straight at; 'new_folder' and 'manual' (and any hand-edited
// destination that no longer matches a real folder) fall back
// to the "create new" option with the free-text path exposed.
const isNew = item.type !== 'existing' || !lastCategories.includes(destValue);
return `
<div class="sort-row" data-idx="${idx}">
<input type="checkbox" class="sort-row-check" ${checked}>
@@ -350,15 +632,40 @@
<span class="sort-badge ${item.type}">${badgeLabel(item.type)}</span>
</div>
<div class="sort-row-reason">${escapeHtml(item.reason || '')}</div>
</div>
<div class="sort-row-dest">
${ICON_SVGS.folder}
<input type="text" class="dest-input ${destValue ? '' : 'empty'}" value="${escapeAttr(destValue)}" placeholder="e.g. IT/AWS">
<div class="sort-row-fields">
<div class="sort-field">
<label>Folder name</label>
<input type="text" class="rename-input" value="${escapeAttr(item.name)}">
</div>
<div class="sort-field">
<label>Destination folder</label>
<div class="dest-picker">
${ICON_SVGS.folder}
<select class="dest-select" data-initial="${escapeAttr(isNew ? NEW_FOLDER_VALUE : destValue)}">
${optionsHtml}
<option value="${NEW_FOLDER_VALUE}">+ Create new folder…</option>
</select>
</div>
<input type="text" class="dest-new-input ${isNew && !destValue ? 'empty' : ''}"
style="display: ${isNew ? 'block' : 'none'};"
value="${escapeAttr(isNew ? destValue : '')}" placeholder="e.g. IT/NewCategory">
</div>
</div>
</div>
</div>`;
}).join('');
container.querySelectorAll('.dest-input').forEach(input => {
container.querySelectorAll('.dest-select').forEach(select => {
select.value = select.dataset.initial;
const row = select.closest('.sort-row');
const newInput = row.querySelector('.dest-new-input');
select.addEventListener('change', () => {
const showNew = select.value === NEW_FOLDER_VALUE;
newInput.style.display = showNew ? 'block' : 'none';
if (showNew) newInput.focus();
});
});
container.querySelectorAll('.dest-new-input').forEach(input => {
input.addEventListener('input', () => {
input.classList.toggle('empty', input.value.trim() === '');
});
@@ -418,14 +725,18 @@
if (!checkbox.checked) return;
const idx = parseInt(row.dataset.idx, 10);
const item = lastItems[idx];
const destInput = row.querySelector('.dest-input');
const destination = destInput.value.trim();
const select = row.querySelector('.dest-select');
const newInput = row.querySelector('.dest-new-input');
const renameInput = row.querySelector('.rename-input');
const usingNew = select.value === NEW_FOLDER_VALUE;
const destination = (usingNew ? newInput.value : select.value).trim();
const name = renameInput.value.trim();
if (!destination) {
skippedEmpty.push(item.name);
destInput.classList.add('empty');
if (usingNew) newInput.classList.add('empty');
return;
}
moves.push({ source: item.path, destination });
moves.push({ source: item.path, destination, name });
});
if (skippedEmpty.length) {
@@ -464,7 +775,7 @@
if (errorCount) {
console.warn('Unsorted apply errors:', data.errors);
}
scanUnsorted();
loadCategories().then(() => scanUnsorted());
})
.catch(() => {
btn.disabled = false;
@@ -473,7 +784,558 @@
});
}
scanUnsorted();
function refreshLibraryCache() {
const status = document.getElementById('library-refresh-status');
status.style.color = 'var(--text-muted)';
status.textContent = 'Refreshing…';
fetch('/api/library/refresh', { method: 'POST' })
.then(r => r.json())
.then(data => {
status.style.color = data.success ? 'var(--success)' : 'var(--error)';
status.innerHTML = data.success ? (ICON_SVGS.check + ' Refreshed') : 'Could not refresh';
if (data.success) setTimeout(() => { status.textContent = ''; }, 3000);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server';
});
}
// ---- Bulk rename: find/replace across every course/folder name ----
let bulkRenameMatches = [];
function previewBulkRename() {
const pattern = document.getElementById('bulk-rename-find').value;
const replacement = document.getElementById('bulk-rename-replace').value;
const status = document.getElementById('bulk-rename-status');
const preview = document.getElementById('bulk-rename-preview');
preview.innerHTML = '';
if (!pattern) {
status.style.color = 'var(--error)';
status.textContent = 'Enter text to find first.';
return;
}
status.style.color = 'var(--text-muted)';
status.textContent = 'Searching...';
fetch(`/api/bulk-rename/preview?pattern=${encodeURIComponent(pattern)}&replacement=${encodeURIComponent(replacement)}`)
.then(r => r.json())
.then(data => {
bulkRenameMatches = data.matches || [];
renderBulkRenamePreview();
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
function renderBulkRenamePreview() {
const status = document.getElementById('bulk-rename-status');
const preview = document.getElementById('bulk-rename-preview');
if (bulkRenameMatches.length === 0) {
status.style.color = 'var(--text-muted)';
status.textContent = 'No matches.';
preview.innerHTML = '';
return;
}
status.textContent = '';
const rows = bulkRenameMatches.map((m, i) => `
<div class="bulk-rename-row">
<div class="bulk-rename-row-names">
<div class="bulk-rename-old">${m.old_name}</div>
<div class="bulk-rename-new">→ ${m.new_name}</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="removeBulkRenameMatch(${i})">Remove</button>
</div>
`).join('');
const count = bulkRenameMatches.length;
preview.innerHTML = rows +
`<button class="btn" style="margin-top: 10px;" onclick="applyBulkRename()">Apply ${count} Rename${count === 1 ? '' : 's'}</button>`;
}
function removeBulkRenameMatch(index) {
bulkRenameMatches.splice(index, 1);
renderBulkRenamePreview();
}
function applyBulkRename() {
if (!confirm(`Rename ${bulkRenameMatches.length} item(s)? This changes real folders on disk.`)) return;
const status = document.getElementById('bulk-rename-status');
status.style.color = 'var(--text-muted)';
status.textContent = 'Applying...';
fetch('/api/bulk-rename/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: bulkRenameMatches })
})
.then(r => r.json())
.then(data => {
const results = data.results || [];
const succeeded = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success);
const activeCourseReset = results.some(r => r.active_course_reset);
status.style.color = failed.length ? 'var(--error)' : 'var(--success)';
let message = `${succeeded} renamed`;
if (failed.length) {
message += `, ${failed.length} failed: ` +
failed.map(f => `${f.old_name} (${f.error})`).join('; ');
} else {
message += '.';
}
if (activeCourseReset) {
message += ' Your active course was renamed - reload the main page to pick it up under its new name.';
}
status.textContent = message;
bulkRenameMatches = [];
document.getElementById('bulk-rename-preview').innerHTML = '';
document.getElementById('bulk-rename-find').value = '';
document.getElementById('bulk-rename-replace').value = '';
loadHiddenList();
loadCategories();
loadCurateLevel(null, document.getElementById('curate-tree'), true);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
// ---- Manage Library: hide/show courses & folders ----
function loadHiddenList() {
fetch('/api/hidden-paths')
.then(r => r.json())
.then(data => {
const wrap = document.getElementById('hidden-list-wrap');
const list = document.getElementById('hidden-list');
const items = data.hidden_paths || [];
if (items.length === 0) {
wrap.style.display = 'none';
return;
}
wrap.style.display = 'block';
list.innerHTML = items.map(item => `
<div class="curate-row">
<div class="curate-row-name">
<span>${ICON_SVGS.ban}</span>
<span class="name">${item.name}</span>
</div>
<button class="btn btn-secondary btn-sm" onclick="toggleHidden('${item.path.replace(/'/g, "\\'")}', false)">Show</button>
</div>
`).join('');
})
.catch(() => {});
}
function loadCurateLevel(path, container, isRoot) {
const url = path ? `/library/manage?path=${encodeURIComponent(path)}` : '/library/manage';
fetch(url)
.then(r => r.json())
.then(data => {
if (isRoot) {
document.getElementById('curate-path-bar').textContent = `Browsing ${data.library_path}`;
}
renderCurateLevel(data, container);
})
.catch(() => {
container.innerHTML = '<p style="color:var(--error);">Could not reach the library scanner.</p>';
});
}
function renderCurateLevel(data, container) {
if (!data.items || data.items.length === 0) {
const reason = (data.errors && data.errors.length) ? data.errors.join(' ') : 'Nothing here.';
container.innerHTML = `<div class="curate-empty-hint">${reason}</div>`;
return;
}
container.innerHTML = data.items.map(item => {
const safePath = item.path.replace(/'/g, "\\'");
const safeName = item.name.replace(/'/g, "\\'");
const icon = item.type === 'course' ? ICON_SVGS['graduation-cap'] : ICON_SVGS.folder;
const hiddenClass = item.hidden ? 'is-hidden' : '';
const moveBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startMove(this, '${safePath}', '${safeName}')">Move</button>`;
const renameBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); startRename(this, '${safePath}', '${safeName}')" title="Rename">${ICON_SVGS.pencil}</button>`;
const toggleBtn = `<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); toggleHidden('${safePath}', ${!item.hidden}, this)">${item.hidden ? 'Show' : 'Hide'}</button>`;
const actions = `<div class="curate-actions">${moveBtn}${renameBtn}${toggleBtn}</div>`;
if (item.type === 'course') {
return `
<div class="curate-row ${hiddenClass}">
<div class="curate-row-name">
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${actions}
</div>
`;
}
return `
<div class="curate-row ${hiddenClass}" onclick="toggleCurateDir(this, '${safePath}')">
<div class="curate-row-name">
<span class="curate-toggle"></span>
<span>${icon}</span>
<span class="name">${item.name}</span>
</div>
${actions}
</div>
<div class="curate-children" data-loaded="false"></div>
`;
}).join('');
}
function toggleCurateDir(rowEl, path) {
const content = rowEl.nextElementSibling;
const toggleIcon = rowEl.querySelector('.curate-toggle');
if (!content || !content.classList.contains('curate-children')) return;
if (content.classList.contains('expanded')) {
content.classList.remove('expanded');
if (toggleIcon) toggleIcon.textContent = '▶';
return;
}
content.classList.add('expanded');
if (toggleIcon) toggleIcon.textContent = '▼';
if (content.dataset.loaded === 'true') return;
content.innerHTML = '<div class="curate-empty-hint">Loading...</div>';
content.dataset.loaded = 'true';
content.dataset.path = path;
loadCurateLevel(path, content, false);
}
function toggleHidden(path, hidden, btnEl) {
fetch('/api/hidden-paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, hidden: hidden })
})
.then(r => r.json())
.then(data => {
if (data.success) {
loadHiddenList();
// Refresh just the level this toggle happened in, rather
// than collapsing the whole tree back to root.
const container = btnEl ? (btnEl.closest('.curate-children') || document.getElementById('curate-tree'))
: document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
}
})
.catch(() => {});
}
// ---- Manage Library: rename a course/folder in place on disk ----
function startRename(btnEl, path, currentName) {
const row = btnEl.closest('.curate-row');
const nameSpan = row ? row.querySelector('.curate-row-name .name') : null;
if (!row || !nameSpan) return;
let settled = false;
const input = document.createElement('input');
input.type = 'text';
input.className = 'curate-rename-input';
input.value = currentName;
input.onclick = (e) => e.stopPropagation();
nameSpan.replaceWith(input);
input.focus();
input.select();
function cancel() {
if (settled) return;
settled = true;
input.replaceWith(nameSpan);
}
function commit() {
if (settled) return;
const newName = input.value.trim();
if (!newName || newName === currentName) {
cancel();
return;
}
settled = true;
submitRename(btnEl, path, newName, row, nameSpan, input);
}
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { e.preventDefault(); cancel(); }
});
input.addEventListener('blur', commit);
}
function submitRename(btnEl, path, newName, row, nameSpan, input) {
fetch('/api/rename-path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, new_name: newName })
})
.then(r => r.json().then(data => ({ ok: r.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
input.replaceWith(nameSpan); // nameSpan still shows the original, unchanged name
showRowError(row, data.error || 'Rename failed');
return;
}
if (data.active_course_reset) {
alert('That was your active course - reload the main page to pick it up under its new name.');
}
loadHiddenList();
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
})
.catch(() => {
input.replaceWith(nameSpan);
showRowError(row, 'Could not reach the server');
});
}
function showRowError(row, message) {
let status = row.querySelector('.curate-row-status');
if (!status) {
status = document.createElement('div');
status.className = 'curate-row-status';
row.appendChild(status);
}
status.textContent = message;
}
// ---- Manage Library: move a course/folder elsewhere in the library ----
function startMove(btnEl, path, currentName) {
const row = btnEl.closest('.curate-row');
if (!row) return;
const existing = row.nextElementSibling;
if (existing && existing.classList.contains('curate-move-panel')) {
existing.remove();
return;
}
document.querySelectorAll('.curate-move-panel').forEach(p => p.remove());
const optionsHtml = categoryOptionsHtml();
const panel = document.createElement('div');
panel.className = 'curate-move-panel';
panel.innerHTML = `
<div class="sort-field">
<label>Move "${escapeHtml(currentName)}" to</label>
<div class="dest-picker">
${ICON_SVGS.folder}
<select class="move-dest-select">
${optionsHtml}
<option value="${NEW_FOLDER_VALUE}">+ Create new folder…</option>
</select>
</div>
<input type="text" class="move-dest-new-input text-input" style="display: none; margin-top: 6px;" placeholder="e.g. IT/NewCategory">
</div>
<div class="curate-move-actions">
<button class="btn btn-sm">Move</button>
<button class="btn btn-secondary btn-sm">Cancel</button>
</div>
<div class="curate-move-status"></div>
`;
row.after(panel);
const select = panel.querySelector('.move-dest-select');
const newInput = panel.querySelector('.move-dest-new-input');
select.addEventListener('change', () => {
const showNew = select.value === NEW_FOLDER_VALUE;
newInput.style.display = showNew ? 'block' : 'none';
if (showNew) newInput.focus();
});
if (!optionsHtml) {
select.value = NEW_FOLDER_VALUE;
newInput.style.display = 'block';
newInput.focus();
}
const [moveBtn, cancelBtn] = panel.querySelectorAll('.curate-move-actions button');
cancelBtn.onclick = () => panel.remove();
moveBtn.onclick = () => submitMove(panel, btnEl, path);
}
function submitMove(panel, btnEl, path) {
const select = panel.querySelector('.move-dest-select');
const newInput = panel.querySelector('.move-dest-new-input');
const status = panel.querySelector('.curate-move-status');
const usingNew = select.value === NEW_FOLDER_VALUE;
const destination = (usingNew ? newInput.value : select.value).trim();
if (!destination) {
status.style.color = 'var(--error)';
status.textContent = 'Choose or type a destination first.';
if (usingNew) newInput.focus();
return;
}
status.style.color = 'var(--text-muted)';
status.textContent = 'Moving…';
fetch('/api/library/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: path, destination })
})
.then(r => r.json().then(data => ({ ok: r.ok, data })))
.then(({ ok, data }) => {
if (!ok || !data.success) {
status.style.color = 'var(--error)';
status.textContent = data.error || 'Move failed';
return;
}
if (data.active_course_reset) {
alert('That was your active course - reload the main page to pick it up under its new location.');
}
loadHiddenList();
loadCategories();
const container = btnEl.closest('.curate-children') || document.getElementById('curate-tree');
const isRootContainer = container.id === 'curate-tree';
const refreshPath = isRootContainer ? null : container.dataset.path;
loadCurateLevel(refreshPath, container, isRootContainer);
})
.catch(() => {
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
// ---- Duplicate Courses ----
function scanDuplicates() {
const btn = document.getElementById('dup-scan-btn');
const status = document.getElementById('dup-status');
const results = document.getElementById('dup-results');
btn.disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = 'Scanning…';
results.innerHTML = '';
fetch('/api/library/duplicates')
.then(r => r.json())
.then(data => {
btn.disabled = false;
const groups = data.groups || [];
if (!groups.length) {
status.style.color = 'var(--success)';
status.textContent = `No likely duplicates found across ${data.course_count} course${data.course_count === 1 ? '' : 's'}.`;
return;
}
status.style.color = 'var(--text-muted)';
status.textContent = `${groups.length} possible duplicate group${groups.length === 1 ? '' : 's'}, out of ${data.course_count} courses.`;
results.innerHTML = groups.map(group => `
<div class="dup-group">
<div class="dup-group-header">${Math.round(group.similarity * 100)}% similar</div>
${group.courses.map(c => `
<div class="dup-course-row">
<span class="dup-course-name">${escapeHtml(c.name)}</span>
<span class="dup-course-path">${escapeHtml(c.path)}</span>
<button class="btn btn-secondary btn-sm" onclick="hideDuplicateCourse(this, '${c.path.replace(/'/g, "\\'")}')">Hide</button>
</div>
`).join('')}
</div>
`).join('');
})
.catch(() => {
btn.disabled = false;
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
function hideDuplicateCourse(btnEl, path) {
fetch('/api/hidden-paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, hidden: true })
})
.then(r => r.json())
.then(data => {
if (data.success) {
btnEl.disabled = true;
btnEl.textContent = 'Hidden';
const row = btnEl.closest('.dup-course-row');
if (row) row.style.opacity = '0.5';
}
})
.catch(() => {});
}
// ---- Stale References ----
let lastStaleItems = [];
function scanStaleReferences() {
const btn = document.getElementById('stale-scan-btn');
const status = document.getElementById('stale-status');
const results = document.getElementById('stale-results');
btn.disabled = true;
status.style.color = 'var(--text-muted)';
status.textContent = 'Scanning…';
results.innerHTML = '';
fetch('/api/library/stale-references')
.then(r => r.json())
.then(data => {
btn.disabled = false;
const categoryLabels = { hidden: 'Hidden', next_up: 'Next Up', recent_views: 'Recently Viewed' };
const items = [];
for (const category of ['hidden', 'next_up', 'recent_views']) {
for (const entry of (data[category] || [])) {
items.push({ path: entry.path, name: entry.name, category, label: categoryLabels[category] });
}
}
if (!items.length) {
status.style.color = 'var(--success)';
status.textContent = 'No stale references found.';
return;
}
status.textContent = '';
lastStaleItems = items;
results.innerHTML = `
<div id="stale-rows">${items.map((item, i) => `
<div class="bulk-rename-row" data-idx="${i}">
<div class="bulk-rename-row-names">
<span class="sort-badge new_folder">${item.label}</span>
${escapeHtml(item.name)}
</div>
<button class="btn btn-secondary btn-sm" onclick="removeStaleItem(${i})">Remove</button>
</div>
`).join('')}</div>
<button class="btn" style="margin-top: 10px;" onclick="cleanAllStaleReferences()">Remove All ${items.length}</button>
`;
})
.catch(() => {
btn.disabled = false;
status.style.color = 'var(--error)';
status.textContent = 'Could not reach the server.';
});
}
function removeStaleItem(index) {
const item = lastStaleItems[index];
if (!item) return;
fetch('/api/library/stale-references/clean', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: [{ category: item.category, path: item.path }] })
})
.then(r => r.json())
.then(data => {
if (data.success) scanStaleReferences();
})
.catch(() => {});
}
function cleanAllStaleReferences() {
if (!lastStaleItems.length) return;
if (!confirm(`Remove all ${lastStaleItems.length} stale reference(s)? This does not touch any files on disk.`)) return;
fetch('/api/library/stale-references/clean', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: lastStaleItems.map(i => ({ category: i.category, path: i.path })) })
})
.then(r => r.json())
.then(() => scanStaleReferences())
.catch(() => {});
}
loadCategories().then(() => scanUnsorted());
loadHiddenList();
loadCurateLevel(null, document.getElementById('curate-tree'), true);
</script>
</body>
</html>