From 254b751799cf09d0e125a20330dd00368073cb29 Mon Sep 17 00:00:00 2001 From: primal Date: Thu, 29 Jan 2026 12:36:58 -0500 Subject: [PATCH] Add rich text links, language filter, and domain deny feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use labeled links (Article · Audio) instead of raw URLs in posts - Add language filter dropdown to dashboard with toggle selection - Auto-deny feeds with no language on discovery - Add deny/undeny buttons for domains to block crawling - Denied domains set feeds to dead status, preventing future checks Co-Authored-By: Claude Opus 4.5 --- crawler.go | 3 + dashboard.go | 168 ++++++++++++++++++++++++++++++++++++------- feed.go | 7 +- publisher.go | 79 ++++++++++++++------ static/dashboard.js | 170 ++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 366 insertions(+), 61 deletions(-) diff --git a/crawler.go b/crawler.go index f0338b8..65cb636 100644 --- a/crawler.go +++ b/crawler.go @@ -250,7 +250,10 @@ func (c *Crawler) StartPublishLoop() { itemToPublish := item if item.Link != "" { if shortURL, err := c.GetShortURLForPost(item.Link, &item.ID, item.FeedURL); err == nil { + fmt.Printf("Publish: shortened %s -> %s\n", item.Link[:min(40, len(item.Link))], shortURL) itemToPublish.Link = shortURL + } else { + fmt.Printf("Publish: short URL failed for %s: %v\n", item.Link[:min(40, len(item.Link))], err) } } if item.Enclosure != nil && item.Enclosure.URL != "" { diff --git a/dashboard.go b/dashboard.go index 0099ffe..5cc8732 100644 --- a/dashboard.go +++ b/dashboard.go @@ -240,11 +240,6 @@ func (c *Crawler) StartDashboard(addr string) error { c.handleDashboard(w, r) }) - // URL shortener redirect handler (legacy /r/ path) - http.HandleFunc("/r/", func(w http.ResponseWriter, r *http.Request) { - c.handleRedirect(w, r) - }) - // Root handler for url.1440.news short URLs http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { host := r.Host @@ -350,6 +345,15 @@ func (c *Crawler) StartDashboard(addr string) error { http.HandleFunc("/api/updateProfile", func(w http.ResponseWriter, r *http.Request) { c.handleAPIUpdateProfile(w, r) }) + http.HandleFunc("/api/languages", func(w http.ResponseWriter, r *http.Request) { + c.handleAPILanguages(w, r) + }) + http.HandleFunc("/api/denyDomain", func(w http.ResponseWriter, r *http.Request) { + c.handleAPIDenyDomain(w, r) + }) + http.HandleFunc("/api/undenyDomain", func(w http.ResponseWriter, r *http.Request) { + c.handleAPIUndenyDomain(w, r) + }) http.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/static/", http.FileServer(http.Dir("static"))).ServeHTTP(w, r) }) @@ -1217,7 +1221,8 @@ func (c *Crawler) handleAPIFilter(w http.ResponseWriter, r *http.Request) { domain := r.URL.Query().Get("domain") feedStatus := r.URL.Query().Get("feedStatus") domainStatus := r.URL.Query().Get("domainStatus") - show := r.URL.Query().Get("show") // "feeds" or "domains" + languages := r.URL.Query().Get("languages") // comma-separated list + show := r.URL.Query().Get("show") // "feeds" or "domains" limit := 100 offset := 0 @@ -1231,9 +1236,20 @@ func (c *Crawler) handleAPIFilter(w http.ResponseWriter, r *http.Request) { fmt.Sscanf(o, "%d", &offset) } + // Parse languages into slice + var langList []string + if languages != "" { + for _, lang := range strings.Split(languages, ",") { + lang = strings.TrimSpace(lang) + if lang != "" { + langList = append(langList, lang) + } + } + } + // Determine what to show based on filters if show == "" { - if feedStatus != "" || domain != "" { + if feedStatus != "" || domain != "" || len(langList) > 0 { show = "feeds" } else { show = "domains" @@ -1241,7 +1257,7 @@ func (c *Crawler) handleAPIFilter(w http.ResponseWriter, r *http.Request) { } if show == "feeds" { - c.filterFeeds(w, tld, domain, feedStatus, limit, offset) + c.filterFeeds(w, tld, domain, feedStatus, langList, limit, offset) } else { c.filterDomains(w, tld, domainStatus, limit, offset) } @@ -1308,11 +1324,11 @@ func (c *Crawler) filterDomains(w http.ResponseWriter, tld, status string, limit }) } -func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string, limit, offset int) { +func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string, languages []string, limit, offset int) { var args []interface{} argNum := 1 query := ` - SELECT url, title, type, category, source_host, tld, status, error_count, last_error, item_count + SELECT url, title, type, category, source_host, tld, status, error_count, last_error, item_count, language FROM feeds WHERE 1=1` @@ -1331,6 +1347,20 @@ func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string, args = append(args, status) argNum++ } + if len(languages) > 0 { + // Build IN clause for languages, handling 'unknown' as empty string + placeholders := make([]string, len(languages)) + for i, lang := range languages { + placeholders[i] = fmt.Sprintf("$%d", argNum) + if lang == "unknown" { + args = append(args, "") + } else { + args = append(args, lang) + } + argNum++ + } + query += fmt.Sprintf(" AND COALESCE(language, '') IN (%s)", strings.Join(placeholders, ",")) + } query += fmt.Sprintf(" ORDER BY url ASC LIMIT $%d OFFSET $%d", argNum, argNum+1) args = append(args, limit, offset) @@ -1353,14 +1383,15 @@ func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string, ErrorCount int `json:"error_count,omitempty"` LastError string `json:"last_error,omitempty"` ItemCount int `json:"item_count,omitempty"` + Language string `json:"language,omitempty"` } var feeds []FeedInfo for rows.Next() { var f FeedInfo - var title, category, sourceHost, tldVal, lastError *string + var title, category, sourceHost, tldVal, lastError, language *string var errorCount, itemCount *int - if err := rows.Scan(&f.URL, &title, &f.Type, &category, &sourceHost, &tldVal, &f.Status, &errorCount, &lastError, &itemCount); err != nil { + if err := rows.Scan(&f.URL, &title, &f.Type, &category, &sourceHost, &tldVal, &f.Status, &errorCount, &lastError, &itemCount, &language); err != nil { continue } f.Title = StringValue(title) @@ -1378,6 +1409,7 @@ func (c *Crawler) filterFeeds(w http.ResponseWriter, tld, domain, status string, if itemCount != nil { f.ItemCount = *itemCount } + f.Language = StringValue(language) feeds = append(feeds, f) } @@ -2258,19 +2290,9 @@ func (c *Crawler) handleAPIStats(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(stats) } -// handleRedirect handles short URL redirects -// Supports both /r/{code} (legacy) and /{code} (for url.1440.news) +// handleRedirect handles short URL redirects for url.1440.news func (c *Crawler) handleRedirect(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - var code string - - // Support both /r/{code} and /{code} formats - if strings.HasPrefix(path, "/r/") { - code = strings.TrimPrefix(path, "/r/") - } else { - code = strings.TrimPrefix(path, "/") - } - + code := strings.TrimPrefix(r.URL.Path, "/") if code == "" { http.NotFound(w, r) return @@ -2294,13 +2316,105 @@ func (c *Crawler) handleRedirect(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, shortURL.OriginalURL, http.StatusFound) } +// handleAPILanguages returns distinct languages with counts +func (c *Crawler) handleAPILanguages(w http.ResponseWriter, r *http.Request) { + rows, err := c.db.Query(` + SELECT COALESCE(NULLIF(language, ''), 'unknown') as lang, COUNT(*) as cnt + FROM feeds + GROUP BY lang + ORDER BY cnt DESC + `) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + type LangInfo struct { + Language string `json:"language"` + Count int `json:"count"` + } + + var languages []LangInfo + for rows.Next() { + var l LangInfo + if err := rows.Scan(&l.Language, &l.Count); err != nil { + continue + } + languages = append(languages, l) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(languages) +} + +// handleAPIDenyDomain denies a domain and all its feeds +func (c *Crawler) handleAPIDenyDomain(w http.ResponseWriter, r *http.Request) { + host := r.URL.Query().Get("host") + if host == "" { + http.Error(w, "host parameter required", http.StatusBadRequest) + return + } + + // Update domain status to denied + _, err := c.db.Exec(`UPDATE domains SET status = 'denied' WHERE host = $1`, host) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Deny all feeds from this domain + feedsAffected, err := c.db.Exec(`UPDATE feeds SET publish_status = 'deny', status = 'dead' WHERE source_host = $1`, host) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "host": host, + "feeds_denied": feedsAffected, + }) +} + +// handleAPIUndenyDomain removes denied status from a domain +func (c *Crawler) handleAPIUndenyDomain(w http.ResponseWriter, r *http.Request) { + host := r.URL.Query().Get("host") + if host == "" { + http.Error(w, "host parameter required", http.StatusBadRequest) + return + } + + // Update domain status back to checked + _, err := c.db.Exec(`UPDATE domains SET status = 'checked' WHERE host = $1 AND status = 'denied'`, host) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Restore feeds to held status and active + feedsRestored, err := c.db.Exec(`UPDATE feeds SET publish_status = 'held', status = 'active' WHERE source_host = $1 AND status = 'dead'`, host) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "host": host, + "feeds_restored": feedsRestored, + }) +} + const dashboardHTML = ` 1440.news Feed Crawler - +

1440.news Feed Crawler

@@ -2356,6 +2470,7 @@ const dashboardHTML = `
+ | @@ -2365,6 +2480,9 @@ const dashboardHTML = `
+ diff --git a/feed.go b/feed.go index de55287..810643d 100644 --- a/feed.go +++ b/feed.go @@ -178,9 +178,14 @@ type Feed struct { // saveFeed stores a feed in PostgreSQL func (c *Crawler) saveFeed(feed *Feed) error { // Default publishStatus to "held" if not set + // Auto-deny feeds with no language specified publishStatus := feed.PublishStatus if publishStatus == "" { - publishStatus = "held" + if feed.Language == "" { + publishStatus = "deny" + } else { + publishStatus = "held" + } } _, err := c.db.Exec(` diff --git a/publisher.go b/publisher.go index bb625e1..b00b950 100644 --- a/publisher.go +++ b/publisher.go @@ -316,30 +316,61 @@ func (p *Publisher) PublishItem(session *PDSSession, item *Item) (string, error) } } - // Build post text: title + all links + // Build post text: title + link labels // Bluesky has 300 grapheme limit - use rune count as approximation const maxGraphemes = 295 // Leave some margin - // Calculate space needed for URLs (in runes) - urlSpace := 0 - for _, u := range allURLs { - urlSpace += utf8.RuneCountInString(u) + 2 // +2 for \n\n + // Create labeled links: "Article", "Audio", etc. + type labeledLink struct { + Label string + URL string } + var links []labeledLink + + for i, u := range allURLs { + if i == 0 { + // First URL is the article link + links = append(links, labeledLink{Label: "Article", URL: u}) + } else if item.Enclosure != nil && u == item.Enclosure.URL { + // Enclosure URL - label based on type + encType := strings.ToLower(item.Enclosure.Type) + if strings.HasPrefix(encType, "audio/") { + links = append(links, labeledLink{Label: "Audio", URL: u}) + } else if strings.HasPrefix(encType, "video/") { + links = append(links, labeledLink{Label: "Video", URL: u}) + } else { + links = append(links, labeledLink{Label: "Media", URL: u}) + } + } else if strings.Contains(u, "news.ycombinator.com") { + links = append(links, labeledLink{Label: "Comments", URL: u}) + } else { + links = append(links, labeledLink{Label: "Link", URL: u}) + } + } + + // Calculate space needed for labels (in runes) + // Format: "Article · Audio" or just "Article" + labelSpace := 0 + for i, link := range links { + labelSpace += utf8.RuneCountInString(link.Label) + if i > 0 { + labelSpace += 3 // " · " separator + } + } + labelSpace += 2 // \n\n before labels // Truncate title if needed title := item.Title titleRunes := utf8.RuneCountInString(title) - maxTitleRunes := maxGraphemes - urlSpace - 3 // -3 for "..." + maxTitleRunes := maxGraphemes - labelSpace - 3 // -3 for "..." - if titleRunes+urlSpace > maxGraphemes { + if titleRunes+labelSpace > maxGraphemes { if maxTitleRunes > 10 { - // Truncate title to fit runes := []rune(title) if len(runes) > maxTitleRunes { title = string(runes[:maxTitleRunes]) + "..." } } else { - // Title too long even with minimal space - just truncate hard runes := []rune(title) if len(runes) > 50 { title = string(runes[:50]) + "..." @@ -347,12 +378,17 @@ func (p *Publisher) PublishItem(session *PDSSession, item *Item) (string, error) } } - // Build final text + // Build final text with labels var textBuilder strings.Builder textBuilder.WriteString(title) - for _, u := range allURLs { + if len(links) > 0 { textBuilder.WriteString("\n\n") - textBuilder.WriteString(u) + for i, link := range links { + if i > 0 { + textBuilder.WriteString(" · ") + } + textBuilder.WriteString(link.Label) + } } text := textBuilder.String() @@ -368,13 +404,15 @@ func (p *Publisher) PublishItem(session *PDSSession, item *Item) (string, error) CreatedAt: createdAt.Format(time.RFC3339), } - // Add facets for all URLs - for _, u := range allURLs { - linkStart := strings.Index(text, u) - if linkStart >= 0 { - // Use byte positions (for UTF-8 this matters) - byteStart := len(text[:linkStart]) - byteEnd := byteStart + len(u) + // Add facets for labeled links + // Find each label in the text and create a facet linking to its URL + searchPos := len(title) + 2 // Start after title + \n\n + for _, link := range links { + labelStart := strings.Index(text[searchPos:], link.Label) + if labelStart >= 0 { + labelStart += searchPos + byteStart := len(text[:labelStart]) + byteEnd := byteStart + len(link.Label) post.Facets = append(post.Facets, BskyFacet{ Index: BskyByteSlice{ @@ -384,10 +422,11 @@ func (p *Publisher) PublishItem(session *PDSSession, item *Item) (string, error) Features: []BskyFeature{ { Type: "app.bsky.richtext.facet#link", - URI: u, + URI: link.URL, }, }, }) + searchPos = labelStart + len(link.Label) } } diff --git a/static/dashboard.js b/static/dashboard.js index 2501cb0..947f671 100644 --- a/static/dashboard.js +++ b/static/dashboard.js @@ -14,6 +14,8 @@ function initDashboard() { let currentFilters = {}; let infiniteScrollState = null; let isLoadingMore = false; + let availableLanguages = []; + let selectedLanguages = new Set(); // Update command input to reflect current filters function updateCommandInput() { @@ -22,6 +24,7 @@ function initDashboard() { if (currentFilters.domain) parts.push('domain:' + currentFilters.domain); if (currentFilters.feedStatus) parts.push('feeds:' + currentFilters.feedStatus); if (currentFilters.domainStatus) parts.push('domains:' + currentFilters.domainStatus); + if (selectedLanguages.size > 0) parts.push('lang:' + Array.from(selectedLanguages).join(',')); document.getElementById('commandInput').value = parts.length > 0 ? parts.join(' ') : '/help'; } @@ -74,6 +77,9 @@ function initDashboard() { if (currentFilters.domainStatus) { parts.push('domains:' + escapeHtml(currentFilters.domainStatus) + ''); } + if (selectedLanguages.size > 0) { + parts.push('lang:' + escapeHtml(Array.from(selectedLanguages).join(',')) + ' ✕'); + } breadcrumb.innerHTML = parts.join(' / '); breadcrumb.style.display = parts.length > 1 ? 'block' : 'none'; @@ -84,6 +90,8 @@ function initDashboard() { const action = el.dataset.action; if (action === 'home') { currentFilters = {}; + selectedLanguages.clear(); + updateLangButton(); showHelp(); } else if (action === 'tld') { delete currentFilters.domain; @@ -93,6 +101,11 @@ function initDashboard() { } else if (action === 'domain') { delete currentFilters.feedStatus; executeFilters(); + } else if (action === 'clearLang') { + selectedLanguages.clear(); + updateLangButton(); + updateLangDropdown(); + executeFilters(); } }); }); @@ -123,12 +136,22 @@ function initDashboard() { // Render helpers function renderDomainRow(d) { - let html = '
'; + const isDenied = d.status === 'denied'; + const rowStyle = isDenied ? 'padding: 8px 10px; border-bottom: 1px solid #202020; opacity: 0.5;' : 'padding: 8px 10px; border-bottom: 1px solid #202020;'; + let html = '
'; html += '
'; html += '' + escapeHtml(d.host) + ''; html += ''; + if (isDenied) { + html += 'DENIED'; + } html += '' + commaFormat(d.feed_count) + ' feeds'; - html += ''; + if (isDenied) { + html += ''; + } else { + html += ''; + } + html += ''; html += '
'; if (d.status === 'error' && d.last_error) { html += '
Error: ' + escapeHtml(d.last_error) + '
'; @@ -181,19 +204,59 @@ function initDashboard() { }); el.addEventListener('mouseenter', () => el.style.background = '#1a1a1a'); el.addEventListener('mouseleave', () => el.style.background = 'transparent'); - const btn = el.querySelector('.revisit-btn'); - if (btn) { - btn.addEventListener('click', async (e) => { + const revisitBtn = el.querySelector('.revisit-btn'); + if (revisitBtn) { + revisitBtn.addEventListener('click', async (e) => { e.stopPropagation(); - btn.disabled = true; - btn.textContent = '...'; + revisitBtn.disabled = true; + revisitBtn.textContent = '...'; try { - await fetch('/api/revisitDomain?host=' + encodeURIComponent(btn.dataset.host)); - btn.textContent = 'queued'; - btn.style.color = '#0a0'; + await fetch('/api/revisitDomain?host=' + encodeURIComponent(revisitBtn.dataset.host)); + revisitBtn.textContent = 'queued'; + revisitBtn.style.color = '#0a0'; } catch (err) { - btn.textContent = 'error'; - btn.style.color = '#f66'; + revisitBtn.textContent = 'error'; + revisitBtn.style.color = '#f66'; + } + }); + } + const denyBtn = el.querySelector('.deny-btn'); + if (denyBtn) { + denyBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + denyBtn.disabled = true; + denyBtn.textContent = '...'; + try { + const resp = await fetch('/api/denyDomain?host=' + encodeURIComponent(denyBtn.dataset.host)); + const result = await resp.json(); + denyBtn.textContent = 'denied (' + result.feeds_denied + ')'; + denyBtn.style.background = '#000'; + el.style.opacity = '0.5'; + // Update status in data + el.dataset.status = 'denied'; + } catch (err) { + denyBtn.textContent = 'error'; + denyBtn.style.color = '#f66'; + } + }); + } + const undenyBtn = el.querySelector('.undeny-btn'); + if (undenyBtn) { + undenyBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + undenyBtn.disabled = true; + undenyBtn.textContent = '...'; + try { + const resp = await fetch('/api/undenyDomain?host=' + encodeURIComponent(undenyBtn.dataset.host)); + const result = await resp.json(); + undenyBtn.textContent = 'restored (' + result.feeds_restored + ')'; + undenyBtn.style.background = '#000'; + el.style.opacity = '1'; + // Update status in data + el.dataset.status = 'checked'; + } catch (err) { + undenyBtn.textContent = 'error'; + undenyBtn.style.color = '#f66'; } }); } @@ -272,10 +335,11 @@ function initDashboard() { const output = document.getElementById('output'); // Determine what to show - const showFeeds = currentFilters.feedStatus || currentFilters.domain; + const hasLang = selectedLanguages.size > 0; + const showFeeds = currentFilters.feedStatus || currentFilters.domain || hasLang; const showDomains = currentFilters.domainStatus || (!showFeeds && currentFilters.tld); - if (!currentFilters.tld && !currentFilters.domain && !currentFilters.feedStatus && !currentFilters.domainStatus) { + if (!currentFilters.tld && !currentFilters.domain && !currentFilters.feedStatus && !currentFilters.domainStatus && !hasLang) { showHelp(); return; } @@ -286,6 +350,7 @@ function initDashboard() { if (currentFilters.domain) params.set('domain', currentFilters.domain); if (currentFilters.feedStatus) params.set('feedStatus', currentFilters.feedStatus); if (currentFilters.domainStatus) params.set('domainStatus', currentFilters.domainStatus); + if (hasLang) params.set('languages', Array.from(selectedLanguages).join(',')); if (showFeeds) params.set('show', 'feeds'); else if (showDomains) params.set('show', 'domains'); @@ -635,12 +700,85 @@ function initDashboard() { } } + // Language dropdown functions + async function loadLanguages() { + try { + const response = await fetch('/api/languages'); + availableLanguages = await response.json(); + updateLangDropdown(); + } catch (err) { + console.error('Failed to load languages:', err); + } + } + + function updateLangDropdown() { + const langList = document.getElementById('langList'); + if (!langList) return; + + langList.innerHTML = availableLanguages.map(l => { + const checked = selectedLanguages.has(l.language) ? 'checked' : ''; + const displayLang = l.language === 'unknown' ? '(unknown)' : l.language; + return ``; + }).join(''); + + // Add change handlers + langList.querySelectorAll('input[type="checkbox"]').forEach(cb => { + cb.addEventListener('change', () => { + const lang = cb.dataset.lang; + if (cb.checked) { + selectedLanguages.add(lang); + } else { + selectedLanguages.delete(lang); + } + updateLangButton(); + updateLangDropdown(); + executeFilters(); + }); + }); + } + + function updateLangButton() { + const btn = document.getElementById('langBtn'); + if (!btn) return; + if (selectedLanguages.size > 0) { + btn.textContent = 'lang (' + selectedLanguages.size + ')'; + btn.style.background = '#030'; + btn.style.borderColor = '#0f0'; + } else { + btn.textContent = 'lang'; + btn.style.background = ''; + btn.style.borderColor = ''; + } + } + + function setupLangDropdown() { + const langBtn = document.getElementById('langBtn'); + const langDropdown = document.getElementById('langDropdown'); + if (!langBtn || !langDropdown) return; + + langBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const isVisible = langDropdown.style.display !== 'none'; + langDropdown.style.display = isVisible ? 'none' : 'block'; + }); + + // Close dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!langDropdown.contains(e.target) && e.target !== langBtn) { + langDropdown.style.display = 'none'; + } + }); + } + // Setup command input function setupCommandInput() { const input = document.getElementById('commandInput'); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') processCommand(input.value); }); input.addEventListener('focus', () => input.select()); - document.querySelectorAll('.cmd-btn').forEach(btn => { + document.querySelectorAll('.cmd-btn[data-cmd]').forEach(btn => { btn.addEventListener('click', () => { const cmd = btn.dataset.cmd; // Special commands that reset filters @@ -692,6 +830,8 @@ function initDashboard() { // Initialize setupCommandInput(); + setupLangDropdown(); + loadLanguages(); showHelp(); updateStats(); setInterval(updateStats, 1000);