Some checks failed
test / test (push) Failing after 24s
Реализация целевого дизайна (docs/architecture/target-design.md): - src/: модульная структура (config, logger, errors, http, core: auth/history/jobs/engines/comfy/codex/images) - Очередь заданий: FIFO, кониурентность codex=1 / comfy-upscale=2, MAX_QUEUED_JOBS=5, JSONL-журнал и восстановление после рестарта (running → interrupted, upscale requeue) - Персистентные сессии (sha256-хеши токенов), scrypt-хеш пароля, rate-limit входа, Origin-проверка, magic-byte валидация аплоадов - Атомарная запись манифеста истории, каскадное удаление, URL с фактическим расширением + 302-алиас /image.jpg, миграция history/ → data/history - Даунскейл sharp до 1024px перед Codex + масштабирование области референса - SSE /api/events с фолбэком на polling, фронтенд переведён на ES-модули (public/js/) - Тесты node:test + supertest (27), CI workflow test.yml, Docker/деплой: том kadr-data - Документация: README, docs/architecture/
358 lines
11 KiB
JavaScript
358 lines
11 KiB
JavaScript
/**
|
||
* history-tree.js — Дерево версий, свёртка групп, выбор и удаление
|
||
*/
|
||
|
||
import { apiFetch } from "./api.js";
|
||
import { getState, setCurrentVersionId, setHistoryVersions } from "./state.js";
|
||
import {
|
||
formatHistoryDate,
|
||
versionFileName,
|
||
resetResult,
|
||
updateDownloadCurrent,
|
||
showSourcePreview,
|
||
clearSourcePreview,
|
||
setJobStatus,
|
||
} from "./ui.js";
|
||
import { rebuildReferenceSelect, clearReference, getReferenceState } from "./reference.js";
|
||
import { updateSuggestCodexButton } from "./suggest.js";
|
||
|
||
function historyItemBadge(version) {
|
||
if (version.kind === "original") return "Исходник";
|
||
if (version.kind === "upscale") return "Апскейл";
|
||
return "Правка";
|
||
}
|
||
|
||
function buildChildrenMap(versions) {
|
||
const map = new Map();
|
||
for (const version of versions) {
|
||
const key = version.parentId || "";
|
||
if (!map.has(key)) {
|
||
map.set(key, []);
|
||
}
|
||
map.get(key).push(version);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function countSubtreeSize(id, childrenMap) {
|
||
let count = 1;
|
||
const children = childrenMap.get(id);
|
||
if (children) {
|
||
for (const child of children) {
|
||
count += countSubtreeSize(child.id, childrenMap);
|
||
}
|
||
}
|
||
return count;
|
||
}
|
||
|
||
function renderTreeNode(version, depth, childrenMap, container) {
|
||
const state = getState();
|
||
const node = document.createElement("div");
|
||
node.className = "history-node";
|
||
node.style.setProperty("--depth", depth);
|
||
|
||
if (version.id === state.currentVersionId) {
|
||
node.classList.add("is-current");
|
||
}
|
||
|
||
const main = document.createElement("button");
|
||
main.type = "button";
|
||
main.className = "history-node-main";
|
||
main.setAttribute("aria-label", version.label || "Версия");
|
||
main.addEventListener("click", () => selectHistoryVersion(version));
|
||
|
||
const thumb = document.createElement("img");
|
||
thumb.className = "history-node-thumb";
|
||
thumb.src = version.url;
|
||
thumb.alt = "";
|
||
thumb.loading = "lazy";
|
||
|
||
const meta = document.createElement("span");
|
||
meta.className = "history-node-meta";
|
||
|
||
const title = document.createElement("strong");
|
||
title.className = "history-node-title";
|
||
title.textContent = version.label || "Версия";
|
||
|
||
const badge = document.createElement("span");
|
||
badge.className = "history-node-badge";
|
||
badge.textContent = historyItemBadge(version);
|
||
|
||
const date = document.createElement("span");
|
||
date.className = "history-node-date";
|
||
date.textContent = formatHistoryDate(version.createdAt);
|
||
|
||
meta.append(title, badge, date);
|
||
|
||
if (version.kind === "edit" && version.prompt) {
|
||
const prompt = document.createElement("div");
|
||
prompt.className = "history-node-prompt";
|
||
prompt.textContent = version.prompt;
|
||
prompt.title = version.prompt;
|
||
meta.appendChild(prompt);
|
||
}
|
||
|
||
main.append(thumb, meta);
|
||
|
||
const actions = document.createElement("div");
|
||
actions.className = "history-node-actions";
|
||
|
||
const download = document.createElement("a");
|
||
download.className = "history-node-action history-download";
|
||
download.href = version.url;
|
||
download.download = versionFileName(version);
|
||
download.textContent = "Скачать";
|
||
download.addEventListener("click", (event) => event.stopPropagation());
|
||
|
||
const remove = document.createElement("button");
|
||
remove.type = "button";
|
||
remove.className = "history-node-action history-delete";
|
||
remove.textContent = "Удалить";
|
||
remove.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
deleteVersion(version);
|
||
});
|
||
|
||
actions.append(download, remove);
|
||
node.append(main, actions);
|
||
|
||
if (childrenMap.has(version.id)) {
|
||
const body = document.createElement("div");
|
||
body.className = "history-node-children";
|
||
for (const child of childrenMap.get(version.id)) {
|
||
renderTreeNode(child, depth + 1, childrenMap, body);
|
||
}
|
||
node.appendChild(body);
|
||
}
|
||
|
||
container.appendChild(node);
|
||
}
|
||
|
||
export function renderHistory() {
|
||
const list = document.querySelector("#history-list");
|
||
const empty = document.querySelector("#history-empty");
|
||
if (!list || !empty) return;
|
||
|
||
const state = getState();
|
||
const versions = state.historyVersions;
|
||
|
||
list.querySelectorAll(".history-group").forEach((group) => group.remove());
|
||
empty.hidden = versions.length > 0;
|
||
|
||
const childrenMap = buildChildrenMap(versions);
|
||
const roots = versions
|
||
.filter(
|
||
(version) =>
|
||
version.kind === "original" ||
|
||
!version.parentId ||
|
||
!childrenMap.has(version.parentId)
|
||
)
|
||
.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
|
||
|
||
rebuildReferenceSelect();
|
||
|
||
for (const root of roots) {
|
||
const group = document.createElement("div");
|
||
group.className = "history-group";
|
||
group.dataset.group = root.id;
|
||
|
||
const rootNode = document.createElement("div");
|
||
rootNode.className = "history-node is-root";
|
||
rootNode.style.setProperty("--depth", 0);
|
||
|
||
if (root.id === state.currentVersionId) {
|
||
rootNode.classList.add("is-current");
|
||
}
|
||
|
||
const toggle = document.createElement("button");
|
||
toggle.type = "button";
|
||
toggle.className = "history-node-toggle";
|
||
toggle.setAttribute("aria-label", "Свернуть ветку");
|
||
const collapsed = state.collapsedGroups.has(root.id);
|
||
toggle.setAttribute("aria-expanded", String(!collapsed));
|
||
toggle.textContent = collapsed ? "▸" : "▾";
|
||
toggle.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
if (state.collapsedGroups.has(root.id)) {
|
||
state.collapsedGroups.delete(root.id);
|
||
} else {
|
||
state.collapsedGroups.set(root.id, true);
|
||
}
|
||
renderHistory();
|
||
});
|
||
|
||
const rootMain = document.createElement("button");
|
||
rootMain.type = "button";
|
||
rootMain.className = "history-node-main";
|
||
rootMain.setAttribute("aria-label", root.label || "Версия");
|
||
rootMain.addEventListener("click", () => selectHistoryVersion(root));
|
||
|
||
const thumb = document.createElement("img");
|
||
thumb.className = "history-node-thumb";
|
||
thumb.src = root.url;
|
||
thumb.alt = "";
|
||
thumb.loading = "lazy";
|
||
|
||
const meta = document.createElement("span");
|
||
meta.className = "history-node-meta";
|
||
|
||
const title = document.createElement("strong");
|
||
title.className = "history-node-title";
|
||
title.textContent = root.label || "Версия";
|
||
|
||
const badge = document.createElement("span");
|
||
badge.className = "history-node-badge";
|
||
badge.textContent = historyItemBadge(root);
|
||
|
||
const date = document.createElement("span");
|
||
date.className = "history-node-date";
|
||
date.textContent = formatHistoryDate(root.createdAt);
|
||
|
||
meta.append(title, badge, date);
|
||
|
||
if (root.kind === "edit" && root.prompt) {
|
||
const prompt = document.createElement("div");
|
||
prompt.className = "history-node-prompt";
|
||
prompt.textContent = root.prompt;
|
||
prompt.title = root.prompt;
|
||
meta.appendChild(prompt);
|
||
}
|
||
|
||
rootMain.append(thumb, meta);
|
||
|
||
const actions = document.createElement("div");
|
||
actions.className = "history-node-actions";
|
||
|
||
const download = document.createElement("a");
|
||
download.className = "history-node-action history-download";
|
||
download.href = root.url;
|
||
download.download = versionFileName(root);
|
||
download.textContent = "Скачать";
|
||
download.addEventListener("click", (event) => event.stopPropagation());
|
||
|
||
const remove = document.createElement("button");
|
||
remove.type = "button";
|
||
remove.className = "history-node-action history-delete";
|
||
remove.textContent = "Удалить";
|
||
remove.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
deleteVersion(root);
|
||
});
|
||
|
||
actions.append(download, remove);
|
||
rootNode.append(toggle, rootMain, actions);
|
||
group.appendChild(rootNode);
|
||
|
||
const count = document.createElement("span");
|
||
count.className = "history-group-count";
|
||
const subtreeSize = countSubtreeSize(root.id, childrenMap);
|
||
count.textContent = subtreeSize === 1 ? "1 версия" : `${subtreeSize} версий`;
|
||
group.appendChild(count);
|
||
|
||
if (!collapsed) {
|
||
const body = document.createElement("div");
|
||
body.className = "history-group-body";
|
||
const children = childrenMap.get(root.id);
|
||
if (children) {
|
||
for (const child of children) {
|
||
renderTreeNode(child, 1, childrenMap, body);
|
||
}
|
||
}
|
||
group.appendChild(body);
|
||
}
|
||
|
||
list.appendChild(group);
|
||
}
|
||
}
|
||
|
||
export function selectHistoryVersion(version) {
|
||
const state = getState();
|
||
if (state.isBusy) return;
|
||
|
||
setCurrentVersionId(version.id);
|
||
|
||
resetResult();
|
||
updateDownloadCurrent(version);
|
||
showSourcePreview(
|
||
version.url,
|
||
version.label || "Версия",
|
||
version.width && version.height ? `${version.width}×${version.height}` : "Готово к обработке"
|
||
);
|
||
renderHistory();
|
||
updateSuggestCodexButton();
|
||
setJobStatus("idle", "");
|
||
|
||
const editBtn = document.querySelector("#edit-button");
|
||
const upscaleBtn = document.querySelector("#upscale-button");
|
||
const promptInput = document.querySelector("#prompt");
|
||
|
||
if (upscaleBtn) upscaleBtn.disabled = false;
|
||
if (editBtn) {
|
||
editBtn.disabled = !promptInput?.value?.trim();
|
||
}
|
||
}
|
||
|
||
export async function refreshHistory(selectLatest = false) {
|
||
try {
|
||
const data = await apiFetch("/api/history", { cache: "no-store" });
|
||
const versions = Array.isArray(data.versions) ? data.versions : [];
|
||
setHistoryVersions(versions);
|
||
|
||
if (selectLatest && versions.length > 0) {
|
||
const latest = versions[versions.length - 1];
|
||
selectHistoryVersion(latest);
|
||
} else {
|
||
renderHistory();
|
||
updateSuggestCodexButton();
|
||
}
|
||
} catch (err) {
|
||
console.error("Ошибка обновления истории:", err);
|
||
}
|
||
}
|
||
|
||
export async function deleteVersion(version) {
|
||
if (!confirm("Удалить версию и все её ответвления?")) return;
|
||
|
||
try {
|
||
const data = await apiFetch(`/api/history/${version.id}`, {
|
||
method: "DELETE",
|
||
cache: "no-store",
|
||
});
|
||
|
||
const versions = Array.isArray(data.versions) ? data.versions : [];
|
||
setHistoryVersions(versions);
|
||
|
||
const state = getState();
|
||
state.collapsedGroups.delete(version.id);
|
||
|
||
const currentSurvives = versions.some((v) => v.id === state.currentVersionId);
|
||
if (!currentSurvives) {
|
||
const parent = version.parentId ? versions.find((v) => v.id === version.parentId) : null;
|
||
if (parent) {
|
||
selectHistoryVersion(parent);
|
||
} else if (versions.length > 0) {
|
||
setCurrentVersionId(null);
|
||
resetResult();
|
||
updateDownloadCurrent(null);
|
||
clearSourcePreview();
|
||
renderHistory();
|
||
} else {
|
||
setCurrentVersionId(null);
|
||
resetResult();
|
||
updateDownloadCurrent(null);
|
||
clearSourcePreview();
|
||
renderHistory();
|
||
}
|
||
} else {
|
||
renderHistory();
|
||
}
|
||
|
||
const refState = getReferenceState();
|
||
if (refState?.source?.type === "history" && refState.source.versionId === version.id) {
|
||
clearReference();
|
||
}
|
||
} catch (err) {
|
||
setJobStatus("error", err.message || "Не удалось удалить версию.");
|
||
}
|
||
}
|