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 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 20:17:20 -04:00
co-authored by Claude Sonnet 5
parent c871d6efae
commit 8b365c4463
3 changed files with 569 additions and 98 deletions
+176 -41
View File
@@ -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'])