Add Unsorted folder auto-sort with keyword matching
Scans an Unsorted folder at the library root and proposes destinations for new courses by matching keywords against the existing category tree, suggesting new subfolders when nothing matches closely, and flagging items for manual review when nothing matches at all. Review and apply happen on a dedicated /unsorted page linked from Settings; nothing moves until the user confirms. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,16 @@ silently stay blank instead of erroring.
|
|||||||
- Hide courses/folders from the browser without touching anything on disk;
|
- Hide courses/folders from the browser without touching anything on disk;
|
||||||
bulk-select to hide or queue several at once
|
bulk-select to hide or queue several at once
|
||||||
- Bulk rename across course/folder names (find & replace)
|
- Bulk rename across course/folder names (find & replace)
|
||||||
|
- **Sort Unsorted** ([/unsorted](templates/unsorted.html), linked from
|
||||||
|
Settings): drop new/incoming courses into an `Unsorted` folder at the
|
||||||
|
library root, then scan proposes a destination for each one by keyword
|
||||||
|
overlap against the *existing* category tree - read fresh from disk every
|
||||||
|
scan, so it adapts to whatever folders you actually have rather than any
|
||||||
|
hardcoded subject list. Three outcomes per item: a confident match to an
|
||||||
|
existing folder, a suggested new subfolder under a broader category match,
|
||||||
|
or "needs review" (unchecked by default) when nothing overlaps at all.
|
||||||
|
Every destination is editable before applying, and nothing on disk moves
|
||||||
|
until you review and hit Apply.
|
||||||
|
|
||||||
**Dashboard**
|
**Dashboard**
|
||||||
- Library-wide stats (courses, lessons completed, time watched, time
|
- Library-wide stats (courses, lessons completed, time watched, time
|
||||||
@@ -185,6 +195,10 @@ MyCourse/
|
|||||||
No metadata files needed — course/section/lesson names come straight from
|
No metadata files needed — course/section/lesson names come straight from
|
||||||
folder and file names.
|
folder and file names.
|
||||||
|
|
||||||
|
An `Unsorted/` folder at the library root (alongside the real category
|
||||||
|
folders) is the drop point for new/incoming courses — see Sort Unsorted
|
||||||
|
above.
|
||||||
|
|
||||||
## Supported file types
|
## Supported file types
|
||||||
|
|
||||||
| Type | Extensions |
|
| Type | Extensions |
|
||||||
|
|||||||
+275
-1
@@ -12,6 +12,7 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
import argparse
|
import argparse
|
||||||
import io
|
import io
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -22,7 +23,7 @@ import urllib.error
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from typing import List, Dict, Optional, Any, Tuple, Iterator
|
from typing import List, Dict, Optional, Any, Tuple, Iterator, Set
|
||||||
from flask import Flask, render_template, request, jsonify, send_file, redirect, url_for
|
from flask import Flask, render_template, request, jsonify, send_file, redirect, url_for
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@@ -1183,6 +1184,190 @@ def get_all_course_dirs() -> List[Path]:
|
|||||||
return cache_get_or_compute('course_dirs', lambda: list(iter_all_courses(get_library_root())))
|
return cache_get_or_compute('course_dirs', lambda: list(iter_all_courses(get_library_root())))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Unsorted intake: propose where a newly-dropped course belongs ----
|
||||||
|
#
|
||||||
|
# Workflow: new/incoming courses get dropped into a folder named Unsorted
|
||||||
|
# directly under the library root, sitting alongside the real category
|
||||||
|
# tree (e.g. IT/, IT/AWS/, Business/). Scanning proposes a destination for
|
||||||
|
# each item by keyword overlap against that *existing* tree - read fresh
|
||||||
|
# from disk on every scan, never hardcoded - so it adapts automatically to
|
||||||
|
# whatever categories actually exist. Nothing gets moved until the moves
|
||||||
|
# are explicitly applied (see /api/unsorted/apply).
|
||||||
|
UNSORTED_FOLDER_NAME = 'Unsorted'
|
||||||
|
|
||||||
|
_TOKEN_SPLIT_RE = re.compile(r'[^a-z0-9]+')
|
||||||
|
_TOKEN_STOPWORDS = {
|
||||||
|
'a', 'an', 'and', 'for', 'from', 'in', 'is', 'of', 'on', 'or', 'the', 'to', 'with',
|
||||||
|
'course', 'courses', 'training', 'class', 'complete', 'full', 'guide', 'tutorial',
|
||||||
|
'part', 'vol', 'volume', 'edition',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize(name: str) -> Set[str]:
|
||||||
|
"""
|
||||||
|
Break a folder/course name into lowercase keyword tokens for matching.
|
||||||
|
Splits camelCase/PascalCase boundaries before the general split, since
|
||||||
|
course names are commonly dot- or dash-joined with no spaces at all
|
||||||
|
(e.g. "AWSCertifiedSolutionsArchitect" as much as "AWS.Certified...").
|
||||||
|
Keeps short tokens (unlike most tokenizers) because real category
|
||||||
|
names here are often exactly that short - "IT", "PM" - and drops pure
|
||||||
|
numbers (section/edition numbers) and common filler words instead,
|
||||||
|
which is what actually carries no signal in this context.
|
||||||
|
"""
|
||||||
|
return set(_tokenize_ordered(name))
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize_ordered(name: str) -> List[str]:
|
||||||
|
"""Same tokens as _tokenize, but as a de-duplicated list in the order
|
||||||
|
they first appear - used when a token subset needs to read back as a
|
||||||
|
sensible phrase (a suggested new folder name) rather than just being
|
||||||
|
compared as a set."""
|
||||||
|
spaced = re.sub(r'(?<=[a-z0-9])(?=[A-Z])', ' ', name)
|
||||||
|
raw = _TOKEN_SPLIT_RE.split(spaced.lower())
|
||||||
|
seen: Set[str] = set()
|
||||||
|
ordered = []
|
||||||
|
for t in raw:
|
||||||
|
if len(t) >= 2 and not t.isdigit() and t not in _TOKEN_STOPWORDS and t not in seen:
|
||||||
|
seen.add(t)
|
||||||
|
ordered.append(t)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def _build_category_index() -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Every category/subcategory folder currently in the library (e.g. IT,
|
||||||
|
IT/AWS) as a possible destination, each with keyword tokens from its
|
||||||
|
own path components plus the titles of any courses already filed
|
||||||
|
directly under it - that second part lets a folder match on subjects
|
||||||
|
its name alone doesn't mention (an "IT/AWS" folder full of courses
|
||||||
|
titled "...Solutions Architect..." now also matches on "solutions" and
|
||||||
|
"architect"), without hardcoding any subject list. Stops recursing
|
||||||
|
into a folder once it reads as a course itself (_looks_like_course,
|
||||||
|
the same rule the Library browser uses), so individual courses never
|
||||||
|
show up as if they were categories to file things under. The Unsorted
|
||||||
|
folder itself is excluded - it's the source, never a valid destination.
|
||||||
|
"""
|
||||||
|
library_root = Path(get_library_root())
|
||||||
|
try:
|
||||||
|
unsorted_root = (library_root / UNSORTED_FOLDER_NAME).resolve()
|
||||||
|
except OSError:
|
||||||
|
unsorted_root = None
|
||||||
|
categories: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
def walk(directory: Path, path_parts: List[str]):
|
||||||
|
try:
|
||||||
|
entries = sorted(
|
||||||
|
(p for p in directory.iterdir() if p.is_dir() and not p.name.startswith('.')),
|
||||||
|
key=lambda p: p.name.lower()
|
||||||
|
)
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
return
|
||||||
|
for entry in entries:
|
||||||
|
try:
|
||||||
|
if unsorted_root is not None and entry.resolve() == unsorted_root:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if _looks_like_course(entry):
|
||||||
|
continue
|
||||||
|
new_parts = path_parts + [entry.name]
|
||||||
|
tokens: Set[str] = set()
|
||||||
|
for part in new_parts:
|
||||||
|
tokens |= _tokenize(part)
|
||||||
|
try:
|
||||||
|
for child in entry.iterdir():
|
||||||
|
if child.is_dir() and not child.name.startswith('.') and _looks_like_course(child):
|
||||||
|
tokens |= _tokenize(child.name)
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
pass
|
||||||
|
categories.append({
|
||||||
|
'path': str(entry),
|
||||||
|
'relative': '/'.join(new_parts),
|
||||||
|
'depth': len(new_parts),
|
||||||
|
'tokens': tokens,
|
||||||
|
})
|
||||||
|
walk(entry, new_parts)
|
||||||
|
|
||||||
|
walk(library_root, [])
|
||||||
|
return categories
|
||||||
|
|
||||||
|
|
||||||
|
def _list_unsorted_items() -> List[Path]:
|
||||||
|
"""Direct children of the Unsorted folder - each one is a course
|
||||||
|
waiting to be filed into the real category tree."""
|
||||||
|
unsorted_root = Path(get_library_root()) / UNSORTED_FOLDER_NAME
|
||||||
|
if not unsorted_root.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
(p for p in unsorted_root.iterdir() if p.is_dir() and not p.name.startswith('.')),
|
||||||
|
key=lambda p: p.name.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _propose_destination(item_name: str, categories: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Best-effort destination for one Unsorted item, by keyword overlap
|
||||||
|
against the existing category tree. Three outcomes:
|
||||||
|
- 'existing': a real folder already matches confidently - move there.
|
||||||
|
- 'new_folder': a broader category matches, but nothing that specific
|
||||||
|
already exists under it - propose creating a new subfolder there,
|
||||||
|
named from whichever of the item's own keywords the category
|
||||||
|
didn't already cover.
|
||||||
|
- 'manual': nothing matches with any confidence - needs a human.
|
||||||
|
"Confident" here means either a fairly specific existing folder (2+
|
||||||
|
levels deep, e.g. IT/AWS) matched on even one shared keyword, or a
|
||||||
|
shallower folder matched on 2+ - a single shared word with a bare
|
||||||
|
top-level category ("IT") isn't enough to drop something straight
|
||||||
|
into it; that's exactly the case a new subfolder proposal is for.
|
||||||
|
"""
|
||||||
|
item_tokens_ordered = _tokenize_ordered(item_name)
|
||||||
|
item_tokens = set(item_tokens_ordered)
|
||||||
|
if not item_tokens:
|
||||||
|
return {'type': 'manual', 'destination': None,
|
||||||
|
'reason': "Couldn't extract any usable keywords from this name."}
|
||||||
|
|
||||||
|
scored = []
|
||||||
|
for cat in categories:
|
||||||
|
overlap = item_tokens & cat['tokens']
|
||||||
|
if overlap:
|
||||||
|
scored.append((len(overlap), cat['depth'], cat, overlap))
|
||||||
|
|
||||||
|
if not scored:
|
||||||
|
return {'type': 'manual', 'destination': None,
|
||||||
|
'reason': "No existing folder shares any keywords with this name."}
|
||||||
|
|
||||||
|
scored.sort(key=lambda s: (s[0], s[1]), reverse=True)
|
||||||
|
best_count, best_depth, best_cat, best_overlap = scored[0]
|
||||||
|
overlap_words = ', '.join(w for w in item_tokens_ordered if w in best_overlap)
|
||||||
|
|
||||||
|
if best_depth >= 2 or best_count >= 2:
|
||||||
|
return {
|
||||||
|
'type': 'existing',
|
||||||
|
'destination': best_cat['relative'],
|
||||||
|
'reason': f'Matches existing folder "{best_cat["relative"]}" on: {overlap_words}',
|
||||||
|
}
|
||||||
|
|
||||||
|
leftover = item_tokens - best_cat['tokens']
|
||||||
|
if leftover:
|
||||||
|
# preserve the item's own word order rather than the set's
|
||||||
|
# arbitrary one, so "Docker Compose Basics" suggests "Compose
|
||||||
|
# Basics" (dropping the already-covered "docker"), not a
|
||||||
|
# scrambled "Basics Compose"
|
||||||
|
leftover_ordered = [w for w in item_tokens_ordered if w in leftover]
|
||||||
|
new_name = ' '.join(w.upper() if len(w) <= 3 else w.capitalize() for w in leftover_ordered)
|
||||||
|
return {
|
||||||
|
'type': 'new_folder',
|
||||||
|
'destination': f"{best_cat['relative']}/{new_name}",
|
||||||
|
'reason': f'"{best_cat["relative"]}" matches on {overlap_words}, but no existing subfolder covers this - suggesting a new one.',
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'existing',
|
||||||
|
'destination': best_cat['relative'],
|
||||||
|
'reason': f'Matches existing folder "{best_cat["relative"]}" on: {overlap_words}',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
@@ -2486,6 +2671,89 @@ def refresh_library_api():
|
|||||||
return jsonify({'success': True})
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/unsorted/scan')
|
||||||
|
def scan_unsorted_api():
|
||||||
|
"""Propose a destination for every item currently sitting in the Unsorted folder. Read-only - nothing moves until /api/unsorted/apply is called."""
|
||||||
|
library_root = get_library_root()
|
||||||
|
unsorted_root = os.path.join(library_root, UNSORTED_FOLDER_NAME)
|
||||||
|
if not os.path.isdir(unsorted_root):
|
||||||
|
return jsonify({'items': [], 'unsorted_exists': False, 'unsorted_path': unsorted_root})
|
||||||
|
|
||||||
|
items = _list_unsorted_items()
|
||||||
|
categories = _build_category_index()
|
||||||
|
results = []
|
||||||
|
for item in items:
|
||||||
|
proposal = _propose_destination(item.name, categories)
|
||||||
|
results.append({'name': item.name, 'path': str(item), **proposal})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'items': results,
|
||||||
|
'unsorted_exists': True,
|
||||||
|
'unsorted_path': unsorted_root,
|
||||||
|
'category_count': len(categories),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/unsorted/apply', methods=['POST'])
|
||||||
|
def apply_unsorted_api():
|
||||||
|
"""
|
||||||
|
Move a batch of Unsorted items to their (possibly user-edited)
|
||||||
|
destinations, creating new subfolders as needed. Every source must
|
||||||
|
actually be inside the Unsorted folder and every destination must
|
||||||
|
stay inside the library root and outside Unsorted itself - guards
|
||||||
|
against a hand-edited destination path escaping the library, the same
|
||||||
|
zip-slip-style check used for backup restore.
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
moves = data.get('moves')
|
||||||
|
if not isinstance(moves, list) or not moves:
|
||||||
|
return jsonify({'success': False, 'error': 'No moves provided'}), 400
|
||||||
|
|
||||||
|
library_root = os.path.abspath(get_library_root())
|
||||||
|
unsorted_root = os.path.abspath(os.path.join(library_root, UNSORTED_FOLDER_NAME))
|
||||||
|
|
||||||
|
moved = []
|
||||||
|
errors = []
|
||||||
|
for move in moves:
|
||||||
|
source = (move or {}).get('source', '')
|
||||||
|
destination_relative = ((move or {}).get('destination') or '').strip().strip('/')
|
||||||
|
|
||||||
|
if not source or not destination_relative:
|
||||||
|
errors.append({'source': source, 'error': 'Missing source or destination'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_abs = os.path.abspath(source)
|
||||||
|
if not (source_abs == unsorted_root or source_abs.startswith(unsorted_root + os.sep)):
|
||||||
|
errors.append({'source': source, 'error': 'Source is not inside the Unsorted folder'})
|
||||||
|
continue
|
||||||
|
if not os.path.isdir(source_abs):
|
||||||
|
errors.append({'source': source, 'error': 'No longer exists - already moved?'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
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)):
|
||||||
|
errors.append({'source': source, 'error': 'Destination is outside the library'})
|
||||||
|
continue
|
||||||
|
if dest_dir == unsorted_root or dest_dir.startswith(unsorted_root + os.sep):
|
||||||
|
errors.append({'source': source, 'error': 'Destination cannot be inside Unsorted'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
final_path = os.path.join(dest_dir, os.path.basename(source_abs))
|
||||||
|
if os.path.exists(final_path):
|
||||||
|
errors.append({'source': source, 'error': f'"{os.path.basename(source_abs)}" already exists at that destination'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(dest_dir, exist_ok=True)
|
||||||
|
shutil.move(source_abs, final_path)
|
||||||
|
moved.append({'source': source, 'destination': final_path})
|
||||||
|
except OSError as e:
|
||||||
|
errors.append({'source': source, 'error': str(e)})
|
||||||
|
|
||||||
|
invalidate_cache()
|
||||||
|
return jsonify({'success': True, 'moved': len(moved), 'errors': errors})
|
||||||
|
|
||||||
|
|
||||||
# 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
|
||||||
@@ -2981,6 +3249,12 @@ def notes_hub():
|
|||||||
return render_template('notes_hub.html', notes=get_all_notes(), active_tab='notes')
|
return render_template('notes_hub.html', notes=get_all_notes(), active_tab='notes')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/unsorted')
|
||||||
|
def unsorted_page():
|
||||||
|
"""Review page for filing new courses out of the Unsorted folder - see /api/unsorted/scan for the actual proposals."""
|
||||||
|
return render_template('unsorted.html', active_tab=None)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/library/search-transcripts')
|
@app.route('/library/search-transcripts')
|
||||||
def search_transcripts_api():
|
def search_transcripts_api():
|
||||||
"""Search inside lesson subtitle files across the library."""
|
"""Search inside lesson subtitle files across the library."""
|
||||||
|
|||||||
@@ -462,6 +462,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Sort Unsorted Courses</h2>
|
||||||
|
<p class="setting-desc" style="margin-bottom: 12px;">
|
||||||
|
Drop new courses in an "Unsorted" folder at the root of your library, then use this to
|
||||||
|
propose where each one belongs based on your existing folder structure - nothing moves
|
||||||
|
until you review and confirm on its own page.
|
||||||
|
</p>
|
||||||
|
<a class="btn btn-secondary" href="/unsorted">{{ icons.icon('folder', 14) }} Open Sorter</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Manage Library</h2>
|
<h2>Manage Library</h2>
|
||||||
<p class="setting-desc" style="margin-bottom: 12px;">
|
<p class="setting-desc" style="margin-bottom: 12px;">
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
{% import '_icons.html' as icons %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sort Unsorted - OfflineU</title>
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<meta name="theme-color" content="#007acc">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #1a1a1a;
|
||||||
|
--bg-secondary: #2d2d2d;
|
||||||
|
--bg-tertiary: #3d3d3d;
|
||||||
|
--bg-tertiary-hover: #404040;
|
||||||
|
--text-primary: #e0e0e0;
|
||||||
|
--text-muted: #999;
|
||||||
|
--border-color: #555;
|
||||||
|
--accent: #007acc;
|
||||||
|
--accent-hover: #005a9e;
|
||||||
|
--font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
--font-size-base: 16px;
|
||||||
|
--container-max-width: 1600px;
|
||||||
|
--radius: 8px;
|
||||||
|
--success: #28a745;
|
||||||
|
--error: #ff6b6b;
|
||||||
|
}
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-primary: #f2f2f2;
|
||||||
|
--bg-secondary: #ffffff;
|
||||||
|
--bg-tertiary: #eeeeee;
|
||||||
|
--bg-tertiary-hover: #e2e2e2;
|
||||||
|
--text-primary: #222222;
|
||||||
|
--text-muted: #666666;
|
||||||
|
--border-color: #cccccc;
|
||||||
|
}
|
||||||
|
[data-card-style="elevated"] .card {
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
[data-card-style="bordered"] .card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.page-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
padding-bottom: 90px;
|
||||||
|
}
|
||||||
|
.app-header {
|
||||||
|
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-tertiary));
|
||||||
|
padding: 18px 0;
|
||||||
|
border-bottom: 3px solid var(--accent);
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.app-header .header-inner {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
.app-header .brand-mark { flex-shrink: 0; display: block; }
|
||||||
|
.app-header a.brand-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 1.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: -3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.subtitle { color: var(--text-muted); margin-bottom: 20px; }
|
||||||
|
.card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px 25px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--accent-hover); }
|
||||||
|
.btn:disabled { background: #666; cursor: not-allowed; }
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: var(--bg-tertiary-hover); }
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
#scan-status {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.empty-hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
.sort-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.sort-row-check {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sort-row-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
.sort-row-name {
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sort-badge {
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sort-badge.existing { background: rgba(40, 167, 69, 0.18); color: var(--success); border: 1px solid var(--success); }
|
||||||
|
.sort-badge.new_folder { background: rgba(0, 122, 204, 0.15); color: var(--accent); border: 1px solid var(--accent); }
|
||||||
|
.sort-badge.manual { background: rgba(255, 107, 107, 0.15); color: var(--error); border: 1px solid var(--error); }
|
||||||
|
.sort-row-reason {
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.sort-row-dest {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
.sort-row-dest input {
|
||||||
|
flex: 1;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
.sort-row-dest input:invalid,
|
||||||
|
.sort-row-dest input.empty {
|
||||||
|
border-color: var(--error);
|
||||||
|
}
|
||||||
|
.action-bar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding: 14px 20px;
|
||||||
|
margin: 0 -25px -20px;
|
||||||
|
border-radius: 0 0 var(--radius) var(--radius);
|
||||||
|
}
|
||||||
|
#apply-status {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.bottom-tab-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 9000;
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.72em;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.tab-item:hover,
|
||||||
|
.tab-item.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-header">
|
||||||
|
<div class="header-inner">
|
||||||
|
<svg class="brand-mark" viewBox="0 0 64 64" width="28" height="28" aria-hidden="true">
|
||||||
|
<rect x="2" y="2" width="60" height="60" rx="16" fill="var(--accent)"></rect>
|
||||||
|
<path d="M24 20 L24 44 L46 32 Z" fill="var(--bg-secondary)"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/reset_course" class="brand-link">OfflineU</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="container">
|
||||||
|
<h1>{{ icons.icon('folder', 24) }} Sort Unsorted</h1>
|
||||||
|
<p class="subtitle">Propose a destination for every course sitting in the <strong>Unsorted</strong> folder, based on your existing category structure. Nothing moves until you apply it.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn" id="scan-btn" onclick="scanUnsorted()">{{ icons.icon('refresh', 14) }} Scan Unsorted Folder</button>
|
||||||
|
<span id="scan-status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="results-card" class="card" style="display: none;">
|
||||||
|
<div class="toolbar" style="margin-bottom: 14px; justify-content: space-between;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px; font-size: 0.9em; color: var(--text-muted);">
|
||||||
|
<input type="checkbox" id="select-all" class="sort-row-check" onchange="toggleSelectAll(this.checked)">
|
||||||
|
Select all
|
||||||
|
</label>
|
||||||
|
<span id="result-summary" style="font-size: 0.85em; color: var(--text-muted);"></span>
|
||||||
|
</div>
|
||||||
|
<div id="sort-rows"></div>
|
||||||
|
<div class="action-bar">
|
||||||
|
<span id="apply-status"></span>
|
||||||
|
<button class="btn" id="apply-btn" onclick="applySelected()">{{ icons.icon('check', 14) }} Apply Selected</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="empty-state" class="empty-hint" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="bottom-tab-bar">
|
||||||
|
<a href="/reset_course" class="tab-item {% if active_tab == 'home' %}active{% endif %}">
|
||||||
|
<span class="tab-icon">{{ icons.icon('home', 20) }}</span><span class="tab-label">Home</span>
|
||||||
|
</a>
|
||||||
|
<a href="/notes" class="tab-item {% if active_tab == 'notes' %}active{% endif %}">
|
||||||
|
<span class="tab-icon">{{ icons.icon('pencil', 20) }}</span><span class="tab-label">Notes</span>
|
||||||
|
</a>
|
||||||
|
<a href="/help" class="tab-item {% if active_tab == 'help' %}active{% endif %}">
|
||||||
|
<span class="tab-icon">{{ icons.icon('help', 20) }}</span><span class="tab-label">Help</span>
|
||||||
|
</a>
|
||||||
|
<a href="/settings" class="tab-item {% if active_tab == 'settings' %}active{% endif %}">
|
||||||
|
<span class="tab-icon">{{ icons.icon('settings', 20) }}</span><span class="tab-label">Settings</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script src="/static/theme.js"></script>
|
||||||
|
<script>
|
||||||
|
// Client-rendered mirror of a templates/_icons.html entry - JS
|
||||||
|
// template literals can't call the Jinja macro.
|
||||||
|
const ICON_SVGS = {
|
||||||
|
folder: '<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg>',
|
||||||
|
};
|
||||||
|
|
||||||
|
let lastItems = [];
|
||||||
|
|
||||||
|
function badgeLabel(type) {
|
||||||
|
if (type === 'existing') return 'Existing folder';
|
||||||
|
if (type === 'new_folder') return 'New folder';
|
||||||
|
return 'Needs review';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(text) {
|
||||||
|
return String(text).replace(/&/g, '&').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows(items) {
|
||||||
|
const container = document.getElementById('sort-rows');
|
||||||
|
container.innerHTML = items.map((item, idx) => {
|
||||||
|
const checked = item.type !== 'manual' ? 'checked' : '';
|
||||||
|
const destValue = item.destination || '';
|
||||||
|
return `
|
||||||
|
<div class="sort-row" data-idx="${idx}">
|
||||||
|
<input type="checkbox" class="sort-row-check" ${checked}>
|
||||||
|
<div class="sort-row-main">
|
||||||
|
<div class="sort-row-name">
|
||||||
|
${escapeHtml(item.name)}
|
||||||
|
<span class="sort-badge ${item.type}">${badgeLabel(item.type)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="sort-row-reason">${escapeHtml(item.reason || '')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sort-row-dest">
|
||||||
|
${ICON_SVGS.folder}
|
||||||
|
<input type="text" class="dest-input ${destValue ? '' : 'empty'}" value="${escapeAttr(destValue)}" placeholder="e.g. IT/AWS">
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.querySelectorAll('.dest-input').forEach(input => {
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
input.classList.toggle('empty', input.value.trim() === '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanUnsorted() {
|
||||||
|
const btn = document.getElementById('scan-btn');
|
||||||
|
const status = document.getElementById('scan-status');
|
||||||
|
btn.disabled = true;
|
||||||
|
status.textContent = 'Scanning…';
|
||||||
|
document.getElementById('empty-state').style.display = 'none';
|
||||||
|
document.getElementById('results-card').style.display = 'none';
|
||||||
|
|
||||||
|
fetch('/api/unsorted/scan')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
btn.disabled = false;
|
||||||
|
status.textContent = '';
|
||||||
|
|
||||||
|
if (!data.unsorted_exists) {
|
||||||
|
document.getElementById('empty-state').textContent =
|
||||||
|
`No "Unsorted" folder found at the root of your library (looked for ${data.unsorted_path || 'Unsorted'}). Create one and drop new courses in it.`;
|
||||||
|
document.getElementById('empty-state').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.items.length) {
|
||||||
|
document.getElementById('empty-state').textContent = 'Nothing to sort right now - the Unsorted folder is empty.';
|
||||||
|
document.getElementById('empty-state').style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastItems = data.items;
|
||||||
|
renderRows(lastItems);
|
||||||
|
document.getElementById('select-all').checked = true;
|
||||||
|
document.getElementById('result-summary').textContent =
|
||||||
|
`${data.items.length} item${data.items.length === 1 ? '' : 's'} · matched against ${data.category_count} existing folder${data.category_count === 1 ? '' : 's'}`;
|
||||||
|
document.getElementById('results-card').style.display = 'block';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
btn.disabled = false;
|
||||||
|
status.textContent = 'Could not reach the server.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll(checked) {
|
||||||
|
document.querySelectorAll('.sort-row-check').forEach(cb => { cb.checked = checked; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySelected() {
|
||||||
|
const rows = Array.from(document.querySelectorAll('.sort-row'));
|
||||||
|
const moves = [];
|
||||||
|
const skippedEmpty = [];
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const checkbox = row.querySelector('.sort-row-check');
|
||||||
|
if (!checkbox.checked) return;
|
||||||
|
const idx = parseInt(row.dataset.idx, 10);
|
||||||
|
const item = lastItems[idx];
|
||||||
|
const destInput = row.querySelector('.dest-input');
|
||||||
|
const destination = destInput.value.trim();
|
||||||
|
if (!destination) {
|
||||||
|
skippedEmpty.push(item.name);
|
||||||
|
destInput.classList.add('empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moves.push({ source: item.path, destination });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (skippedEmpty.length) {
|
||||||
|
alert(`Set a destination for: ${skippedEmpty.join(', ')} (or uncheck them) before applying.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!moves.length) {
|
||||||
|
alert('Nothing selected to move.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm(`Move ${moves.length} course${moves.length === 1 ? '' : 's'} to their destination${moves.length === 1 ? '' : 's'} now? This reorganizes files on disk.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = document.getElementById('apply-status');
|
||||||
|
const btn = document.getElementById('apply-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
status.textContent = 'Moving…';
|
||||||
|
|
||||||
|
fetch('/api/unsorted/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ moves })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
btn.disabled = false;
|
||||||
|
if (!data.success) {
|
||||||
|
status.style.color = 'var(--error)';
|
||||||
|
status.textContent = data.error || 'Move failed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errorCount = (data.errors || []).length;
|
||||||
|
status.style.color = errorCount ? 'var(--error)' : 'var(--success)';
|
||||||
|
status.textContent = `Moved ${data.moved}${errorCount ? `, ${errorCount} error${errorCount === 1 ? '' : 's'}` : ''}`;
|
||||||
|
if (errorCount) {
|
||||||
|
console.warn('Unsorted apply errors:', data.errors);
|
||||||
|
}
|
||||||
|
scanUnsorted();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
btn.disabled = false;
|
||||||
|
status.style.color = 'var(--error)';
|
||||||
|
status.textContent = 'Could not reach the server.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scanUnsorted();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user