// Книги: выделение → перевод/объяснение/вопрос, потоковые ответы ИИ, рекапы, отметки прочитанного. "use strict"; const esc = s => s.replace(/[&<>"']/g, c => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[c])); // Мини-Markdown: сначала экранируем всё, потом размечаем — HTML из ответов ИИ не исполняется. function md(src) { const lines = esc(src || "").split("\n"); const out = []; let list = null, code = false; const inline = t => t .replace(/`([^`]+)`/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2") .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1'); const close = () => { if (list) { out.push(``); list = null; } }; for (const l of lines) { if (l.startsWith("```")) { close(); out.push(code ? "" : "
"); code = !code; continue; }
    if (code) { out.push(l); continue; }
    let m;
    if ((m = l.match(/^(#{1,4})\s+(.*)/))) { close(); const n = Math.min(4, m[1].length + 1); out.push(`${inline(m[2])}`); }
    else if ((m = l.match(/^\s*[-*•]\s+(.*)/))) { if (list !== "ul") { close(); out.push("
"); return out.join("\n"); } document.querySelectorAll(".md").forEach(el => { el.innerHTML = md(el.textContent); }); // Потоковый запрос: текст ответа приходит кусками. async function streamTo(url, body, el, method = "POST") { el.innerHTML = '

Думаю…

'; let acc = ""; try { const r = await fetch(url, {method, headers: {"Content-Type": "application/json"}, body: body ? JSON.stringify(body) : null}); if (!r.ok) { el.innerHTML = `

Ошибка ${r.status}: ${esc(await r.text())}

`; return ""; } const rd = r.body.getReader(), dec = new TextDecoder(); for (;;) { const {done, value} = await rd.read(); if (done) break; acc += dec.decode(value, {stream: true}); el.innerHTML = md(acc); } } catch (e) { el.innerHTML = `

Сеть: ${esc(String(e))}

`; } return acc; } // ---- вопросы (по книге и по главе) document.querySelectorAll("form.ask").forEach(f => f.addEventListener("submit", async e => { e.preventDefault(); const q = f.q.value.trim(); if (!q) return; const btn = f.querySelector("button"); btn.disabled = true; await streamTo("/api/ask", {q, book: f.dataset.book, ch: f.dataset.ch || null, web: f.web.checked}, f.parentElement.querySelector(".answer")); btn.disabled = false; })); // ---- выделение в тексте главы const text = document.getElementById("text"); const bar = document.getElementById("selbar"), panel = document.getElementById("panel"); let picked = ""; if (text && bar) { const show = () => { const s = window.getSelection(); const t = s && s.toString().trim(); if (!t || t.length < 2 || !text.contains(s.anchorNode)) { bar.hidden = true; return; } picked = t; const rect = s.getRangeAt(0).getBoundingClientRect(); bar.style.top = (window.scrollY + rect.bottom + 8) + "px"; bar.style.left = Math.max(8, Math.min(window.innerWidth - 300, rect.left)) + "px"; bar.hidden = false; }; document.addEventListener("selectionchange", () => { clearTimeout(show.t); show.t = setTimeout(show, 350); }); const run = (act, extra) => { panel.hidden = false; bar.hidden = true; const body = document.getElementById("panel-body"); const ask = document.getElementById("panel-ask"); document.getElementById("panel-title").textContent = {translate: "Перевод", explain: "Объяснение", ask: "Вопрос о выделенном"}[act]; if (act === "ask") { ask.hidden = false; body.innerHTML = `
${esc(picked.slice(0, 400))}
`; ask.querySelector("textarea").focus(); return; } ask.hidden = true; streamTo(`/api/selection/${act}`, {book: text.dataset.book, ch: text.dataset.ch, text: picked}, body); }; bar.addEventListener("mousedown", e => e.preventDefault()); // не сбрасывать выделение кликом bar.querySelectorAll("button").forEach(b => b.addEventListener("click", () => run(b.dataset.act))); document.getElementById("panel-close").onclick = () => { panel.hidden = true; }; document.getElementById("panel-ask-go").onclick = () => { const q = document.querySelector("#panel-ask textarea").value.trim(); if (!q) return; streamTo("/api/ask", {q, book: text.dataset.book, ch: text.dataset.ch, selection: picked, web: document.getElementById("panel-web").checked}, document.getElementById("panel-body")); }; } // ---- рекап главы const gen = document.getElementById("gen-recap"); if (gen) gen.onclick = async () => { gen.disabled = true; const view = document.getElementById("recap-view"); const t = await streamTo(`/api/recap/${gen.dataset.book}/${gen.dataset.ch}`, null, view); if (t && !t.startsWith("⚠️")) location.reload(); else gen.disabled = false; }; const save = document.getElementById("save-recap"); if (save) save.onclick = async () => { const val = document.getElementById("recap-edit").value; const r = await fetch(`/api/recap/${save.dataset.book}/${save.dataset.ch}`, {method: "PUT", headers: {"Content-Type": "application/json"}, body: JSON.stringify({md: val})}); if (r.ok) { document.getElementById("recap-view").innerHTML = md(val); save.textContent = "Сохранено ✓"; setTimeout(() => save.textContent = "Сохранить", 1500); } }; const useAi = document.getElementById("use-ai"); if (useAi) useAi.onclick = e => { e.preventDefault(); document.getElementById("recap-edit").value = document.getElementById("ai-md").value; }; // ---- прочитано const read = document.getElementById("read"); if (read) read.onchange = () => fetch(`/api/read/${read.dataset.book}/${read.dataset.ch}`, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({read: read.checked})}); // ---- рекапы выбранных глав по очереди const many = document.getElementById("recap-selected"); if (many) many.onclick = async () => { const only = document.getElementById("only-missing").checked; const picks = [...document.querySelectorAll(".pick:checked")].filter(c => !(only && c.dataset.has)); if (!picks.length) { alert("Отметь главы галочками"); return; } many.disabled = true; for (const c of picks) { const st = c.closest("li").querySelector(".state"); st.textContent = "⏳ рекап…"; const r = await fetch(`/api/recap/${many.dataset.book}/${c.value}`, {method: "POST"}); const t = r.ok ? await r.text() : ""; st.textContent = r.ok && !t.startsWith("⚠️") ? "📝 готово" : "⚠️ не вышло"; if (r.ok) { c.dataset.has = "1"; c.checked = false; } } many.disabled = false; }; // ---- книга ещё обрабатывается — опрос статуса const busy = document.getElementById("busy"); if (busy) { const poll = async () => { const r = await fetch(`/api/status/${busy.dataset.book}`); const s = await r.json(); if (s.status === "ready" || s.status === "error") location.reload(); else setTimeout(poll, 3000); }; setTimeout(poll, 3000); }