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:
@@ -281,6 +281,30 @@
|
||||
font-size: 0.8em;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
.bulk-rename-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-tertiary);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.bulk-rename-row-names {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
.bulk-rename-old {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.bulk-rename-new {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
#save-status {
|
||||
font-size: 0.9em;
|
||||
color: #28a745;
|
||||
@@ -427,6 +451,25 @@
|
||||
<div id="curate-tree"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Bulk Rename</h2>
|
||||
<p class="setting-desc" style="margin-bottom: 12px;">
|
||||
Find and replace text across every course/folder name in the library at once - handy for
|
||||
stripping a release-group suffix off a batch of downloads. Nothing changes until you apply.
|
||||
</p>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px;">
|
||||
<input type="text" id="bulk-rename-find" class="hex-input"
|
||||
style="flex: 1; min-width: 140px; width: auto; font-family: var(--font-family);"
|
||||
placeholder="Find (e.g. .BOOKWARE-LERNSTUF)">
|
||||
<input type="text" id="bulk-rename-replace" class="hex-input"
|
||||
style="flex: 1; min-width: 140px; width: auto; font-family: var(--font-family);"
|
||||
placeholder="Replace with (blank to remove)">
|
||||
<button class="btn" onclick="previewBulkRename()">Preview</button>
|
||||
</div>
|
||||
<div id="bulk-rename-status" style="font-size: 0.85em; min-height: 1.2em; margin-bottom: 8px;"></div>
|
||||
<div id="bulk-rename-preview"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Load a Course Manually</h2>
|
||||
<div class="setting-row" style="flex-direction: column; align-items: stretch; gap: 8px;">
|
||||
@@ -468,6 +511,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Backup & Export</h2>
|
||||
<div class="setting-row">
|
||||
<label>Download a backup
|
||||
<span class="setting-desc">Settings, hidden-path curation, recently-viewed history, and every course's progress/notes - not the course files themselves.</span>
|
||||
</label>
|
||||
<a class="btn" href="/api/backup">Download Backup</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button>
|
||||
<span id="save-status">✓ Saved</span>
|
||||
@@ -740,6 +793,106 @@
|
||||
loadHiddenList();
|
||||
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
||||
|
||||
// ---- Bulk rename: find/replace across every course/folder name ----
|
||||
let bulkRenameMatches = [];
|
||||
|
||||
function previewBulkRename() {
|
||||
const pattern = document.getElementById('bulk-rename-find').value;
|
||||
const replacement = document.getElementById('bulk-rename-replace').value;
|
||||
const status = document.getElementById('bulk-rename-status');
|
||||
const preview = document.getElementById('bulk-rename-preview');
|
||||
preview.innerHTML = '';
|
||||
if (!pattern) {
|
||||
status.style.color = '#ff6b6b';
|
||||
status.textContent = 'Enter text to find first.';
|
||||
return;
|
||||
}
|
||||
status.style.color = 'var(--text-muted)';
|
||||
status.textContent = 'Searching...';
|
||||
fetch(`/api/bulk-rename/preview?pattern=${encodeURIComponent(pattern)}&replacement=${encodeURIComponent(replacement)}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
bulkRenameMatches = data.matches || [];
|
||||
renderBulkRenamePreview();
|
||||
})
|
||||
.catch(() => {
|
||||
status.style.color = '#ff6b6b';
|
||||
status.textContent = 'Could not reach the server.';
|
||||
});
|
||||
}
|
||||
|
||||
function renderBulkRenamePreview() {
|
||||
const status = document.getElementById('bulk-rename-status');
|
||||
const preview = document.getElementById('bulk-rename-preview');
|
||||
if (bulkRenameMatches.length === 0) {
|
||||
status.style.color = 'var(--text-muted)';
|
||||
status.textContent = 'No matches.';
|
||||
preview.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
status.textContent = '';
|
||||
const rows = bulkRenameMatches.map((m, i) => `
|
||||
<div class="bulk-rename-row">
|
||||
<div class="bulk-rename-row-names">
|
||||
<div class="bulk-rename-old">${m.old_name}</div>
|
||||
<div class="bulk-rename-new">→ ${m.new_name}</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="removeBulkRenameMatch(${i})">Remove</button>
|
||||
</div>
|
||||
`).join('');
|
||||
const count = bulkRenameMatches.length;
|
||||
preview.innerHTML = rows +
|
||||
`<button class="btn" style="margin-top: 10px;" onclick="applyBulkRename()">Apply ${count} Rename${count === 1 ? '' : 's'}</button>`;
|
||||
}
|
||||
|
||||
function removeBulkRenameMatch(index) {
|
||||
bulkRenameMatches.splice(index, 1);
|
||||
renderBulkRenamePreview();
|
||||
}
|
||||
|
||||
function applyBulkRename() {
|
||||
if (!confirm(`Rename ${bulkRenameMatches.length} item(s)? This changes real folders on disk.`)) return;
|
||||
const status = document.getElementById('bulk-rename-status');
|
||||
status.style.color = 'var(--text-muted)';
|
||||
status.textContent = 'Applying...';
|
||||
fetch('/api/bulk-rename/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ items: bulkRenameMatches })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const results = data.results || [];
|
||||
const succeeded = results.filter(r => r.success).length;
|
||||
const failed = results.filter(r => !r.success);
|
||||
const activeCourseReset = results.some(r => r.active_course_reset);
|
||||
|
||||
status.style.color = failed.length ? '#ff6b6b' : '#28a745';
|
||||
let message = `${succeeded} renamed`;
|
||||
if (failed.length) {
|
||||
message += `, ${failed.length} failed: ` +
|
||||
failed.map(f => `${f.old_name} (${f.error})`).join('; ');
|
||||
} else {
|
||||
message += '.';
|
||||
}
|
||||
if (activeCourseReset) {
|
||||
message += ' Your active course was renamed - reload the main page to pick it up under its new name.';
|
||||
}
|
||||
status.textContent = message;
|
||||
|
||||
bulkRenameMatches = [];
|
||||
document.getElementById('bulk-rename-preview').innerHTML = '';
|
||||
document.getElementById('bulk-rename-find').value = '';
|
||||
document.getElementById('bulk-rename-replace').value = '';
|
||||
loadHiddenList();
|
||||
loadCurateLevel(null, document.getElementById('curate-tree'), true);
|
||||
})
|
||||
.catch(() => {
|
||||
status.style.color = '#ff6b6b';
|
||||
status.textContent = 'Could not reach the server.';
|
||||
});
|
||||
}
|
||||
|
||||
function saveLibraryPath() {
|
||||
const input = document.getElementById('library_path');
|
||||
const status = document.getElementById('library-path-status');
|
||||
|
||||
Reference in New Issue
Block a user