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:
+176
-41
@@ -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'])
|
||||
|
||||
Reference in New Issue
Block a user