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
+197 -5
View File
@@ -620,7 +620,7 @@ const removeRegistryVersion = (registry, identity, versionRecord) => {
return null;
}
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.currentVersionId = nextCurrent?.versionId || null;
asset.updatedAt = nowIso();
@@ -640,6 +640,7 @@ const buildModelListItem = (asset, ref, registry) => {
status: asset.status || "ready",
currentVersion: asset.currentVersion || null,
currentVersionId: asset.currentVersionId || null,
versions: Array.isArray(asset.versions) ? asset.versions : [],
updatedAt: asset.updatedAt || asset.createdAt || null,
ref: {
id: ref.id,
@@ -1385,6 +1386,185 @@ const collectVersionUploadSrcs = async (versionRecord) => {
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 session = getCurrentBimSession(req);
sendJSON(res, 200, {
@@ -1533,6 +1713,10 @@ const handleGetShare = async (req, res, token) => {
}
const session = getCurrentBimSession(req);
const authenticated = !!session;
const versionHistory = await getAssetVersionHistory(req, {
projectId: share.projectId,
assetId: share.assetId
}).catch(() => null);
sendJSON(res, 200, {
ok: true,
mode: authenticated ? "standard" : "guest",
@@ -1546,7 +1730,9 @@ const handleGetShare = async (req, res, token) => {
type: share.type || guessViewerTypeFromSrc(share.src, "xkt"),
name: share.name || path.basename(share.src),
assetId: share.assetId || null,
versionId: share.versionId || null
projectId: share.projectId || null,
versionId: share.versionId || versionHistory?.currentVersionId || null,
versions: versionHistory?.versions || []
},
share: {
token: share.token,
@@ -1707,7 +1893,7 @@ const handleGetComments = async (req, res, url) => {
const comments = items
.filter((item) => item?.sourceKey === source.sourceKey && !item.deletedAt)
.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, {
ok: true,
user: summarizeRegistryUser(user),
@@ -1873,7 +2059,7 @@ const handleGetModels = async (req, res) => {
};
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, {
ok: true,
user: summarizeRegistryUser(user),
@@ -2191,6 +2377,7 @@ const handleRawUpload = async (req, res, url) => {
});
sendJSON(res, 201, {
assetId,
projectId: uploadId,
conversion,
originalFilename: rawName,
sha256,
@@ -2220,6 +2407,7 @@ const handleRawUpload = async (req, res, url) => {
});
sendJSON(res, 201, {
assetId,
projectId: uploadId,
originalFilename: rawName,
sha256,
src,
@@ -2529,7 +2717,7 @@ const handleDeleteUploadVersion = async (req, res) => {
? assetManifest.currentVersionId === versionToDelete.versionId
: Number(assetManifest.currentVersion) === Number(versionToDelete.version);
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];
const nextManifest = {
...assetManifest,
@@ -3096,6 +3284,10 @@ const requestHandler = async (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") {
return handleConversionStatus(res, url.searchParams);
}