Files
photo-editor/public/js/history-tree.js
Coder 401969cfdb
Some checks failed
test / test (push) Failing after 24s
v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Реализация целевого дизайна (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/
2026-08-27 12:52:27 +07:00

358 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 || "Не удалось удалить версию.");
}
}