Add Notes Hub, transcript search, stale nudges, study guide export, Next Up queue, activity heatmap

Six more dashboard features, all built on the per-course progress-file
scanning infrastructure from earlier sessions:

- Notes Hub (/notes): every lesson note across the library in one place,
  newest first, reusing the existing /recent/open cross-course jump route.
- Transcript search: opt-in checkbox on the Library search box, searching
  inside .srt/.vtt files. Kept cheap by doing a raw substring match as the
  filter step and only extracting a snippet for files that actually match.
- Stale course nudges ("Pick Back Up"): courses with progress that haven't
  been touched in 14+ days.
- Study guide export: compiles a course's notes into one downloadable
  markdown file, section/lesson structure preserved.
- Next Up queue: an ordered, persisted "what to tackle next" list with
  up/down reordering and a queue button on every course row.
- Activity heatmap: a 90-day contribution-style calendar in the Library
  Stats card.

_scan_library_activity() now does one walk over every course's progress
file per dashboard load; library stats, stale courses, and the heatmap all
derive from that single scan instead of three independent ones.

Also refactored the duplicated lesson-progress-key lookup (used in three
places now) into _resolve_lesson_progress_key(), and flagged a pre-existing
bug found along the way (not fixed here, kept out of scope): subtitle files
never actually attach to a Lesson object, so the video player's caption
track has never had anything to render.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 19:04:01 -04:00
co-authored by Claude Sonnet 5
parent 145f91a647
commit 0454834e62
7 changed files with 942 additions and 38 deletions
+49
View File
@@ -275,6 +275,55 @@ before.
- App is unauthenticated by design (matches upstream) — settings and hidden-path - App is unauthenticated by design (matches upstream) — settings and hidden-path
curation apply app-wide, not per-browser/per-user. curation apply app-wide, not per-browser/per-user.
## Six more features (this session)
Building on `iter_all_courses`/per-course-progress-file scanning from
earlier: Notes Hub, transcript search, stale-course nudges, study guide
export, a Next Up queue, and an activity heatmap.
- **One shared library scan** — `_scan_library_activity()` walks every
course's progress file once; `format_library_stats()`,
`format_stale_courses()`, and `format_activity_heatmap()` all derive from
a single call in `index()`, instead of three separate full-library reads
on the same dashboard load.
- **Notes Hub** (`/notes`, `templates/notes_hub.html`) — every lesson note
across the whole library in one place, newest first, since a note was
otherwise only visible from its own lesson page. Reuses the existing
`/recent/open` cross-course jump route for navigation - no new route
needed there.
- **Transcript search** — opt-in checkbox on the existing Library search
box ("Also search transcripts"). `search_transcripts()` reads subtitle
file *contents* for a raw substring match as the cheap filter step, only
extracting a cleaned snippet for files that actually match - deliberately
scoped to avoid reintroducing the exact O(every file) perf problem fixed
earlier this session. Found along the way (flagged as a separate task,
not fixed here): subtitle files never actually attach to a Lesson object
today - `DynamicCourseParser._create_lesson_from_file` sets
`subtitle_file` then immediately returns `None` before ever constructing
the `Lesson`, so the `<track kind="subtitles">` element in
`lesson_view.html` has never had anything to show. Transcript search
works around this by matching a subtitle file to its lesson via same-stem
video/audio file lookup instead of depending on `lesson.subtitle_file`.
- **Stale course nudges** ("⏰ Pick Back Up") — courses with some progress,
not fully done, untouched 14+ days. Known limitation: "not fully done" is
judged only from progress-file entries (lessons never opened at all
aren't in that file), so a course with several never-touched lessons
alongside one marked-complete lesson can look falsely "finished" - getting
a true completion count would mean re-scanning every course's full file
tree, the exact cost this whole scanning approach exists to avoid.
- **Study guide export** (`/course/study-guide`) — compiles every note
written for the loaded course into one markdown file, section/lesson
structure preserved, skipping anything without a note. Stdlib only,
matching how `/api/backup` already works.
- **Next Up queue** — ordered, persisted list of courses to tackle next
(`next_up.json`, mirrors `hidden_paths.json`'s pattern but as an ordered
list, since order matters here). Reorder via ▲▼ buttons rather than
drag-and-drop - more reliable on mobile. "📌" button added to Library
browser course rows and the loaded-course stats card.
- **Activity heatmap** — GitHub-style 90-day contribution calendar folded
into the existing Library Stats card, using the same per-day counts the
shared scan already computes for the streak stat.
## Workflow that's been in use ## Workflow that's been in use
Edit locally → `git add . && git commit -m "..." && git push` to the private Edit locally → `git add . && git commit -m "..." && git push` to the private
Gitea repo → redeploy the stack in Dockhand (which builds from the fresh Gitea repo → redeploy the stack in Dockhand (which builds from the fresh
+389 -31
View File
@@ -596,6 +596,59 @@ def set_path_hidden(path: str, hidden: bool) -> List[str]:
return result return result
NEXT_UP_FILE = os.path.join(DATA_DIR, 'next_up.json')
def get_next_up_paths() -> List[str]:
"""Load the ordered 'Next Up' course queue - order matters here, unlike hidden_paths, so it's a list, not a set."""
try:
if os.path.exists(NEXT_UP_FILE):
with open(NEXT_UP_FILE, 'r') as f:
data = json.load(f)
if isinstance(data, list):
return data
except (json.JSONDecodeError, OSError) as e:
print(f"Could not load Next Up queue: {e}")
return []
def _save_next_up_paths(paths: List[str]) -> None:
os.makedirs(DATA_DIR, exist_ok=True)
with open(NEXT_UP_FILE, 'w') as f:
json.dump(paths, f, indent=2)
def set_path_queued(path: str, queued: bool) -> List[str]:
"""Add (to the end) or remove a course from the Next Up queue; returns the updated ordered list."""
paths = get_next_up_paths()
normalized = os.path.abspath(path)
if queued:
if normalized not in paths:
paths.append(normalized)
else:
paths = [p for p in paths if p != normalized]
_save_next_up_paths(paths)
return paths
def reorder_next_up(paths: List[str]) -> List[str]:
"""Replace the Next Up order wholesale - the client computes the new order (via up/down buttons) and posts it back."""
normalized = [os.path.abspath(p) for p in paths]
_save_next_up_paths(normalized)
return normalized
def get_next_up_courses() -> List[Dict[str, Any]]:
"""Next Up queue resolved to displayable course summaries, silently dropping any path no longer on disk."""
results = []
for path in get_next_up_paths():
p = Path(path)
if p.is_dir():
results.append(_course_summary(p))
return results
return result
def _rebase_prefix(value: str, old_abs: str, new_abs: str) -> str: def _rebase_prefix(value: str, old_abs: str, new_abs: str) -> str:
"""If `value` equals or is nested under `old_abs`, rewrite that prefix to `new_abs`.""" """If `value` equals or is nested under `old_abs`, rewrite that prefix to `new_abs`."""
if value == old_abs: if value == old_abs:
@@ -813,6 +866,78 @@ def search_library_courses(dir_path: str, query: str) -> List[Dict[str, Any]]:
] ]
def _extract_subtitle_snippet(text: str, query_lower: str, context_chars: int = 80) -> str:
"""A short excerpt around the first match, with subtitle sequence-number/timestamp lines stripped."""
lower = text.lower()
idx = lower.find(query_lower)
if idx == -1:
return ''
start = max(0, idx - context_chars)
end = min(len(text), idx + len(query_lower) + context_chars)
excerpt_lines = text[start:end].splitlines()
cleaned = [
line.strip() for line in excerpt_lines
if line.strip() and '-->' not in line and not line.strip().isdigit()
and line.strip().upper() != 'WEBVTT'
]
snippet = ' '.join(cleaned)
return ('' if start > 0 else '') + snippet + ('' if end < len(text) else '')
def search_transcripts(query: str, limit: int = 30) -> List[Dict[str, Any]]:
"""
Search inside lesson subtitle files (.srt/.vtt/etc.) for `query`,
case-insensitive. Unlike course-name search, subtitles live inside each
course's own directory tree rather than at the course boundary, so this
needs an actual per-course file walk - kept cheap by doing a raw
substring check as the filter step and only extracting a cleaned-up
snippet for files that actually match.
Subtitle files aren't wired into the Lesson tree today (see
DynamicCourseParser._create_lesson_from_file - a subtitle file never
becomes part of a Lesson, so lesson.subtitle_file is always empty).
Rather than depend on that, this matches a subtitle file to its lesson
by finding a same-named video/audio file next to it, which is how
these files are conventionally paired on disk regardless.
"""
query_lower = query.lower()
results: List[Dict[str, Any]] = []
for course_dir in iter_all_courses(get_library_root()):
for subtitle_file in course_dir.rglob('*'):
if len(results) >= limit:
return results
if not subtitle_file.is_file() or subtitle_file.suffix.lower() not in SUBTITLE_EXTENSIONS:
continue
try:
text = subtitle_file.read_text(encoding='utf-8', errors='ignore')
except OSError:
continue
if query_lower not in text.lower():
continue
media_match = next(
(f for f in subtitle_file.parent.iterdir()
if f.is_file() and f.stem == subtitle_file.stem
and f.suffix.lower() in VIDEO_EXTENSIONS | AUDIO_EXTENSIONS),
None
)
if not media_match:
continue # no lesson to navigate to - skip rather than dead-end
lesson_title = DynamicCourseParser._clean_lesson_name(media_match.stem)
lesson_relative = media_match.relative_to(course_dir).as_posix()
results.append({
'course_path': str(course_dir),
'course_name': course_dir.name,
'lesson_path': f"{lesson_relative}/{lesson_title.replace(' ', '_')}",
'lesson_title': lesson_title,
'snippet': _extract_subtitle_snippet(text, query_lower)
})
return results
def _humanize_days_ago(dt: datetime) -> str: def _humanize_days_ago(dt: datetime) -> str:
"""'Today' / 'Yesterday' / 'N days ago' / a plain date once it's old enough to matter less.""" """'Today' / 'Yesterday' / 'N days ago' / a plain date once it's old enough to matter less."""
days = (datetime.now().date() - dt.date()).days days = (datetime.now().date() - dt.date()).days
@@ -849,12 +974,14 @@ def get_recently_added_courses(limit: int = 5) -> List[Dict[str, Any]]:
return results return results
def get_library_stats() -> Dict[str, Any]: def _scan_library_activity() -> Dict[str, Any]:
""" """
Library-wide overview for the dashboard: total courses, lessons with One walk over every course's progress file, computing everything the
any progress record, completed lessons, total time watched, and the dashboard's stats card / stale-course nudges / activity heatmap need -
current daily streak. Reads each course's small progress JSON file library_stats/stale_courses/activity_heatmap in index() all derive from
directly rather than re-scanning course directory contents, so this a single call to this rather than each re-scanning every course's
progress.json independently. Reads each course's small progress JSON
file directly, not by re-scanning course directory contents, so this
stays cheap regardless of how many files are inside each course. stays cheap regardless of how many files are inside each course.
""" """
total_courses = 0 total_courses = 0
@@ -862,6 +989,9 @@ def get_library_stats() -> Dict[str, Any]:
completed_lessons = 0 completed_lessons = 0
watched_seconds = 0 watched_seconds = 0
active_dates = set() active_dates = set()
activity_by_date: Dict[str, int] = {}
courses: List[Dict[str, Any]] = []
heatmap_cutoff = datetime.now().date() - timedelta(days=89)
for course_dir in iter_all_courses(get_library_root()): for course_dir in iter_all_courses(get_library_root()):
total_courses += 1 total_courses += 1
@@ -871,22 +1001,44 @@ def get_library_stats() -> Dict[str, Any]:
except (FileNotFoundError, json.JSONDecodeError, OSError): except (FileNotFoundError, json.JSONDecodeError, OSError):
continue continue
course_total = 0
course_completed = 0
course_last_activity: Optional[datetime] = None
for key, entry in progress.items(): for key, entry in progress.items():
if key == 'last_accessed_path' or not isinstance(entry, dict): if key == 'last_accessed_path' or not isinstance(entry, dict):
continue continue
lessons_tracked += 1 lessons_tracked += 1
course_total += 1
if entry.get('completed'): if entry.get('completed'):
completed_lessons += 1 completed_lessons += 1
course_completed += 1
watched_seconds += entry.get('duration_seconds') or 0 watched_seconds += entry.get('duration_seconds') or 0
else: else:
watched_seconds += entry.get('progress_seconds') or 0 watched_seconds += entry.get('progress_seconds') or 0
last_accessed = entry.get('last_accessed') last_accessed = entry.get('last_accessed')
if last_accessed: if not last_accessed:
continue
try: try:
active_dates.add(datetime.fromisoformat(last_accessed).date()) accessed_dt = datetime.fromisoformat(last_accessed)
except ValueError: except ValueError:
pass continue
accessed_date = accessed_dt.date()
active_dates.add(accessed_date)
if course_last_activity is None or accessed_dt > course_last_activity:
course_last_activity = accessed_dt
if accessed_date >= heatmap_cutoff:
iso = accessed_date.isoformat()
activity_by_date[iso] = activity_by_date.get(iso, 0) + 1
if course_total > 0:
courses.append({
'path': str(course_dir),
'total': course_total,
'completed': course_completed,
'last_activity': course_last_activity
})
streak_days = 0 streak_days = 0
day = datetime.now().date() day = datetime.now().date()
@@ -898,11 +1050,61 @@ def get_library_stats() -> Dict[str, Any]:
'total_courses': total_courses, 'total_courses': total_courses,
'lessons_tracked': lessons_tracked, 'lessons_tracked': lessons_tracked,
'completed_lessons': completed_lessons, 'completed_lessons': completed_lessons,
'watched_display': format_duration(watched_seconds), 'watched_seconds': watched_seconds,
'streak_days': streak_days 'streak_days': streak_days,
'activity_by_date': activity_by_date,
'courses': courses,
} }
def format_library_stats(scan: Dict[str, Any]) -> Dict[str, Any]:
"""Library-wide overview for the dashboard's stats card, from a _scan_library_activity() result."""
return {
'total_courses': scan['total_courses'],
'lessons_tracked': scan['lessons_tracked'],
'completed_lessons': scan['completed_lessons'],
'watched_display': format_duration(scan['watched_seconds']),
'streak_days': scan['streak_days']
}
def format_stale_courses(scan: Dict[str, Any], threshold_days: int = 14, limit: int = 5) -> List[Dict[str, Any]]:
"""
Courses with some progress that haven't been touched in a while, oldest
first - courses with zero progress (never started) and fully completed
ones are both excluded, since neither is something to "pick back up."
"""
cutoff = datetime.now() - timedelta(days=threshold_days)
candidates = [
c for c in scan['courses']
if c['completed'] < c['total'] and c['last_activity'] and c['last_activity'] < cutoff
]
candidates.sort(key=lambda c: c['last_activity'])
results = []
for c in candidates[:limit]:
item = _course_summary(Path(c['path']))
days_ago = (datetime.now() - c['last_activity']).days
item['last_touched_display'] = f"{days_ago} day{'s' if days_ago != 1 else ''} ago"
results.append(item)
return results
def format_activity_heatmap(scan: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
The last 90 days as a flat list (oldest first) for the dashboard's
contribution-style calendar, each with an ISO date and that day's
activity count.
"""
activity_by_date = scan['activity_by_date']
today = datetime.now().date()
return [
{'date': (today - timedelta(days=offset)).isoformat(),
'count': activity_by_date.get((today - timedelta(days=offset)).isoformat(), 0)}
for offset in range(89, -1, -1)
]
# ---- Outline integration ---- # ---- Outline integration ----
# Deliberately kept separate from DEFAULT_SETTINGS/load_settings/save_settings: # Deliberately kept separate from DEFAULT_SETTINGS/load_settings/save_settings:
# those all flow through GET /api/settings, which theme.js fetches on every # those all flow through GET /api/settings, which theme.js fetches on every
@@ -1167,6 +1369,24 @@ def get_recent_views_for_display() -> List[Dict[str, Any]]:
return entries return entries
def _resolve_lesson_progress_key(course: Course, lesson: Lesson, progress: Dict[str, Any]) -> Optional[str]:
"""
Match a Lesson to its progress-file key. Lessons have historically been
keyed two ways - by their relative file path alone, or with the
lesson's title suffix appended (see get_lesson_url) - so check both
rather than assuming one.
"""
lesson_path = os.path.relpath(lesson.path, course.path).replace('\\', '/')
if lesson_path.startswith('/'):
lesson_path = lesson_path[1:]
if lesson_path in progress:
return lesson_path
lesson_path_with_title = f"{lesson_path}/{lesson.title.replace(' ', '_')}"
if lesson_path_with_title in progress:
return lesson_path_with_title
return None
class ProgressTracker: class ProgressTracker:
"""Handles progress tracking and persistence""" """Handles progress tracking and persistence"""
@@ -1287,24 +1507,13 @@ class ProgressTracker:
def apply_to_node(node: DirectoryNode): def apply_to_node(node: DirectoryNode):
# Apply progress to lessons in this node # Apply progress to lessons in this node
for lesson in node.lessons: for lesson in node.lessons:
lesson_path = os.path.relpath(lesson.path, course.path) key = _resolve_lesson_progress_key(course, lesson, progress)
lesson_path = lesson_path.replace('\\', '/') if key:
if lesson_path.startswith('/'): entry = progress[key]
lesson_path = lesson_path[1:] lesson.completed = entry.get('completed', False)
lesson.last_accessed = entry.get('last_accessed')
# Check both the base path and path with title lesson.progress_seconds = entry.get('progress_seconds', 0)
lesson_path_with_title = f"{lesson_path}/{lesson.title.replace(' ', '_')}" lesson.duration_seconds = entry.get('duration_seconds', 0)
if lesson_path in progress:
lesson.completed = progress[lesson_path].get('completed', False)
lesson.last_accessed = progress[lesson_path].get('last_accessed')
lesson.progress_seconds = progress[lesson_path].get('progress_seconds', 0)
lesson.duration_seconds = progress[lesson_path].get('duration_seconds', 0)
elif lesson_path_with_title in progress:
lesson.completed = progress[lesson_path_with_title].get('completed', False)
lesson.last_accessed = progress[lesson_path_with_title].get('last_accessed')
lesson.progress_seconds = progress[lesson_path_with_title].get('progress_seconds', 0)
lesson.duration_seconds = progress[lesson_path_with_title].get('duration_seconds', 0)
# Recursively apply to children # Recursively apply to children
for child in node.children.values(): for child in node.children.values():
@@ -1319,6 +1528,91 @@ class ProgressTracker:
return DynamicCourseParser._calculate_completion_stats(course.root_node) return DynamicCourseParser._calculate_completion_stats(course.root_node)
def course_has_any_notes(course: Course) -> bool:
"""Whether any lesson in the course has a saved note - used to decide whether to offer a study-guide download."""
progress = ProgressTracker.load_progress(course)
return any(
isinstance(entry, dict) and entry.get('note')
for key, entry in progress.items() if key != 'last_accessed_path'
)
def get_all_notes() -> List[Dict[str, Any]]:
"""
Every lesson note across the whole library, newest first - a note is
otherwise only visible from its own lesson page (or Outline, once
pushed), so this is the one place to see everything you've written.
"""
notes = []
for course_dir in iter_all_courses(get_library_root()):
try:
with open(course_dir / '.offlineu_progress.json', 'r') as f:
progress = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
continue
for lesson_path, entry in progress.items():
if lesson_path == 'last_accessed_path' or not isinstance(entry, dict):
continue
note = entry.get('note')
if not note:
continue
notes.append({
'course_path': str(course_dir),
'course_name': course_dir.name,
'lesson_path': lesson_path,
'lesson_title': lesson_path.rsplit('/', 1)[-1].replace('_', ' '),
'note': note,
'last_accessed': entry.get('last_accessed', ''),
'pushed_to_outline': bool(entry.get('outline_document_id'))
})
notes.sort(key=lambda n: n['last_accessed'], reverse=True)
return notes
def build_study_guide_markdown(course: Course) -> str:
"""
Compile every note written for a course into one markdown document,
following the course's own section/lesson structure - only sections
and lessons that actually have a note appear; everything else is
skipped rather than padding the guide with empty headings.
"""
progress = ProgressTracker.load_progress(course)
def node_has_notes(node: DirectoryNode) -> bool:
for lesson in node.lessons:
key = _resolve_lesson_progress_key(course, lesson, progress)
if key and progress[key].get('note'):
return True
return any(node_has_notes(child) for child in node.children.values())
lines = [f"# {course.name}", ""]
def walk(node: DirectoryNode, heading_level: int):
if node.name and node.name != "Course Root":
if not node_has_notes(node):
return
lines.append(f"{'#' * min(heading_level, 6)} {node.name}")
lines.append("")
for lesson in node.lessons:
key = _resolve_lesson_progress_key(course, lesson, progress)
note = progress[key].get('note') if key else None
if not note:
continue
lines.append(f"{'#' * min(heading_level + 1, 6)} {lesson.title}")
lines.append("")
lines.append(note)
lines.append("")
for child in node.children.values():
walk(child, heading_level + 1)
walk(course.root_node, 2)
return "\n".join(lines)
# Global course storage # Global course storage
current_course = None current_course = None
@@ -1355,14 +1649,18 @@ def index():
continue_watching, recent_views = _split_continue_watching(get_recent_views_for_display()) continue_watching, recent_views = _split_continue_watching(get_recent_views_for_display())
if current_course is None: if current_course is None:
# Show dashboard with course selection option # One scan covers stats/stale-courses/heatmap - see _scan_library_activity.
scan = _scan_library_activity()
return render_template('course_dashboard.html', return render_template('course_dashboard.html',
course=None, course=None,
stats={'total_lessons': 0, 'completed_lessons': 0, 'completion_percentage': 0}, stats={'total_lessons': 0, 'completed_lessons': 0, 'completion_percentage': 0},
continue_watching=continue_watching, continue_watching=continue_watching,
recent_views=recent_views, recent_views=recent_views,
recently_added=get_recently_added_courses(), recently_added=get_recently_added_courses(),
library_stats=get_library_stats()) library_stats=format_library_stats(scan),
stale_courses=format_stale_courses(scan),
activity_heatmap=format_activity_heatmap(scan),
next_up=get_next_up_courses())
# Apply progress data to tree # Apply progress data to tree
ProgressTracker.apply_progress_to_tree(current_course) ProgressTracker.apply_progress_to_tree(current_course)
@@ -1374,7 +1672,9 @@ def index():
course=current_course, course=current_course,
stats=stats, stats=stats,
continue_watching=continue_watching, continue_watching=continue_watching,
recent_views=recent_views) recent_views=recent_views,
has_note=bool(course_has_any_notes(current_course)),
is_queued=os.path.abspath(current_course.path) in get_next_up_paths())
@app.route('/browse') @app.route('/browse')
@@ -1865,6 +2165,64 @@ def help_page():
return render_template('help.html') return render_template('help.html')
@app.route('/notes')
def notes_hub():
"""Every lesson note across the whole library in one place."""
return render_template('notes_hub.html', notes=get_all_notes())
@app.route('/library/search-transcripts')
def search_transcripts_api():
"""Search inside lesson subtitle files across the library."""
query = request.args.get('q', '').strip()
if not query:
return jsonify({'results': []})
return jsonify({'results': search_transcripts(query)})
@app.route('/course/study-guide')
def download_study_guide():
"""Download every note written for the currently loaded course as one markdown file."""
global current_course
if not current_course:
return "No course loaded", 404
markdown = build_study_guide_markdown(current_course)
buffer = io.BytesIO(markdown.encode('utf-8'))
safe_name = re.sub(r'[^\w\-. ]', '_', current_course.name).strip() or 'course'
return send_file(buffer, mimetype='text/markdown', as_attachment=True,
download_name=f"{safe_name} - Study Guide.md")
@app.route('/api/next-up', methods=['POST'])
def set_next_up_api():
"""Add or remove a course from the Next Up queue."""
data = request.json or {}
path = data.get('path', '')
queued = bool(data.get('queued', True))
if not path:
return jsonify({'error': 'path is required'}), 400
library_root = os.path.abspath(get_library_root())
target = os.path.abspath(path)
if not (target == library_root or target.startswith(library_root + os.sep)):
return jsonify({'error': 'Path outside library root'}), 403
updated = set_path_queued(target, queued)
return jsonify({'success': True, 'next_up': updated})
@app.route('/api/next-up/reorder', methods=['POST'])
def reorder_next_up_api():
"""Replace the Next Up order wholesale, from the client's up/down-reordered list."""
data = request.json or {}
paths = data.get('paths')
if not isinstance(paths, list):
return jsonify({'error': 'paths must be a list'}), 400
updated = reorder_next_up(paths)
return jsonify({'success': True, 'next_up': updated})
@app.route('/lesson/<path:lesson_path>') @app.route('/lesson/<path:lesson_path>')
def view_lesson(lesson_path: str): def view_lesson(lesson_path: str):
"""View specific lesson by path""" """View specific lesson by path"""
+261 -2
View File
@@ -277,6 +277,41 @@
margin-top: 2px; margin-top: 2px;
} }
.heatmap-scroll {
overflow-x: auto;
margin-top: 15px;
padding-bottom: 4px;
}
.heatmap-grid {
display: flex;
gap: 3px;
width: max-content;
}
.heatmap-week {
display: flex;
flex-direction: column;
gap: 3px;
}
.heatmap-day {
width: 11px;
height: 11px;
border-radius: 2px;
background: var(--accent);
opacity: 0.12;
}
.heatmap-day.level-1 { opacity: 0.4; }
.heatmap-day.level-2 { opacity: 0.7; }
.heatmap-day.level-3 { opacity: 1; }
.btn-sm {
padding: 5px 10px;
font-size: 0.8em;
}
.library-breadcrumb { .library-breadcrumb {
color: var(--text-muted); color: var(--text-muted);
font-size: 13px; font-size: 13px;
@@ -521,6 +556,45 @@
font-size: 14px; font-size: 14px;
} }
.transcript-search-toggle {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 0.85em;
color: var(--text-muted);
cursor: pointer;
}
.transcript-result {
background: var(--bg-tertiary);
border-radius: var(--radius);
padding: 10px 15px;
margin-bottom: 5px;
border-left: 3px solid var(--accent);
cursor: pointer;
}
.transcript-result:hover {
background: var(--bg-tertiary-hover);
}
.transcript-result-title {
font-weight: 500;
}
.transcript-result-course {
font-size: 0.8em;
color: var(--text-muted);
}
.transcript-result-snippet {
font-size: 0.85em;
color: var(--text-muted);
margin-top: 4px;
font-style: italic;
}
.lesson-name { .lesson-name {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -677,9 +751,17 @@
</div> </div>
{% if stats.total_lessons %} {% if stats.total_lessons %}
<button class="btn btn-secondary" style="margin-top: 5px;" onclick="markCourseWatched()"> <div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 5px;">
<button class="btn btn-secondary" onclick="markCourseWatched()">
Mark all as completed Mark all as completed
</button> </button>
<button class="btn btn-secondary" id="queue-toggle-btn" onclick="toggleQueued()">
{{ '📌 Remove from Next Up' if is_queued else '📌 Add to Next Up' }}
</button>
{% if has_note %}
<a class="btn btn-secondary" href="/course/study-guide">Download Study Guide</a>
{% endif %}
</div>
{% endif %} {% endif %}
{% if stats.last_accessed_path %} {% if stats.last_accessed_path %}
@@ -816,6 +898,60 @@
<div class="stats-tile-label">Day streak</div> <div class="stats-tile-label">Day streak</div>
</div> </div>
</div> </div>
{% if activity_heatmap %}
<div class="heatmap-scroll">
<div class="heatmap-grid">
{% for week in activity_heatmap|batch(7) %}
<div class="heatmap-week">
{% for day in week %}
{% set level = 0 if day.count == 0 else (1 if day.count == 1 else (2 if day.count == 2 else 3)) %}
<div class="heatmap-day level-{{ level }}"
title="{{ day.date }}: {{ day.count }} lesson{{ '' if day.count == 1 else 's' }}"></div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
{% if next_up %}
<div class="container">
<div class="card" id="next-up-card">
<h2>📌 Next Up</h2>
<div id="next-up-list" style="margin-top: 12px;">
{% for item in next_up %}
<div class="lesson-item" data-path="{{ item.path }}">
<div class="lesson-title" style="cursor: pointer;" onclick="loadCoursePath('{{ item.path|replace("'", "\\'") }}')">
{% if item.has_thumbnail %}
<img class="lesson-thumb" src="/library/thumbnail?path={{ item.path | urlencode }}" alt=""
onerror="this.replaceWith(courseFallbackIcon('🎓'))">
{% else %}
<span class="lesson-icon">🎓</span>
{% endif %}
<span class="lesson-name">{{ item.name }}</span>
</div>
<div class="lesson-meta">
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); moveNextUp(this, -1)" title="Move up"></button>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); moveNextUp(this, 1)" title="Move down"></button>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); removeNextUp(this)" title="Remove"></button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if stale_courses %}
<div class="container">
<div class="card">
<h2>⏰ Pick Back Up</h2>
<div style="margin-top: 12px;">
{% for item in stale_courses %}
{{ render_course_row(item, 'Last touched ' + item.last_touched_display) }}
{% endfor %}
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -866,8 +1002,13 @@
<option value="name-desc">Name (Z→A)</option> <option value="name-desc">Name (Z→A)</option>
</select> </select>
</div> </div>
<label class="transcript-search-toggle">
<input type="checkbox" id="search-transcripts-toggle" onchange="handleLibrarySearchInput(document.getElementById('library-search-input').value)">
Also search transcripts
</label>
<p id="library-path-bar" class="library-breadcrumb"></p> <p id="library-path-bar" class="library-breadcrumb"></p>
<div id="library-groups" style="margin-top: 15px;"></div> <div id="library-groups" style="margin-top: 15px;"></div>
<div id="transcript-results"></div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -879,6 +1020,7 @@
📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a> 📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a>
</div> </div>
<div class="footer-links"> <div class="footer-links">
<a href="/notes">📝 Notes</a>
<a href="/help">❓ Help</a> <a href="/help">❓ Help</a>
<a href="/settings">⚙ Settings</a> <a href="/settings">⚙ Settings</a>
</div> </div>
@@ -931,6 +1073,8 @@
searchActive = false; searchActive = false;
const searchInput = document.getElementById('library-search-input'); const searchInput = document.getElementById('library-search-input');
if (searchInput) searchInput.value = ''; if (searchInput) searchInput.value = '';
const transcriptResults = document.getElementById('transcript-results');
if (transcriptResults) transcriptResults.innerHTML = '';
const container = document.getElementById('library-groups'); const container = document.getElementById('library-groups');
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Loading...</p>'; container.innerHTML = '<p style="color:#999; padding: 6px 0;">Loading...</p>';
@@ -1015,7 +1159,10 @@
${iconHtml} ${iconHtml}
<span class="lesson-name">${item.name}</span> <span class="lesson-name">${item.name}</span>
</div> </div>
<span class="lesson-meta">${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span> <div class="lesson-meta">
<span>${item.media_files} media file${item.media_files === 1 ? '' : 's'}</span>
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation(); queueCourse('${safePath}', this)" title="Add to Next Up">📌</button>
</div>
</div> </div>
`; `;
} }
@@ -1067,7 +1214,10 @@
function runLibrarySearch(query) { function runLibrarySearch(query) {
searchActive = true; searchActive = true;
const container = document.getElementById('library-groups'); const container = document.getElementById('library-groups');
const transcriptContainer = document.getElementById('transcript-results');
container.innerHTML = '<p style="color:#999; padding: 6px 0;">Searching...</p>'; container.innerHTML = '<p style="color:#999; padding: 6px 0;">Searching...</p>';
if (transcriptContainer) transcriptContainer.innerHTML = '';
fetch(`/library/search?q=${encodeURIComponent(query)}`) fetch(`/library/search?q=${encodeURIComponent(query)}`)
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
@@ -1083,6 +1233,53 @@
.catch(() => { .catch(() => {
container.innerHTML = '<p style="color:#ff6b6b;">Search failed.</p>'; container.innerHTML = '<p style="color:#ff6b6b;">Search failed.</p>';
}); });
const searchTranscripts = document.getElementById('search-transcripts-toggle');
if (searchTranscripts && searchTranscripts.checked) {
runTranscriptSearch(query);
}
}
function runTranscriptSearch(query) {
const transcriptContainer = document.getElementById('transcript-results');
if (!transcriptContainer) return;
transcriptContainer.innerHTML = '<p style="color:#999; padding: 6px 0;">Searching transcripts...</p>';
fetch(`/library/search-transcripts?q=${encodeURIComponent(query)}`)
.then(r => r.json())
.then(data => {
const results = data.results || [];
if (!results.length) {
transcriptContainer.innerHTML = '';
return;
}
transcriptContainer.innerHTML = `<div style="font-weight:600; margin: 10px 0 8px; font-size: 0.9em; color: var(--text-muted);">In transcripts</div>` +
results.map(r => `
<div class="transcript-result" onclick="window.location.href='/recent/open?course_path=${encodeURIComponent(r.course_path)}&lesson_path=${encodeURIComponent(r.lesson_path)}'">
<div class="transcript-result-title">${r.lesson_title}</div>
<div class="transcript-result-course">${r.course_name}</div>
<div class="transcript-result-snippet">${r.snippet}</div>
</div>
`).join('');
})
.catch(() => {
transcriptContainer.innerHTML = '';
});
}
function queueCourse(path, btnEl) {
fetch('/api/next-up', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, queued: true })
})
.then(r => r.json())
.then(data => {
if (data.success && btnEl) {
btnEl.textContent = '✓';
btnEl.disabled = true;
}
})
.catch(() => {});
} }
function loadCoursePath(path) { function loadCoursePath(path) {
@@ -1101,6 +1298,49 @@
}); });
} }
// ---- Next Up queue (dashboard card) ----
function saveNextUpOrder() {
const paths = Array.from(document.querySelectorAll('#next-up-list .lesson-item')).map(row => row.dataset.path);
fetch('/api/next-up/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: paths })
}).catch(() => {});
}
function moveNextUp(btnEl, direction) {
const row = btnEl.closest('.lesson-item');
const sibling = direction < 0 ? row.previousElementSibling : row.nextElementSibling;
if (!sibling) return;
if (direction < 0) {
row.parentNode.insertBefore(row, sibling);
} else {
row.parentNode.insertBefore(sibling, row);
}
saveNextUpOrder();
}
function removeNextUp(btnEl) {
const row = btnEl.closest('.lesson-item');
const path = row.dataset.path;
fetch('/api/next-up', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: path, queued: false })
})
.then(r => r.json())
.then(data => {
if (data.success) {
row.remove();
const list = document.getElementById('next-up-list');
if (list && list.children.length === 0) {
document.getElementById('next-up-card').closest('.container').remove();
}
}
})
.catch(() => {});
}
function markCourseWatched() { function markCourseWatched() {
if (!confirm('Mark every lesson in this course as completed?')) return; if (!confirm('Mark every lesson in this course as completed?')) return;
fetch('/api/course/mark-watched', { method: 'POST' }) fetch('/api/course/mark-watched', { method: 'POST' })
@@ -1114,6 +1354,25 @@
}); });
} }
{% if course %}
function toggleQueued() {
const btn = document.getElementById('queue-toggle-btn');
const currentlyQueued = btn.textContent.includes('Remove');
fetch('/api/next-up', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: {{ course.path|tojson }}, queued: !currentlyQueued })
})
.then(r => r.json())
.then(data => {
if (data.success) {
btn.textContent = currentlyQueued ? '📌 Add to Next Up' : '📌 Remove from Next Up';
}
})
.catch(() => {});
}
{% endif %}
document.addEventListener('DOMContentLoaded', loadLibrary); document.addEventListener('DOMContentLoaded', loadLibrary);
</script> </script>
+1
View File
@@ -167,6 +167,7 @@
📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a> 📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a>
</div> </div>
<div class="footer-links"> <div class="footer-links">
<a href="/notes">📝 Notes</a>
<a href="/">← Back to app</a> <a href="/">← Back to app</a>
</div> </div>
</div> </div>
+1
View File
@@ -862,6 +862,7 @@
📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a> 📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a>
</div> </div>
<div class="footer-links"> <div class="footer-links">
<a href="/notes">📝 Notes</a>
<a href="/help">❓ Help</a> <a href="/help">❓ Help</a>
<a href="/settings">⚙ Settings</a> <a href="/settings">⚙ Settings</a>
</div> </div>
+235
View File
@@ -0,0 +1,235 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notes - 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;
}
[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,
[data-card-style="elevated"] .note-card {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
}
[data-card-style="bordered"] .card,
[data-card-style="bordered"] .note-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;
}
.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: 800px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 12px;
}
.app-header .brand-icon { font-size: 1.6em; }
.app-header a.brand-link {
color: var(--accent);
text-decoration: none;
font-size: 1.3em;
font-weight: 600;
letter-spacing: 0.5px;
}
.app-footer {
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
padding: 18px 0;
}
.app-footer .footer-inner {
max-width: 800px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
flex-wrap: wrap;
}
.footer-brand {
color: var(--text-muted);
font-size: 0.9em;
display: flex;
align-items: center;
gap: 8px;
}
.footer-links { display: flex; gap: 20px; }
.footer-links a {
color: var(--accent);
text-decoration: none;
font-size: 0.9em;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 { color: var(--accent); margin-bottom: 5px; }
.subtitle { color: var(--text-muted); margin-bottom: 25px; }
.empty-hint {
color: var(--text-muted);
text-align: center;
padding: 40px 20px;
}
.note-card {
background: var(--bg-secondary);
border-radius: var(--radius);
padding: 18px 20px;
margin-bottom: 15px;
border-left: 4px solid var(--accent);
}
.note-card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.note-card-icon {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
font-size: 1.3em;
display: flex;
align-items: center;
justify-content: center;
}
.note-card-titles {
flex: 1;
min-width: 0;
}
.note-lesson-title {
display: block;
font-weight: 600;
}
.note-course-name {
display: block;
font-size: 0.85em;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outline-badge {
font-size: 0.75em;
background: var(--bg-primary);
color: var(--accent);
padding: 2px 8px;
border-radius: 3px;
border: 1px solid var(--accent);
white-space: nowrap;
flex-shrink: 0;
}
.note-card-body {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
}
.note-card-link {
display: block;
margin-top: 12px;
font-size: 0.85em;
color: var(--accent);
text-decoration: none;
}
.note-card-link:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="app-header">
<div class="header-inner">
<span class="brand-icon">📚</span>
<a href="/reset_course" class="brand-link">OfflineU</a>
</div>
</div>
<div class="page-content">
<div class="container">
<h1>📝 Notes</h1>
<p class="subtitle">Every lesson note across your library, newest first.</p>
{% if not notes %}
<div class="empty-hint">No notes yet - jot something down on a lesson page and it'll show up here.</div>
{% endif %}
{% for note in notes %}
<div class="note-card">
<div class="note-card-header">
<span class="note-card-icon">📝</span>
<div class="note-card-titles">
<span class="note-lesson-title">{{ note.lesson_title }}</span>
<span class="note-course-name">{{ note.course_name }}</span>
</div>
{% if note.pushed_to_outline %}
<span class="outline-badge">Outline</span>
{% endif %}
</div>
<div class="note-card-body">{{ note.note }}</div>
<a class="note-card-link"
href="/recent/open?course_path={{ note.course_path | urlencode }}&lesson_path={{ note.lesson_path | urlencode }}">
Open lesson →
</a>
</div>
{% endfor %}
</div>
</div>
<footer class="app-footer">
<div class="footer-inner">
<div class="footer-brand">
📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a>
</div>
<div class="footer-links">
<a href="/">← Back to app</a>
</div>
</div>
</footer>
<script src="/static/theme.js"></script>
</body>
</html>
+1
View File
@@ -564,6 +564,7 @@
📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a> 📚 <a href="/reset_course" style="color: inherit; text-decoration: none;">OfflineU</a>
</div> </div>
<div class="footer-links"> <div class="footer-links">
<a href="/notes">📝 Notes</a>
<a href="/help">❓ Help</a> <a href="/help">❓ Help</a>
<a href="/">← Back to app</a> <a href="/">← Back to app</a>
</div> </div>