Add BIM model version controls

This commit is contained in:
CODEX 2026-06-23 11:32:49 +03:00
parent 09492a5fdc
commit bf34f35d6c
4 changed files with 948 additions and 22 deletions

View File

@ -1183,6 +1183,55 @@ header {
min-width: 0; min-width: 0;
} }
.inspector-version-heading {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.inspector-version-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
}
.inspector-icon-btn {
display: grid;
width: 32px;
height: 32px;
place-items: center;
border: 0;
border-radius: 12px;
background: var(--nodedc-glass-control-bg);
box-shadow: var(--nodedc-glass-control-shadow);
color: rgba(245, 245, 250, 0.72);
cursor: pointer;
transition: background 140ms ease, color 140ms ease, opacity 140ms ease;
}
.inspector-icon-btn:hover {
color: rgba(245, 245, 250, 0.94);
background: var(--nodedc-glass-control-hover);
}
.inspector-icon-btn:disabled {
cursor: default;
opacity: 0.42;
}
.inspector-icon-btn svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.55;
stroke-linecap: round;
stroke-linejoin: round;
}
.inspector-color-host { .inspector-color-host {
min-width: 0; min-width: 0;
} }
@ -3174,10 +3223,174 @@ body[data-viewer-mode="guest"] .tb-btn:not([data-action="measure"]):not([data-ac
body[data-viewer-mode="guest"] #settingsPanel, body[data-viewer-mode="guest"] #settingsPanel,
body[data-viewer-mode="guest"] #templatesPanel, body[data-viewer-mode="guest"] #templatesPanel,
body[data-viewer-mode="guest"] #projectNameSection, body[data-viewer-mode="guest"] #projectNameSection,
body[data-viewer-mode="guest"] #saveProjectSection { body[data-viewer-mode="guest"] #saveProjectSection,
body[data-viewer-mode="guest"] #modelVersionUploadButton {
display: none !important; display: none !important;
} }
.version-history-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal, 10000);
background: rgba(0, 0, 0, 0.58);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.version-history-modal {
position: fixed;
left: 50%;
top: 50%;
z-index: calc(var(--z-modal, 10000) + 1);
display: flex;
width: min(96vw, 1280px);
max-height: min(76vh, 720px);
min-height: 220px;
flex-direction: column;
overflow: hidden;
border-radius: 22px;
background: rgba(20, 20, 22, 0.96);
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55);
transform: translate(-50%, -50%);
}
.version-history-header {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px 14px;
}
.version-history-title {
min-width: 0;
}
.version-history-title strong {
display: block;
overflow: hidden;
color: rgba(245, 245, 250, 0.94);
font-size: 15px;
font-weight: 760;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.version-history-title span {
display: block;
overflow: hidden;
margin-top: 6px;
color: rgba(245, 245, 250, 0.55);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.version-history-close {
flex: 0 0 auto;
}
.version-history-body {
min-height: 0;
flex: 1 1 auto;
overflow: auto;
padding: 0 16px 16px;
}
.version-history-table {
min-width: 980px;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.035);
}
.version-history-row {
display: grid;
grid-template-columns: minmax(260px, 1.6fr) 80px 96px 140px 140px 150px 120px;
align-items: center;
gap: 12px;
min-height: 56px;
padding: 10px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.065);
color: rgba(245, 245, 250, 0.72);
font-size: 12px;
}
.version-history-row:last-child {
border-bottom: 0;
}
.version-history-row--head {
min-height: 34px;
color: rgba(245, 245, 250, 0.52);
font-size: 11px;
font-weight: 760;
text-transform: uppercase;
}
.version-history-file,
.version-history-cell {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.version-history-file strong {
display: block;
overflow: hidden;
color: rgba(245, 245, 250, 0.82);
font-weight: 760;
text-overflow: ellipsis;
white-space: nowrap;
}
.version-history-file span {
display: block;
overflow: hidden;
margin-top: 5px;
color: rgba(245, 245, 250, 0.42);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.version-history-current {
display: block;
margin-top: 4px;
color: var(--modal-btn-primary, var(--accent));
font-size: 10px;
font-weight: 760;
}
.version-history-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.version-history-empty {
border-radius: 16px;
background: rgba(255, 255, 255, 0.035);
padding: 34px 16px;
color: rgba(245, 245, 250, 0.56);
font-size: 13px;
text-align: center;
}
@media (max-width: 720px) {
.version-history-modal {
width: min(94vw, 560px);
max-height: 78vh;
}
.version-history-body {
padding: 0 10px 12px;
}
}
.section-toolbar { .section-toolbar {
position: fixed; position: fixed;
left: var(--section-toolbar-left); left: var(--section-toolbar-left);

View File

@ -102,6 +102,11 @@ const customDisplayColorControlHost = document.getElementById("customDisplayColo
const customDisplaySaturationControl = document.getElementById("customDisplaySaturationControl"); const customDisplaySaturationControl = document.getElementById("customDisplaySaturationControl");
const lightingModeControl = document.getElementById("lightingModeControl"); const lightingModeControl = document.getElementById("lightingModeControl");
const lightingModeSelectHost = document.getElementById("lightingModeSelect"); const lightingModeSelectHost = document.getElementById("lightingModeSelect");
const modelVersionControl = document.getElementById("modelVersionControl");
const modelVersionSelectHost = document.getElementById("modelVersionSelect");
const modelVersionHistoryButton = document.getElementById("modelVersionHistoryButton");
const modelVersionUploadButton = document.getElementById("modelVersionUploadButton");
const modelVersionFileInput = document.getElementById("modelVersionFileInput");
const navigationAxisControl = document.getElementById("navigationAxisControl"); const navigationAxisControl = document.getElementById("navigationAxisControl");
const navigationAxisSelectHost = document.getElementById("navigationAxisSelect"); const navigationAxisSelectHost = document.getElementById("navigationAxisSelect");
const zoomSpeedControl = document.getElementById("zoomSpeedControl"); const zoomSpeedControl = document.getElementById("zoomSpeedControl");
@ -178,6 +183,7 @@ const MODEL_SETTINGS_API = "/api/model-settings";
const AUTH_SESSION_API = "/api/auth/session"; const AUTH_SESSION_API = "/api/auth/session";
const SHARES_API = "/api/shares"; const SHARES_API = "/api/shares";
const COMMENTS_API = "/api/comments"; const COMMENTS_API = "/api/comments";
const UPLOAD_VERSIONS_API = "/api/uploads/versions";
const acceptByType = { const acceptByType = {
xkt: ".xkt", xkt: ".xkt",
@ -286,6 +292,10 @@ const activeModels = [];
let viewerMode = "standard"; let viewerMode = "standard";
let runtimeSessionInfo = { authRequired: false, authenticated: false, canShare: true, user: null }; let runtimeSessionInfo = { authRequired: false, authenticated: false, canShare: true, user: null };
let startupSharePayload = null; let startupSharePayload = null;
let modelVersionHistory = null;
let modelVersionHistoryLoading = false;
let modelVersionUploading = false;
let versionHistoryModal = null;
const GUEST_TOOLBAR_ACTIONS = new Set(["measure", "section", "projection"]); const GUEST_TOOLBAR_ACTIONS = new Set(["measure", "section", "projection"]);
const memoryStore = new Map(); // url -> ArrayBuffer const memoryStore = new Map(); // url -> ArrayBuffer
const blobUrls = []; const blobUrls = [];
@ -305,6 +315,7 @@ let displayModeSelect = null;
let customDisplayColorControl = null; let customDisplayColorControl = null;
let customDisplaySaturationSlider = null; let customDisplaySaturationSlider = null;
let lightingModeSelect = null; let lightingModeSelect = null;
let modelVersionSelect = null;
let navigationAxisSelect = null; let navigationAxisSelect = null;
let zoomSpeedSlider = null; let zoomSpeedSlider = null;
let defaultCameraWorldAxis = null; let defaultCameraWorldAxis = null;
@ -1795,13 +1806,16 @@ const getShareableModelPayload = () => {
if (!src) { if (!src) {
return null; return null;
} }
const versionIdentity = getModelVersionIdentity(entry);
return { return {
assetId: entry.meta?.assetId || entry.meta?.conversion?.assetId || null, assetId: entry.meta?.assetId || entry.meta?.conversion?.assetId || null,
projectId: entry.meta?.projectId || entry.meta?.conversion?.projectId || versionIdentity.projectId || null,
name: entry.name || entry.meta?.label || src.split("/").pop() || "model", name: entry.name || entry.meta?.label || src.split("/").pop() || "model",
settingsSrc: settingsSrc || src, settingsSrc: settingsSrc || src,
src, src,
type: entry.type || normalizeType(null, src), type: entry.type || normalizeType(null, src),
versionId: entry.meta?.versionId || entry.meta?.conversion?.versionId || null, versionId: entry.meta?.versionId || entry.meta?.conversion?.versionId || null,
versions: Array.isArray(entry.meta?.versions) ? entry.meta.versions : [],
}; };
}; };
@ -3015,6 +3029,7 @@ const updatePanelsVisibility = () => {
} }
updateSectionControls(); updateSectionControls();
updateCommentsVisibility(); updateCommentsVisibility();
updateModelVersionControl();
applyRuntimeMode(); applyRuntimeMode();
}; };
@ -4181,12 +4196,13 @@ const fetchJSONWithTimeout = async (url, options = {}, timeoutMs = 5000) => {
} }
}; };
const uploadBuffer = async (bytes, filename) => { const uploadBuffer = async (bytes, filename, options = {}) => {
const safeName = filename || "upload.bin"; const safeName = filename || "upload.bin";
const params = new URLSearchParams({ filename: safeName }); const params = new URLSearchParams({ filename: safeName });
if (currentProjectMeta?.id) { const projectId = options.projectId || currentProjectMeta?.id;
params.set("projectId", currentProjectMeta.id); if (projectId) params.set("projectId", projectId);
} if (options.assetId) params.set("assetId", options.assetId);
if (options.version) params.set("version", String(options.version));
const res = await fetch(`/api/uploads?${params.toString()}`, { const res = await fetch(`/api/uploads?${params.toString()}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/octet-stream" }, headers: { "Content-Type": "application/octet-stream" },
@ -4205,7 +4221,423 @@ const uploadBuffer = async (bytes, filename) => {
if (!data || !data.src) { if (!data || !data.src) {
throw new Error("Upload response is missing src"); throw new Error("Upload response is missing src");
} }
return data.src; return data;
};
const getVersionKey = (version) => version?.versionId || `version-${version?.version || "unknown"}`;
const getModelVersionIdentity = (entry = getPrimaryModelEntry()) => {
const meta = entry?.meta || {};
const direct = {
projectId: meta.projectId || meta.conversion?.projectId || null,
assetId: meta.assetId || meta.conversion?.assetId || null,
};
if (direct.projectId && direct.assetId) return direct;
const candidates = [
meta.sourceSrc,
meta.settingsSrc,
meta.artifactSrc,
meta.conversion?.sourceSrc,
meta.conversion?.artifactSrc,
entry?.src,
];
for (const candidate of candidates) {
const identity = extractUploadIdentityFromSrc(candidate);
if (identity.projectId && identity.assetId) return identity;
}
return direct;
};
const normalizeVersionSource = (value) => {
if (!value || typeof value !== "string") return "";
const normalized = value.trim();
if (normalized.startsWith("uploads/")) return `/${normalized}`;
try {
return new URL(normalized, window.location.href).toString();
} catch (_) {
return normalized;
}
};
const getVersionViewerSrc = (version) => {
if (!version?.previewAvailable) return "";
return normalizeVersionSource(version.src || version.viewerUrl || version.downloadUrl || "");
};
const getVersionSettingsSrc = (version) => (
version?.sourceSrc ||
version?.conversion?.sourceSrc ||
version?.downloadUrl ||
version?.src ||
""
);
const getVersionViewerType = (version) => {
const src = getVersionViewerSrc(version);
return normalizeType(version?.type, src) || normalizeType(guessTypeFromName(src || version?.originalFilename || ""));
};
const getCurrentVersionIdForEntry = (entry = getPrimaryModelEntry()) => {
const meta = entry?.meta || {};
return meta.versionId || meta.conversion?.versionId || modelVersionHistory?.currentVersionId || null;
};
const isCurrentVersionRecord = (version, entry = getPrimaryModelEntry()) => {
const currentVersionId = getCurrentVersionIdForEntry(entry);
if (currentVersionId && version?.versionId) return currentVersionId === version.versionId;
const currentVersion = entry?.meta?.version || modelVersionHistory?.currentVersion;
return Number(currentVersion) === Number(version?.version);
};
const formatVersionOptionLabel = (version) => {
const number = Number(version?.version);
const prefix = Number.isFinite(number) ? `v${number}` : "Версия";
const name = version?.originalFilename || "model";
return `${prefix} · ${name}`;
};
const buildVersionHistoryParams = (entry = getPrimaryModelEntry()) => {
const identity = getModelVersionIdentity(entry);
const params = new URLSearchParams();
if (identity.projectId && identity.assetId) {
params.set("projectId", identity.projectId);
params.set("assetId", identity.assetId);
return params;
}
const src = getModelSettingsSrc(entry) || entry?.meta?.sourceSrc || entry?.meta?.artifactSrc || entry?.src || "";
if (src) params.set("src", src);
return params;
};
const setEntryVersionHistory = (entry, history) => {
if (!entry || !history) return;
const versions = Array.isArray(history.versions) ? history.versions : [];
const meta = entry.meta || {};
entry.meta = {
...meta,
assetId: history.assetId || meta.assetId || null,
projectId: history.projectId || meta.projectId || null,
versionId: meta.versionId || history.currentVersionId || null,
version: meta.version || history.currentVersion || null,
versions,
};
};
const fetchVersionHistoryForEntry = async (entry = getPrimaryModelEntry()) => {
const params = buildVersionHistoryParams(entry);
if (!params.toString()) return null;
return fetchJSON(`${UPLOAD_VERSIONS_API}?${params.toString()}`);
};
const updateModelVersionControl = () => {
const entry = getPrimaryModelEntry();
const versions = modelVersionHistory?.versions || entry?.meta?.versions || [];
const identity = getModelVersionIdentity(entry);
const hasControl = !!entry && (!!identity.assetId || versions.length > 0);
modelVersionControl?.classList.toggle("hidden", !hasControl);
if (!hasControl) return;
const currentVersionId = getCurrentVersionIdForEntry(entry);
const currentRecord = versions.find((version) => currentVersionId && version?.versionId === currentVersionId) ||
versions.find((version) => Number(version?.version) === Number(entry?.meta?.version)) ||
versions[versions.length - 1];
const currentValue = currentRecord ? getVersionKey(currentRecord) :
currentVersionId ||
"";
const options = versions.length
? versions.map((version) => ({ value: getVersionKey(version), label: formatVersionOptionLabel(version) }))
: [{ value: "loading", label: modelVersionHistoryLoading ? "Загружаем версии..." : "Версии не найдены" }];
if (modelVersionSelect) {
modelVersionSelect.setOptions(options, currentValue || options[0]?.value);
}
if (modelVersionHistoryButton) {
modelVersionHistoryButton.disabled = modelVersionHistoryLoading || !versions.length;
}
if (modelVersionUploadButton) {
modelVersionUploadButton.disabled = isGuestMode() || modelVersionUploading || !identity.assetId || !identity.projectId;
modelVersionUploadButton.title = modelVersionUploading ? "Загружаем версию..." : "Добавить версию";
}
};
const refreshModelVersionHistory = async ({ silent = false } = {}) => {
const entry = getPrimaryModelEntry();
if (!entry) {
modelVersionHistory = null;
updateModelVersionControl();
return null;
}
const params = buildVersionHistoryParams(entry);
if (!params.toString()) {
updateModelVersionControl();
return null;
}
modelVersionHistoryLoading = true;
updateModelVersionControl();
try {
const history = await fetchVersionHistoryForEntry(entry);
modelVersionHistory = history;
setEntryVersionHistory(entry, history);
updateModelVersionControl();
return history;
} catch (err) {
if (!silent) console.warn("model version history unavailable", err);
updateModelVersionControl();
return null;
} finally {
modelVersionHistoryLoading = false;
updateModelVersionControl();
}
};
const openModelVersion = async (version) => {
const viewerSrc = getVersionViewerSrc(version);
const viewerType = getVersionViewerType(version);
if (!viewerSrc || !viewerType) {
setStatus("Эта версия еще не готова к просмотру");
return;
}
const previousEntry = getPrimaryModelEntry();
const identity = getModelVersionIdentity(previousEntry);
const settingsSrc = getVersionSettingsSrc(version);
const meta = {
...(previousEntry?.meta || {}),
assetId: version.assetId || identity.assetId || previousEntry?.meta?.assetId || null,
projectId: version.projectId || identity.projectId || previousEntry?.meta?.projectId || null,
version: version.version || null,
versionId: version.versionId || null,
versions: modelVersionHistory?.versions || previousEntry?.meta?.versions || [],
label: version.originalFilename || previousEntry?.name || "model",
sourceSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc,
settingsSrc: normalizeModelSettingsSrc(settingsSrc) || settingsSrc,
artifactSrc: normalizeModelSettingsSrc(viewerSrc) || viewerSrc,
viewerSettings: null,
};
const settings = await fetchModelSettings(settingsSrc).catch(() => null);
if (settings?.viewerSettings) {
meta.viewerSettings = settings.viewerSettings;
}
await loadModelAsync({
type: viewerType,
url: viewerSrc,
name: version.originalFilename || previousEntry?.name || "model",
replace: true,
id: previousEntry?.model?.id || "model",
meta,
});
modelVersionHistory = {
...(modelVersionHistory || {}),
currentVersion: version.version || modelVersionHistory?.currentVersion || null,
currentVersionId: version.versionId || modelVersionHistory?.currentVersionId || null,
versions: modelVersionHistory?.versions || meta.versions || [],
};
updateModelVersionControl();
};
const uploadModelVersion = async (file) => {
const entry = getPrimaryModelEntry();
const identity = getModelVersionIdentity(entry);
if (!file || !entry || !identity.projectId || !identity.assetId || isGuestMode()) return;
const detected = guessTypeFromName(file.name);
const uploadOnly = /\.stp?$/i.test(file.name);
if (!detected && !uploadOnly) {
showError("Формат файла не поддерживается. Выберите .xkt, .glb, .gltf, .bim, .las, .laz, .obj, .stl, .step или .stp");
return;
}
const versions = modelVersionHistory?.versions || entry.meta?.versions || [];
const nextVersion = Math.max(0, ...versions.map((version) => Number(version?.version) || 0), Number(entry.meta?.version) || 0) + 1;
modelVersionUploading = true;
updateModelVersionControl();
try {
const bytes = cloneToUint8(await file.arrayBuffer()) || new Uint8Array(0);
const uploaded = await uploadBuffer(bytes, file.name, {
projectId: identity.projectId,
assetId: identity.assetId,
version: nextVersion,
});
uploadCache.set(entry.src || file.name, uploaded.src);
const history = await refreshModelVersionHistory({ silent: true });
const newVersion = history?.versions?.find((version) => version.versionId === uploaded.versionId) ||
history?.versions?.find((version) => Number(version.version) === Number(uploaded.version));
if (newVersion?.previewAvailable) {
await openModelVersion(newVersion);
} else {
setStatus(`Версия v${uploaded.version || nextVersion} загружена. Просмотр появится после подготовки модели.`);
}
} catch (err) {
console.error(err);
showError(err.message || "Не удалось загрузить версию модели");
} finally {
modelVersionUploading = false;
updateModelVersionControl();
}
};
const closeVersionHistoryModal = () => {
versionHistoryModal?.backdrop?.remove();
versionHistoryModal?.modal?.remove();
versionHistoryModal = null;
};
const renderVersionHistoryModal = () => {
if (!versionHistoryModal) return;
const entry = getPrimaryModelEntry();
const versions = modelVersionHistory?.versions || entry?.meta?.versions || [];
const { body, title, subtitle } = versionHistoryModal;
title.textContent = "История версий";
subtitle.textContent = entry?.name || entry?.meta?.label || modelVersionHistory?.originalFilename || "model";
body.replaceChildren();
if (!versions.length) {
const empty = document.createElement("div");
empty.className = "version-history-empty";
empty.textContent = modelVersionHistoryLoading ? "Загружаем версии..." : "История версий пока не создана.";
body.appendChild(empty);
return;
}
const table = document.createElement("div");
table.className = "version-history-table";
const header = document.createElement("div");
header.className = "version-history-row version-history-row--head";
["Файл", "Версия", "Размер", "Автор", "Дата", "Статус", "Действия"].forEach((label) => {
const cell = document.createElement("div");
cell.textContent = label;
if (label === "Действия") cell.style.textAlign = "right";
header.appendChild(cell);
});
table.appendChild(header);
[...versions].sort((a, b) => Number(b.version || 0) - Number(a.version || 0)).forEach((version) => {
const row = document.createElement("div");
row.className = "version-history-row";
const fileCell = document.createElement("div");
fileCell.className = "version-history-file";
const fileName = document.createElement("strong");
fileName.textContent = version.originalFilename || "model";
fileCell.appendChild(fileName);
if (version.sha256) {
const sha = document.createElement("span");
sha.textContent = `sha256 ${version.sha256}`;
fileCell.appendChild(sha);
}
row.appendChild(fileCell);
const versionCell = document.createElement("div");
versionCell.className = "version-history-cell";
versionCell.textContent = Number.isFinite(Number(version.version)) ? `v${version.version}` : "—";
if (isCurrentVersionRecord(version, entry)) {
const current = document.createElement("span");
current.className = "version-history-current";
current.textContent = "текущая";
versionCell.appendChild(current);
}
row.appendChild(versionCell);
const sizeCell = document.createElement("div");
sizeCell.className = "version-history-cell";
sizeCell.textContent = formatFileSize(version.size) || "—";
row.appendChild(sizeCell);
const authorCell = document.createElement("div");
authorCell.className = "version-history-cell";
authorCell.textContent = version.uploadedBy || "—";
row.appendChild(authorCell);
const dateCell = document.createElement("div");
dateCell.className = "version-history-cell";
dateCell.textContent = formatDateTime(version.uploadedAt);
row.appendChild(dateCell);
const statusCell = document.createElement("div");
statusCell.className = "version-history-cell";
statusCell.textContent = version.status === "ready"
? "Модель готова"
: version.status === "failed"
? "Ошибка подготовки"
: "Ожидает подготовки";
row.appendChild(statusCell);
const actions = document.createElement("div");
actions.className = "version-history-actions";
const viewButton = document.createElement("button");
viewButton.type = "button";
viewButton.className = "inspector-icon-btn";
viewButton.title = "Посмотреть";
viewButton.innerHTML = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M1.8 8s2.1-4.1 6.2-4.1S14.2 8 14.2 8 12.1 12.1 8 12.1 1.8 8 1.8 8Z"></path><circle cx="8" cy="8" r="1.7"></circle></svg>`;
viewButton.disabled = !getVersionViewerSrc(version);
viewButton.addEventListener("click", () => {
closeVersionHistoryModal();
openModelVersion(version);
});
actions.appendChild(viewButton);
if (version.downloadUrl) {
const download = document.createElement("a");
download.className = "inspector-icon-btn";
download.href = version.downloadUrl;
download.target = "_blank";
download.rel = "noopener noreferrer";
download.download = version.originalFilename || "model";
download.title = "Скачать";
download.innerHTML = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 2.8v7.1"></path><path d="m5.3 7.2 2.7 2.7 2.7-2.7"></path><path d="M3 12.8h10"></path></svg>`;
actions.appendChild(download);
}
const deleteButton = document.createElement("button");
deleteButton.type = "button";
deleteButton.className = "inspector-icon-btn";
deleteButton.title = "Удалить версию";
deleteButton.innerHTML = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.2 4.5h9.6"></path><path d="M6.2 4.5V3.1h3.6v1.4"></path><path d="m4.4 4.5.55 8.4h6.1l.55-8.4"></path><path d="M6.65 6.5v4.3"></path><path d="M9.35 6.5v4.3"></path></svg>`;
deleteButton.disabled = isGuestMode() || isCurrentVersionRecord(version, entry);
deleteButton.addEventListener("click", async () => {
deleteButton.disabled = true;
try {
await fetchJSON("/api/uploads/version", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(version),
});
await refreshModelVersionHistory({ silent: true });
renderVersionHistoryModal();
} catch (err) {
showError(err.message || "Не удалось удалить версию");
}
});
actions.appendChild(deleteButton);
row.appendChild(actions);
table.appendChild(row);
});
body.appendChild(table);
};
const openVersionHistoryModal = async () => {
if (versionHistoryModal) {
renderVersionHistoryModal();
return;
}
const backdrop = document.createElement("div");
backdrop.className = "version-history-backdrop";
const modal = document.createElement("div");
modal.className = "version-history-modal";
const header = document.createElement("div");
header.className = "version-history-header";
const titleWrap = document.createElement("div");
titleWrap.className = "version-history-title";
const title = document.createElement("strong");
const subtitle = document.createElement("span");
titleWrap.append(title, subtitle);
const close = document.createElement("button");
close.type = "button";
close.className = "inspector-icon-btn version-history-close";
close.title = "Закрыть";
close.innerHTML = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.25 4.25 11.75 11.75M11.75 4.25 4.25 11.75"></path></svg>`;
header.append(titleWrap, close);
const body = document.createElement("div");
body.className = "version-history-body";
modal.append(header, body);
document.body.append(backdrop, modal);
versionHistoryModal = { backdrop, modal, title, subtitle, body };
close.addEventListener("click", closeVersionHistoryModal);
backdrop.addEventListener("click", closeVersionHistoryModal);
renderVersionHistoryModal();
await refreshModelVersionHistory({ silent: true });
renderVersionHistoryModal();
}; };
const loadProjectsList = async () => { const loadProjectsList = async () => {
@ -4353,11 +4785,17 @@ const buildProjectSnapshot = async (name) => {
const modelType = normalizeType(entry.type, entry.src) || normalizeType(guessTypeFromName(entry.src)) || entry.type || null; const modelType = normalizeType(entry.type, entry.src) || normalizeType(guessTypeFromName(entry.src)) || entry.type || null;
const src = typeof entry.src === "string" ? entry.src : ""; const src = typeof entry.src === "string" ? entry.src : "";
const meta = entry.meta || {}; const meta = entry.meta || {};
const versionIdentity = getModelVersionIdentity(entry);
const baseModel = { const baseModel = {
id: model.id, id: model.id,
type: modelType, type: modelType,
meta: { meta: {
label: meta.label || entry.name || model.id, label: meta.label || entry.name || model.id,
assetId: meta.assetId || meta.conversion?.assetId || versionIdentity.assetId || null,
projectId: meta.projectId || meta.conversion?.projectId || versionIdentity.projectId || null,
version: meta.version || null,
versionId: meta.versionId || meta.conversion?.versionId || null,
versions: Array.isArray(meta.versions) ? meta.versions : [],
metadataPath: meta.metadataPath || null, metadataPath: meta.metadataPath || null,
settingsSrc: getModelSettingsSrc(entry) || null, settingsSrc: getModelSettingsSrc(entry) || null,
sourceSrc: meta.sourceSrc || meta.conversion?.sourceSrc || meta.manifest?.sourceSrc || null, sourceSrc: meta.sourceSrc || meta.conversion?.sourceSrc || meta.manifest?.sourceSrc || null,
@ -4387,11 +4825,21 @@ const buildProjectSnapshot = async (name) => {
} }
const bytes = cloneToUint8(buffer); const bytes = cloneToUint8(buffer);
const filename = (entry.name && entry.name.includes(".")) ? entry.name : `${model.id || "model"}.${modelType || "bin"}`; const filename = (entry.name && entry.name.includes(".")) ? entry.name : `${model.id || "model"}.${modelType || "bin"}`;
const uploadedSrc = await uploadBuffer(bytes, filename); const uploaded = await uploadBuffer(bytes, filename);
const uploadedSrc = uploaded.src;
uploadCache.set(cacheKey, uploadedSrc); uploadCache.set(cacheKey, uploadedSrc);
models.push({ models.push({
...baseModel, ...baseModel,
src: uploadedSrc src: uploadedSrc,
meta: {
...baseModel.meta,
assetId: uploaded.assetId || baseModel.meta.assetId || null,
projectId: uploaded.projectId || baseModel.meta.projectId || null,
version: uploaded.version || baseModel.meta.version || null,
versionId: uploaded.versionId || baseModel.meta.versionId || null,
sourceSrc: uploaded.src || baseModel.meta.sourceSrc || null,
settingsSrc: uploaded.src || baseModel.meta.settingsSrc || null,
}
}); });
} }
@ -5262,6 +5710,7 @@ const initViewer = async () => {
}); });
updateNavCubeVisibility(); updateNavCubeVisibility();
updatePanelsVisibility(); updatePanelsVisibility();
refreshModelVersionHistory({ silent: true }).catch(() => {});
// если есть сохранённые трансформы для модели — применим // если есть сохранённые трансформы для модели — применим
applyStoredTransformsToModel(id); applyStoredTransformsToModel(id);
@ -5354,7 +5803,9 @@ const initViewer = async () => {
settingsSrc: normalizeModelSettingsSrc(settingsSrc) || normalizeModelSettingsSrc(src), settingsSrc: normalizeModelSettingsSrc(settingsSrc) || normalizeModelSettingsSrc(src),
artifactSrc: normalizeModelSettingsSrc(src), artifactSrc: normalizeModelSettingsSrc(src),
assetId: sharedViewer?.assetId || null, assetId: sharedViewer?.assetId || null,
projectId: sharedViewer?.projectId || null,
versionId: sharedViewer?.versionId || null, versionId: sharedViewer?.versionId || null,
versions: Array.isArray(sharedViewer?.versions) ? sharedViewer.versions : [],
shareToken: startupSharePayload?.share?.token || getShareTokenFromPath(), shareToken: startupSharePayload?.share?.token || getShareTokenFromPath(),
viewerSettings: null, viewerSettings: null,
}; };
@ -5736,6 +6187,20 @@ const loadProjectSnapshot = async (project) => {
applyHighlightTheme(); applyHighlightTheme();
applyMeasureTheme(); applyMeasureTheme();
applySectionPlaneTheme(); applySectionPlaneTheme();
if (modelVersionControl && modelVersionSelectHost) {
modelVersionSelect = createGlassSelect({
host: modelVersionSelectHost,
value: "loading",
options: [{ value: "loading", label: "Версии не найдены" }],
ariaLabel: "Версия модели",
onChange: (value) => {
const versions = modelVersionHistory?.versions || getPrimaryModelEntry()?.meta?.versions || [];
const version = versions.find((item) => getVersionKey(item) === value);
if (version) openModelVersion(version);
},
});
updateModelVersionControl();
}
if (displayModeControl && displayModeSelectHost) { if (displayModeControl && displayModeSelectHost) {
displayModeSelect = createGlassSelect({ displayModeSelect = createGlassSelect({
host: displayModeSelectHost, host: displayModeSelectHost,
@ -5833,6 +6298,16 @@ const loadProjectSnapshot = async (project) => {
setInspectorCollapsed(false); setInspectorCollapsed(false);
bringFloatingWindowToFront("inspector"); bringFloatingWindowToFront("inspector");
}); });
modelVersionHistoryButton?.addEventListener("click", () => openVersionHistoryModal());
modelVersionUploadButton?.addEventListener("click", () => {
if (isGuestMode() || modelVersionUploading) return;
modelVersionFileInput?.click();
});
modelVersionFileInput?.addEventListener("change", async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) await uploadModelVersion(file);
});
registerFloatingWindowElement("inspector", sidePanelEl); registerFloatingWindowElement("inspector", sidePanelEl);
registerFloatingWindowElement("comments", commentsPanel); registerFloatingWindowElement("comments", commentsPanel);
registerFloatingWindowElement("settings", settingsPanel); registerFloatingWindowElement("settings", settingsPanel);
@ -5935,7 +6410,7 @@ const loadProjectSnapshot = async (project) => {
}); });
} }
const handleFile = (file) => { const handleFile = async (file) => {
if (isGuestMode()) { if (isGuestMode()) {
setStatus("Гостевой просмотр не принимает загрузку файлов"); setStatus("Гостевой просмотр не принимает загрузку файлов");
return; return;
@ -5946,16 +6421,39 @@ const loadProjectSnapshot = async (project) => {
return; return;
} }
log(`Файл выбран: ${file.name} (${detected})`, false); log(`Файл выбран: ${file.name} (${detected})`, false);
const urlKey = `memory:${crypto.randomUUID ? crypto.randomUUID() : Date.now()}`; setStatus(`Загружаем: ${file.name}...`);
file.arrayBuffer().then((buf) => { setLoading(true);
// Храним копию буфера, чтобы загрузчик не детачил исходный try {
const buf = await file.arrayBuffer();
const bytes = cloneToUint8(buf) || new Uint8Array(buf.byteLength || 0); const bytes = cloneToUint8(buf) || new Uint8Array(buf.byteLength || 0);
memoryStore.set(urlKey, bytes); const uploaded = await uploadBuffer(bytes, file.name);
loadModel({ type: detected, url: urlKey, name: file.name, replace: !addMode.checked }); const uploadIdentity = {
}).catch((err) => { projectId: uploaded.projectId || null,
console.error(err); assetId: uploaded.assetId || null,
showError(err.message || err); };
loadModel({
type: detected,
url: uploaded.src,
name: file.name,
replace: !addMode.checked,
meta: {
label: file.name,
source: "upload",
settingsSrc: uploaded.src,
sourceSrc: uploaded.src,
artifactSrc: uploaded.src,
assetId: uploadIdentity.assetId,
projectId: uploadIdentity.projectId,
version: uploaded.version || null,
versionId: uploaded.versionId || null,
versions: [],
}
}); });
} catch (err) {
console.error(err);
showError(err.message || "Не удалось загрузить модель");
setLoading(false);
}
}; };
if (chooseFileBtn) { if (chooseFileBtn) {

View File

@ -264,6 +264,29 @@
<div class="panel-section inspector-main-section"> <div class="panel-section inspector-main-section">
<div id="treeContainer" class="tree"></div> <div id="treeContainer" class="tree"></div>
<div class="inspector-controls"> <div class="inspector-controls">
<div class="inspector-control-row inspector-control-row--full hidden" id="modelVersionControl">
<div class="inspector-version-heading">
<div class="inspector-control-label">Версия модели</div>
<div class="inspector-version-actions">
<button class="inspector-icon-btn" id="modelVersionHistoryButton" type="button" title="История версий" aria-label="История версий">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3.2 3.4v3.3h3.3"></path>
<path d="M3.7 6.7A4.7 4.7 0 1 0 5.1 3"></path>
<path d="M8 5.1v3.2l2.1 1.2"></path>
</svg>
</button>
<button class="inspector-icon-btn" id="modelVersionUploadButton" type="button" title="Добавить версию" aria-label="Добавить версию">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 11.4V3.6"></path>
<path d="M5.1 6.4 8 3.5l2.9 2.9"></path>
<path d="M3.2 11.8v1.4h9.6v-1.4"></path>
</svg>
</button>
</div>
</div>
<div class="inspector-select-host" id="modelVersionSelect"></div>
<input id="modelVersionFileInput" class="hidden" type="file" accept=".xkt,.glb,.gltf,.bim,.las,.laz,.obj,.stl,.step,.stp">
</div>
<div class="inspector-control-row inspector-control-row--half" id="displayModeControl"> <div class="inspector-control-row inspector-control-row--half" id="displayModeControl">
<div class="inspector-control-label">Режим отображения</div> <div class="inspector-control-label">Режим отображения</div>
<div class="inspector-select-host" id="displayModeSelect"></div> <div class="inspector-select-host" id="displayModeSelect"></div>

View File

@ -620,7 +620,7 @@ const removeRegistryVersion = (registry, identity, versionRecord) => {
return null; return null;
} }
asset.versions = asset.versions.filter((version) => version?.versionId !== versionRecord.versionId); asset.versions = asset.versions.filter((version) => version?.versionId !== versionRecord.versionId);
const nextCurrent = asset.versions.toSorted((first, second) => Number(second?.version || 0) - Number(first?.version || 0))[0] || null; const nextCurrent = [...asset.versions].sort((first, second) => Number(second?.version || 0) - Number(first?.version || 0))[0] || null;
asset.currentVersion = nextCurrent?.version || null; asset.currentVersion = nextCurrent?.version || null;
asset.currentVersionId = nextCurrent?.versionId || null; asset.currentVersionId = nextCurrent?.versionId || null;
asset.updatedAt = nowIso(); asset.updatedAt = nowIso();
@ -640,6 +640,7 @@ const buildModelListItem = (asset, ref, registry) => {
status: asset.status || "ready", status: asset.status || "ready",
currentVersion: asset.currentVersion || null, currentVersion: asset.currentVersion || null,
currentVersionId: asset.currentVersionId || null, currentVersionId: asset.currentVersionId || null,
versions: Array.isArray(asset.versions) ? asset.versions : [],
updatedAt: asset.updatedAt || asset.createdAt || null, updatedAt: asset.updatedAt || asset.createdAt || null,
ref: { ref: {
id: ref.id, id: ref.id,
@ -1385,6 +1386,185 @@ const collectVersionUploadSrcs = async (versionRecord) => {
return srcs; return srcs;
}; };
const inferVersionInfoFromUploadSrc = (src) => {
const normalized = normalizeUploadSrcValue(src);
const match = normalized ? normalized.match(/(?:^|\/)(ver_(\d+))(?:\/|$)/) : null;
const version = match ? Number.parseInt(match[2], 10) : null;
return {
version: Number.isFinite(version) ? version : null,
versionId: match ? match[1] : null
};
};
const hydrateAssetVersionRecord = async (versionRecord) => {
const sourcePath = resolveUploadSrc(versionRecord?.sourceSrc || versionRecord?.downloadSrc || versionRecord?.src);
const manifest = sourcePath ? await readJSONFile(manifestPathForSource(sourcePath)) : null;
const inferred = inferVersionInfoFromUploadSrc(versionRecord?.sourceSrc || versionRecord?.downloadSrc || versionRecord?.src || manifest?.sourceSrc);
const rawVersion = versionRecord?.version ?? manifest?.version ?? inferred.version;
const version = Number(rawVersion);
return {
...(versionRecord || {}),
...(manifest && typeof manifest === "object" ? manifest : {}),
assetId: versionRecord?.assetId || manifest?.assetId || null,
projectId: versionRecord?.projectId || manifest?.projectId || null,
version: Number.isFinite(version) && version > 0 ? version : null,
versionId: stringOrNull(versionRecord?.versionId || manifest?.versionId || inferred.versionId),
originalFilename: stringOrNull(versionRecord?.originalFilename || manifest?.originalFilename) || (sourcePath ? path.basename(sourcePath) : null),
sourceSrc: normalizeUploadSrcValue(manifest?.sourceSrc || versionRecord?.sourceSrc || versionRecord?.src),
downloadSrc: normalizeUploadSrcValue(versionRecord?.downloadSrc || manifest?.downloadSrc || manifest?.sourceSrc || versionRecord?.sourceSrc || versionRecord?.src),
artifactSrc: normalizeUploadSrcValue(manifest?.artifactSrc || versionRecord?.artifactSrc),
metadataSrc: normalizeUploadSrcValue(manifest?.metadataSrc || versionRecord?.metadataSrc)
};
};
const mergeHydratedVersionRecords = (previous, next) => {
if (!previous) {
return next;
}
return {
...previous,
...next,
artifactSrc: next.artifactSrc || previous.artifactSrc || null,
downloadSrc: next.downloadSrc || previous.downloadSrc || null,
metadataSrc: next.metadataSrc || previous.metadataSrc || null,
originalFilename: next.originalFilename || previous.originalFilename || null,
sourceSrc: next.sourceSrc || previous.sourceSrc || null,
status: previous.status === "ready" || next.status === "ready" ? "ready" : (next.status || previous.status || null),
version: next.version || previous.version || null,
versionId: next.versionId || previous.versionId || null
};
};
const publicModelVersionRecord = (req, versionRecord) => {
const sourceSrc = normalizeUploadSrcValue(versionRecord?.sourceSrc || versionRecord?.src || versionRecord?.downloadSrc);
const downloadSrc = normalizeUploadSrcValue(versionRecord?.downloadSrc || sourceSrc);
const artifactSrc = normalizeUploadSrcValue(versionRecord?.artifactSrc);
const metadataSrc = normalizeUploadSrcValue(versionRecord?.metadataSrc);
const sourceFormat = (stringOrNull(versionRecord?.sourceFormat) || guessViewerTypeFromSrc(downloadSrc || sourceSrc || "", "file") || "")
.replace(/^\./, "")
.toLowerCase();
const conversionRequired = sourceFormat && CONVERTIBLE_MODEL_FORMATS.has(`.${sourceFormat}`);
const status = stringOrNull(versionRecord?.status) || (conversionRequired && !artifactSrc ? "conversion_required" : "ready");
const artifactType = stringOrNull(versionRecord?.artifactType || versionRecord?.targetFormat);
const viewerSrc = status === "ready" && artifactSrc ? artifactSrc : (downloadSrc || sourceSrc);
const viewerType = artifactSrc ? (artifactType || "gltf") : guessViewerTypeFromSrc(viewerSrc || "", sourceFormat);
const conversion = conversionRequired
? {
artifactSrc,
artifactType: artifactType || null,
componentTreeRequired: versionRecord?.componentTreeRequired !== false,
message: stringOrNull(versionRecord?.message),
metadataSrc,
size: Number(versionRecord?.size) || 0,
sourceFormat,
sourceSrc,
status,
targetFormat: stringOrNull(versionRecord?.targetFormat) || "xkt",
updatedAt: stringOrNull(versionRecord?.updatedAt || versionRecord?.uploadedAt)
}
: null;
const previewAvailable = status === "ready" && !!viewerSrc && (!conversionRequired || !!artifactSrc);
const viewerUrl = previewAvailable && viewerType
? (() => {
const url = new URL("/", `${getRequestBaseUrl(req)}/`);
url.searchParams.set("url", publicUrlForUploadSrc(req, viewerSrc));
url.searchParams.set("type", viewerType);
url.searchParams.set("settingsSrc", publicUrlForUploadSrc(req, sourceSrc || downloadSrc || viewerSrc));
if (versionRecord?.originalFilename) url.searchParams.set("name", versionRecord.originalFilename);
return url.toString();
})()
: null;
return {
assetId: stringOrNull(versionRecord?.assetId),
projectId: stringOrNull(versionRecord?.projectId),
conversion,
downloadUrl: downloadSrc ? publicUrlForUploadSrc(req, downloadSrc) : "",
originalFilename: stringOrNull(versionRecord?.originalFilename) || (downloadSrc ? path.basename(downloadSrc) : "model"),
previewAvailable,
sha256: stringOrNull(versionRecord?.sha256),
size: Number(versionRecord?.size) || 0,
sourceSrc: sourceSrc || null,
src: viewerSrc ? publicUrlForUploadSrc(req, viewerSrc) : "",
status,
type: viewerType || sourceFormat || "file",
uploadedBy: stringOrNull(versionRecord?.uploadedBy),
uploadedAt: stringOrNull(versionRecord?.uploadedAt || versionRecord?.updatedAt),
version: Number(versionRecord?.version) || null,
versionId: stringOrNull(versionRecord?.versionId),
viewerUrl
};
};
const getAssetVersionHistory = async (req, identity) => {
const normalized = normalizeAssetIdentity(identity);
if (!normalized || normalized.legacy) {
return null;
}
const assetDir = path.join(UPLOADS_DIR, normalized.projectId, normalized.assetId);
const manifest = await readAssetManifest(assetDir);
if (!manifest) {
return null;
}
const hydratedVersions = await Promise.all(
(Array.isArray(manifest.versions) ? manifest.versions : []).map(hydrateAssetVersionRecord)
);
const versionsByKey = new Map();
hydratedVersions
.filter((version) => Number.isFinite(Number(version?.version)) && Number(version.version) > 0)
.forEach((version) => {
const key = version.versionId || `version-${version.version}`;
versionsByKey.set(key, mergeHydratedVersionRecords(versionsByKey.get(key), version));
});
const versions = [...versionsByKey.values()]
.sort((first, second) => Number(first.version || 0) - Number(second.version || 0))
.map((version) => publicModelVersionRecord(req, version));
return {
assetId: normalized.assetId,
projectId: normalized.projectId,
assetKey: normalized.assetKey,
currentVersion: manifest.currentVersion || versions[versions.length - 1]?.version || null,
currentVersionId: manifest.currentVersionId || versions[versions.length - 1]?.versionId || null,
originalFilename: manifest.originalFilename || versions[versions.length - 1]?.originalFilename || null,
updatedAt: manifest.updatedAt || null,
versions
};
};
const resolveAssetVersionIdentityFromSearchParams = (searchParams) => {
const direct = normalizeAssetIdentity({
projectId: searchParams.get("projectId"),
assetId: searchParams.get("assetId")
});
if (direct) {
return direct;
}
const src = normalizeUploadSrcValue(
searchParams.get("src") ||
searchParams.get("url") ||
searchParams.get("sourceSrc") ||
searchParams.get("downloadSrc") ||
searchParams.get("artifactSrc")
);
return src ? getAssetIdentityFromSrc(src) : null;
};
const handleGetUploadVersions = async (req, res, searchParams) => {
const identity = resolveAssetVersionIdentityFromSearchParams(searchParams);
if (!identity) {
sendText(res, 400, "Invalid model asset identity");
return;
}
const history = await getAssetVersionHistory(req, identity);
if (!history) {
sendText(res, 404, "Model asset versions not found");
return;
}
sendJSON(res, 200, {
ok: true,
...history
});
};
const handleAuthSession = (req, res) => { const handleAuthSession = (req, res) => {
const session = getCurrentBimSession(req); const session = getCurrentBimSession(req);
sendJSON(res, 200, { sendJSON(res, 200, {
@ -1533,6 +1713,10 @@ const handleGetShare = async (req, res, token) => {
} }
const session = getCurrentBimSession(req); const session = getCurrentBimSession(req);
const authenticated = !!session; const authenticated = !!session;
const versionHistory = await getAssetVersionHistory(req, {
projectId: share.projectId,
assetId: share.assetId
}).catch(() => null);
sendJSON(res, 200, { sendJSON(res, 200, {
ok: true, ok: true,
mode: authenticated ? "standard" : "guest", mode: authenticated ? "standard" : "guest",
@ -1546,7 +1730,9 @@ const handleGetShare = async (req, res, token) => {
type: share.type || guessViewerTypeFromSrc(share.src, "xkt"), type: share.type || guessViewerTypeFromSrc(share.src, "xkt"),
name: share.name || path.basename(share.src), name: share.name || path.basename(share.src),
assetId: share.assetId || null, assetId: share.assetId || null,
versionId: share.versionId || null projectId: share.projectId || null,
versionId: share.versionId || versionHistory?.currentVersionId || null,
versions: versionHistory?.versions || []
}, },
share: { share: {
token: share.token, token: share.token,
@ -1707,7 +1893,7 @@ const handleGetComments = async (req, res, url) => {
const comments = items const comments = items
.filter((item) => item?.sourceKey === source.sourceKey && !item.deletedAt) .filter((item) => item?.sourceKey === source.sourceKey && !item.deletedAt)
.map(publicComment) .map(publicComment)
.toSorted((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || ""))); .sort((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || "")));
sendJSON(res, 200, { sendJSON(res, 200, {
ok: true, ok: true,
user: summarizeRegistryUser(user), user: summarizeRegistryUser(user),
@ -1873,7 +2059,7 @@ const handleGetModels = async (req, res) => {
}; };
return buildModelListItem(asset, ref, registry); return buildModelListItem(asset, ref, registry);
}) })
.toSorted((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || ""))); .sort((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || "")));
sendJSON(res, 200, { sendJSON(res, 200, {
ok: true, ok: true,
user: summarizeRegistryUser(user), user: summarizeRegistryUser(user),
@ -2191,6 +2377,7 @@ const handleRawUpload = async (req, res, url) => {
}); });
sendJSON(res, 201, { sendJSON(res, 201, {
assetId, assetId,
projectId: uploadId,
conversion, conversion,
originalFilename: rawName, originalFilename: rawName,
sha256, sha256,
@ -2220,6 +2407,7 @@ const handleRawUpload = async (req, res, url) => {
}); });
sendJSON(res, 201, { sendJSON(res, 201, {
assetId, assetId,
projectId: uploadId,
originalFilename: rawName, originalFilename: rawName,
sha256, sha256,
src, src,
@ -2529,7 +2717,7 @@ const handleDeleteUploadVersion = async (req, res) => {
? assetManifest.currentVersionId === versionToDelete.versionId ? assetManifest.currentVersionId === versionToDelete.versionId
: Number(assetManifest.currentVersion) === Number(versionToDelete.version); : Number(assetManifest.currentVersion) === Number(versionToDelete.version);
const nextCurrent = deletedCurrent const nextCurrent = deletedCurrent
? remainingVersions.toSorted((first, second) => Number(second.version || 0) - Number(first.version || 0))[0] ? [...remainingVersions].sort((first, second) => Number(second.version || 0) - Number(first.version || 0))[0]
: remainingVersions.find((version) => version.versionId === assetManifest.currentVersionId) || remainingVersions[remainingVersions.length - 1]; : remainingVersions.find((version) => version.versionId === assetManifest.currentVersionId) || remainingVersions[remainingVersions.length - 1];
const nextManifest = { const nextManifest = {
...assetManifest, ...assetManifest,
@ -3096,6 +3284,10 @@ const requestHandler = async (req, res) => {
return handleDeleteUploadVersion(req, res); return handleDeleteUploadVersion(req, res);
} }
if (req.method === "GET" && url.pathname === "/api/uploads/versions") {
return handleGetUploadVersions(req, res, url.searchParams);
}
if (req.method === "GET" && url.pathname === "/api/conversions/status") { if (req.method === "GET" && url.pathname === "/api/conversions/status") {
return handleConversionStatus(res, url.searchParams); return handleConversionStatus(res, url.searchParams);
} }