From 8b365c4463a73d0742eae4f121a7cb583b3555e5 Mon Sep 17 00:00:00 2001 From: rmsitz Date: Sat, 22 Aug 2026 20:17:20 -0400 Subject: [PATCH] Add timestamped notes: multi-note support, quick-capture hotkey, live saved indicator Replace the single free-text note per lesson with a list of timestamped note entries, migrated transparently from the old format. Adds a floating notes panel on the lesson page with an always-reachable quick-capture bar (press N to pause and jump in), inline edit/delete, click-to-seek timestamps, and a live "Saved Xs ago" indicator. Notes Hub, the study guide export, and the Outline push all updated to render multiple timestamped notes per lesson. Co-Authored-By: Claude Sonnet 5 --- offlineu_core.py | 217 +++++++++++++++---- templates/lesson_view.html | 433 ++++++++++++++++++++++++++++++++----- templates/notes_hub.html | 17 +- 3 files changed, 569 insertions(+), 98 deletions(-) diff --git a/offlineu_core.py b/offlineu_core.py index 2c4d885..278bed2 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -11,6 +11,7 @@ import re import sys import argparse import io +import uuid import zipfile import urllib.request import urllib.error @@ -1248,9 +1249,14 @@ def push_note_to_outline(course: Course, lesson_path: str, lesson_title: str, progress = ProgressTracker.load_progress(course) entry = progress.get(lesson_path, {}) - note = entry.get('note', '') - if not note: + notes = ProgressTracker._notes_from_entry(entry) + if not notes: return {'success': False, 'error': 'No note to push'} + note = "\n\n".join( + f"**[{format_timestamp(n.get('timestamp_seconds'))}]** {n.get('text', '')}" + if n.get('timestamp_seconds') is not None else n.get('text', '') + for n in notes + ) if not topic_id: if not new_topic_name: @@ -1427,9 +1433,11 @@ class ProgressTracker: elif existing.get('duration_seconds'): entry['duration_seconds'] = existing['duration_seconds'] - # Same for a note - this call has no opinion on it, so don't let a - # routine playback-progress save wipe one out. - if existing.get('note'): + # Same for notes - this call has no opinion on them, so don't let a + # routine playback-progress save wipe them out. + if existing.get('notes'): + entry['notes'] = existing['notes'] + elif existing.get('note'): entry['note'] = existing['note'] progress[lesson_path] = entry @@ -1440,15 +1448,73 @@ class ProgressTracker: ProgressTracker.save_progress(course, progress) @staticmethod - def update_lesson_note(course: Course, lesson_path: str, note: str): - """Save (or clear) a lesson's note, without touching its playback progress.""" + def _notes_from_entry(entry: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + This entry's timestamped notes, transparently upgrading a legacy + single-string `note` field into a one-item list so every caller can + treat notes as a list without caring which shape is on disk. + """ + if entry.get('notes'): + return entry['notes'] + legacy = entry.get('note') + if legacy: + return [{ + 'id': 'legacy', + 'timestamp_seconds': None, + 'text': legacy, + 'created_at': entry.get('last_accessed', '') + }] + return [] + + @staticmethod + def get_lesson_notes(course: Course, lesson_path: str) -> List[Dict[str, Any]]: + """A lesson's timestamped notes, newest-shape-or-migrated-legacy.""" + progress = ProgressTracker.load_progress(course) + return ProgressTracker._notes_from_entry(progress.get(lesson_path, {})) + + @staticmethod + def add_lesson_note(course: Course, lesson_path: str, text: str, + timestamp_seconds: Optional[int]) -> List[Dict[str, Any]]: + """Append a new timestamped note, migrating a legacy single-note entry to the list shape if needed.""" progress = ProgressTracker.load_progress(course) entry = progress.setdefault(lesson_path, {}) - if note: - entry['note'] = note - else: - entry.pop('note', None) + notes = ProgressTracker._notes_from_entry(entry) + notes.append({ + 'id': uuid.uuid4().hex[:8], + 'text': text, + 'timestamp_seconds': timestamp_seconds, + 'created_at': datetime.now().isoformat() + }) + entry['notes'] = notes + entry.pop('note', None) ProgressTracker.save_progress(course, progress) + return notes + + @staticmethod + def update_lesson_note_text(course: Course, lesson_path: str, note_id: str, text: str) -> List[Dict[str, Any]]: + """Edit one timestamped note's text in place.""" + progress = ProgressTracker.load_progress(course) + entry = progress.setdefault(lesson_path, {}) + notes = ProgressTracker._notes_from_entry(entry) + for note in notes: + if note.get('id') == note_id: + note['text'] = text + break + entry['notes'] = notes + entry.pop('note', None) + ProgressTracker.save_progress(course, progress) + return notes + + @staticmethod + def delete_lesson_note(course: Course, lesson_path: str, note_id: str) -> List[Dict[str, Any]]: + """Remove one timestamped note.""" + progress = ProgressTracker.load_progress(course) + entry = progress.setdefault(lesson_path, {}) + notes = [n for n in ProgressTracker._notes_from_entry(entry) if n.get('id') != note_id] + entry['notes'] = notes + entry.pop('note', None) + ProgressTracker.save_progress(course, progress) + return notes @staticmethod def set_lesson_outline_topic(course: Course, lesson_path: str, topic_id: str, topic_name: str): @@ -1532,16 +1598,25 @@ 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') + isinstance(entry, dict) and ProgressTracker._notes_from_entry(entry) for key, entry in progress.items() if key != 'last_accessed_path' ) +def format_timestamp(seconds: Optional[float]) -> Optional[str]: + """Render a note's captured playback position as "MM:SS", or None if it wasn't timestamped.""" + if seconds is None: + return None + seconds = int(seconds) + return f"{seconds // 60}:{seconds % 60:02d}" + + 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. + Every timestamped 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. A + lesson with several notes contributes one row per note. """ notes = [] for course_dir in iter_all_courses(get_library_root()): @@ -1554,20 +1629,22 @@ def get_all_notes() -> List[Dict[str, Any]]: 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')) - }) + for note in ProgressTracker._notes_from_entry(entry): + created_at = note.get('created_at') or entry.get('last_accessed', '') + 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_id': note.get('id'), + 'text': note.get('text', ''), + 'timestamp_seconds': note.get('timestamp_seconds'), + 'timestamp_label': format_timestamp(note.get('timestamp_seconds')), + 'created_at': created_at, + 'pushed_to_outline': bool(entry.get('outline_document_id')) + }) - notes.sort(key=lambda n: n['last_accessed'], reverse=True) + notes.sort(key=lambda n: n['created_at'], reverse=True) return notes @@ -1583,7 +1660,7 @@ def build_study_guide_markdown(course: Course) -> str: 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'): + if key and ProgressTracker._notes_from_entry(progress[key]): return True return any(node_has_notes(child) for child in node.children.values()) @@ -1598,12 +1675,15 @@ def build_study_guide_markdown(course: Course) -> str: 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: + lesson_notes = ProgressTracker._notes_from_entry(progress[key]) if key else [] + if not lesson_notes: continue lines.append(f"{'#' * min(heading_level + 1, 6)} {lesson.title}") lines.append("") - lines.append(note) + for note in lesson_notes: + ts = format_timestamp(note.get('timestamp_seconds')) + prefix = f"**[{ts}]** " if ts else "" + lines.append(f"- {prefix}{note.get('text', '')}") lines.append("") for child in node.children.values(): @@ -2145,6 +2225,7 @@ def open_recent(): course_path = request.args.get('course_path', '') lesson_path = request.args.get('lesson_path', '') + seek_seconds = request.args.get('t', '') if not course_path or not lesson_path or not os.path.exists(course_path): return redirect(url_for('index')) @@ -2156,7 +2237,10 @@ def open_recent(): print(f"Could not load course for recent view: {e}") return redirect(url_for('index')) - return redirect(url_for('view_lesson', lesson_path=lesson_path)) + lesson_url = url_for('view_lesson', lesson_path=lesson_path) + if seek_seconds: + lesson_url = f"{lesson_url}?t={seek_seconds}" + return redirect(lesson_url) @app.route('/help') @@ -2263,16 +2347,19 @@ def view_lesson(lesson_path: str): # Record for the cross-course "Recently Viewed" list on the dashboard record_recent_view(current_course.name, current_course.path, lesson_path, lesson.title) - # Read the note directly from the progress file rather than the Lesson + # Read notes directly from the progress file rather than the Lesson # object - apply_progress_to_tree (which populates Lesson fields) isn't # called on this code path, only on the dashboard's tree render. lesson_progress = ProgressTracker.load_progress(current_course).get(lesson_path, {}) + seek_seconds = request.args.get('t', type=int) + return render_template('lesson_view.html', course=current_course, lesson=lesson, lesson_path=lesson_path, - lesson_note=lesson_progress.get('note', ''), + lesson_notes=ProgressTracker._notes_from_entry(lesson_progress), + initial_seek_seconds=seek_seconds, outline_topic_id=lesson_progress.get('outline_topic_id', ''), outline_topic_name=lesson_progress.get('outline_topic_name', ''), prev_lesson=prev_lesson, @@ -2362,9 +2449,9 @@ def update_progress(): return jsonify({'error': str(e)}), 500 -@app.route('/api/lesson-note', methods=['POST']) -def update_lesson_note_api(): - """API endpoint to save (or clear) a lesson's note""" +@app.route('/api/lesson-note/add', methods=['POST']) +def add_lesson_note_api(): + """Append a new timestamped note to a lesson.""" global current_course if not current_course: @@ -2372,12 +2459,60 @@ def update_lesson_note_api(): data = request.json or {} lesson_path = data.get('lesson_path') - note = (data.get('note') or '').strip() + text = (data.get('text') or '').strip() if not lesson_path: return jsonify({'error': 'lesson_path is required'}), 400 + if not text: + return jsonify({'error': 'text is required'}), 400 - ProgressTracker.update_lesson_note(current_course, lesson_path, note) - return jsonify({'success': True}) + timestamp_seconds = data.get('timestamp_seconds') + if timestamp_seconds is not None: + try: + timestamp_seconds = int(timestamp_seconds) + except (TypeError, ValueError): + timestamp_seconds = None + + notes = ProgressTracker.add_lesson_note(current_course, lesson_path, text, timestamp_seconds) + return jsonify({'success': True, 'notes': notes}) + + +@app.route('/api/lesson-note/update', methods=['POST']) +def update_lesson_note_api(): + """Edit one of a lesson's timestamped notes.""" + global current_course + + if not current_course: + return jsonify({'error': 'No course loaded'}), 400 + + data = request.json or {} + lesson_path = data.get('lesson_path') + note_id = data.get('note_id') + text = (data.get('text') or '').strip() + if not lesson_path or not note_id: + return jsonify({'error': 'lesson_path and note_id are required'}), 400 + if not text: + return jsonify({'error': 'text is required'}), 400 + + notes = ProgressTracker.update_lesson_note_text(current_course, lesson_path, note_id, text) + return jsonify({'success': True, 'notes': notes}) + + +@app.route('/api/lesson-note/delete', methods=['POST']) +def delete_lesson_note_api(): + """Remove one of a lesson's timestamped notes.""" + global current_course + + if not current_course: + return jsonify({'error': 'No course loaded'}), 400 + + data = request.json or {} + lesson_path = data.get('lesson_path') + note_id = data.get('note_id') + if not lesson_path or not note_id: + return jsonify({'error': 'lesson_path and note_id are required'}), 400 + + notes = ProgressTracker.delete_lesson_note(current_course, lesson_path, note_id) + return jsonify({'success': True, 'notes': notes}) @app.route('/api/lesson-note/topic', methods=['POST']) diff --git a/templates/lesson_view.html b/templates/lesson_view.html index b16c2fe..6f8e1eb 100644 --- a/templates/lesson_view.html +++ b/templates/lesson_view.html @@ -185,21 +185,9 @@ .content { margin: 20px 0; } - .notes-section { + .outline-section { margin: 20px 0; } - .lesson-note-textarea { - width: 100%; - min-height: 90px; - padding: 12px; - background: var(--bg-tertiary); - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: var(--radius); - font-family: var(--font-family); - font-size: 0.95em; - resize: vertical; - } .note-status { display: block; font-size: 0.85em; @@ -207,6 +195,144 @@ margin-top: 6px; min-height: 1.2em; } + .page-content .container { + padding-bottom: 90px; + } + .notes-panel { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 9000; + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); + box-shadow: 0 -4px 14px rgba(0, 0, 0, 0.3); + } + .notes-panel-list { + max-height: 0; + overflow-y: auto; + transition: max-height 0.25s ease; + } + .notes-panel-list.expanded { + max-height: 40vh; + border-bottom: 1px solid var(--border-color); + } + .notes-list { + padding: 10px 16px; + } + .notes-empty-hint { + color: var(--text-muted); + font-size: 0.9em; + margin: 6px 0; + } + .note-entry { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 0; + border-bottom: 1px solid var(--border-color); + } + .note-entry:last-child { + border-bottom: none; + } + .note-timestamp-badge { + flex-shrink: 0; + background: var(--bg-tertiary); + color: var(--accent); + border: none; + border-radius: var(--radius); + padding: 2px 8px; + margin: 0; + font-size: 0.8em; + font-family: var(--font-family); + } + .note-timestamp-badge:hover { + background: var(--bg-tertiary-hover); + } + .note-entry-text { + flex: 1; + min-width: 0; + word-break: break-word; + white-space: pre-wrap; + font-size: 0.95em; + padding-top: 3px; + } + .note-edit-textarea { + flex: 1; + min-width: 0; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 6px 8px; + font-family: var(--font-family); + font-size: 0.95em; + resize: vertical; + } + .note-entry-edit, + .note-entry-delete { + flex-shrink: 0; + background: transparent; + color: var(--text-muted); + padding: 2px 6px; + margin: 0; + font-size: 0.9em; + } + .note-entry-edit:hover, + .note-entry-delete:hover { + background: var(--bg-tertiary-hover); + color: var(--text-primary); + } + .notes-panel-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 10px 16px; + } + .notes-panel-toggle { + background: transparent; + color: var(--text-primary); + padding: 6px 10px; + margin: 0; + font-size: 0.9em; + flex-shrink: 0; + } + .notes-panel-toggle:hover { + background: var(--bg-tertiary-hover); + } + .note-quick-timestamp { + flex-shrink: 0; + color: var(--text-muted); + font-size: 0.85em; + font-family: monospace; + min-width: 2.5em; + } + .note-quick-input { + flex: 1; + min-width: 120px; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 8px 10px; + font-family: var(--font-family); + font-size: 0.95em; + } + .note-quick-save { + flex-shrink: 0; + padding: 8px 16px; + margin: 0; + font-size: 0.9em; + } + .note-saved-row { + padding: 0 16px calc(6px + env(safe-area-inset-bottom, 0px)); + min-height: 1.2em; + } + .note-saved-indicator { + font-size: 0.8em; + color: var(--text-muted); + } .outline-topic-row { display: flex; align-items: center; @@ -376,14 +502,10 @@ {% endif %} -
-

Notes

- - - +
+

Push to Outline

- + + +
+
+
+ + {% if note.timestamp_label %} + {{ note.timestamp_label }} + {% endif %} {% if note.pushed_to_outline %} Outline {% endif %}
-
{{ note.note }}
+
{{ note.text }}
+ href="/recent/open?course_path={{ note.course_path | urlencode }}&lesson_path={{ note.lesson_path | urlencode }}{% if note.timestamp_seconds is not none %}&t={{ note.timestamp_seconds }}{% endif %}"> Open lesson →