/** * Редактор серверной SQLite: дерево bind → таблицы, сетка с сохранением через /api/admin/db/*. */ function escHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function parseInputValue(raw, sampleOriginal) { const t = raw.trim(); if (t === "" && (sampleOriginal === null || sampleOriginal === undefined)) return null; if (t === "") return ""; if (/^-?\d+$/.test(t)) return Number(t); if (/^-?\d+\.\d+$/.test(t)) return Number(t); if (t === "true") return 1; if (t === "false") return 0; return raw; } function closeModal() { const m = document.getElementById("adminSqliteModal"); if (m) m.hidden = true; document.body.style.overflow = ""; } let browseState = { bind: "recipes", table: null, limit: 50, offset: 0, page: null, }; /** Колбэк выбора таблицы (нужен для повторной отрисовки дерева). */ let dbTreePickCallback = null; async function fetchJson(url, options) { const r = await fetch(url, options); const data = await r.json().catch(() => ({})); if (!r.ok) { throw new Error(data.message || `HTTP ${r.status}`); } return data; } function renderTree(binds, preferredBind, onPickTable) { dbTreePickCallback = onPickTable; const root = document.getElementById("adminDbTree"); if (!root) return; root.innerHTML = binds .map((b) => { const open = b.bind === preferredBind ? "is-open" : ""; const rows = (b.tables || []) .map( (t) => `
  • `, ) .join(""); return `
  • ${escHtml(b.bind)}
  • `; }) .join(""); root.querySelectorAll(".wesp-db-tree-caret").forEach((btn) => { btn.addEventListener("click", () => { const li = btn.closest(".wesp-db-tree-node"); if (!li) return; const open = li.classList.toggle("is-open"); btn.setAttribute("aria-expanded", open ? "true" : "false"); }); }); root.querySelectorAll(".wesp-db-tree-table").forEach((btn) => { btn.addEventListener("click", () => { const bind = btn.getAttribute("data-bind"); const table = btn.getAttribute("data-table"); root.querySelectorAll(".wesp-db-tree-table.is-active").forEach((b) => b.classList.remove("is-active")); btn.classList.add("is-active"); onPickTable(bind, table); }); }); } function renderGrid(data, setStatus) { const meta = document.getElementById("adminDbGridMeta"); const wrap = document.getElementById("adminDbGridWrap"); const pager = document.getElementById("adminDbPager"); if (!meta || !wrap || !pager) return; browseState.page = data; const { columns, rows, total, has_rowid: hasRowid } = data; meta.textContent = `${data.bind} / ${data.table} — строк ${total}, показано ${rows.length} (offset ${browseState.offset})`; const colMetaByName = Object.fromEntries((columns || []).map((c) => [c.name, c])); const displayCols = (data.column_names || []).filter((n) => n !== "_wesp_rowid"); let thead = "" + (hasRowid ? "rowid" : "") + displayCols.map((n) => `${escHtml(n)}`).join("") + "Действия"; const body = rows .map((row) => { const rowKey = {}; if (hasRowid && row._wesp_rowid != null) rowKey._wesp_rowid = row._wesp_rowid; (data.pk_names || []).forEach((pk) => { if (pk in row) rowKey[pk] = row[pk]; }); const cells = displayCols .map((name) => { const cm = colMetaByName[name]; const val = row[name]; if (cm && !cm.editable) { return `(BLOB)`; } const v = val == null ? "" : String(val); return ``; }) .join(""); const ridCell = hasRowid ? `${row._wesp_rowid != null ? escHtml(String(row._wesp_rowid)) : "—"}` : ""; const rowKeyEnc = encodeURIComponent(JSON.stringify(rowKey)); const rowJsonEnc = encodeURIComponent(JSON.stringify(row)); return ` ${ridCell}${cells} `; }) .join(""); wrap.innerHTML = `${thead}${body}
    `; wrap.querySelectorAll(".wesp-db-row-delete").forEach((btn) => { btn.addEventListener("click", async () => { const tr = btn.closest("tr"); if (!tr) return; let rowKey; try { rowKey = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-key") || "%7B%7D")); } catch (e) { setStatus("Ошибка разбора строки", true); return; } btn.disabled = true; try { const prev = await fetchJson("/api/admin/db/row/delete-preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bind: browseState.bind, table: browseState.table, row_key: rowKey, }), }); let msg = `Будет удалено строк: ${prev.total}\n\n${(prev.lines || []).join("\n")}`; if (prev.truncated) msg += "\n\n… (в списке не все строки)"; msg += "\n\nУчитываются только связи, объявленные внешними ключами SQLite.\nПродолжить удаление?"; if (!window.confirm(msg)) { setStatus("Удаление отменено"); return; } await fetchJson("/api/admin/db/row/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bind: browseState.bind, table: browseState.table, row_key: rowKey, }), }); setStatus(`Удалено строк: ${prev.total}`); await loadTablePage(setStatus); } catch (e) { setStatus(e.message, true, "Ошибка удаления"); } finally { btn.disabled = false; } }); }); wrap.querySelectorAll(".wesp-db-row-save").forEach((btn) => { btn.addEventListener("click", async () => { const tr = btn.closest("tr"); if (!tr) return; let rowKey; let original; try { rowKey = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-key") || "%7B%7D")); original = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-json") || "%7B%7D")); } catch (e) { setStatus("Ошибка разбора строки", true); return; } const changes = {}; tr.querySelectorAll(".wesp-db-cell[data-col]").forEach((inp) => { const col = inp.getAttribute("data-col"); if (!col || inp.readOnly) return; const oldVal = Object.prototype.hasOwnProperty.call(original, col) ? original[col] : undefined; const newVal = parseInputValue(inp.value, oldVal); const same = (oldVal == null && (newVal === "" || newVal === null)) || JSON.stringify(oldVal) === JSON.stringify(newVal === "" && oldVal == null ? null : newVal); if (!same) { changes[col] = newVal === "" && oldVal == null ? null : newVal; } }); if (!Object.keys(changes).length) { setStatus("Нет изменений в строке"); return; } btn.disabled = true; try { await fetchJson("/api/admin/db/row", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bind: browseState.bind, table: browseState.table, row_key: rowKey, changes, }), }); setStatus("Строка сохранена на сервере"); await loadTablePage(setStatus); } catch (e) { setStatus(e.message, true, "Ошибка сохранения"); } finally { btn.disabled = false; } }); }); const prev = browseState.offset > 0; const next = browseState.offset + rows.length < total; pager.innerHTML = ` `; document.getElementById("adminDbPrev")?.addEventListener("click", async () => { browseState.offset = Math.max(0, browseState.offset - browseState.limit); await loadTablePage(setStatus); }); document.getElementById("adminDbNext")?.addEventListener("click", async () => { browseState.offset += browseState.limit; await loadTablePage(setStatus); }); } async function loadTablePage(setStatus) { const errEl = document.getElementById("adminSqliteError"); if (errEl) errEl.textContent = ""; if (!browseState.table) return; const u = `/api/admin/db/table?bind=${encodeURIComponent(browseState.bind)}&table=${encodeURIComponent(browseState.table)}&limit=${browseState.limit}&offset=${browseState.offset}`; const data = await fetchJson(u); renderGrid(data, setStatus); } export async function refreshAdminDbTree(setStatus) { const modal = document.getElementById("adminSqliteModal"); const errEl = document.getElementById("adminSqliteError"); if (!modal || modal.hidden) return; try { const tree = await fetchJson("/api/admin/db/tree"); const pref = browseState.bind || "recipes"; const cb = dbTreePickCallback; if (typeof cb !== "function") { if (typeof setStatus === "function") setStatus("Подождите, дерево ещё загружается"); return; } renderTree(tree.binds || [], pref, cb); document.querySelectorAll(".wesp-db-tree-table").forEach((btn) => { if ( btn.getAttribute("data-bind") === browseState.bind && btn.getAttribute("data-table") === browseState.table ) { document.querySelectorAll(".wesp-db-tree-table.is-active").forEach((b) => b.classList.remove("is-active")); btn.classList.add("is-active"); } }); if (typeof setStatus === "function") setStatus("Каталог таблиц обновлён"); } catch (e) { if (errEl) errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию"; if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию"); } } export async function refreshAdminDbGrid(setStatus) { const errEl = document.getElementById("adminSqliteError"); if (!browseState.table) { if (typeof setStatus === "function") setStatus("Сначала выберите таблицу в дереве"); return; } if (errEl) errEl.textContent = ""; try { await loadTablePage(setStatus); if (typeof setStatus === "function") setStatus("Таблица обновлена с сервера"); } catch (e) { if (errEl) errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию"; if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию"); } } export async function openAdminDbBrowser(options = {}) { const { preferredBind = "recipes", setStatus } = options; const modal = document.getElementById("adminSqliteModal"); const errEl = document.getElementById("adminSqliteError"); if (!modal || !errEl) { if (typeof setStatus === "function") setStatus("Нет разметки редактора БД", true); return; } browseState = { bind: preferredBind, table: null, limit: 50, offset: 0, page: null, }; modal.hidden = false; document.body.style.overflow = "hidden"; errEl.textContent = ""; document.getElementById("adminDbGridMeta").textContent = ""; document.getElementById("adminDbGridWrap").innerHTML = '

    Выберите таблицу в дереве слева.

    '; document.getElementById("adminDbPager").innerHTML = ""; try { const tree = await fetchJson("/api/admin/db/tree"); renderTree(tree.binds || [], preferredBind, async (bind, table) => { browseState.bind = bind; browseState.table = table; browseState.offset = 0; errEl.textContent = ""; try { await loadTablePage(setStatus); } catch (e) { errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию"; if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию"); } }); if (typeof setStatus === "function") setStatus("Дерево БД загружено"); } catch (e) { errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию"; if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию"); } } export function wireAdminDbBrowserModal() { document.getElementById("adminSqliteModalClose")?.addEventListener("click", closeModal); document.getElementById("adminSqliteModalMask")?.addEventListener("click", closeModal); }