Retire artists.js and inline Artists page, ship unification at 2.40

Part D + E of the deferred cleanup + the final version bump that
publishes the whole Search/Artists unification project.

Deletions:
  - webui/static/artists.js (1903 lines) — removed entirely. The 2
    remaining externally-referenced helpers (lazyLoadArtistImages +
    showCompletionError) moved into shared-helpers.js first.
  - webui/index.html — 140-line #artists-page HTML block and the
    <script src="artists.js"> tag both removed.

init.js wiring:
  - 'case artists:' removed from loadPageData switch (no page to init).
  - navigateToPage top-level alias extended: 'artists' → 'search'
    (same pattern as the existing 'downloads' → 'search' alias).
    Legacy /artists bookmarks land on the unified Search page, the
    natural place to find an artist now.
  - _getPageFromPath now maps artist-detail → library as its parent
    (was artists). Matches the existing library-nav-highlight at
    init.js:2161.

Version bump:
  - _SOULSYNC_BASE_VERSION 2.39 → 2.40.
  - WHATS_NEW entries lose the 'unreleased' scaffolding and gain a
    new top entry summarizing the unified artist-detail page + the
    final artists.js retirement.
  - version-info modal gets a 'Search & Artists Unification' section
    at the top.
  - The _getLatestWhatsNewVersion filter added during the unreleased-
    tracking phase is rolled back — entries now display as soon as
    they land in WHATS_NEW, matching the pre-unification behaviour.

Test suite:
  - tests/test_script_split_integrity.py SPLIT_MODULES updated:
    'artists.js' dropped, 'shared-helpers.js' added. escapeHtml's
    cross-file dupe list entry updated to reference shared-helpers.
  - 354/354 tests pass.

User-visible result after this commit:
  - Sidebar: Search, Downloads, Discover, Library, Wishlist, etc. —
    no more Artists entry.
  - Click any artist anywhere: lands on the same /artist-detail page.
  - Search page has a source dropdown; Soulseek is just another option.
  - Legacy /downloads and /artists URLs alias to /search.
  - Version button shows v2.3 (Docker major); "What's New" panel
    opens to the unification summary.

Closes the project Cin requested in Discord. Future work: source-aware
/api/artist-detail could be extended to fall back through the whole
source priority chain when a specific source is given but returns no
discography. Not needed for the current flows.
pull/361/head
Broque Thomas 1 month ago
parent 1c345e4eb5
commit 71ff5cb5c3

