PageSourceSearch

https://sqltest.online/script.js?20261006

js sqltest.online collected 2026-10-06 00:07:13 UTC 63,106 bytes, 1,661 lines download raw bytes

1
2let windowObjectReference = null; // global variable
3const externalScriptPromises = {};
4
5function loadExternalScriptOnce(src) {
6    if (externalScriptPromises[src]) {
7        return externalScriptPromises[src];
8    }
9
10    externalScriptPromises[src] = new Promise((resolve, reject) => {
11        const existingScript = document.querySelector(`script[src="${src}"]`);
12        if (existingScript) {
13            if (existingScript.dataset.loaded === 'true') {
14                resolve();
15                return;
16            }
17
18            existingScript.addEventListener('load', () => resolve(), { once: true });
19            existingScript.addEventListener('error', () => reject(new Error(`Failed to load script: ${src}`)), { once: true });
20            return;
21        }
22
23        const script = document.createElement('script');
24        script.src = src;
25        script.async = true;
26        script.onload = () => {
27            script.dataset.loaded = 'true';
28            resolve();
29        };
30        script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
31        document.head.appendChild(script);
32    });
33
34    return externalScriptPromises[src];
35}
36
37function runWhenBrowserIdle(callback, timeout = 2000) {
38    if ('requestIdleCallback' in window) {
39        window.requestIdleCallback(callback, { timeout });
40        return;
41    }
42
43    window.setTimeout(callback, 1500);
44}
45function lazyInitShareThis() {
46    if (!document.querySelector('.sharethis-inline-share-buttons')) {
47        return;
48    }
49
50    const loadShareThis = () => {
51        runWhenBrowserIdle(() => {
52            loadExternalScriptOnce('https://platform-api.sharethis.com/js/sharethis.js#property=685bb6a18ca9160019f294e2&product=sop')
53                .catch(error => console.error(error));
54        });
55    };
56
57    if (document.readyState === 'complete') {
58        loadShareThis();
59        return;
60    }
61
62    window.addEventListener('load', loadShareThis, { once: true });
63}
64
65function openRequestedTab(href) {
66    if (windowObjectReference === null || windowObjectReference.closed) {
67        // const url = `${href}_${window.UIConfig.theme === 'dark' ?  'dark' : 'light'}.png`;
68        const url = `${href}?theme=${window.UIConfig.theme === 'dark' ? 'dark' : 'light'}`;
69        const popUpParams = `scrollbars=no,resizable=yes,status=no,location=no,toolbar=no,menubar=no,width=0,height=0,left=-1000,top=-1000`;
70        windowObjectReference = window.open(url, 'Sakila DB ER Diagram', popUpParams);
71    } else {
72        windowObjectReference.focus();
73    }
74}
75
76function switchTheme(e) {
77    const currentTheme = e.target.checked ?  'dark' : 'light';
78    if (window.sql_editor) {
79        window.sql_editor.setTheme(getAceTheme(currentTheme));
80    }
81    document.documentElement.setAttribute('data-theme', currentTheme);
82    window.UIConfig.theme = currentTheme;
83    saveUIConfig();
84}
85
86function getAceTheme(theme) {
87    return theme === 'dark' ? 'ace/theme/github_dark' : 'ace/theme/xcode';
88}
89
90function formatCode() {
91    const beautify = ace.require("ace/ext/beautify");
92    const editor_session = window.sql_editor.session;
93    beautify.beautify(editor_session);
94}
95function setLoader(id) {
96    return document.getElementById(id).innerHTML = '<div class="loader">Loading...</div>';
97}
98
99function showToast(state, msg) {
100    const toast = document.getElementById("toast");
101    if (state !== "error") {
102        toast.classList.remove("error");
103        state = "info";
104    }
105    toast.innerText = msg || "...";
106    toast.classList.remove("info");
107    toast.classList.add(state);
108    toast.classList.toggle("visible");
109    const showTime = state === "error" ? 5000 : 2000;
110    setTimeout((function() {
111        toast.classList.toggle("visible");
112    }
113    ), showTime)
114}
115function loadMenu(questionnire) {
116    fetch(`/${lang}/menu?questionnire=${questionnire}`, {
117        method: "GET",
118        mode: "cors",
119        cache: "default",
120        credentials: "same-origin",
121    })
122    .then((async response=>{
123        if (!response.ok) throw Error('Something went wrong.');
124        return await response.text();
125    }))
126    .then((message)=>{
127        // The response is the whole <nav id="menu">: replace the element, don't nest it
128        const menu = document.getElementById('menu');
129        menu.insertAdjacentHTML('afterend', message);
130        menu.remove();
131        menuGroupsRequest = null;
132        window.UIConfig.questionnire = questionnire;
133        saveUIConfig();
134        setMenuEventListeners(document.getElementById('menu'));
135    })
136    .catch(err=>{
137        console.log(err)
138    });;
139}
140function copyCode(msg) {
141    const editor = window.sql_editor;
142    navigator.clipboard && navigator.clipboard.writeText(editor.getValue()),
143    showToast('info', msg)
144}
145
146function clearEditor() {
147    const editor = window.sql_editor;
148    editor.setValue('');
149    editor.focus();
150    editor.session.selection.clearSelection();
151}
152
153function clearFreeAnswer() {
154    stopVoiceInput();
155    const textarea = document.getElementById('free-answer-input');
156    if (!textarea) return;
157    textarea.value = '';
158    textarea.focus();
159}
160
161function toggleLoginWindow() {
162    const popup = document.getElementById('login-menu');
163    popup.classList.toggle("hidden");
164    // const loginWindow = document.getElementById("login-popup");
165    // setTimeout((function() {
166    //     loginWindow.classList.toggle("visible");
167    // }
168    // ), 333)
169}
170
171function toggleMobileMenu() {
172    const dropdown = document.getElementById('mobileMenuDropdown');
173    dropdown.classList.toggle("hidden");
174}
175
176/**
177 * User menu in the top menu (#userMenuBtn, top-menu.tpl / m.top-menu.tpl): opens the profile and achievements popup.
178 * Clicks inside the popup don't close it; the button, Esc or a click outside do.
179 */
180function toggleAchievements(lang) {
181    const popup = document.getElementById('achievements-popup');
182    const button = document.getElementById('userMenuBtn');
183
184    if (!popup.classList.contains('hidden')) {
185        closeAchievements();
186        return;
187    }
188    popup.classList.remove('hidden');
189    button?.setAttribute('aria-expanded', 'true');
190    document.addEventListener('click', closeAchievementsOutside, true);
191    document.addEventListener('keydown', closeAchievementsOnEscape);
192
193    setLoader('achievements-popup');
194    // Load achievements when opening popup
195    fetch(`/${lang}/user/achievements`)
196        .then((async response=>{
197            if (!response.ok) throw Error('Something went wrong.');
198            return await response.text();
199        }))
200        .then((message)=>{
201            popup.innerHTML = message;
202        })
203        .catch(error => {
204            popup.innerHTML = '<p>Something went wrong.</p>';
205        });
206}
207
208function closeAchievements() {
209    document.getElementById('achievements-popup')?.classList.add('hidden');
210    document.getElementById('userMenuBtn')?.setAttribute('aria-expanded', 'false');
211    document.removeEventListener('click', closeAchievementsOutside, true);
212    document.removeEventListener('keydown', closeAchievementsOnEscape);
213}
214
215function closeAchievementsOutside(event) {
216    const popup = document.getElementById('achievements-popup');
217    const button = document.getElementById('userMenuBtn');
218    if (!popup.contains(event.target) && !button?.contains(event.target)) {
219        closeAchievements();
220    }
221}
222
223function closeAchievementsOnEscape(event) {
224    if (event.key === 'Escape') {
225        closeAchievements();
226        document.getElementById('userMenuBtn')?.focus();
227    }
228}
229function jsonToTable(jsonObject) {
230    let htmlTable = '';
231    let rn = 0;
232    try {
233        htmlTable = "<table class='result-table'><tr><th></th><th>" + jsonObject.headers.map(h=>h.header).join('</th><th>') + "</th></tr>";
234        for (let r of jsonObject.data) {
235            htmlTable += "<tr><td>" + (++rn) +"</td><td>" + r.map(el=>el === null ? '[null]' : el).join('</td><td>') + "</td></tr>";
236        }
237        htmlTable += "</table>";
238    } catch(e) {
239        htmlTable = 'Something went wrong. Please review your query and try again.'
240    }
241    return htmlTable;
242}
243
244function errorToTable(jsonObject) {
245    const error = document.createElement('span');
246    error.className = 'sql_error';
247    error.textContent = jsonObject.error;
248    return error.outerHTML;
249}
250
251// SQL errors under the editor (runQuery, testQuery, the playground's executeQuery): where the error is, marked
252// in the message and in the editor, and the "Explain the error" AI button: on task pages it calls
253// Controller::explain_error (data-explain-url), in the playground it asks the playground AI chat
254// (data-explain-question). Both are paid from the AI tokens.
255// Settings and labels come from the data-* attributes of #code-result (index.tpl, m.index.tpl, playground templates).
256
257/**
258 * Where an engine's error message points in the query: {row, column, length} (0-based), or null.
259 * Formats: PostgreSQL/DuckDB "LINE 2: ... ^", Firebird "line 2, column 10", MySQL "near '...' at line 2",
260 * otherwise the first quoted name ('foo', "foo", no such table: foo) searched in the query.
261 */
262function sqlErrorLocation(error, sql) {
263    const lines = sql.replace(/\r\n?/g, '\n').split('\n');
264    const wordAt = (row, column) => {
265        const match = (lines[row] || '').slice(column).match(/^[\w$.]+|^\S/);
266        return match ? match[0].length : 1;
267    };
268    const at = (row, column) => (row >= 0 && row < lines.length && column >= 0 && column <= lines[row].length)
269        ? {row, column, length: wordAt(row, column)}
270        : null;
271    const find = (text, row) => {
272        if (!text) return null;
273        const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
274        const pattern = new RegExp((/^\w/.test(text) ? '(^|[^\\w$])' : '()') + '(' + escaped + ')' + (/\w$/.test(text) ? '(?![\\w$])' : ''), 'i');
275        const rows = row === undefined ? lines.map((_, i) => i) : [row];
276        for (const r of rows) {
277            const match = (lines[r] || '').match(pattern);
278            if (match) return {row: r, column: match.index + match[1].length, length: text.length};
279        }
280        return null;
281    };
282
283    let match = error.match(/LINE (\d+): ([^\n]*)\n( *)\^/);
284    if (match) {
285        const row = parseInt(match[1]) - 1;
286        let column = match[3].length - ('LINE ' + match[1] + ': ').length;
287        // A long line is shown cut: "...fragment..."; find the fragment in the line
288        if (match[2].startsWith('...')) {
289            const fragment = match[2].slice(3).replace(/\.\.\.$/, '');
290            const offset = (lines[row] || '').indexOf(fragment);
291            column = offset < 0 ? -1 : offset + column - 3;
292        }
293        const location = at(row, column);
294        if (location) return location;
295    }
296    match = error.match(/line (\d+), column (\d+)/i);
297    if (match) {
298        const location = at(parseInt(match[1]) - 1, parseInt(match[2]) - 1);
299        if (location) return location;
300    }
301    match = error.match(/near '([\s\S]*)' at line (\d+)/);
302    if (match) {
303        const row = parseInt(match[2]) - 1;
304        const fragment = match[1].split('\n')[0].trim();
305        if (fragment === '') {
306            // Unexpected end of the query: mark its last word
307            const lastRow = Math.min(row, lines.length - 1);
308            const lastWord = lastRow >= 0 ? lines[lastRow].match(/[\w$.]+\s*$|\S\s*$/) : null;
309            return lastWord ? at(lastRow, lastWord.index) : null;
310        }
311        const location = find(fragment, row) || find(fragment);
312        if (location) return {...location, length: wordAt(location.row, location.column)};
313    }
314    match = error.match(/['"`]([^'"`\n]{1,64})['"`]|no such (?:table|column): ([\w$.]+)/);
315    if (match) {
316        const name = match[1] || match[2];
317        return find(name) || find(name.split('.').pop());
318    }
319    return null;
320}
321
322function clearSqlErrorMarker() {
323    const marker = window.sqlErrorMarker;
324    if (marker && window.sql_editor) {
325        window.sql_editor.session.removeMarker(marker.id);
326        window.sql_editor.session.removeGutterDecoration(marker.row, 'sql-error-gutter');
327    }
328    window.sqlErrorMarker = null;
329}
330
331function markSqlErrorInEditor(location) {
332    clearSqlErrorMarker();
333    if (!window.sql_editor) return;
334    const Range = ace.require('ace/range').Range;
335    const session = window.sql_editor.session;
336    window.sqlErrorMarker = {
337        id: session.addMarker(new Range(location.row, location.column, location.row, location.column + location.length), 'sql-error-marker', 'text'),
338        row: location.row
339    };
340    session.addGutterDecoration(location.row, 'sql-error-gutter');
341    // Remove the mark once the query is edited
342    session.once('change', clearSqlErrorMarker);
343}
344
345/**
346 * Adds the location and the AI button to each .sql_error in container
347 *
348 * @param {HTMLElement} container #code-result
349 * @param {string} sql The query that produced the result
350 */
351function enhanceSqlErrors(container, sql) {
352    const settings = document.getElementById('code-result')?.dataset || {};
353    container.querySelectorAll('.sql_error').forEach(errorElement => {
354        const error = errorElement.textContent;
355        const location = sqlErrorLocation(error, sql);
356        const tools = document.createElement('div');
357        tools.className = 'sql-error-tools';
358
359        if (location) {
360            markSqlErrorInEditor(location);
361            const goTo = document.createElement('button');
362            goTo.type = 'button';
363            goTo.className = 'text-button blue sql-error-goto';
364            goTo.title = settings.gotoLabel || '';
365            goTo.textContent = (settings.locationLabel || 'Line {line}, column {column}')
366                .replace('{line}', location.row + 1)
367                .replace('{column}', location.column + 1);
368            goTo.addEventListener('click', () => {
369                if (!window.sql_editor) return;
370                window.sql_editor.selection.setRange(new (ace.require('ace/range').Range)(location.row, location.column, location.row, location.column + location.length));
371                window.sql_editor.scrollToLine(location.row, true, true);
372                window.sql_editor.focus();
373            });
374            tools.appendChild(goTo);
375        }
376
377        const chatMode = settings.explainQuestion && typeof window.aiAssistantAsk === 'function';
378        if (!chatMode && !settings.explainUrl) {
379            errorElement.after(tools);
380            return;
381        }
382        const explain = sqlErrorExplainButton(settings);
383        tools.appendChild(explain);
384        const answer = document.createElement('div');
385        answer.className = 'sql-error-explanation hidden';
386        errorElement.after(tools, answer);
387
388        explain.addEventListener('click', () => {
389            if (settings.logged !== '1') {
390                showSqlErrorLoginPrompt(settings, answer, sql);
391            } else if (chatMode) {
392                // Playground: the question goes to its AI chat panel (js/ai-assistant.js), which sees the error itself
393                window.aiAssistantAsk(settings.explainQuestion);
394            } else {
395                explainSqlError(settings, explain, answer, sql, error);
396            }
397        });
398
399        // Back from the login started by the guest prompt: point at the button
400        if (window.sqlErrorExplainResumed) {
401            window.sqlErrorExplainResumed = false;
402            explain.scrollIntoView({block: 'center', behavior: 'smooth'});
403            explain.classList.add('ai-attention');
404        }
405    });
406}
407
408// Guests: say why the AI needs an account, right under the error, with a button that opens the login menu.
409// The query is kept in sessionStorage, so after the login (the page reloads) it is run again.
410const SQL_ERROR_EXPLAIN_PENDING = 'sqlErrorExplainPending';
411
412function showSqlErrorLoginPrompt(settings, container, sql) {
413    container.classList.remove('hidden');
414    if (container.querySelector('.sql-error-login')) return;
415    const prompt = document.createElement('div');
416    prompt.className = 'sql-error-login';
417    const text = document.createElement('p');
418    text.textContent = settings.loginText;
419    const button = document.createElement('button');
420    button.type = 'button';
421    button.className = 'button green';
422    button.textContent = settings.loginButton;
423    button.addEventListener('click', event => {
424        // Keep the document click handlers from closing the menu right away
425        event.stopPropagation();
426        try {
427            sessionStorage.setItem(SQL_ERROR_EXPLAIN_PENDING, JSON.stringify({path: location.pathname, sql}));
428        } catch (e) {}
429        openLoginMenu();
430    });
431    prompt.append(text, button);
432    container.appendChild(prompt);
433}
434
435function openLoginMenu() {
436    const menu = document.getElementById('login-menu');
437    if (!menu) return;
438    menu.classList.remove('hidden');
439    (menu.closest('header') || menu).scrollIntoView({block: 'start', behavior: 'smooth'});
440    // Restart the highlight animation
441    menu.classList.remove('ai-attention');
442    void menu.offsetWidth;
443    menu.classList.add('ai-attention');
444}
445
446// After the login: put the query back and run it, so the error and the explain button show up again
447function resumeSqlErrorExplain() {
448    let pending = null;
449    try {
450        pending = JSON.parse(sessionStorage.getItem(SQL_ERROR_EXPLAIN_PENDING) || 'null');
451    } catch (e) {}
452    const settings = document.getElementById('code-result')?.dataset;
453    if (!pending || !settings || settings.logged !== '1' || !window.sql_editor) return;
454    try {
455        sessionStorage.removeItem(SQL_ERROR_EXPLAIN_PENDING);
456    } catch (e) {}
457    if (pending.path !== location.pathname || !pending.sql) return;
458
459    window.sql_editor.setValue(pending.sql, 1);
460    window.sqlErrorExplainResumed = true;
461    if (typeof executeQuery === 'function') {
462        executeQuery();
463    } else if (questionId && questionId !== 'null') {
464        runQuery(lang, questionId);
465    }
466}
467// After the playground's own editor setup on DOMContentLoaded (shared snippets)
468window.addEventListener('load', () => setTimeout(resumeSqlErrorExplain, 300));
469
470function sqlErrorExplainButton(settings) {
471    const button = document.createElement('button');
472    button.type = 'button';
473    button.className = 'text-button blue sql-error-explain';
474    button.title = settings.explainHint || '';
475    button.textContent = '✨ ' + settings.explainLabel;
476    return button;
477}
478
479function explainSqlError(settings, button, answer, sql, error) {
480    if (button.disabled) return;
481    const label = button.textContent;
482    button.disabled = true;
483    button.textContent = settings.explainLoading;
484    let formData = new FormData();
485    formData.append('sql', sql);
486    formData.append('error', error);
487    fetch(settings.explainUrl, {
488        method: "POST",
489        credentials: "same-origin",
490        body: formData,
491    })
492    .then(response => response.json())
493    .then(data => {
494        answer.classList.remove('hidden');
495        if (data.answer_html) {
496            answer.innerHTML = data.answer_html;
497            if (data.quota && settings.tokensLeftLabel) {
498                const quota = document.createElement('div');
499                quota.className = 'sql-error-quota';
500                quota.textContent = settings.tokensLeftLabel + ' ' + data.quota.remaining_text;
501                answer.appendChild(quota);
502            }
503            button.remove();
504            return;
505        }
506        // Server messages are site translations, some with links (buy tokens, log in)
507        answer.innerHTML = data.message || 'Something went wrong.';
508        button.disabled = false;
509        button.textContent = label;
510    })
511    .catch(() => {
512        answer.classList.remove('hidden');
513        answer.textContent = 'Something went wrong.';
514        button.disabled = false;
515        button.textContent = label;
516    });
517}
518
519/**
520 * "Get hint": the hint goes to #hint-panel (hint_panel.tpl) above the editor, so the query result stays.
521 * The button toggles the panel; the hint is loaded once. Pages without the panel show it in #code-result.
522 */
523function getHelp(lang, questionId) {
524    const panel = document.getElementById('hint-panel');
525    const content = document.getElementById('hint-panel-content');
526    if (panel && !panel.classList.contains('hidden')) {
527        closeHint();
528        return;
529    }
530    if (panel && content.dataset.loaded) {
531        showHint(panel);
532        return;
533    }
534    const target = panel ? content : document.getElementById('code-result');
535    if (panel) {
536        showHint(panel);
537    }
538    setLoader(target.id);
539    fetch(`/${lang}/question/${questionId}/query-help`, {
540          method: "GET",
541          mode: "cors",
542          cache: "default",
543          credentials: "same-origin",
544      })
545      .then((async response=>{
546          if (!response.ok) throw Error('Something went wrong.');
547          return await response.text();
548      }))
549      .then((message)=>{
550          target.innerHTML = message;
551          if (panel) {
552              content.dataset.loaded = '1';
553          }
554      })
555      .catch(()=>{
556          target.textContent = 'Something went wrong. Please try again.';
557      });
558}
559
560function showHint(panel) {
561    panel.classList.remove('hidden');
562    document.getElementById('getHelpBtn')?.setAttribute('aria-expanded', 'true');
563    panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
564}
565
566function closeHint() {
567    document.getElementById('hint-panel')?.classList.add('hidden');
568    document.getElementById('getHelpBtn')?.setAttribute('aria-expanded', 'false');
569}
570
571function runQuery(lang, questionId) {
572  setLoader('code-result');
573  clearSqlErrorMarker();
574  const sql = window.sql_editor.getValue();
575  let formData = new FormData();
576  formData.append('query', sql);
577  fetch(`/${lang}/question/${questionId}/query-run`, {
578      method: "POST",
579      mode: "cors",
580      cache: "default",
581      credentials: "same-origin",
582      body: formData,
583  })
584  .then((async response=>{
585      if (!response.ok) throw Error('Something went wrong.');
586      return await response.text();
587  }))
588  .then(JSON.parse)
589  .then((JSONmessage)=>{
590      let html = '✓ (Done)';
591      if (JSONmessage && JSONmessage[0]) {
592          const jsonObject = JSONmessage[0];
593          html = jsonObject.error 
594            ? errorToTable(jsonObject) 
595            : jsonToTable(jsonObject);
596      }
597      document.getElementById('code-result').innerHTML = html;
598      enhanceSqlErrors(document.getElementById('code-result'), sql);
599  })
600  .catch(err=>{
601    document.getElementById('code-result').innerHTML = 'Something went wrong. Please review your query and try again or contact us by email: <a href="mailto:[email protected]">[email protected]</a>.';
602  });
603}
604function checkAnswers(lang, questionId) {
605    setLoader('code-result');
606    const answers = [...document.querySelectorAll('input[name=answers]:checked')]
607        .reduce(
608            (res, el)=>{res.push(parseInt(el.value)); return res;}, 
609            []
610        )
611        .toSorted();
612
613    let formData = new FormData();
614    formData.append('answers', JSON.stringify(answers));
615    fetch(`/${lang}/question/${questionId}/check-answers`, {
616        method: "POST",
617        mode: "cors",
618        cache: "default",
619        credentials: "same-origin",
620        body: formData,
621    })
622    .then((async response=>{
623        if (response.ok) {
624        if (response.ok) {
625            showSolvedProgress(response);
626        }
627        if (response.ok && document.getElementById("nextTaskBtn")) {
628            document.getElementById("nextTaskBtn").classList.remove("hidden");
629            setTimeout(()=>{
630                document.getElementById("main3").scrollTo({
631                    top: document.getElementById("nextTaskBtn").offsetTop,
632                    behavior: "smooth" 
633                })
634            }, 300)
635        }
636        }
637        return await response.text();
638    }))
639    .then((message)=>{
640        document.getElementById('code-result').innerHTML = message;
641        placeNewAchievement(document.getElementById('code-result'));
642    })
643    .catch(err=>{
644        document.getElementById('code-result').innerHTML = 'Something went wrong. Please review your query and try again or c
644ontact us by email: <a href="mailto:[email protected]">[email protected]</a>.';
645    });
646}
647function checkFreeAnswer(lang, questionId) {
648    // One check at a time: each check is paid from the AI token balance
649    const checkBtn = document.getElementById('checkFreeAnswerBtn');
650    if (checkBtn?.disabled) {
651        return;
652    }
653    if (checkBtn) {
654        checkBtn.disabled = true;
655    }
656    setLoader('code-result');
657    let formData = new FormData();
658    formData.append('answer', document.getElementById('free-answer-input').value);
659    fetch(`/${lang}/question/${questionId}/check-free-answer`, {
660        method: "POST",
661        mode: "cors",
662        cache: "default",
663        credentials: "same-origin",
664        body: formData,
665    })
666    .then((async response=>{
667        // The check is paid from the AI token balance: refresh the counter and the buy button
668        const remaining = response.headers.get('X-AI-Tokens-Remaining');
669        if (remaining !== null && document.getElementById('free-answer-tokens-remaining')) {
670            document.getElementById('free-answer-tokens-remaining').textContent = decodeURIComponent(remaining);
671            document.getElementById('buyTokensBtn')?.classList.toggle('hidden', response.headers.get('X-AI-Tokens-Low') !== '1');
672        }
673        if (response.ok) {
674            showSolvedProgress(response);
675        }
676        if (response.ok && document.getElementById("nextTaskBtn")) {
677            document.getElementById("nextTaskBtn").classList.remove("hidden");
678            setTimeout(()=>{
679                if (document.getElementById("main3")) {
680                    document.getElementById("main3").scrollTo({
681                        top: document.getElementById("nextTaskBtn").offsetTop,
682                        behavior: "smooth"
683                    })
684                } else {
685                    window.scrollTo({
686                        top: document.getElementById("db-description").offsetTop - window.outerHeight,
687                        behavior: "smooth"
688                    })
689                }
690            }, 300)
691        }
692        return await response.text();
693    }))
694    .then((message)=>{
695        document.getElementById('code-result').innerHTML = message;
696        placeNewAchievement(document.getElementById('code-result'));
697    })
698    .catch(err=>{
699        document.getElementById('code-result').innerHTML = 'Something went wrong. Please review your answer and try again or contact us by email: <a href="mailto:[email protected]">[email protected]</a>.';
700    })
701    .finally(()=>{
702        if (checkBtn) {
703            checkBtn.disabled = false;
704        }
705    });
706}
707// Maps site interface language codes to BCP-47 locale tags the Web Speech API expects.
708const VOICE_INPUT_LANG_MAP = {
709    en: 'en-US',
710    ru: 'ru-RU',
711    pt: 'pt-BR',
712    fr: 'fr-FR',
713    zh: 'zh-CN',
714};
715
716let voiceRecognition = null;
717let voiceInputActive = false;
718
719function getSpeechRecognitionClass() {
720    return window.SpeechRecognition || window.webkitSpeechRecognition || null;
721}
722
723function toggleVoiceInput(lang) {
724    if (voiceInputActive) {
725        stopVoiceInput();
726        return;
727    }
728    const SpeechRecognitionClass = getSpeechRecognitionClass();
729    if (!SpeechRecognitionClass) {
730        showToast('error', 'Voice input is not supported in this browser.');
731        return;
732    }
733    startVoiceInput(SpeechRecognitionClass, lang);
734}
735
736function startVoiceInput(SpeechRecognitionClass, lang) {
737    const textarea = document.getElementById('free-answer-input');
738    const btn = document.getElementById('voiceInputBtn');
739    if (!textarea) return;
740
741    voiceRecognition = new SpeechRecognitionClass();
742    voiceRecognition.lang = VOICE_INPUT_LANG_MAP[lang] || 'en-US';
743    // continuous:true is unreliable on mobile Chrome — it can re-emit already-finalized
744    // results, duplicating whatever was just spoken. Instead we recognize one utterance
745    // at a time and auto-restart in onend, which behaves consistently on desktop and mobile.
746    voiceRecognition.continuous = false;
747    voiceRecognition.interimResults = true;
748
749    // Recognized speech is appended after whatever was already in the textarea
750    // (typed text or a previously saved answer) rather than overwriting it. Each
751    // finished utterance gets folded into this once it's final, before the next
752    // utterance's (fresh, zero-indexed) results start arriving.
753    let baseValue = textarea.value;
754
755    voiceRecognition.onresult = function(event) {
756        let transcript = '';
757        for (let i = 0; i < event.results.length; i++) {
758            transcript += event.results[i][0].transcript;
759        }
760        textarea.value = (baseValue + ' ' + transcript).trim();
761        if (event.results[event.results.length - 1].isFinal) {
762            baseValue = textarea.value;
763        }
764    };
765
766    voiceRecognition.onerror = function(event) {
767        if (event.error === 'no-speech' || event.error === 'aborted') {
768            // Not fatal — onend fires right after and restarts listening if still active.
769            return;
770        }
771        showToast('error', 'Voice input error: ' + event.error);
772        stopVoiceInput();
773    };
774
775    voiceRecognition.onend = function() {
776        // Each utterance ends the recognizer on its own (continuous is off), so keep
777        // dictation going by restarting — unless the user already clicked stop.
778        if (voiceInputActive) {
779            try {
780                voiceRecognition.start();
781            } catch (e) {
782                stopVoiceInput();
783            }
784        }
785    };
786
787    voiceInputActive = true;
788    if (btn) btn.classList.add('listening');
789    voiceRecognition.start();
790}
791
792function stopVoiceInput() {
793    voiceInputActive = false;
794    const btn = document.getElementById('voiceInputBtn');
795    if (btn) btn.classList.remove('listening');
796    if (voiceRecognition) {
797        const recognitionToStop = voiceRecognition;
798        voiceRecognition = null;
799        recognitionToStop.onend = null;
800        recognitionToStop.onresult = null;
801        recognitionToStop.onerror = null;
802        recognitionToStop.stop();
803    }
804}
805
806/**
807 * New-achievement line (new_achievement.tpl): opening a link in it (the achievement page or a share link) or closing
808 * it (× or Esc) marks the achievement viewed, so it isn't shown again.
809 */
810function initNewAchievement(block) {
811    const viewUrl = block.dataset.achievementViewUrl;
812    let isMarkedViewed = false;
813
814    const markViewed = function () {
815        if (isMarkedViewed || !viewUrl) {
816            return;
817        }
818        isMarkedViewed = true;
819        fetch(viewUrl, { method: 'GET', credentials: 'same-origin', keepalive: true })
820            .catch(function () {
821                // Best effort: at worst the achievement is shown once more
822            });
823    };
824    const closeOnEscape = function (event) {
825        if (event.key === 'Escape') {
826            close();
827        }
828    };
829    const close = function () {
830        markViewed();
831        block.remove();
832        document.removeEventListener('keydown', closeOnEscape);
833    };
834
835    block.addEventListener('click', function (event) {
836        if (event.target.closest('a')) {
837            markViewed();
838            return;
839        }
840        if (event.target.closest('.new-achievement__close')) {
841            close();
842        }
843    });
844    document.addEventListener('keydown', closeOnEscape);
845}
846
847/**
848 * A correct check response may start with a new-achievement line (Controller::sendSolvedProgress()): move it above
849 * the task, replacing the one shown on page load. The mobile page has no main column: it stays in the result there.
850 */
851function placeNewAchievement(container) {
852    const block = container.querySelector('.new-achievement');
853    if (!block) {
854        return;
855    }
856    const mainColumn = document.querySelector('main.column');
857    if (mainColumn) {
858        document.querySelectorAll('.new-achievement').forEach(el => el !== block && el.remove());
859        mainColumn.prepend(block);
860    }
861    initNewAchievement(block);
862}
863
864/**
865 * After a correct check: mark the task solved in the menu and refresh "My progress" (my_progress.tpl)
866 * from the X-Solved-Count / X-Questions-Count headers (Controller::sendSolvedProgress()).
867 */
868function showSolvedProgress(response) {
869    document.querySelectorAll('.question-link.current-question:not(.solved)').forEach(el => {
870        el.classList.add('solved');
871        // "12 / 40" in the header of the task's menu group
872        const count = el.closest('.panel')?.previousElementSibling?.querySelector('.accordion-count');
873        if (count) {
874            count.dataset.solved = parseInt(count.dataset.solved || '0', 10) + 1;
875            count.textContent = `${count.dataset.solved}\u2009/\u2009${count.dataset.total}`;
876        }
877    });
878
879    const solved = parseInt(response.headers.get('X-Solved-Count'), 10);
880    const total = parseInt(response.headers.get('X-Questions-Count'), 10);
881    if (isNaN(solved) || !total) {
882        return;
883    }
884    const percent = solved / total * 100;
885    document.querySelectorAll('.progress-bar').forEach(el => el.style.width = `${percent}%`);
886    document.querySelectorAll('.progress-count').forEach(el => el.textContent = `${solved}/${total}`);
887    document.querySelectorAll('.progress-percentage').forEach(el => el.textContent = `${Math.round(percent * 10) / 10}%`);
888}
889
890function testQuery(lang, questionId) {
891    setLoader('code-result');
892    clearSqlErrorMarker();
893    const sql = window.sql_editor.getValue();
894    let formData = new FormData();
895    formData.append('query', sql);
896    fetch(`/${lang}/question/${questionId}/query-test`, {
897        method: "POST",
898        mode: "cors",
899        cache: "default",
900        credentials: "same-origin",
901        body: formData,
902    })
903    .then((async response=>{
904        if (response.ok) {
905            showSolvedProgress(response);
906        }
907        if (response.headers.get('X-Sample-Rows') === 'available') {
908            unlockSampleRows();
909        }
910        if (response.ok && document.getElementById("nextTaskBtn")) {
911            document.getElementById("nextTaskBtn").classList.remove("hidden");
912            setTimeout(()=>{
913                if (document.getElementById("main3")) {
914                    document.getElementById("main3").scrollTo({
915                        top: document.getElementById("nextTaskBtn").offsetTop,
916                        behavior: "smooth" 
917                    })
918                } else {
919                    window.scrollTo({
920                        top: document.getElementById("db-description").offsetTop - window.outerHeight,
921                        behavior: "smooth" 
922                    })
923                }
924            }, 300)
925        }
926        return await response.text();
927    }))
928    .then((message)=>{
929        document.getElementById('code-result').innerHTML = message;
930        placeNewAchievement(document.getElementById('code-result'));
931        enhanceSqlErrors(document.getElementById('code-result'), sql);
932    })
933    .catch(err=>{
934        document.getElementById('code-result').innerHTML = 'Something went wrong. Please review your query and try again or c
934ontact us by email: <a href="mailto:[email protected]">[email protected]</a>.';
935    });
936}
937// Expected result block (expected_result.tpl): the sample rows became available after a wrong check
938function unlockSampleRows() {
939    const block = document.getElementById('expected-result');
940    if (!block) {
941        return;
942    }
943    const locked = block.querySelector('[data-sample-state="locked"]');
944    if (locked) {
945        locked.remove();
946        block.querySelector('[data-sample-state="available"]')?.classList.remove('hidden');
947    }
948}
949// Load the first rows of the expected result into its block and open it
950function showSampleRows(lang, questionId) {
951    const block = document.getElementById('expected-result');
952    if (!block) {
953        return;
954    }
955    block.open = true;
956    const reveal = () => block.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
957    if (block.dataset.sampleLoaded) {
958        reveal();
959        return;
960    }
961    fetch(`/${lang}/question/${questionId}/expected-sample`, { credentials: 'same-origin' })
962    .then(response => response.ok ? response.text() : Promise.reject(response.status))
963    .then(html => {
964        document.getElementById('expected-result-rows').innerHTML = html;
965        block.dataset.sampleLoaded = '1';
966        block.querySelector('[data-sample-state="available"]')?.remove();
967        block.querySelector('[data-sample-state="shown"]')?.classList.remove('hidden');
968        reveal();
969    })
970    .catch(err => console.error(err));
971}
972function toggleFavorites(lang, questionId) {
973    let formData = new FormData();
974    fetch(`/${lang}/question/${questionId}/favorite`, {
975        method: "POST",
976        mode: "cors",
977        cache: "default",
978        credentials: "same-origin",
979        body: formData,
980    })
981    .then((async response=>{
982        if (response.ok) {
983            if (document.getElementById("favoriteStar")) {
984                const message =  await response.text();
985                showToast('info', message);
986                const favored = document.getElementById("favoriteStar").classList.toggle("favored");
987                document.getElementById("favoriteStar").setAttribute("aria-pressed", favored ? "true" : "false");
988                // document.getElementById("favoriteStar").title = 'Favored'
989            } 
990        }
991    }))
992    .catch(err=>{
993        showToast('error', 'Something went wrong. Please ask admin for help.');
994    });
995}
996function moveQuestionPosition(questionId, categoryId, direction) {
997    fetch(`/admin/question-category-position`, {
998        method: "POST",
999        mode: "cors",
1000        cache: "default",
1001        credentials: "same-origin",
1002        headers: { 'Content-Type': 'application/json' },
1003        body: JSON.stringify({
1004            question_id: questionId,
1005            category_id: categoryId,
1006            direction: direction
1007        }),
1008    })
1009    .then((async response=>{
1010        const data = await response.json();
1011        if (response.ok && data.ok) {
1012            loadMenu(window.UIConfig.questionnire);
1013        } else {
1014            showToast('error', data.error || 'Something went wrong.');
1015        }
1016    }))
1017    .catch(err=>{
1018        showToast('error', 'Something went wrong. Please ask admin for help.');
1019    });
1020}
1021function checkSolution(url) {
1022    setLoader('code-result');
1023    let formData = new FormData();
1024    if (window.sql_editor) {
1025        formData.append('query', window.sql_editor.getValue());
1026    }
1027    if (document.getElementById('answers-list')) {
1028        const answers = [...document.querySelectorAll('input[name=answers]:checked')]
1029        .reduce(
1030            (res, el)=>{res.push(parseInt(el.value)); return res;}, 
1031            []
1032        )
1033        .toSorted();
1034        formData.append('answers', JSON.stringify(answers));
1035    }
1036    if (document.getElementById('free-answer-input')) {
1037        const freeAnswer = document.getElementById('free-answer-input').value;
1038        formData.append('free-answer', freeAnswer);
1039        console.log('Free answer:', freeAnswer);
1040    }
1041    fetch(url, {
1042        method: "POST",
1043        mode: "cors",
1044        cache: "default",
1045        credentials: "same-origin",
1046        body: formData,
1047    })
1048    .then((async response=>{
1049        if (response.ok) {
1050            document.getElementById("checkSolutionBtn") && document.getElementById("checkSolutionBtn").classList.toggle("hidden");
1051            document.getElementById("nextQuestionBtn") && document.getElementById("nextQuestionBtn").classList.toggle("hidden");
1052        } else {
1053            // decrease attempts counter
1054            let attempts = document.getElementById('attemptsCount').innerText;
1055            if (parseInt(attempts) > 0) {
1056                document.getElementById('attemptsCount').innerText = (attempts - 1).toString();
1057            }
1058        }
1059        return await response.text();
1060    }))
1061    .then((message)=>{
1062        document.getElementById('code-result').innerHTML = message;
1063    })
1064    .catch(err=>{
1065        document.getElementById('code-result').innerHTML = 'Something went wrong. Please review your query and try again.';
1066    });
1067}
1068function showMySolutions(questionId) {
1069    showSolutions(questionId, 'my');
1070}
1071function showOthersSolutions(questionId) {
1072    showSolutions(questionId, 'others');
1073}
1074function showSolutions(questionId, whom) {
1075    document.getElementById('right-panel').innerHTML = '<div id="pre-loader" style="width: 21vw;"></div>';
1076    setLoader('pre-loader');
1077    const url = whom === 'my' ? `/${lang}/question/${questionId}/my-solutions` : `/${lang}/question/${questionId}/solutions`;
1078    fetch(url, {
1079        method: "GET",
1080        mode: "cors",
1081        cache: "default",
1082        credentials: "same-origin",
1083    })
1084    .then((async response=>{
1085        if (!response.ok) throw Error('Something went wrong.');
1086        return await response.text();
1087    }))
1088    .then((message)=>{
1089        document.getElementById('right-panel').innerHTML = message;
1090    })
1091    .then(()=>{
1092      [...document.getElementsByClassName("solution-block")].map(el=>{
1093        ace
1094                    .edit(el.id, {
1095                            mode: "ace/mode/mysql",
1096                            theme: getAceTheme(window.UIConfig.theme),
1097                            dragEnabled: false,
1098                            useWorker: false,
1099                            readOnly: true
1100                    });
1101      });
1102    })
1103    .catch(err=>{
1104        document.getElementById('right-panel').innerHTML = 'Something went wrong.';
1105    });
1106}
1107function solutionUpdate(solutionId, action) {
1108    return fetch(`/${lang}/solution/${solutionId}/${action}`, {
1109        method: "POST",
1110        mode: "cors",
1111        cache: "default",
1112        credentials: "same-origin",
1113    })
1114    .then((async response=>{
1115        if (response.ok) {
1116            const message =  await response.text();
1117            showToast('info', message);
1118        }
1119    }))
1120    .catch(err=>{
1121        showToast('error', 'Something went wrong. Please ask admin for help.');
1122    });
1123}
1124function solutionLike(solutionId) {
1125    return solutionUpdate(solutionId, 'like')
1126    .then(()=>{
1127            [...document.getElementById(`solution-likes-${solutionId}`).children].map(el=>el.classList.toggle("hidden"));
1128            document.getElementById(`solution-likes-count-${solutionId}`).innerText = parseInt(document.getElementById(`solution-likes-count-${solutionId}`).innerText) + 1;
1129    });
1130}
1131function solutionUnlike(solutionId) {
1132    return solutionUpdate(solutionId, 'unlike')
1133    .then(()=>{
1134        [...document.getElementById(`solution-likes-${solutionId}`).children].map(el=>el.classList.toggle("hidden"));
1135        document.getElementById(`solution-likes-count-${solutionId}`).innerText = parseInt(document.getElementById(`solution-likes-count-${solutionId}`).innerText) - 1;
1136    });
1137}
1138// Cookie helpers and welcome-page handlers
1139function setCookie(name, value, days) {
1140    let expires = '';
1141    if (days) {
1142        const date = new Date();
1143        date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
1144        expires = '; expires=' + date.toUTCString();
1145    }
1146    document.cookie = name + '=' + (value || '') + expires + '; path=/';
1147}
1148function hideWelcome(hide) {
1149    if (hide) {
1150        setCookie('HideWelcome', '1', 365);
1151    } else {
1152        setCookie('HideWelcome', '', -1); // Remove cookie
1153    }
1154}
1155function solutionReport(lang, questionId, solutionId) {
1156    solutionUpdate(solutionId, 'report')
1157    .then(()=>showOthersSolutions(questionId));
1158}
1159function solutionDelete(lang, questionId, solutionId) {
1160    return fetch(`/${lang}/solution/${solutionId}/delete`, {
1161        method: "POST",
1162        mode: "cors",
1163        cache: "default",
1164        credentials: "same-origin",
1165    })
1166    .then((async response=>{
1167      if (response.ok) {
1168        const message =  await response.text();
1169        showToast('info', message);
1170      }
1171    }))
1172    .then(()=>showMySolutions(questionId))
1173    .catch(err=>{
1174        showToast('error', 'Something went wrong. Please ask admin for help.');
1175    });
1176}
1177function solutionRun(lang, questionId, solutionId) {
1178    const solution = ace.edit(`solution-${solutionId}`).getValue();
1179    window.sql_editor.setValue(solution);
1180    return runQuery(lang, questionId);
1181}
1182
1183function rateQuestion(questionId, rate) {
1184    let formData = new FormData();
1185    formData.append('rate', rate);
1186    fetch(`/${lang}/question/${questionId}/rate`, {
1187        method: "POST",
1188        mode: "cors",
1189        cache: "default",
1190        credentials: "same-origin",
1191        body: formData,
1192    })
1193    .then((async response=>{
1194        if (response.ok) {
1195            const message =  await response.text();
1196            showToast('info', message);
1197        }
1198    }))
1199    .catch(err=>{
1200        showToast('error', 'Something went wrong. Please ask admin for help.');
1201    });
1202}
1203function toggleSolvedTasks(e) {
1204    // CSS hides the solved tasks and swaps the eye icons, also in the groups loaded later
1205    document.documentElement.classList.toggle('hide-solved-tasks');
1206    window.UIConfig.hideSolvedTasks = !window.UIConfig.hideSolvedTasks;
1207    saveUIConfig();
1208    return false;
1209}
1210function toggleNotFavoritsTasks(e) {
1211    document.getElementById('toggleNotFavoritTasks').classList.toggle("favored");
1212    [...document.getElementsByClassName("question-link solved")].map(el=>{
1213        el.parentNode.classList.toggle("invisible")
1214    });
1215    window.UIConfig.hidenotFavoredTasks = !window.UIConfig.hidenotFavoredTasks;
1216    saveUIConfig();
1217    return false;
1218}
1219
1220function toggleInfoPanel() {
1221    document.getElementsByClassName("right")[0].classList.toggle("hidden");
1222    document.getElementsByClassName("main")[0].classList.toggle("wide");
1223    [...document.getElementsByClassName("splitter")[0].children].map(el=>el.classList.toggle("hidden"));
1224    window.UIConfig.hideInfoPanel = !window.UIConfig.hideInfoPanel;
1225    saveUIConfig();
1226    return false;
1227}
1228
1229function scrollQuestionPanel() {
1230    const activePanel = document.getElementsByClassName("panel active")[0];
1231    const qurrentQuestion = document.getElementsByClassName("current-question")[0];
1232    if (activePanel && qurrentQuestion) {
1233        activePanel.scrollTop = qurrentQuestion.offsetTop - activePanel.offsetTop;
1234    }
1235}
1236function openLinkedinLoginPopUp() {
1237    window.open(
1238        `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=77scnm5m8z804e&redirect_uri=${window.location.protocol}//${window.location.host}/login/linkedin/&state=SignupAuth&scope=openid%20email%20profile`, 
1239        'LinkedIn Login', 
1240        `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=530,height=950,left=${(window.outerWidth - 530) / 2},top=${(window.outerHeight - 950) / 2}`
1241    );
1242}
1243function openGitHubLoginPopUp() {
1244    const githubClientId = window.AppConfig?.githubClientId || '';
1245    if (!githubClientId) {
1246        showToast('error', 'GitHub login is not configured.');
1247        return;
1248    }
1249
1250    window.open(
1251        `https://github.com/login/oauth/authorize?client_id=${encodeURIComponent(githubClientId)}&redirect_uri=${window.location.protocol}//${window.location.host}/login/github/&scope=user`, 
1252        'GitHub Login', 
1253        `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=530,height=950,left=${(window.outerWidth - 530) / 2},top=${(window.outerHeight - 950) / 2}`
1254    );
1255}
1256function openVKLoginPopUp() {
1257    const uuid = '29890eb2-6a16-0613-190f-250e54537e18'; // Generate a random string. We recommend using at least 36 characters. This string will be used to verify that the request is coming from your app.
1258    const appId = 51931966; // Your app identifier.
1259    // const redirect_uri = `${window.location.protocol}//${window.location.host}/login/vk/`;
1260    const redirect_uri = `${window.location.protocol}//${window.location.host}/login/vk/?lang=${lang}`;
1261    const redirect_state = 'login'; // Your app's state or any arbitrary string that will be added to the URL after authentication.
1262
1263    const query = `uuid=${uuid}&app_id=${appId}&response_type=silent_token&redirect_uri=${redirect_uri}&redirect_state=${redirect_state}`;
1264
1265    window.open(
1266        `https://id.vk.com/auth?${query}`,
1267        'VK Login',
1268        `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=530,height=950,left=${(window.outerWidth - 530) / 2},top=${(window.outerHeight - 950) / 2}`
1269    );
1270}
1271
1272// Email/Password Login Functions
1273function openEmailLoginPopUp() {
1274    document.getElementById('emailLoginPopup').style.display = 'flex';
1275    document.getElementById('loginFormContainer').style.display = 'block';
1276    document.getElementById('registerFormContainer').style.display = 'none';
1277}
1278
1279function closeEmailLoginPopUp() {
1280    document.getElementById('emailLoginPopup').style.display = 'none';
1281    document.getElementById('emailLoginForm').reset();
1282    document.getElementById('emailRegisterForm').reset();
1283    hideLoginError();
1284    hideRegisterError();
1285}
1286
1287function showLoginError(message) {
1288    const errorDiv = document.getElementById('loginErrorMessage');
1289    errorDiv.textContent = message;
1290    errorDiv.style.display = 'block';
1291}
1292
1293function hideLoginError() {
1294    const errorDiv = document.getElementById('loginErrorMessage');
1295    errorDiv.style.display = 'none';
1296    errorDiv.textContent = '';
1297}
1298
1299function showRegisterError(message) {
1300    const errorDiv = document.getElementById('registerErrorMessage');
1301    errorDiv.textContent = message;
1302    errorDiv.style.display = 'block';
1303}
1304
1305function hideRegisterError() {
1306    const errorDiv = document.getElementById('registerErrorMessage');
1307    errorDiv.style.display = 'none';
1308    errorDiv.textContent = '';
1309}
1310
1311function switchToRegister(event) {
1312    event.preventDefault();
1313    hideLoginError();
1314    document.getElementById('loginFormContainer').style.display = 'none';
1315    document.getElementById('registerFormContainer').style.display = 'block';
1316}
1317
1318function switchToLogin(event) {
1319    event.preventDefault();
1320    hideRegisterError();
1321    document.getElementById('registerFormContainer').style.display = 'none';
1322    document.getElementById('loginFormContainer').style.display = 'block';
1323}
1324
1325function handleEmailLogin(event) {
1326    event.preventDefault();
1327    hideLoginError();
1328    
1329    const email = document.getElementById('loginEmail').value;
1330    const password = document.getElementById('loginPassword').value;
1331    
1332    // Send login request to server
1333    const ajax = 1;
1334    fetch('/login/password/', {
1335        method: 'POST',
1336        headers: {
1337            'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
1338        },
1339        body: new URLSearchParams({ email, password, ajax, lang }).toString()
1340    })
1341    .then(response => response.json())
1342    .then(data => {
1343        if (data.status && data.status === 'ok') {
1344            window.location.reload(); // Reload page on successful login
1345        } else {
1346            showLoginError(data.message || 'Login failed. Please check your credentials.');
1347        }
1348    })
1349    .catch(error => {
1350        console.error('Error:', error);
1351        showLoginError('An error occurred during login. Please try again.');
1352    });
1353}
1354
1355function handleForgotPassword(event) {
1356    event.preventDefault();
1357    hideLoginError();
1358
1359    const email = document.getElementById('loginEmail').value.trim();
1360    if (!email) {
1361        showLoginError('Please enter your email address first.');
1362        return;
1363    }
1364
1365    fetch(`/${lang}/forgot-password/`, {
1366        method: 'POST',
1367        headers: {
1368            'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
1369        },
1370        body: new URLSearchParams({ email }).toString()
1371    })
1372    .then(response => response.json())
1373    .then(data => showLoginError(data.message || 'If an account exists, a temporary password has been sent.'))
1374    .catch(error => {
1375        console.error('Error:', error);
1376        showLoginError('An error occurred. Please try again later.');
1377    });
1378}
1379
1380function handleEmailRegister(event) {
1381    event.preventDefault();
1382    hideRegisterError();
1383    
1384    const full_name = document.getElementById('registerName').value;
1385    const email = document.getElementById('registerEmail').value;
1386    const password = document.getElementById('registerPassword').value;
1387    const passwordConfirm = document.getElementById('registerPasswordConfirm').value;
1388    
1389    if (password !== passwordConfirm) {
1390        showRegisterError('Passwords do not match!');
1391        return;
1392    }
1393    
1394    // Send registration request to server
1395    const ajax = 1;
1396    fetch(`/${lang}/register/`, {
1397        method: 'POST',
1398        headers: {
1399            'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
1400        },
1401        body: new URLSearchParams({ full_name, email, password, ajax, lang }).toString()
1402    })
1403    .then(response => response.json())
1404    .then(data => {
1405        if (data.status && data.status === 'ok') {
1406            switchToLogin(event);
1407        } else {
1408            showRegisterError(data.message || 'Registration failed. Please try again.');
1409        }
1410    })
1411    .catch(error => {
1412        console.error('Error:', error);
1413        showRegisterError('An error occurred during registration. Please try again.');
1414    });
1415}
1416
1417function openGoogleLoginPopUp() {
1418    const googleClientId = window.AppConfig?.googleClientId || document.querySelector('meta[name="google-signin-client_id"]')?.content || '';
1419    if (!googleClientId) {
1420        showToast('error', 'Google login is not configured.');
1421        return;
1422    }
1423
1424    const params = {
1425        response_type: 'code',
1426        client_id: googleClientId,
1427        redirect_uri: `${window.location.protocol}//${window.location.host}/login/google/`,
1428        scope:'openid email',
1429        state: ''
1430    };
1431    window.open(
1432        'https://accounts.google.com/o/oauth2/v2/auth?' + new URLSearchParams(params).toString(), 
1433        'Google Login', 
1434        `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,width=530,height=600,left=${(window.outerWidth - 530) / 2},top=${(window.outerHeight - 950) / 2}`
1435    );
1436}
1437
1438function saveUIConfig() {
1439    localStorage.setItem("UIConfig", JSON.stringify(window.UIConfig));
1440}
1441function applyUIConfig() {
1442    if (window.UIConfig.hideSolvedTasks) {
1443        window.UIConfig.hideSolvedTasks = !window.UIConfig.hideSolvedTasks;
1444        toggleSolvedTasks();
1445    }
1446
1447    if (window.UIConfig.hideInfoPanel) {
1448        window.UIConfig.hideInfoPanel = !window.UIConfig.hideInfoPanel;
1449        toggleInfoPanel();
1450    }
1451    document.querySelector('#theme-switch-checkbox').checked = (window.UIConfig.theme === 'dark' ? 1 : 0);
1452}
1453
1454/**
1455 * Accordions (the task menu groups, the database tables in the right panel), the "hide solved" eyes and the
1456 * menu search. scope: the element whose accordions to bind (the menu after loadMenu() replaced it).
1457 */
1458function setMenuEventListeners(scope = document) {
1459    scope.querySelectorAll(".accordion").forEach(el=>{
1460      el.addEventListener ('click', function() {
1461          const parentElement = this.parentElement;
1462          const wasActive = this.classList.contains("active");
1463          if (parentElement.id === 'menu-content') {
1464            //close all panels
1465            for (let el of parentElement.getElementsByClassName("panel")) el.classList.remove("active");
1466            for (let el of parentElement.getElementsByClassName("accordion")) el.classList.remove("active");
1467          }
1468          this.classList.toggle("active", !wasActive);
1469          const panel = this.nextElementSibling;
1470          panel.classList.toggle("active", !wasActive);
1471          if (!wasActive && panel.dataset.group && !panel.children.length) {
1472              loadMenuGroups(panel.dataset.group);
1473          }
1474      });
1475    });
1476
1477    scope.querySelectorAll(".eye-btn").forEach(el=>{
1478      el.addEventListener ('click', e=>{
1479          e.preventDefault();
1480          // Don't collapse the group under the button
1481          e.stopPropagation();
1482          toggleSolvedTasks()
1483      });
1484    });
1485
1486    const search = scope.querySelector('#menu-search-input');
1487    if (search) {
1488        let timer = null;
1489        search.addEventListener('input', () => {
1490            clearTimeout(timer);
1491            timer = setTimeout(() => searchMenu(search.value), 150);
1492        });
1493    }
1494}
1495
1496// Task menu groups other than the current one come without their task lists (menu.tpl); load one group,
1497// or 'all' for the search. Resolves when the lists are in place.
1498let menuGroupsRequest = null;
1499function loadMenuGroups(group) {
1500    const content = document.getElementById('menu-content');
1501    if (!content) return Promise.resolve();
1502    if (group === 'all' && menuGroupsRequest) return menuGroupsRequest;
1503    const questionnire = document.querySelector('input[name=menu_groups]:checked')?.value || 'category';
1504    const request = fetch(`/${lang}/menu?questionnire=${encodeURIComponent(questionnire)}&group=${encodeURIComponent(group)}`, {
1505        credentials: "same-origin",
1506    })
1507    .then(response => {
1508        if (!response.ok) throw Error('Something went wrong.');
1509        return response.text();
1510    })
1511    .then(html => {
1512        const fill = (id, listHtml) => {
1513            const panel = content.querySelector(`.panel[data-group="${id}"]`);
1514            if (panel && !panel.children.length) panel.innerHTML = listHtml;
1515        };
1516        if (group === 'all') {
1517            const holder = document.createElement('div');
1518            holder.innerHTML = html;
1519            holder.querySelectorAll(':scope > [data-group]').forEach(el => fill(el.dataset.group, el.innerHTML));
1520        } else {
1521            fill(group, html);
1522        }
1523    })
1524    .catch(err => {
1525        if (group === 'all') menuGroupsRequest = null;
1526        console.log(err);
1527    });
1528    if (group === 'all') menuGroupsRequest = request;
1529    return request;
1530}
1531
1532// Search by task title or number: shows the matching tasks in all groups, hides the rest
1533function searchMenu(query) {
1534    const content = document.getElementById('menu-content');
1535    const empty = document.querySelector('.menu-search-empty');
1536    if (!content) return;
1537    const words = query.trim().toLowerCase().split(/\s+/).filter(
1537Boolean);
1538    if (!words.length) {
1539        content.classList.remove('searching');
1540        content.querySelectorAll('.search-match, .search-hit').forEach(el => el.classList.remove('search-match', 'search-hit'));
1541        empty?.classList.add('hidden');
1542        return;
1543    }
1544    loadMenuGroups('all').then(() => {
1545        // The query may have changed while the lists were loading
1546        if (document.getElementById('menu-search-input')?.value.trim().toLowerCase().split(/\s+/).join(' ') !== words.join(' ')) return;
1547        content.classList.add('searching');
1548        let found = 0;
1549        content.querySelectorAll('.panel').forEach(panel => {
1550            let panelFound = 0;
1551            panel.querySelectorAll('li').forEach(li => {
1552                const text = li.textContent.replace(/\s+/g, ' ').toLowerCase();
1553                const hit = words.every(word => text.includes(word));
1554                li.classList.toggle('search-hit', hit);
1555                if (hit) panelFound++;
1556            });
1557            panel.classList.toggle('search-match', panelFound > 0);
1558            panel.previousElementSibling?.classList.toggle('search-match', panelFound > 0);
1559            found += panelFound;
1560        });
1561        empty?.classList.toggle('hidden', found > 0);
1562    });
1563}
1564
1565function setEventListeners() {
1566    [...document.querySelectorAll(".db-description .sql")].map(el=>{
1567        el.addEventListener ('dblclick', e=>{
1568            e.preventDefault();
1569            window.sql_editor.session.insert(window.sql_editor.getCursorPosition(), ` ${el.innerText} `);
1570            window.sql_editor.focus();
1571        });
1572    })
1573    const toggleSwitch = document.querySelector('#theme-switch-checkbox');
1574    toggleSwitch.addEventListener('change', switchTheme, false);
1575
1576    const link = document.querySelector("a[target='ERDWindow']");
1577    if (link) {
1578        link.addEventListener(
1579            "click",
1580            (event) => {
1581                openRequestedTab(link.href);
1582                event.preventDefault();
1583            },
1584            false,
1585        );
1586    }
1587}
1588
1589setMenuEventListeners();
1590setEventListeners();
1591document.querySelectorAll('.new-achievement').forEach(initNewAchievement);
1592applyUIConfig();
1593lazyInitShareThis();
1594if (document.getElementById("sql-code")) {
1595
1596    window.sql_editor = ace.edit("sql-code", {
1597        mode: "ace/mode/mysql",
1598        theme: getAceTheme(window.UIConfig.theme),
1599        selectionStyle: "text",
1600        dragEnabled: false,
1601        useWorker: false
1602    });
1603
1604    window.sql_editor.setShowPrintMargin(false);
1605    window.sql_editor.setOptions({enableBasicAutocompletion: true});
1606    // Shown while the editor is empty (data-placeholder: index.tpl, m.index.tpl)
1607    const editorPlaceholder = document.getElementById("sql-code").dataset.placeholder;
1608    if (editorPlaceholder) {
1609        window.sql_editor.setOption("placeholder", editorPlaceholder);
1610    }
1611}
1612
1613window.onload = function() {
1614    scrollQuestionPanel();
1615    if (!getSpeechRecognitionClass() && document.getElementById('voiceInputBtn')) {
1616        document.getElementById('voiceInputBtn').classList.add('hidden');
1617    }
1618    document.addEventListener('keydown', function(event) {
1619        if (event.ctrlKey && event.key === 'Enter' && window.sql_editor) {
1620            runQuery(lang, questionId);
1621        }
1622        // if (event.altKey && event.key === 'Enter') {
1623        //     testQuery(lang, questionId);
1624        // }
1625    });
1626
1627    // Close mobile menu when clicking outside
1628    document.addEventListener('click', function(event) {
1629        const mobileMenuDropdown = document.getElementById('mobileMenuDropdown');
1630        const mobileMenuToggle = document.getElementById('mobileMenuToggle');
1631        
1632        if (mobileMenuDropdown && mobileMenuToggle && 
1633            !mobileMenuDropdown.contains(event.target) && 
1634            !mobileMenuToggle.contains(event.target)) {
1635            mobileMenuDropdown.classList.add('hidden');
1636        }
1637    });
1638
1639    if (document.getElementById('yandexLogin') && window.YaAuthSuggest) {
1640        window.YaAuthSuggest.init(
1641        {
1642            client_id: '6a7ad9d0d23a496987255a596b83b9db',
1643            response_type: 'code',
1644            redirect_uri: `${window.location.protocol}//${window.location.host}/login/yandex/?lang=${lang}&db=${db}&questionId=${questionId}`
1645        },
1646        `${window.location.protocol}//${window.location.host}/login/yandex/?lang=${lang}&db=${db}&questionId=${questionId}`,
1647        {
1648            view: "button",
1649            parentId: "yandexLogin",
1650            buttonSize: 'xl',
1651            buttonView: 'main',
1652            buttonTheme: 'light',
1653            buttonBorderRadius: "6",
1654            buttonIcon: 'ya',
1655        }
1656        )
1657        .then(({handler}) => handler())
1658        .catch(error => alert('Обработка ошибки', error));
1659    }
1660
1661};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.