Add BIM model comments

This commit is contained in:
CODEX
2026-06-20 16:58:24 +03:00
parent c248217976
commit 684f85b382
6 changed files with 1849 additions and 6 deletions
+290
View File
@@ -23,9 +23,11 @@ const DATA_DIR = path.join(DATA_ROOT, "projects");
const UPLOADS_DIR = path.join(DATA_ROOT, "uploads");
const SHARES_DIR = path.join(DATA_ROOT, "shares");
const MODELS_DIR = path.join(DATA_ROOT, "models");
const COMMENTS_DIR = path.join(DATA_ROOT, "comments");
const INDEX_FILE = path.join(DATA_DIR, "index.json");
const SHARES_INDEX_FILE = path.join(SHARES_DIR, "index.json");
const MODEL_REGISTRY_FILE = path.join(MODELS_DIR, "registry.json");
const COMMENTS_INDEX_FILE = path.join(COMMENTS_DIR, "index.json");
const FRONTEND_DIST = path.join(__dirname, "..", "frontend", "dist");
const FRONTEND_ROOT = path.join(__dirname, "..", "frontend");
const PORT = parseInt(process.env.PORT, 10) || 8080;
@@ -98,6 +100,7 @@ const ensureStorage = async () => {
await fs.mkdir(UPLOADS_DIR, {recursive: true});
await fs.mkdir(SHARES_DIR, {recursive: true});
await fs.mkdir(MODELS_DIR, {recursive: true});
await fs.mkdir(COMMENTS_DIR, {recursive: true});
try {
await fs.access(INDEX_FILE);
} catch (err) {
@@ -113,6 +116,11 @@ const ensureStorage = async () => {
} catch (err) {
await fs.writeFile(MODEL_REGISTRY_FILE, JSON.stringify(createEmptyModelRegistry(), null, 2), "utf8");
}
try {
await fs.access(COMMENTS_INDEX_FILE);
} catch (err) {
await fs.writeFile(COMMENTS_INDEX_FILE, "[]", "utf8");
}
};
const readIndex = async () => {
@@ -145,6 +153,27 @@ const writeSharesIndex = async (items) => {
await fs.writeFile(SHARES_INDEX_FILE, JSON.stringify(items, null, 2), "utf8");
};
const readCommentsIndex = async () => {
try {
const raw = await fs.readFile(COMMENTS_INDEX_FILE, "utf8");
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (err) {
if (err?.code === "ENOENT") {
await fs.mkdir(COMMENTS_DIR, {recursive: true});
await fs.writeFile(COMMENTS_INDEX_FILE, "[]", "utf8");
return [];
}
console.error("[server] failed to read comments index.json", err);
return [];
}
};
const writeCommentsIndex = async (items) => {
await fs.mkdir(COMMENTS_DIR, {recursive: true});
await fs.writeFile(COMMENTS_INDEX_FILE, JSON.stringify(items, null, 2), "utf8");
};
const createEmptyModelRegistry = () => ({
version: 1,
assets: [],
@@ -839,6 +868,9 @@ const requestNeedsBimSession = (req, url) => {
if (url.pathname.startsWith("/api/models")) {
return true;
}
if (url.pathname.startsWith("/api/comments")) {
return true;
}
if (url.pathname.startsWith("/api/projects")) {
return true;
}
@@ -1522,6 +1554,250 @@ const handleGetShare = async (req, res, token) => {
});
};
const sanitizeCommentText = (value, maxLength = 4000) => {
if (typeof value !== "string") {
return "";
}
return value.trim().slice(0, maxLength);
};
const normalizeCommentWorldPos = (value) => {
if (!Array.isArray(value) || value.length !== 3) {
return null;
}
const next = value.map((item) => Number(item));
return next.every((item) => Number.isFinite(item)) ? next : null;
};
const sanitizeCommentAttachments = (value) => {
if (!Array.isArray(value)) {
return [];
}
return value.slice(0, 8).flatMap((item) => {
if (!item || typeof item !== "object") {
return [];
}
const dataUrl = typeof item.dataUrl === "string" ? item.dataUrl : "";
if (!/^data:image\/(png|jpe?g|webp|gif);base64,/i.test(dataUrl)) {
return [];
}
if (dataUrl.length > 4_500_000) {
return [];
}
return [{
id: stringOrNull(item.id) || `att_${randomBase64Url(8)}`,
name: sanitizeFilename(item.name || "image", "image"),
type: stringOrNull(item.type) || "image",
size: Number.isFinite(Number(item.size)) ? Number(item.size) : null,
dataUrl
}];
});
};
const resolveCommentSource = (payload = {}) => {
const identity = resolveAssetIdentityFromPayload(payload);
const rawSrc = stringOrNull(payload.src) ||
stringOrNull(payload.url) ||
stringOrNull(payload.sourceSrc) ||
stringOrNull(payload.settingsSrc) ||
stringOrNull(payload.artifactSrc);
const sourceSrc = normalizeUploadSrcValue(rawSrc) || rawSrc || null;
if (identity) {
return {
sourceKey: identity.assetKey,
projectId: identity.projectId,
assetId: identity.assetId,
sourceSrc
};
}
if (!sourceSrc) {
return null;
}
return {
sourceKey: `src:${shortHash(sourceSrc, 24)}`,
projectId: safeProjectId(payload.projectId) || null,
assetId: safeAssetId(payload.assetId) || null,
sourceSrc
};
};
const buildCommentMessage = (payload, user, fallbackBody = "") => {
const source = typeof payload === "string" ? {body: payload} : (payload || {});
const body = sanitizeCommentText(source.body ?? source.text ?? source.message ?? fallbackBody);
const attachments = sanitizeCommentAttachments(source.attachments);
if (!body && !attachments.length) {
return null;
}
const now = nowIso();
return {
id: `msg_${randomBase64Url(10)}`,
body,
attachments,
createdAt: now,
updatedAt: now,
createdBy: summarizeRegistryUser(user)
};
};
const publicComment = (comment) => ({
id: comment.id,
sourceKey: comment.sourceKey,
sourceSrc: comment.sourceSrc || null,
projectId: comment.projectId || null,
assetId: comment.assetId || null,
modelId: comment.modelId || null,
objectId: comment.objectId || null,
title: comment.title || "",
body: comment.body || "",
worldPos: comment.worldPos || null,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
createdBy: comment.createdBy || null,
messages: Array.isArray(comment.messages) ? comment.messages : []
});
const handleGetComments = async (req, res, url) => {
const user = requireModelRegistryUser(req, res);
if (!user) {
return;
}
const source = resolveCommentSource(Object.fromEntries(url.searchParams.entries()));
if (!source?.sourceKey) {
sendJSON(res, 200, {ok: true, user: summarizeRegistryUser(user), comments: []});
return;
}
const items = await readCommentsIndex();
const comments = items
.filter((item) => item?.sourceKey === source.sourceKey && !item.deletedAt)
.map(publicComment)
.toSorted((first, second) => String(second.updatedAt || "").localeCompare(String(first.updatedAt || "")));
sendJSON(res, 200, {
ok: true,
user: summarizeRegistryUser(user),
source,
comments
});
};
const handleCreateComment = async (req, res) => {
const user = requireModelRegistryUser(req, res);
if (!user) {
return;
}
let payload = {};
try {
payload = await parseJSONBody(req);
} catch (err) {
sendText(res, 400, err.message || "Invalid JSON");
return;
}
const source = resolveCommentSource(payload);
const worldPos = normalizeCommentWorldPos(payload?.worldPos);
const title = sanitizeCommentText(payload?.title, 180);
const body = sanitizeCommentText(payload?.body ?? payload?.description, 8000);
if (!source?.sourceKey) {
sendText(res, 400, "Comment source is required");
return;
}
if (!worldPos) {
sendText(res, 400, "Comment worldPos is required");
return;
}
if (!title && !body) {
sendText(res, 400, "Comment title or body is required");
return;
}
const now = nowIso();
const firstMessage = buildCommentMessage({
body,
attachments: payload?.attachments
}, user);
const comment = {
id: `cmt_${randomBase64Url(14)}`,
sourceKey: source.sourceKey,
sourceSrc: source.sourceSrc || null,
projectId: source.projectId || null,
assetId: source.assetId || null,
modelId: stringOrNull(payload?.modelId),
objectId: stringOrNull(payload?.objectId),
title: title || "Комментарий",
body,
worldPos,
createdAt: now,
updatedAt: now,
createdBy: summarizeRegistryUser(user),
messages: firstMessage ? [firstMessage] : []
};
const items = await readCommentsIndex();
items.unshift(comment);
await writeCommentsIndex(items);
sendJSON(res, 201, {ok: true, comment: publicComment(comment)});
};
const handlePatchComment = async (req, res, commentId) => {
const user = requireModelRegistryUser(req, res);
if (!user) {
return;
}
const safeId = stringOrNull(commentId);
if (!safeId) {
sendText(res, 400, "Invalid comment id");
return;
}
let payload = {};
try {
payload = await parseJSONBody(req);
} catch (err) {
sendText(res, 400, err.message || "Invalid JSON");
return;
}
const items = await readCommentsIndex();
const index = items.findIndex((item) => item?.id === safeId && !item.deletedAt);
if (index < 0) {
sendText(res, 404, "Comment not found");
return;
}
const comment = items[index];
const isOwner = comment?.createdBy?.id && comment.createdBy.id === user.id;
const now = nowIso();
if (payload?.delete === true) {
if (!isOwner) {
sendJSON(res, 403, {ok: false, error: "comment_owner_required"});
return;
}
comment.deletedAt = now;
comment.updatedAt = now;
await writeCommentsIndex(items);
sendJSON(res, 200, {ok: true, deleted: true, id: safeId});
return;
}
const hasTitle = Object.prototype.hasOwnProperty.call(payload, "title");
const hasBody = Object.prototype.hasOwnProperty.call(payload, "body");
const nextTitle = hasTitle ? sanitizeCommentText(payload.title, 180) || comment.title || "Комментарий" : comment.title;
const nextBody = hasBody ? sanitizeCommentText(payload.body, 8000) : comment.body;
const titleChanged = hasTitle && nextTitle !== comment.title;
const bodyChanged = hasBody && nextBody !== comment.body;
if ((titleChanged || bodyChanged) && !isOwner) {
sendJSON(res, 403, {ok: false, error: "comment_owner_required"});
return;
}
if (titleChanged) {
comment.title = nextTitle;
}
if (bodyChanged) {
comment.body = nextBody;
}
const nextMessage = buildCommentMessage(payload?.message || null, user);
if (nextMessage) {
comment.messages = Array.isArray(comment.messages) ? comment.messages : [];
comment.messages.push(nextMessage);
}
comment.updatedAt = now;
items[index] = comment;
await writeCommentsIndex(items);
sendJSON(res, 200, {ok: true, comment: publicComment(comment)});
};
const requireModelRegistryUser = (req, res) => {
const user = getRequestRegistryUser(req);
if (!user) {
@@ -2709,6 +2985,20 @@ const requestHandler = async (req, res) => {
return handleCreateShare(req, res);
}
if (url.pathname.startsWith("/api/comments")) {
if (req.method === "GET" && url.pathname === "/api/comments") {
return handleGetComments(req, res, url);
}
if (req.method === "POST" && url.pathname === "/api/comments") {
return handleCreateComment(req, res);
}
if (req.method === "PATCH" && /^\/api\/comments\/[^/]+\/?$/.test(url.pathname)) {
return handlePatchComment(req, res, url.pathname.split("/")[3]);
}
sendText(res, 405, "Method Not Allowed");
return;
}
if (url.pathname.startsWith("/api/models")) {
if (req.method === "GET" && url.pathname === "/api/models") {
return handleGetModels(req, res);