@ -27,8 +27,9 @@ _ROOT = Path(__file__).resolve().parent.parent
_STATIC = _ROOT / "webui" / "static"
_INDEX = _ROOT / "webui" / "index.html"
# The 17 modules that replaced script.js + shared-helpers.js extracted from
# artists.js (order matters for first/last checks)
# The modules that replaced script.js, minus artists.js (which was retired
# after the Search/Artists unification) plus shared-helpers.js (extracted
# from artists.js). Order matters for first/last checks.
SPLIT_MODULES = [
"core.js",
"shared-helpers.js",
@ -39,7 +40,6 @@ SPLIT_MODULES = [
"downloads.js",
"wishlist-tools.js",
"sync-services.js",
"artists.js",
"api-monitor.js",
"library.js",
"beatport-ui.js",
@ -57,7 +57,7 @@ NON_SPLIT_JS = {"setup-wizard.js", "docs.js", "helper.js", "particles.js", "work
# In a plain <script> context the last-loaded declaration wins. These are NOT
# regressions from the split — they should be deduplicated in a follow-up.
KNOWN_CROSS_FILE_DUPES = {
"escapeHtml", # downloads.js, artists.js, discover.js
"escapeHtml", # downloads.js, shared-helpers.js, discover.js
"formatDuration", # sync-spotify.js, wishlist-tools.js, sync-services.js
"matchedDownloadTrack", # downloads.js, wishlist-tools.js
"matchedDownloadAlbum", # downloads.js, wishlist-tools.js

@ -37,7 +37,7 @@ _log_dir = Path(_log_path).parent
logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, version-info endpoint, etc.
_SOULSYNC_BASE_VERSION = "2.39"
_SOULSYNC_BASE_VERSION = "2.40"
def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -22898,6 +22898,20 @@ def get_version_info():
"title": "What's New in SoulSync",
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
"sections": [
{
"title": "Search & Artists Unification",
"description": "Cin flagged that SoulSync had grown several overlapping search/browse surfaces (Enhanced vs Basic search, Artists page vs global widget vs Library detail page, sidebar label 'Search' mapping to page id 'downloads'). This release consolidates them",
"features": [
"• One Search page in the sidebar with an explicit 'Search from' source picker — All sources (Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files). Picking a specific source hits only that provider so there are no more surprise Spotify rate-limit hits",
"• Soulseek folded into the source picker as a selectable option — no more separate Basic/Enhanced toggle",
"• Artists sidebar entry retired — same flow through unified Search. Deep link /artists still resolves (aliased to /search) so bookmarks keep working",
"• One artist detail page for everything. Click an artist anywhere (Library, Search, Discover, Watchlist, Stats, Media player) and land on the same /artist-detail URL. Backend endpoint now accepts a ?source= param and falls back to metadata-source lookup when the artist isn't in the library",
"• Page id renamed from 'downloads' to 'search' — matches the sidebar label and stops clashing with the real Downloads page. /downloads URL still aliases to /search",
"• Embedded Download Manager panel removed from the Search page — the dedicated Downloads page is now the single downloads UI",
"• artists.js (4600 lines) deleted. Shared helpers extracted to webui/static/shared-helpers.js",
"• Interactive help annotations + 'Your First Download' guided tour updated for the new flow",
],
},
{
"title": "Fix Wrong-Artist Tracks Silently Downloading",
"description": "A critical bug where searching for a track could silently download a completely different artist's song with the same name",

@ -2100,146 +2100,6 @@
</div>
</div>
<!-- Artists Page -->
<div class="page" id="artists-page">
<!-- Initial Search State -->
<div class="artists-search-state" id="artists-search-state">
<div class="artists-search-container">
<div class="artists-welcome-section">
<h2 class="artists-welcome-title"><img src="/static/discover.png" class="page-header-icon" alt=""><span>Discover Artists</span></h2>
<p class="artists-welcome-subtitle">Search for your favorite artists and explore their
complete discography</p>
</div>
<div class="artists-search-input-container">
<input type="text" id="artists-search-input" class="artists-search-input"
placeholder="Search for an artist...">
<div class="artists-search-icon">🔍</div>
</div>
<div class="artists-search-status" id="artists-search-status">
Start typing to search for artists
</div>
</div>
</div>
<!-- Search Results State -->
<div class="artists-results-state hidden" id="artists-results-state">
<div class="artists-results-header">
<button class="artists-back-button" id="artists-back-button">
<span class="back-icon"></span>
<span>Back to Search</span>
</button>
<div class="artists-search-header">
<input type="text" id="artists-header-search-input" class="artists-header-search-input"
placeholder="Search for an artist...">
</div>
</div>
<div class="artists-results-content">
<div class="artists-results-title">Search Results</div>
<div class="artists-cards-container" id="artists-cards-container">
<!-- Artist cards will be dynamically populated here -->
</div>
</div>
</div>
<!-- Artist Detail State -->
<div class="artist-detail-state hidden" id="artist-detail-state">
<!-- Hero Section -->
<div class="artists-hero-section" id="artists-hero-section">
<div class="artists-hero-bg" id="artists-hero-bg"></div>
<div class="artists-hero-overlay"></div>
<div class="artists-hero-content">
<button class="artists-hero-back" id="artist-detail-back-button">
<span></span> Back
</button>
<div class="artists-hero-main">
<div class="artists-hero-image" id="artists-hero-image"></div>
<div class="artists-hero-info">
<h1 class="artists-hero-name" id="artists-hero-name">Artist Name</h1>
<div class="artists-hero-badges" id="artists-hero-badges"></div>
<div class="artists-hero-genres" id="artists-hero-genres"></div>
<div class="artists-hero-bio" id="artists-hero-bio"></div>
<div class="artists-hero-stats" id="artists-hero-stats"></div>
<button class="discog-download-btn discog-btn-compact" id="discog-download-btn-artists" onclick="openDiscographyModal()" style="display:none;">
<span class="discog-btn-icon"></span>
<span class="discog-btn-text">Download Discography</span>
<span class="discog-btn-shimmer"></span>
</button>
</div>
</div>
<div class="artists-hero-actions">
<button class="artist-detail-watchlist-btn" id="artist-detail-watchlist-btn">
<span class="watchlist-icon">👁️</span>
<span class="watchlist-text">Add to Watchlist</span>
</button>
<button class="artist-detail-watchlist-settings-btn hidden" id="artist-detail-watchlist-settings-btn" title="Watchlist Settings">
&#9881;
</button>
</div>
</div>
</div>
<!-- Keep old hidden elements for backward compatibility with JS refs -->
<div id="search-artist-detail-image" style="display:none"></div>
<div id="search-artist-detail-name" style="display:none"></div>
<div id="search-artist-detail-genres" style="display:none"></div>
<div class="artist-detail-content">
<div class="artist-detail-tabs">
<button class="artist-tab active" data-tab="albums" id="albums-tab">
<span class="tab-icon">💿</span>
<span>Albums</span>
</button>
<button class="artist-tab" data-tab="singles" id="singles-tab">
<span class="tab-icon">🎵</span>
<span>Singles & EPs</span>
</button>
</div>
<div class="artist-detail-discography">
<div class="tab-content active" id="albums-content">
<div class="album-cards-container" id="album-cards-container">
<!-- Album cards will be populated here -->
</div>
</div>
<div class="tab-content" id="singles-content">
<div class="singles-cards-container" id="singles-cards-container">
<!-- Singles cards will be populated here -->
</div>
</div>
</div>
<!-- Similar Artists Section -->
<div class="similar-artists-section" id="similar-artists-section">
<div class="similar-artists-header">
<h3 class="similar-artists-title">Similar Artists</h3>
<p class="similar-artists-subtitle">Discover artists with a similar sound</p>
</div>
<!-- Loading State -->
<div class="similar-artists-loading hidden" id="similar-artists-loading">
<div class="loading-spinner-small"></div>
<span>Finding similar artists...</span>
</div>
<!-- Error State -->
<div class="similar-artists-error hidden" id="similar-artists-error">
<span class="error-icon">⚠️</span>
<span class="error-text">Unable to load similar artists</span>
</div>
<!-- Similar Artists Bubbles Container -->
<div class="similar-artists-bubbles-container" id="similar-artists-bubbles-container">
<!-- Artist bubble cards will be populated here -->
</div>
</div>
</div>
</div>
</div>
<!-- Automations Page -->
<div class="page" id="automations-page">
@ -7996,7 +7856,6 @@
<script src="{{ url_for('static', filename='downloads.js') }}"></script>
<script src="{{ url_for('static', filename='wishlist-tools.js') }}"></script>
<script src="{{ url_for('static', filename='sync-services.js') }}"></script>
<script src="{{ url_for('static', filename='artists.js') }}"></script>
<script src="{{ url_for('static', filename='api-monitor.js') }}"></script>
<script src="{{ url_for('static', filename='library.js') }}"></script>
<script src="{{ url_for('static', filename='beatport-ui.js') }}"></script>

File diff suppressed because it is too large Load Diff

@ -3546,21 +3546,18 @@ function closeHelperSearch() {
// WHAT'S NEW (Phase 6)
// ═══════════════════════════════════════════════════════════════════════════
// Entries tagged with `unreleased: true` are accumulating under a version label
// but won't display until the build version catches up. The Search/Artists
// unification project stays folded here at 2.40 until the whole thing ships.
const WHATS_NEW = {
'2.40': [
// --- Search & Artists unification (in progress, not yet released) ---
{ date: 'Unreleased — Search & Artists unification', unreleased: true },
{ title: 'Search Source Picker', desc: 'The Search page\'s Enhanced/Basic toggle is replaced by a single "Search from" dropdown at the top — pick All sources (Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files). Auto keeps today\'s multi-source fan-out; picking a specific source hits only that provider so there are no more surprise Spotify rate-limit hits from flows that didn\'t need Spotify. "Soulseek" routes to the raw-file search (what "Basic" used to do), so one picker now covers both old modes. Loading text reflects the selected source', page: 'search', unreleased: true },
{ title: 'Explicit Source Selection on /api/enhanced-search', desc: 'The enhanced-search endpoint now accepts an optional `source` body param (spotify, itunes, deezer, discogs, hydrabase, musicbrainz, auto). When a specific source is chosen, only that provider is queried and db_artists (local library matches) still come back. Cache keys isolate per-source so single-source and multi-source results don\'t collide. Omitted or `auto` preserves the old multi-source fan-out behavior unchanged — nothing breaks for existing callers', page: 'search', unreleased: true },
{ title: 'Shared Enhanced-Search Fetch Helper', desc: 'Internal refactor — the Search page dropdown and the global search widget now route through one shared enhancedSearchFetch helper in search.js instead of duplicating the POST boilerplate. Zero UX change, but it means any future source-picker tweak only needs wiring in one place', page: 'search', unreleased: true },
{ title: 'Search Page Renamed to /search', desc: 'The Search page\'s internal id is now "search" instead of the confusing "downloads" (which clashed with the actual Downloads page). Sidebar label unchanged. URL is now /search; /downloads still resolves so old bookmarks keep working. Profile ACL "Page Access" now saves as "search"; existing profiles with "downloads" in allowed_pages still resolve through a legacy-compat check', page: 'search', unreleased: true },
{ title: 'Embedded Download Manager Removed from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up. The dedicated Downloads sidebar page is now the single downloads UI', page: 'search', unreleased: true },
{ title: 'Artists Sidebar Entry Retired — Use Search Instead', desc: 'Cin flagged that "Artists" in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone. New flow: Sidebar → Search → type artist name → click their result. "Browse Artists" on the empty Watchlist page and "View artist from Wishlist" now open Search pre-filled with the artist\'s name. Removed "Artists" from profile Home Page + Page Access options. Deep link to /artists still resolves so old bookmarks keep working — the page just isn\'t promoted anywhere', page: 'search', unreleased: true },
{ title: 'Artist Detail Back Button Fallback', desc: 'The back button on the Artists-page inline detail view used to dump users on an empty "Search for an artist..." screen when they arrived from outside the Artists page — a dead end now that Artists isn\'t in the sidebar. If you searched inside the Artists page, back still returns to your results list. Otherwise (arriving from Search, Discover, Watchlist, etc.), back uses the browser history to land you on whichever page you came from. Falls back to the Search page only when there\'s no browser history to go back to (the natural place to find another artist)', page: 'search', unreleased: true },
{ title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour', page: 'help', unreleased: true },
// --- April 24, 2026 — Search & Artists unification ---
{ date: 'April 24, 2026 — Search & Artists unification' },
{ title: 'Unified Artist Detail Page', desc: 'Clicking any artist anywhere — Search results, Discover, Watchlist, Stats, Library, API monitor, Wishlist, Media player — now lands on the same standalone /artist-detail page. Before, library artists and metadata-source artists went to two different pages (one inline, one standalone). The backend /api/artist-detail endpoint now accepts an optional ?source= param and falls back to a metadata-source lookup when the local DB lookup misses, so Deezer/Spotify/iTunes/Discogs/Hydrabase/MusicBrainz artists work on the same page as library artists. Stable deep-link URL; source context carried through cleanly', page: 'library' },
{ title: 'Artists Page Retired — artists.js Deleted', desc: 'The Artists sidebar entry was already retired earlier; this release finishes the job by deleting the inline page itself (4600-line file, 140 HTML lines). Shared helpers the rest of the app depended on (escapeHtml used 229 times, service-status polling, image-colour extraction, download-bubble infrastructure, discography completion checking, enrichment card rendering) were extracted into a new webui/static/shared-helpers.js so other modules keep working. Legacy /artists URL now aliases to /search for bookmark compatibility', page: 'library' },
{ title: 'Search Source Picker', desc: 'The Search page\'s Enhanced/Basic toggle is replaced by a single "Search from" dropdown at the top — pick All sources (Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, or Soulseek (raw files). Auto keeps today\'s multi-source fan-out; picking a specific source hits only that provider so there are no more surprise Spotify rate-limit hits from flows that didn\'t need Spotify. "Soulseek" routes to the raw-file search (what "Basic" used to do), so one picker now covers both old modes. Loading text reflects the selected source', page: 'search' },
{ title: 'Explicit Source Selection on /api/enhanced-search', desc: 'The enhanced-search endpoint now accepts an optional `source` body param (spotify, itunes, deezer, discogs, hydrabase, musicbrainz, auto). When a specific source is chosen, only that provider is queried and db_artists (local library matches) still come back. Cache keys isolate per-source so single-source and multi-source results don\'t collide. Omitted or `auto` preserves the old multi-source fan-out behavior unchanged — nothing breaks for existing callers', page: 'search' },
{ title: 'Shared Enhanced-Search Fetch Helper', desc: 'Internal refactor — the Search page dropdown and the global search widget now route through one shared enhancedSearchFetch helper in search.js instead of duplicating the POST boilerplate. Zero UX change, but it means any future source-picker tweak only needs wiring in one place', page: 'search' },
{ title: 'Search Page Renamed to /search', desc: 'The Search page\'s internal id is now "search" instead of the confusing "downloads" (which clashed with the actual Downloads page). Sidebar label unchanged. URL is now /search; /downloads still resolves so old bookmarks keep working. Profile ACL "Page Access" now saves as "search"; existing profiles with "downloads" in allowed_pages still resolve through a legacy-compat check', page: 'search' },
{ title: 'Embedded Download Manager Removed from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up. The dedicated Downloads sidebar page is now the single downloads UI', page: 'search' },
{ title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour', page: 'help' },
],
'2.39': [
// --- April 22, 2026 ---
@ -3765,13 +3762,7 @@ function _getCurrentVersion() {
}
function _getLatestWhatsNewVersion() {
// Only surface entries whose version number is <= the current build. Entries
// sitting at higher versions are unreleased work-in-progress and shouldn't
// flag as "new" in the helper badge until the build catches up.
const buildVer = parseFloat(_getCurrentVersion()) || 2.39;
const versions = Object.keys(WHATS_NEW)
.filter(v => (parseFloat(v) || 0) <= buildVer)
.sort((a, b) => parseFloat(b) - parseFloat(a));
const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a));
return versions[0] || '2.39';
}
@ -3860,11 +3851,8 @@ function _openFullChangelog() {
}
function _showOlderNotes() {
// Cycle to next older version in the what's new panel (skip unreleased entries)
const buildVer = parseFloat(_getCurrentVersion()) || 2.39;
const versions = Object.keys(WHATS_NEW)
.filter(v => (parseFloat(v) || 0) <= buildVer)
.sort((a, b) => parseFloat(b) - parseFloat(a));
// Cycle to next older version in the what's new panel
const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a));
const panel = _helperPopover;
if (!panel) return;
const currentTitle = panel.querySelector('.helper-popover-title');

@ -2010,7 +2010,7 @@ function _getPageFromPath() {
const basePage = path.split('/')[0];
if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard';
// Context-dependent pages fall back to a sensible parent
if (basePage === 'artist-detail') return 'artists';
if (basePage === 'artist-detail') return 'library';
if (basePage === 'playlist-explorer') return 'library';
return basePage;
}
@ -2115,8 +2115,10 @@ function initializeWatchlist() {
}
function navigateToPage(pageId, options = {}) {
// Backwards-compat alias — the Search page used to live under id 'downloads'.
if (pageId === 'downloads') pageId = 'search';
// Backwards-compat aliases — the Search page used to live under id
// 'downloads', and the Artists page was retired and folded into Search.
// Legacy bookmarks to /downloads or /artists all land on /search.
if (pageId === 'downloads' || pageId === 'artists') pageId = 'search';
if (pageId === currentPage) return;
@ -2218,15 +2220,7 @@ async function loadPageData(pageId) {
initializeSearchModeToggle();
initializeFilters();
break;
case 'artists':
// Only fully initialize if not already initialized
if (!artistsPageState.isInitialized) {
initializeArtistsPage();
} else {
// Just restore state if already initialized
restoreArtistsPageState();
}
break;
// 'artists' page retired — aliased to 'search' at the top of navigateToPage
case 'active-downloads':
loadActiveDownloadsPage();
break;

@ -2759,3 +2759,79 @@ function renderEnrichmentCards(enrichment) {
}
// ===============================
// ----------------------------------------------------------------------------
// Additional helpers from the retired Artists page still needed by other files
// ----------------------------------------------------------------------------
async function lazyLoadArtistImages(container) {
if (!container) {
console.error('❌ lazyLoadArtistImages: container is null');
return;
}
const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]');
if (cardsNeedingImages.length === 0) {
console.log('✅ All artist cards have images');
return;
}
console.log(`🖼️ Lazy loading images for ${cardsNeedingImages.length} artist cards`);
const batchSize = 5;
const cards = Array.from(cardsNeedingImages);
for (let i = 0; i < cards.length; i += batchSize) {
const batch = cards.slice(i, i + batchSize);
await Promise.all(batch.map(async (card) => {
const artistId = card.dataset.artistId;
if (!artistId) {
console.warn('⚠️ Card missing artistId:', card);
return;
}
try {
const response = await fetch(`/api/artist/${artistId}/image`);
const data = await response.json();
if (data.success && data.image_url) {
if (card.classList.contains('suggestion-card')) {
card.style.backgroundImage = `url(${data.image_url})`;
card.style.backgroundSize = 'cover';
card.style.backgroundPosition = 'center';
} else if (card.classList.contains('artist-card')) {
const bgElement = card.querySelector('.artist-card-background');
if (bgElement) {
bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`;
}
}
card.dataset.needsImage = 'false';
}
} catch (error) {
console.error(`❌ Failed to load image for artist ${artistId}:`, error);
}
}));
}
}
// Legacy global alias — wishlist-tools.js falls back to window.lazyLoadArtistImages
window.lazyLoadArtistImages = lazyLoadArtistImages;
// ----------------------------------------------------------------------------
// Completion error overlay — called from checkDiscographyCompletion above
// ----------------------------------------------------------------------------
function showCompletionError() {
const allOverlays = document.querySelectorAll('.completion-overlay.checking');
allOverlays.forEach(overlay => {
overlay.classList.remove('checking');
overlay.classList.add('error');
overlay.innerHTML = '<span class="completion-status">Error</span>';
overlay.title = 'Failed to check completion status';
});
}

Loading…
Cancel
Save