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`/
|
- Cover art: uses a manually-placed `cover`/`folder`/`thumbnail`/`thumb`/
|
||||||
`poster` image if a course has one, otherwise auto-generates one via
|
`poster` image if a course has one, otherwise auto-generates one via
|
||||||
`ffmpeg` from a frame of the course's first video
|
`ffmpeg` from a frame of the course's first video
|
||||||
- Hide courses/folders from the browser without touching anything on disk;
|
- **File Management** ([/unsorted](templates/unsorted.html), linked from
|
||||||
bulk-select to hide or queue several at once
|
Settings and from a footer link on the dashboard): a dedicated page for
|
||||||
- Bulk rename across course/folder names (find & replace)
|
everything that touches files on disk, since it needs more room than a
|
||||||
- **Sort Unsorted** ([/unsorted](templates/unsorted.html), linked from
|
Settings card and warrants review before anything actually changes.
|
||||||
Settings): drop new/incoming courses into an `Unsorted` folder at the
|
- *Sort Unsorted*: drop new/incoming courses into an `Unsorted` folder at
|
||||||
library root, then scan proposes a destination for each one by keyword
|
the library root, then scan proposes a destination for each one by keyword
|
||||||
overlap against the *existing* category tree - read fresh from disk every
|
overlap against the *existing* category tree - read fresh from disk every
|
||||||
scan, so it adapts to whatever folders you actually have rather than any
|
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
|
hardcoded subject list. Three outcomes per item: a confident match to an
|
||||||
existing folder, a suggested new subfolder under a broader category match,
|
existing folder, a suggested new subfolder under a broader category match,
|
||||||
or "needs review" (unchecked by default) when nothing overlaps at all.
|
or "needs review" (unchecked by default) when nothing overlaps at all.
|
||||||
Every destination is editable before applying, and nothing on disk moves
|
Every row has a folder-picker dropdown (listing every real category in the
|
||||||
until you review and hit Apply.
|
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**
|
**Dashboard**
|
||||||
- Library-wide stats (courses, lessons completed, time watched, time
|
- 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,
|
actual color distance), or a fully custom accent color, corner radius,
|
||||||
and card style
|
and card style
|
||||||
- Font, text size, page width, and spacing density
|
- Font, text size, page width, and spacing density
|
||||||
- Default library path override + manual "Refresh Library" (bypasses the
|
- Default library path override (Refresh Library and Bulk Rename live on the
|
||||||
5-minute filesystem-scan cache)
|
File Management page - see above)
|
||||||
|
|
||||||
**Backup & integrations**
|
**Backup & integrations**
|
||||||
- One-click backup export and restore: settings, hidden-path choices, the
|
- 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
|
After a directory in the library is renamed/moved on disk, rewrite any
|
||||||
stored absolute paths that pointed inside it (hidden_paths.json,
|
stored absolute paths that pointed inside it (hidden_paths.json,
|
||||||
recent_views.json's course_path) so curation and Recently Viewed don't
|
next_up.json, recent_views.json's course_path) so curation, the Next
|
||||||
silently go stale. Lesson-level progress needs no rebasing - it lives
|
Up queue, and Recently Viewed don't silently go stale. Lesson-level
|
||||||
inside the directory itself, keyed by paths relative to it, so it moves
|
progress needs no rebasing - it lives inside the directory itself,
|
||||||
with the rename automatically.
|
keyed by paths relative to it, so it moves with the rename
|
||||||
|
automatically.
|
||||||
"""
|
"""
|
||||||
hidden = get_hidden_paths()
|
hidden = get_hidden_paths()
|
||||||
rebased_hidden = [_rebase_prefix(p, old_abs, new_abs) for p in hidden]
|
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:
|
with open(HIDDEN_PATHS_FILE, 'w') as f:
|
||||||
json.dump(sorted(set(rebased_hidden)), f, indent=2)
|
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()
|
views = get_recent_views()
|
||||||
changed = False
|
changed = False
|
||||||
for entry in views:
|
for entry in views:
|
||||||
@@ -1200,6 +1206,29 @@ _TOKEN_STOPWORDS = {
|
|||||||
'a', 'an', 'and', 'for', 'from', 'in', 'is', 'of', 'on', 'or', 'the', 'to', 'with',
|
'a', 'an', 'and', 'for', 'from', 'in', 'is', 'of', 'on', 'or', 'the', 'to', 'with',
|
||||||
'course', 'courses', 'training', 'class', 'complete', 'full', 'guide', 'tutorial',
|
'course', 'courses', 'training', 'class', 'complete', 'full', 'guide', 'tutorial',
|
||||||
'part', 'vol', 'volume', 'edition',
|
'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]]:
|
def _build_category_index() -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Every category/subcategory folder currently in the library (e.g. IT,
|
Every category/subcategory folder currently in the library (e.g. IT,
|
||||||
IT/AWS) as a possible destination, each with keyword tokens from its
|
IT/AWS) as a possible destination, each with keyword tokens split into
|
||||||
own path components plus the titles of any courses already filed
|
two tiers of trust:
|
||||||
directly under it - that second part lets a folder match on subjects
|
- 'path_tokens': from the folder's own path components - a deliberate
|
||||||
its name alone doesn't mention (an "IT/AWS" folder full of courses
|
name the user chose, so a match here is always taken at face value.
|
||||||
titled "...Solutions Architect..." now also matches on "solutions" and
|
- 'bonus_tokens': from the titles of courses already filed directly
|
||||||
"architect"), without hardcoding any subject list. Stops recursing
|
under it - lets a folder match on subjects its name alone doesn't
|
||||||
into a folder once it reads as a course itself (_looks_like_course,
|
mention (an "IT/AWS" folder full of courses titled "...Solutions
|
||||||
the same rule the Library browser uses), so individual courses never
|
Architect..." now also matches on "solutions" and "architect"),
|
||||||
show up as if they were categories to file things under. The Unsorted
|
without hardcoding any subject list. Since these come from
|
||||||
folder itself is excluded - it's the source, never a valid destination.
|
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())
|
library_root = Path(get_library_root())
|
||||||
try:
|
try:
|
||||||
@@ -1271,20 +1310,23 @@ def _build_category_index() -> List[Dict[str, Any]]:
|
|||||||
if _looks_like_course(entry):
|
if _looks_like_course(entry):
|
||||||
continue
|
continue
|
||||||
new_parts = path_parts + [entry.name]
|
new_parts = path_parts + [entry.name]
|
||||||
tokens: Set[str] = set()
|
path_tokens: Set[str] = set()
|
||||||
for part in new_parts:
|
for part in new_parts:
|
||||||
tokens |= _tokenize(part)
|
path_tokens |= _tokenize(part)
|
||||||
|
bonus_tokens: Set[str] = set()
|
||||||
try:
|
try:
|
||||||
for child in entry.iterdir():
|
for child in entry.iterdir():
|
||||||
if child.is_dir() and not child.name.startswith('.') and _looks_like_course(child):
|
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):
|
except (PermissionError, OSError):
|
||||||
pass
|
pass
|
||||||
|
bonus_tokens -= path_tokens
|
||||||
categories.append({
|
categories.append({
|
||||||
'path': str(entry),
|
'path': str(entry),
|
||||||
'relative': '/'.join(new_parts),
|
'relative': '/'.join(new_parts),
|
||||||
'depth': len(new_parts),
|
'depth': len(new_parts),
|
||||||
'tokens': tokens,
|
'path_tokens': path_tokens,
|
||||||
|
'bonus_tokens': bonus_tokens,
|
||||||
})
|
})
|
||||||
walk(entry, new_parts)
|
walk(entry, new_parts)
|
||||||
|
|
||||||
@@ -1292,6 +1334,20 @@ def _build_category_index() -> List[Dict[str, Any]]:
|
|||||||
return categories
|
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]:
|
def _list_unsorted_items() -> List[Path]:
|
||||||
"""Direct children of the Unsorted folder - each one is a course
|
"""Direct children of the Unsorted folder - each one is a course
|
||||||
waiting to be filed into the real category tree."""
|
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
|
Best-effort destination for one Unsorted item, by keyword overlap
|
||||||
against the existing category tree. Three outcomes:
|
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
|
named from whichever of the item's own keywords the category
|
||||||
didn't already cover.
|
didn't already cover.
|
||||||
- 'manual': nothing matches with any confidence - needs a human.
|
- 'manual': nothing matches with any confidence - needs a human.
|
||||||
"Confident" here means either a fairly specific existing folder (2+
|
Categories are ranked by evidence quality, not raw shared-word count -
|
||||||
levels deep, e.g. IT/AWS) matched on even one shared keyword, or a
|
a match on a *path* token (the folder's own deliberate name, e.g. the
|
||||||
shallower folder matched on 2+ - a single shared word with a bare
|
"AWS" in IT/AWS) always outranks any number of *bonus* tokens (words
|
||||||
top-level category ("IT") isn't enough to drop something straight
|
only borrowed from a sibling course's title - see
|
||||||
into it; that's exactly the case a new subfolder proposal is for.
|
_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_ordered = _tokenize_ordered(item_name)
|
||||||
item_tokens = set(item_tokens_ordered)
|
item_tokens = set(item_tokens_ordered)
|
||||||
@@ -1328,26 +1400,36 @@ def _propose_destination(item_name: str, categories: List[Dict[str, Any]]) -> Di
|
|||||||
|
|
||||||
scored = []
|
scored = []
|
||||||
for cat in categories:
|
for cat in categories:
|
||||||
overlap = item_tokens & cat['tokens']
|
overlap = item_tokens & (cat['path_tokens'] | cat['bonus_tokens'])
|
||||||
if overlap:
|
if not overlap:
|
||||||
scored.append((len(overlap), cat['depth'], cat, 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:
|
if not scored:
|
||||||
return {'type': 'manual', 'destination': None,
|
return {'type': 'manual', 'destination': None,
|
||||||
'reason': "No existing folder shares any keywords with this name."}
|
'reason': "No existing folder shares any keywords with this name."}
|
||||||
|
|
||||||
scored.sort(key=lambda s: (s[0], s[1]), reverse=True)
|
scored.sort(key=lambda s: (s['path_hits'], s['bonus_best'], s['count'], s['cat']['depth']), reverse=True)
|
||||||
best_count, best_depth, best_cat, best_overlap = scored[0]
|
best = scored[0]
|
||||||
overlap_words = ', '.join(w for w in item_tokens_ordered if w in best_overlap)
|
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 {
|
return {
|
||||||
'type': 'existing',
|
'type': 'existing',
|
||||||
'destination': best_cat['relative'],
|
'destination': best_cat['relative'],
|
||||||
'reason': f'Matches existing folder "{best_cat["relative"]}" on: {overlap_words}',
|
'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:
|
if leftover:
|
||||||
# preserve the item's own word order rather than the set's
|
# preserve the item's own word order rather than the set's
|
||||||
# arbitrary one, so "Docker Compose Basics" suggests "Compose
|
# 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]:
|
def _probe_media_duration_seconds(file_path: Path) -> Optional[float]:
|
||||||
"""Read a single media file's duration via ffprobe. None if ffprobe is
|
"""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."""
|
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 []
|
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]:
|
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
|
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()
|
items = _list_unsorted_items()
|
||||||
categories = _build_category_index()
|
categories = _build_category_index()
|
||||||
|
bonus_df = _bonus_token_frequency(categories)
|
||||||
results = []
|
results = []
|
||||||
for item in items:
|
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})
|
results.append({'name': item.name, 'path': str(item), **proposal})
|
||||||
|
|
||||||
|
category_paths = sorted((c['relative'] for c in categories), key=str.lower)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'items': results,
|
'items': results,
|
||||||
'unsorted_exists': True,
|
'unsorted_exists': True,
|
||||||
'unsorted_path': unsorted_root,
|
'unsorted_path': unsorted_root,
|
||||||
'category_count': len(categories),
|
'category_count': len(categories),
|
||||||
|
'categories': category_paths,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -2698,8 +2902,10 @@ def scan_unsorted_api():
|
|||||||
def apply_unsorted_api():
|
def apply_unsorted_api():
|
||||||
"""
|
"""
|
||||||
Move a batch of Unsorted items to their (possibly user-edited)
|
Move a batch of Unsorted items to their (possibly user-edited)
|
||||||
destinations, creating new subfolders as needed. Every source must
|
destinations, creating new subfolders as needed, and optionally renaming
|
||||||
actually be inside the Unsorted folder and every destination must
|
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
|
stay inside the library root and outside Unsorted itself - guards
|
||||||
against a hand-edited destination path escaping the library, the same
|
against a hand-edited destination path escaping the library, the same
|
||||||
zip-slip-style check used for backup restore.
|
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'})
|
errors.append({'source': source, 'error': 'Destination cannot be inside Unsorted'})
|
||||||
continue
|
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):
|
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
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -2754,6 +2967,129 @@ def apply_unsorted_api():
|
|||||||
return jsonify({'success': True, 'moved': len(moved), 'errors': errors})
|
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
|
# Single-process personal app, so a plain dict + background thread is
|
||||||
# enough state for the duration prewarm - no job queue needed. Guarded by
|
# 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
|
# 'running' so a second click while one's in flight just reports progress
|
||||||
|
|||||||
@@ -197,6 +197,21 @@
|
|||||||
transform: translateY(0);
|
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 {
|
.bottom-tab-bar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -1313,6 +1328,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="page-footer-link">
|
||||||
|
<a href="/unsorted">{{ icons.icon('folder', 14) }} File Management</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="bottom-tab-bar">
|
<nav class="bottom-tab-bar">
|
||||||
|
|||||||
+6
-466
@@ -221,101 +221,6 @@
|
|||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
font-size: 0.85em;
|
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 {
|
#save-status {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
@@ -448,11 +353,6 @@
|
|||||||
<button class="btn" onclick="saveLibraryPath()">Save</button>
|
<button class="btn" onclick="saveLibraryPath()">Save</button>
|
||||||
</div>
|
</div>
|
||||||
<span id="library-path-status" style="font-size: 0.85em; min-height: 1.2em;"></span>
|
<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;">
|
<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>
|
<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>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Sort Unsorted Courses</h2>
|
<h2>File Management</h2>
|
||||||
<p class="setting-desc" style="margin-bottom: 12px;">
|
<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
|
Sort new courses out of an "Unsorted" folder at the root of your library, refresh the
|
||||||
propose where each one belongs based on your existing folder structure - nothing moves
|
library cache, bulk-rename or move things around, hide courses, check for duplicates, and
|
||||||
until you review and confirm on its own page.
|
clean up stale references - all on its own page, with room to review before anything
|
||||||
|
changes on disk.
|
||||||
</p>
|
</p>
|
||||||
<a class="btn btn-secondary" href="/unsorted">{{ icons.icon('folder', 14) }} Open Sorter</a>
|
<a class="btn btn-secondary" href="/unsorted">{{ icons.icon('folder', 14) }} Open File Management</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -627,10 +491,6 @@
|
|||||||
// built dynamically here are kept in sync with the same path data
|
// built dynamically here are kept in sync with the same path data
|
||||||
// by hand.
|
// by hand.
|
||||||
const ICON_SVGS = {
|
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>',
|
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() {
|
function saveLibraryPath() {
|
||||||
const input = document.getElementById('library_path');
|
const input = document.getElementById('library_path');
|
||||||
const status = document.getElementById('library-path-status');
|
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) {
|
function updatePrewarmStatus(data) {
|
||||||
const btn = document.getElementById('prewarm-durations-btn');
|
const btn = document.getElementById('prewarm-durations-btn');
|
||||||
const status = document.getElementById('prewarm-durations-status');
|
const status = document.getElementById('prewarm-durations-status');
|
||||||
|
|||||||
+882
-20
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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">
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
<meta name="theme-color" content="#007acc">
|
<meta name="theme-color" content="#007acc">
|
||||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
@@ -103,6 +103,179 @@
|
|||||||
padding: 20px 25px;
|
padding: 20px 25px;
|
||||||
margin-bottom: 20px;
|
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 {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -188,15 +361,27 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
.sort-row-dest {
|
.sort-row-fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 14px;
|
||||||
flex: 1;
|
margin-top: 10px;
|
||||||
min-width: 240px;
|
|
||||||
}
|
}
|
||||||
.sort-row-dest input {
|
.sort-field {
|
||||||
flex: 1;
|
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);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -204,9 +389,18 @@
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.sort-row-dest input:invalid,
|
.dest-picker {
|
||||||
.sort-row-dest input.empty {
|
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);
|
border-color: var(--error);
|
||||||
}
|
}
|
||||||
.action-bar {
|
.action-bar {
|
||||||
@@ -269,8 +463,11 @@
|
|||||||
|
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>{{ icons.icon('folder', 24) }} Sort Unsorted</h1>
|
<h1>{{ icons.icon('folder', 24) }} File Management</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>
|
<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="card">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -295,6 +492,60 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="empty-state" class="empty-hint" style="display: none;"></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -319,9 +570,15 @@
|
|||||||
// template literals can't call the Jinja macro.
|
// template literals can't call the Jinja macro.
|
||||||
const ICON_SVGS = {
|
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>',
|
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 lastItems = [];
|
||||||
|
let lastCategories = [];
|
||||||
|
const NEW_FOLDER_VALUE = '__new__';
|
||||||
|
|
||||||
function badgeLabel(type) {
|
function badgeLabel(type) {
|
||||||
if (type === 'existing') return 'Existing folder';
|
if (type === 'existing') return 'Existing folder';
|
||||||
@@ -336,11 +593,36 @@
|
|||||||
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
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) {
|
function renderRows(items) {
|
||||||
const container = document.getElementById('sort-rows');
|
const container = document.getElementById('sort-rows');
|
||||||
|
const optionsHtml = categoryOptionsHtml();
|
||||||
|
|
||||||
container.innerHTML = items.map((item, idx) => {
|
container.innerHTML = items.map((item, idx) => {
|
||||||
const checked = item.type !== 'manual' ? 'checked' : '';
|
const checked = item.type !== 'manual' ? 'checked' : '';
|
||||||
const destValue = item.destination || '';
|
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 `
|
return `
|
||||||
<div class="sort-row" data-idx="${idx}">
|
<div class="sort-row" data-idx="${idx}">
|
||||||
<input type="checkbox" class="sort-row-check" ${checked}>
|
<input type="checkbox" class="sort-row-check" ${checked}>
|
||||||
@@ -350,15 +632,40 @@
|
|||||||
<span class="sort-badge ${item.type}">${badgeLabel(item.type)}</span>
|
<span class="sort-badge ${item.type}">${badgeLabel(item.type)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sort-row-reason">${escapeHtml(item.reason || '')}</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>
|
||||||
<div class="sort-row-dest">
|
<div class="sort-field">
|
||||||
|
<label>Destination folder</label>
|
||||||
|
<div class="dest-picker">
|
||||||
${ICON_SVGS.folder}
|
${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>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).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.addEventListener('input', () => {
|
||||||
input.classList.toggle('empty', input.value.trim() === '');
|
input.classList.toggle('empty', input.value.trim() === '');
|
||||||
});
|
});
|
||||||
@@ -418,14 +725,18 @@
|
|||||||
if (!checkbox.checked) return;
|
if (!checkbox.checked) return;
|
||||||
const idx = parseInt(row.dataset.idx, 10);
|
const idx = parseInt(row.dataset.idx, 10);
|
||||||
const item = lastItems[idx];
|
const item = lastItems[idx];
|
||||||
const destInput = row.querySelector('.dest-input');
|
const select = row.querySelector('.dest-select');
|
||||||
const destination = destInput.value.trim();
|
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) {
|
if (!destination) {
|
||||||
skippedEmpty.push(item.name);
|
skippedEmpty.push(item.name);
|
||||||
destInput.classList.add('empty');
|
if (usingNew) newInput.classList.add('empty');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
moves.push({ source: item.path, destination });
|
moves.push({ source: item.path, destination, name });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (skippedEmpty.length) {
|
if (skippedEmpty.length) {
|
||||||
@@ -464,7 +775,7 @@
|
|||||||
if (errorCount) {
|
if (errorCount) {
|
||||||
console.warn('Unsorted apply errors:', data.errors);
|
console.warn('Unsorted apply errors:', data.errors);
|
||||||
}
|
}
|
||||||
scanUnsorted();
|
loadCategories().then(() => scanUnsorted());
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
btn.disabled = false;
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user