dacc0eb4ac
Раньше: клик по .para-pill вызывал setLastPara() → POST с last_para
→ syncPending=true. Тут же вызывался markRead() → второй POST с
mark_read → guard 'if (syncPending) return' молча отбрасывал его.
Результат: каталог показывал 'Продолжить' (last_para пришёл),
но '0 из N прочитано' (paragraphs_read остался пуст).
Два уровня фикса:
1) wirePillTracking объединяет last_para + mark_read в ОДИН POST
через коалесцирующий syncToServer(firstTime ? {mark_read:key} : {})
2) syncToServer теперь не дропает патчи: если предыдущий POST в
полёте, новые поля сохраняются в pendingExtra и отправляются
после .finally() — гарантия 'ни один mark_read не теряется'.
Затрагивает chemistry-9, physics-9, physics8_thermal/electro/optics —
у них теперь '0/N прочитано' начнёт расти при кликах по пилюлям.
472 lines
20 KiB
JavaScript
472 lines
20 KiB
JavaScript
'use strict';
|
||
/* ──────────────────────────────────────────────────────────────────
|
||
textbook-tracker.js — injected into each textbook page.
|
||
- "Back to LearnSpace" button overlay (top-left)
|
||
- localStorage progress tracking (always works, even logged out)
|
||
- Server-side sync when authenticated (via LS.api)
|
||
- Per-paragraph "Прочитано" checkbox UI
|
||
────────────────────────────────────────────────────────────────── */
|
||
(function () {
|
||
const slug = (function () {
|
||
const m = location.pathname.match(/\/textbook\/([\w-]+)/);
|
||
if (m) return m[1];
|
||
// Fallback for direct file access during dev
|
||
const fname = location.pathname.split('/').pop().replace(/\.html$/, '');
|
||
// Normalise physics8_* → physics-8-* (e.g. physics8_thermal → physics-8-thermal)
|
||
const norm = fname.replace(/^physics8_/, 'physics-8-');
|
||
return norm.replace(/_/g, '-');
|
||
})();
|
||
|
||
const lsKey = 'textbook_progress_' + slug;
|
||
const localState = (function () {
|
||
try { return JSON.parse(localStorage.getItem(lsKey) || '{"read":[],"last":null}'); }
|
||
catch { return { read: [], last: null }; }
|
||
})();
|
||
if (!Array.isArray(localState.read)) localState.read = [];
|
||
|
||
/* ── 1. Server sync (best-effort, с очередью) ─────────────────────
|
||
Если POST уже в полёте — следующий патч копится в pendingExtra
|
||
и отправляется после завершения. Так ни один mark_read не
|
||
теряется при быстрых кликах. */
|
||
let syncPending = false;
|
||
let pendingExtra = null;
|
||
function syncToServer(extra) {
|
||
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return;
|
||
if (syncPending) {
|
||
pendingExtra = Object.assign(pendingExtra || {}, extra || {});
|
||
return;
|
||
}
|
||
syncPending = true;
|
||
fetch('/api/textbooks/' + slug + '/progress', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ' + LS.getToken(),
|
||
},
|
||
body: JSON.stringify({ last_para: localState.last, ...(extra || {}) }),
|
||
}).finally(() => {
|
||
syncPending = false;
|
||
if (pendingExtra) {
|
||
const next = pendingExtra; pendingExtra = null;
|
||
syncToServer(next);
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
|
||
/* ── 2. Initial load: merge server data into local state ──────── */
|
||
function loadServerProgress() {
|
||
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return;
|
||
fetch('/api/textbooks/' + slug, {
|
||
headers: { 'Authorization': 'Bearer ' + LS.getToken() },
|
||
})
|
||
.then(r => r.ok ? r.json() : null)
|
||
.then(d => {
|
||
if (!d || !d.progress) return;
|
||
const merged = Array.from(new Set([...(localState.read || []), ...(d.progress.read || [])]));
|
||
localState.read = merged;
|
||
if (!localState.last) localState.last = d.progress.last_para;
|
||
localStorage.setItem(lsKey, JSON.stringify(localState));
|
||
refreshAllUI();
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
/* ── 3. Save helpers ──────────────────────────────────────────── */
|
||
function persist() { try { localStorage.setItem(lsKey, JSON.stringify(localState)); } catch {} }
|
||
|
||
function setLastPara(key) {
|
||
if (!key) return;
|
||
localState.last = key;
|
||
persist();
|
||
syncToServer({});
|
||
}
|
||
|
||
function markRead(key) {
|
||
if (!key || localState.read.includes(key)) return;
|
||
localState.read.push(key);
|
||
persist();
|
||
refreshPillUI(key);
|
||
refreshCheckUI(key);
|
||
syncToServer({ mark_read: key });
|
||
}
|
||
|
||
function unmarkRead(key) {
|
||
if (!key) return;
|
||
const i = localState.read.indexOf(key);
|
||
if (i < 0) return;
|
||
localState.read.splice(i, 1);
|
||
persist();
|
||
refreshPillUI(key);
|
||
refreshCheckUI(key);
|
||
syncToServer({ mark_unread: key });
|
||
}
|
||
|
||
function toggleRead(key) {
|
||
if (localState.read.includes(key)) unmarkRead(key);
|
||
else markRead(key);
|
||
}
|
||
|
||
/* ── 4. UI: back button overlay ───────────────────────────────── */
|
||
function installBackButton() {
|
||
if (document.getElementById('tb-back-btn')) return;
|
||
const btn = document.createElement('a');
|
||
btn.id = 'tb-back-btn';
|
||
btn.href = '/textbooks';
|
||
btn.title = 'К каталогу учебников';
|
||
btn.innerHTML = `
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||
<span>Учебники</span>`;
|
||
Object.assign(btn.style, {
|
||
position: 'fixed', top: '10px', left: '12px', zIndex: '9999',
|
||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||
padding: '6px 11px 6px 9px', borderRadius: '20px',
|
||
background: 'rgba(0,0,0,.45)', color: '#fff',
|
||
fontFamily: "'Inter',system-ui,sans-serif", fontSize: '12.5px', fontWeight: '700',
|
||
textDecoration: 'none', backdropFilter: 'blur(6px)',
|
||
transition: 'background .15s, transform .12s',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,.18)',
|
||
});
|
||
btn.querySelector('svg').style.cssText = 'width:14px;height:14px;flex-shrink:0';
|
||
btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(0,0,0,.7)'; btn.style.transform = 'translateY(-1px)'; });
|
||
btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(0,0,0,.45)'; btn.style.transform = 'none'; });
|
||
document.body.appendChild(btn);
|
||
}
|
||
|
||
/* ── 5. UI: mark-read checkboxes near every paragraph heading ─ */
|
||
function installReadCheckboxes() {
|
||
// Each para has a wrapper with id='p1','p2' etc. (data-para usage)
|
||
document.querySelectorAll('[data-para]').forEach(el => {
|
||
// Skip pill buttons (they only navigate); only target paragraph content blocks
|
||
if (el.classList.contains('para-pill')) return;
|
||
injectCheckIntoSection(el);
|
||
});
|
||
// Also look for sections by id matching pN pattern in case data-para isn't on the section
|
||
document.querySelectorAll('section[id^="p"], div[id^="p"]').forEach(el => {
|
||
if (/^p\d+$/.test(el.id) && !el.querySelector(':scope > .tb-readchk')) {
|
||
injectCheckIntoSection(el);
|
||
}
|
||
});
|
||
}
|
||
|
||
function injectCheckIntoSection(sectionEl) {
|
||
const key = sectionEl.dataset.para || sectionEl.id;
|
||
if (!key || !/^p\d+/.test(key)) return;
|
||
if (sectionEl.querySelector(':scope > .tb-readchk')) return;
|
||
|
||
// Find the first heading inside the section to insert next to it
|
||
const heading = sectionEl.querySelector('h1, h2, h3');
|
||
if (!heading) return;
|
||
|
||
const wrap = document.createElement('button');
|
||
wrap.className = 'tb-readchk';
|
||
wrap.dataset.para = key;
|
||
wrap.type = 'button';
|
||
wrap.title = 'Отметить как прочитанное';
|
||
wrap.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> <span>Прочитано</span>`;
|
||
Object.assign(wrap.style, {
|
||
marginLeft: '12px',
|
||
display: 'inline-flex', alignItems: 'center', gap: '5px',
|
||
padding: '4px 10px', borderRadius: '99px',
|
||
border: '1.5px solid currentColor', background: 'transparent',
|
||
fontFamily: "'Inter',system-ui,sans-serif", fontSize: '11.5px', fontWeight: '700',
|
||
cursor: 'pointer', verticalAlign: 'middle', opacity: '.55',
|
||
transition: 'opacity .15s, background .15s, color .15s',
|
||
});
|
||
wrap.querySelector('svg').style.cssText = 'width:12px;height:12px;flex-shrink:0';
|
||
wrap.addEventListener('mouseenter', () => { wrap.style.opacity = '1'; });
|
||
wrap.addEventListener('mouseleave', () => {
|
||
if (!localState.read.includes(key)) wrap.style.opacity = '.55';
|
||
});
|
||
wrap.addEventListener('click', e => {
|
||
e.preventDefault(); e.stopPropagation();
|
||
toggleRead(key);
|
||
});
|
||
heading.appendChild(wrap);
|
||
refreshCheckUI(key);
|
||
}
|
||
|
||
/* ── 6. UI refreshers ─────────────────────────────────────────── */
|
||
function refreshPillUI(key) {
|
||
document.querySelectorAll(`.para-pill[data-para="${key}"]`).forEach(p => {
|
||
p.classList.toggle('tb-read', localState.read.includes(key));
|
||
});
|
||
}
|
||
function refreshCheckUI(key) {
|
||
document.querySelectorAll(`.tb-readchk[data-para="${key}"]`).forEach(b => {
|
||
const isRead = localState.read.includes(key);
|
||
b.style.background = isRead ? 'rgba(16,185,129,.15)' : 'transparent';
|
||
b.style.color = isRead ? '#059669' : '';
|
||
b.style.opacity = isRead ? '1' : '.55';
|
||
b.querySelector('span').textContent = isRead ? 'Прочитано' : 'Прочитано';
|
||
});
|
||
}
|
||
function refreshAllUI() {
|
||
localState.read.forEach(k => { refreshPillUI(k); refreshCheckUI(k); });
|
||
}
|
||
|
||
/* ── 7. Pill click → last_para + (первый раз) mark_read ────────
|
||
Объединяем оба обновления в один POST, чтобы syncPending-guard
|
||
в syncToServer не дропнул второй вызов в том же тике. */
|
||
function wirePillTracking() {
|
||
document.body.addEventListener('click', e => {
|
||
const pill = e.target.closest('.para-pill[data-para]');
|
||
if (!pill) return;
|
||
const key = pill.dataset.para;
|
||
localState.last = key;
|
||
const firstTime = !localState.read.includes(key);
|
||
if (firstTime) {
|
||
localState.read.push(key);
|
||
refreshPillUI(key);
|
||
refreshCheckUI(key);
|
||
}
|
||
persist();
|
||
syncToServer(firstTime ? { mark_read: key } : {});
|
||
});
|
||
}
|
||
|
||
/* ── 8. Inject styling for read-pills (subtle green dot) ─────── */
|
||
function injectStyles() {
|
||
const s = document.createElement('style');
|
||
s.textContent = `
|
||
.para-pill.tb-read { position: relative; }
|
||
.para-pill.tb-read::after {
|
||
content: ''; position: absolute; top: 3px; right: 3px;
|
||
width: 6px; height: 6px; border-radius: 50%;
|
||
background: #10b981; box-shadow: 0 0 0 1.5px rgba(255,255,255,.9);
|
||
}
|
||
@media (max-width: 600px) {
|
||
#tb-back-btn span { display: none; }
|
||
#tb-back-btn { padding: 7px 8px; }
|
||
}
|
||
`;
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
/* ── 9a. Bookmarks (highlights/notes) ─────────────────────────── */
|
||
let bookmarks = [];
|
||
|
||
function loadBookmarks() {
|
||
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return Promise.resolve();
|
||
return fetch('/api/textbooks/' + slug + '/bookmarks', {
|
||
headers: { 'Authorization': 'Bearer ' + LS.getToken() },
|
||
})
|
||
.then(r => r.ok ? r.json() : { bookmarks: [] })
|
||
.then(d => { bookmarks = d.bookmarks || []; })
|
||
.catch(() => {});
|
||
}
|
||
|
||
function escHtml(s) {
|
||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||
}
|
||
|
||
function installBookmarksBtn() {
|
||
if (document.getElementById('tb-bm-btn')) return;
|
||
const btn = document.createElement('button');
|
||
btn.id = 'tb-bm-btn';
|
||
btn.title = 'Мои закладки';
|
||
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>`;
|
||
Object.assign(btn.style, {
|
||
position: 'fixed', top: '10px', left: '125px', zIndex: '9999',
|
||
width: '34px', height: '32px', border: 'none', borderRadius: '20px',
|
||
background: 'rgba(0,0,0,.45)', color: '#fff',
|
||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||
cursor: 'pointer', backdropFilter: 'blur(6px)',
|
||
transition: 'background .15s, transform .12s',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,.18)',
|
||
});
|
||
btn.querySelector('svg').style.cssText = 'width:14px;height:14px';
|
||
btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(0,0,0,.7)'; });
|
||
btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(0,0,0,.45)'; });
|
||
btn.addEventListener('click', toggleBookmarksPanel);
|
||
document.body.appendChild(btn);
|
||
}
|
||
|
||
function installBookmarksPanel() {
|
||
if (document.getElementById('tb-bm-panel')) return;
|
||
const panel = document.createElement('div');
|
||
panel.id = 'tb-bm-panel';
|
||
Object.assign(panel.style, {
|
||
position: 'fixed', top: '50px', left: '12px', zIndex: '9998',
|
||
width: '320px', maxHeight: '60vh', overflowY: 'auto',
|
||
background: '#fff', color: '#1c1917',
|
||
border: '1px solid rgba(0,0,0,.12)', borderRadius: '12px',
|
||
boxShadow: '0 8px 32px rgba(0,0,0,.25)',
|
||
padding: '12px', display: 'none',
|
||
fontFamily: "'Inter',system-ui,sans-serif", fontSize: '13px',
|
||
});
|
||
document.body.appendChild(panel);
|
||
}
|
||
|
||
function toggleBookmarksPanel() {
|
||
const panel = document.getElementById('tb-bm-panel');
|
||
if (panel.style.display === 'block') { panel.style.display = 'none'; return; }
|
||
renderBookmarksPanel();
|
||
panel.style.display = 'block';
|
||
}
|
||
|
||
function renderBookmarksPanel() {
|
||
const panel = document.getElementById('tb-bm-panel');
|
||
if (!panel) return;
|
||
if (!bookmarks.length) {
|
||
panel.innerHTML = `<div style="padding:14px;text-align:center;color:#78716c">
|
||
Закладок нет<br><small>Выдели любой текст в учебнике и нажми «+ Закладка»</small>
|
||
</div>`;
|
||
return;
|
||
}
|
||
panel.innerHTML = `
|
||
<div style="font-weight:800;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#78716c;margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid rgba(0,0,0,.08)">
|
||
Мои закладки (${bookmarks.length})
|
||
</div>
|
||
${bookmarks.map(b => {
|
||
const colorMap = { yellow:'#fef08a', green:'#bbf7d0', blue:'#bfdbfe', pink:'#fbcfe8' };
|
||
const borderMap= { yellow:'#ca8a04', green:'#16a34a', blue:'#2563eb', pink:'#db2777' };
|
||
const bg = colorMap[b.color] || colorMap.yellow;
|
||
const bd = borderMap[b.color] || borderMap.yellow;
|
||
const paraLink = b.para ? `<a href="#${b.para}" style="text-decoration:none;color:${bd};font-weight:700;margin-right:6px">§${b.para.replace('p','')}</a>` : '';
|
||
return `<div style="background:${bg};border-left:3px solid ${bd};padding:8px 10px;border-radius:6px;margin-bottom:7px;position:relative">
|
||
${paraLink}<span style="font-size:12.5px">«${escHtml(b.text)}»</span>
|
||
${b.note ? `<div style="margin-top:5px;color:#44403c;font-style:italic;font-size:12px">${escHtml(b.note)}</div>` : ''}
|
||
<button onclick="window.__tbDeleteBookmark(${b.id})" style="position:absolute;top:5px;right:5px;border:none;background:none;cursor:pointer;color:#78716c;font-size:13px;line-height:1">×</button>
|
||
</div>`;
|
||
}).join('')}`;
|
||
}
|
||
|
||
window.__tbDeleteBookmark = function (id) {
|
||
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) return;
|
||
fetch('/api/textbooks/bookmarks/' + id, {
|
||
method: 'DELETE',
|
||
headers: { 'Authorization': 'Bearer ' + LS.getToken() },
|
||
}).then(r => {
|
||
if (r.ok) {
|
||
bookmarks = bookmarks.filter(b => b.id !== id);
|
||
renderBookmarksPanel();
|
||
}
|
||
}).catch(() => {});
|
||
};
|
||
|
||
/* Selection → "+ Закладка" floating button */
|
||
function installSelectionHandler() {
|
||
let btn = null;
|
||
document.addEventListener('mouseup', () => {
|
||
setTimeout(() => {
|
||
const sel = window.getSelection();
|
||
const text = (sel ? sel.toString() : '').trim();
|
||
if (!text || text.length < 8 || text.length > 400) { hideBtn(); return; }
|
||
if (sel.rangeCount === 0) { hideBtn(); return; }
|
||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||
if (!rect || rect.width === 0) { hideBtn(); return; }
|
||
showBtn(rect, text);
|
||
}, 10);
|
||
});
|
||
document.addEventListener('mousedown', e => {
|
||
if (e.target.closest('#tb-sel-btn')) return;
|
||
hideBtn();
|
||
});
|
||
|
||
function showBtn(rect, text) {
|
||
if (!btn) {
|
||
btn = document.createElement('button');
|
||
btn.id = 'tb-sel-btn';
|
||
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg><span>Закладка</span>`;
|
||
Object.assign(btn.style, {
|
||
position: 'fixed', zIndex: '10000',
|
||
display: 'inline-flex', alignItems: 'center', gap: '5px',
|
||
padding: '6px 11px', borderRadius: '6px',
|
||
border: 'none', background: '#1c1917', color: '#fff',
|
||
fontFamily: "'Inter',system-ui,sans-serif", fontSize: '12px', fontWeight: '700',
|
||
cursor: 'pointer', boxShadow: '0 4px 14px rgba(0,0,0,.3)',
|
||
});
|
||
document.body.appendChild(btn);
|
||
}
|
||
btn.style.top = (rect.top + window.scrollY - 36) + 'px';
|
||
btn.style.left = (rect.left + window.scrollX + rect.width / 2 - 50) + 'px';
|
||
btn.style.display = 'inline-flex';
|
||
btn.onclick = () => createBookmarkFromSelection(text);
|
||
}
|
||
function hideBtn() { if (btn) btn.style.display = 'none'; }
|
||
}
|
||
|
||
function createBookmarkFromSelection(text) {
|
||
if (typeof LS === 'undefined' || !LS.getToken || !LS.getToken()) {
|
||
alert('Сохранение закладок доступно после входа в систему');
|
||
return;
|
||
}
|
||
const note = prompt('Заметка к закладке (опционально):', '') || '';
|
||
// Find current paragraph from selection
|
||
const sel = window.getSelection();
|
||
let para = null;
|
||
if (sel.rangeCount > 0) {
|
||
let node = sel.getRangeAt(0).startContainer;
|
||
while (node && node !== document.body) {
|
||
if (node.dataset?.para) { para = node.dataset.para; break; }
|
||
if (node.id && /^p\d+$/.test(node.id)) { para = node.id; break; }
|
||
if (node.id && /^ptab-p\d+$/.test(node.id)) { para = node.id.replace('ptab-', ''); break; }
|
||
node = node.parentNode;
|
||
}
|
||
}
|
||
if (!para) para = localState.last; // fallback
|
||
|
||
fetch('/api/textbooks/' + slug + '/bookmarks', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ' + LS.getToken(),
|
||
},
|
||
body: JSON.stringify({ text, note, para, color: 'yellow' }),
|
||
})
|
||
.then(r => r.ok ? r.json() : null)
|
||
.then(d => {
|
||
if (!d) return;
|
||
bookmarks.unshift({ id: d.id, text, note, para, color: 'yellow', created_at: new Date().toISOString() });
|
||
const btnEl = document.getElementById('tb-sel-btn');
|
||
if (btnEl) btnEl.style.display = 'none';
|
||
window.getSelection()?.removeAllRanges();
|
||
});
|
||
}
|
||
|
||
/* ── 9. Boot ──────────────────────────────────────────────────── */
|
||
function openParaByKey(key) {
|
||
if (!key) return;
|
||
if (typeof setParaTab === 'function') {
|
||
try { setParaTab(key); return; } catch {}
|
||
}
|
||
const pill = document.querySelector(`.para-pill[data-para="${key}"]`);
|
||
if (pill) pill.click();
|
||
}
|
||
|
||
function handleHashNav() {
|
||
const m = (location.hash || '').match(/^#(p\d+)$/);
|
||
if (m) {
|
||
openParaByKey(m[1]);
|
||
setLastPara(m[1]);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function boot() {
|
||
injectStyles();
|
||
installBackButton();
|
||
installBookmarksBtn();
|
||
installBookmarksPanel();
|
||
installSelectionHandler();
|
||
installReadCheckboxes();
|
||
wirePillTracking();
|
||
refreshAllUI();
|
||
loadServerProgress();
|
||
loadBookmarks();
|
||
|
||
// Priority: URL hash > last visited paragraph
|
||
if (!handleHashNav() && localState.last) {
|
||
setTimeout(() => openParaByKey(localState.last), 50);
|
||
}
|
||
window.addEventListener('hashchange', handleHashNav);
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', boot);
|
||
} else {
|
||
boot();
|
||
}
|
||
})();
|