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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 & 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');
|
||||
|
||||
+882
-20
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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 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-row-dest">
|
||||
<div class="sort-field">
|
||||
<label>Destination folder</label>
|
||||
<div class="dest-picker">
|
||||
${ICON_SVGS.folder}
|
||||
<input type="text" class="dest-input ${destValue ? '' : 'empty'}" value="${escapeAttr(destValue)}" placeholder="e.g. IT/AWS">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user