Add Recently Added, bulk rename, time remaining, library stats, backup export

Five more usability features on top of the search/thumbnails batch:

- Recently Added: dashboard card of courses by folder mtime, separate from
  Recently Viewed (watched vs. just showed up on disk).
- Bulk find/replace rename across every course/folder name at once, with a
  mandatory preview step before anything touches disk. Refactored the
  single-item rename route to share the same validate/apply logic.
- Estimated time remaining on the loaded course's stats card, computed only
  from lessons that have actually reported a duration.
- Library-wide stats overview: total courses, lessons tracked, time
  watched, daily streak - read from each course's small progress file
  rather than re-scanning course contents.
- One-click backup/export zip of settings, hidden-paths, recently-viewed,
  and every course's progress/notes.

Also fixes a real performance bug found along the way: search was routing
through list_library_directory, which computes a full recursive file count
and thumbnail lookup for every course at every level regardless of match -
turning a search into an O(every file in the library) scan. Gave search its
own lightweight directory-only walk (iter_all_courses), now shared by
Recently Added and the stats overview too, so the expensive per-course work
only runs for courses that actually match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 11:36:05 -04:00
co-authored by Claude Sonnet 5
parent dbf9528fea
commit a6c49d3762
4 changed files with 611 additions and 66 deletions
+87 -2
View File
@@ -251,6 +251,32 @@
border-radius: 10px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 12px;
margin-top: 12px;
}
.stats-tile {
background: var(--bg-tertiary);
border-radius: var(--radius);
padding: 14px 10px;
text-align: center;
}
.stats-tile-value {
font-size: 1.5em;
font-weight: 700;
color: var(--accent);
}
.stats-tile-label {
font-size: 0.8em;
color: var(--text-muted);
margin-top: 2px;
}
.library-breadcrumb {
color: var(--text-muted);
font-size: 13px;
@@ -635,8 +661,15 @@
<div>
<strong>{{ stats.completed_lessons }}/{{ stats.total_lessons }}</strong> lessons completed
</div>
<div style="color: #007acc; font-size: 1.2em;">
{{ "%.1f"|format(stats.completion_percentage) }}%
<div style="text-align: right;">
<div style="color: #007acc; font-size: 1.2em;">
{{ "%.1f"|format(stats.completion_percentage) }}%
</div>
{% if stats.remaining_display %}
<div style="color: var(--text-muted); font-size: 0.85em;">
~{{ stats.remaining_display }} remaining
</div>
{% endif %}
</div>
</div>
<div class="progress-bar">
@@ -746,6 +779,58 @@
</div>
{% endmacro %}
{% macro render_course_row(item, meta_text) %}
<div class="lesson-item" onclick="loadCoursePath('{{ item.path|replace("'", "\\'") }}')">
<div class="lesson-title">
{% 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>
<span class="lesson-meta">{{ meta_text }}</span>
</div>
{% endmacro %}
{% if library_stats and library_stats.total_courses %}
<div class="container">
<div class="card">
<h2>📊 Library Stats</h2>
<div class="stats-grid">
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.total_courses }}</div>
<div class="stats-tile-label">Courses</div>
</div>
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.completed_lessons }}/{{ library_stats.lessons_tracked }}</div>
<div class="stats-tile-label">Lessons completed</div>
</div>
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.watched_display }}</div>
<div class="stats-tile-label">Watched</div>
</div>
<div class="stats-tile">
<div class="stats-tile-value">{{ library_stats.streak_days }}</div>
<div class="stats-tile-label">Day streak</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if recently_added %}
<div class="container">
<div class="card">
<h2>🆕 Recently Added</h2>
<div style="margin-top: 12px;">
{% for item in recently_added %}
{{ render_course_row(item, item.added_display) }}
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if continue_watching %}
<div class="container">
<div class="card">