-
Проекты
+
Проекты
Сортировка по логическому объему файлов.
diff --git a/plane-src/apps/web/core/services/issue/issue_attachment.service.ts b/plane-src/apps/web/core/services/issue/issue_attachment.service.ts
index ab1dbe4..81a10a5 100644
--- a/plane-src/apps/web/core/services/issue/issue_attachment.service.ts
+++ b/plane-src/apps/web/core/services/issue/issue_attachment.service.ts
@@ -17,8 +17,15 @@ import type {
import { EIssueServiceType } from "@plane/types";
// services
import {
+ deleteBeamModelAsset,
+ deleteBeamModelVersion,
+ getBeamModelVersionRecords,
getBeamModelMimeType,
+ getBeamViewerAttachment,
+ getBeamViewerVersionNumber,
isBeamModelFile,
+ mergeBeamModelVersionRecords,
+ type TBeamModelVersionRecord,
type TBeamViewerAttachment,
uploadBeamModelFile,
} from "@/helpers/beam-viewer";
@@ -81,11 +88,12 @@ export class IssueAttachmentService extends APIService {
projectId: string,
issueId: string,
attachmentId: string,
- beamViewer: TBeamViewerAttachment
+ beamViewer: TBeamViewerAttachment,
+ attributes: Record = {}
): Promise {
return this.patch(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${attachmentId}/`,
- { beamViewer }
+ { ...attributes, beamViewer }
)
.then((response) => response?.data)
.catch((error) => {
@@ -93,6 +101,107 @@ export class IssueAttachmentService extends APIService {
});
}
+ async uploadBeamIssueAttachmentVersion(
+ workspaceSlug: string,
+ projectId: string,
+ issueId: string,
+ attachment: TIssueAttachment,
+ file: File,
+ uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"],
+ uploadedBy?: string
+ ): Promise {
+ const previousBeamViewer = getBeamViewerAttachment(attachment);
+ if (!previousBeamViewer) {
+ throw new Error("Для этого вложения нет BIM-метаданных.");
+ }
+ if (!isBeamModelFile(file.name)) {
+ throw new Error("Формат модели не поддерживается BIM Viewer.");
+ }
+
+ const previousVersions = getBeamModelVersionRecords(previousBeamViewer, attachment);
+ const currentVersion = Math.max(
+ getBeamViewerVersionNumber(previousBeamViewer, attachment),
+ ...previousVersions.map((version) => version.version)
+ );
+ const nextVersion = currentVersion + 1;
+ const assetId = previousBeamViewer.assetId || attachment.id;
+ const nextBeamViewer = await uploadBeamModelFile(file, {
+ assetId,
+ issueId,
+ onUploadProgress: uploadProgressHandler,
+ projectId,
+ uploadedBy,
+ version: nextVersion,
+ workspaceSlug,
+ });
+ const nextVersions = mergeBeamModelVersionRecords(
+ { ...previousBeamViewer, assetId, versions: previousVersions },
+ nextBeamViewer,
+ attachment
+ );
+
+ return this.updateBeamIssueAttachmentReference(
+ workspaceSlug,
+ projectId,
+ issueId,
+ attachment.id,
+ {
+ ...nextBeamViewer,
+ assetId,
+ previewAvailable: nextBeamViewer.previewAvailable,
+ uploadedBy,
+ version: nextVersion,
+ versions: nextVersions,
+ viewerUrl: nextBeamViewer.viewerUrl ?? null,
+ },
+ {
+ name: file.name,
+ size: file.size,
+ type: getBeamModelMimeType(file),
+ version: nextVersion,
+ }
+ );
+ }
+
+ async deleteBeamIssueAttachmentVersion(
+ workspaceSlug: string,
+ projectId: string,
+ issueId: string,
+ attachment: TIssueAttachment,
+ versionToDelete: TBeamModelVersionRecord
+ ): Promise {
+ const beamViewer = getBeamViewerAttachment(attachment);
+ if (!beamViewer) {
+ throw new Error("Для этого вложения нет BIM-метаданных.");
+ }
+
+ const currentVersion = getBeamViewerVersionNumber(beamViewer, attachment);
+ const isCurrentVersion = versionToDelete.versionId
+ ? beamViewer.versionId === versionToDelete.versionId
+ : currentVersion === versionToDelete.version;
+ if (isCurrentVersion) {
+ throw new Error("Текущую версию нельзя удалить. Сначала переключите модель на другую версию.");
+ }
+
+ const versions = getBeamModelVersionRecords(beamViewer, attachment);
+ const nextVersions = versions.filter((version) =>
+ versionToDelete.versionId
+ ? version.versionId !== versionToDelete.versionId
+ : version.version !== versionToDelete.version
+ );
+
+ if (nextVersions.length === versions.length) {
+ throw new Error("Версия не найдена.");
+ }
+
+ await deleteBeamModelVersion(versionToDelete);
+
+ return this.updateBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, attachment.id, {
+ ...beamViewer,
+ versions: nextVersions,
+ });
+ }
+
async uploadIssueAttachment(
workspaceSlug: string,
projectId: string,
@@ -167,8 +276,16 @@ export class IssueAttachmentService extends APIService {
workspaceSlug: string,
projectId: string,
issueId: string,
- assetId: string
+ assetId: string,
+ attachment?: TIssueAttachment
): Promise {
+ const beamViewer = getBeamViewerAttachment(attachment);
+ if (beamViewer) {
+ await deleteBeamModelAsset(beamViewer).catch((error) => {
+ console.warn("BIM storage cleanup failed; deleting OPS attachment reference anyway.", error);
+ });
+ }
+
return this.delete(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${assetId}/`
)
diff --git a/plane-src/apps/web/core/store/issue/issue-details/attachment.store.ts b/plane-src/apps/web/core/store/issue/issue-details/attachment.store.ts
index e455144..46d785c 100644
--- a/plane-src/apps/web/core/store/issue/issue-details/attachment.store.ts
+++ b/plane-src/apps/web/core/store/issue/issue-details/attachment.store.ts
@@ -27,7 +27,7 @@ const getAttachmentTimestamp = (attachment: TIssueAttachment) =>
new Date(attachment.created_at ?? attachment.updated_at ?? 0).getTime();
const sortAttachmentIdsByNewest = (attachmentIds: string[], attachmentMap: TIssueAttachmentMap): string[] =>
- [...attachmentIds].sort((firstId, secondId) => {
+ attachmentIds.toSorted((firstId, secondId) => {
const firstAttachment = attachmentMap[firstId];
const secondAttachment = attachmentMap[secondId];
if (!firstAttachment || !secondAttachment) return 0;
@@ -232,11 +232,13 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
removeAttachment = async (workspaceSlug: string, projectId: string, issueId: string, attachmentId: string) => {
const issueAttachmentService = await this.getIssueAttachmentService();
+ const attachment = this.attachmentMap[attachmentId];
const response = await issueAttachmentService.deleteIssueAttachment(
workspaceSlug,
projectId,
issueId,
- attachmentId
+ attachmentId,
+ attachment
);
runInAction(() => {
diff --git a/plane-src/apps/web/helpers/beam-viewer.ts b/plane-src/apps/web/helpers/beam-viewer.ts
index f2ad01c..259b5a2 100644
--- a/plane-src/apps/web/helpers/beam-viewer.ts
+++ b/plane-src/apps/web/helpers/beam-viewer.ts
@@ -1,30 +1,57 @@
import type { AxiosProgressEvent } from "axios";
import type { TIssueAttachment } from "@plane/types";
-export type TBeamViewerAttachment = {
- backend: "beam-viewer-local" | "beam-viewer";
- conversion?: {
- artifactSrc?: string;
- artifactType?: string;
- componentTreeRequired: boolean;
- message?: string;
- metadataSrc?: string;
- sourceFormat: string;
- sourceSrc?: string;
- status: "conversion_required" | "processing" | "ready" | "failed";
- targetFormat: "glb" | "xkt";
- updatedAt?: string;
- };
+export type TBeamViewerConversion = {
+ artifactSrc?: string;
+ artifactType?: string;
+ componentTreeRequired: boolean;
+ message?: string;
+ metadataSrc?: string;
+ size?: number;
+ sourceFormat: string;
+ sourceSrc?: string;
+ status: "conversion_required" | "processing" | "ready" | "failed";
+ targetFormat: "glb" | "xkt";
+ updatedAt?: string;
+};
+
+export type TBeamModelVersionRecord = {
+ assetId?: string;
+ conversion?: TBeamViewerConversion;
downloadUrl: string;
originalFilename: string;
previewAvailable: boolean;
+ sha256?: string;
+ size: number;
src: string;
type: string;
+ uploadedBy?: string;
uploadedAt: string;
- viewerUrl?: string;
+ version: number;
+ versionId?: string;
+ viewerUrl?: string | null;
+};
+
+export type TBeamViewerAttachment = {
+ assetId?: string;
+ backend: "beam-viewer-local" | "beam-viewer";
+ conversion?: TBeamViewerConversion;
+ downloadUrl: string;
+ originalFilename: string;
+ previewAvailable: boolean;
+ sha256?: string;
+ src: string;
+ type: string;
+ uploadedBy?: string;
+ uploadedAt: string;
+ version?: number;
+ versionId?: string;
+ versions?: TBeamModelVersionRecord[];
+ viewerUrl?: string | null;
};
export type TBeamConversionStatus = {
+ assetId?: string;
artifactSrc?: string;
artifactType?: string;
artifactUrl?: string;
@@ -34,11 +61,15 @@ export type TBeamConversionStatus = {
message?: string;
metadataSrc?: string;
metadataUrl?: string;
+ sha256?: string;
sourceFormat?: string;
sourceSrc?: string;
+ size?: number;
status: "conversion_required" | "processing" | "ready" | "failed";
targetFormat?: "glb" | "xkt";
updatedAt?: string;
+ version?: number;
+ versionId?: string;
};
export const BEAM_VIEWER_OPEN_EVENT = "nodedc:beam-viewer-open";
@@ -160,13 +191,120 @@ export const getBeamViewerAttachment = (attachment: TIssueAttachment | undefined
return beamViewer;
};
+const getAttachmentVersionNumber = (attachment: TIssueAttachment | undefined): number => {
+ const rawVersion = attachment?.attributes.version;
+ if (typeof rawVersion === "number" && Number.isFinite(rawVersion)) return rawVersion;
+ if (typeof rawVersion === "string") {
+ const parsed = Number.parseInt(rawVersion.replace(/^v/i, ""), 10);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return 1;
+};
+
+export const getBeamViewerVersionNumber = (
+ beamViewer: TBeamViewerAttachment | null | undefined,
+ attachment?: TIssueAttachment
+): number => {
+ if (typeof beamViewer?.version === "number" && Number.isFinite(beamViewer.version)) return beamViewer.version;
+ return getAttachmentVersionNumber(attachment);
+};
+
+export const createBeamModelVersionRecord = (
+ beamViewer: TBeamViewerAttachment,
+ options: {
+ attachment?: TIssueAttachment;
+ size?: number;
+ uploadedBy?: string;
+ } = {}
+): TBeamModelVersionRecord => ({
+ assetId: beamViewer.assetId,
+ conversion: beamViewer.conversion,
+ downloadUrl: beamViewer.downloadUrl,
+ originalFilename: beamViewer.originalFilename,
+ previewAvailable: beamViewer.previewAvailable,
+ sha256: beamViewer.sha256,
+ size: options.size ?? beamViewer.conversion?.size ?? Number(options.attachment?.attributes.size ?? 0),
+ src: beamViewer.src,
+ type: beamViewer.type,
+ uploadedBy: beamViewer.uploadedBy || options.uploadedBy || options.attachment?.created_by,
+ uploadedAt: beamViewer.uploadedAt,
+ version: getBeamViewerVersionNumber(beamViewer, options.attachment),
+ versionId: beamViewer.versionId,
+ viewerUrl: beamViewer.viewerUrl,
+});
+
+const isSameBeamVersionRecord = (first: TBeamModelVersionRecord, second: TBeamModelVersionRecord): boolean => {
+ if (first.versionId && second.versionId) return first.versionId === second.versionId;
+ return first.version === second.version;
+};
+
+export const getBeamModelVersionRecords = (
+ beamViewer: TBeamViewerAttachment | null | undefined,
+ attachment?: TIssueAttachment
+): TBeamModelVersionRecord[] => {
+ if (!beamViewer) return [];
+ const currentRecord = createBeamModelVersionRecord(beamViewer, { attachment });
+ const storedVersions = Array.isArray(beamViewer.versions) ? beamViewer.versions : [];
+ const versions = storedVersions.some((version) => isSameBeamVersionRecord(version, currentRecord))
+ ? storedVersions
+ : [...storedVersions, currentRecord];
+
+ return versions
+ .filter((version) => typeof version?.version === "number" && Number.isFinite(version.version))
+ .toSorted((first, second) => first.version - second.version);
+};
+
+export const mergeBeamModelVersionRecords = (
+ previousBeamViewer: TBeamViewerAttachment,
+ nextBeamViewer: TBeamViewerAttachment,
+ attachment?: TIssueAttachment
+): TBeamModelVersionRecord[] => {
+ const previousVersions = getBeamModelVersionRecords(previousBeamViewer, attachment);
+ const nextRecord =
+ nextBeamViewer.versions?.find((version) =>
+ nextBeamViewer.versionId
+ ? version.versionId === nextBeamViewer.versionId
+ : version.version === nextBeamViewer.version
+ ) ||
+ nextBeamViewer.versions?.[0] ||
+ createBeamModelVersionRecord(nextBeamViewer);
+ return [...previousVersions.filter((version) => !isSameBeamVersionRecord(version, nextRecord)), nextRecord].toSorted(
+ (first, second) => first.version - second.version
+ );
+};
+
+export const syncCurrentBeamVersionRecord = (
+ beamViewer: TBeamViewerAttachment,
+ attachment?: TIssueAttachment
+): TBeamViewerAttachment => {
+ const currentRecord = createBeamModelVersionRecord(beamViewer, { attachment });
+ const versions = getBeamModelVersionRecords(beamViewer, attachment).map((version) => {
+ if (!isSameBeamVersionRecord(version, currentRecord)) return version;
+ return Object.assign({}, version, currentRecord);
+ });
+ return { ...beamViewer, versions };
+};
+
+export const getBeamVersionViewerUrl = (version: TBeamModelVersionRecord): string | undefined => {
+ if (version.viewerUrl) return version.viewerUrl;
+ if (version.conversion?.status !== "ready" || !version.conversion.artifactSrc) return undefined;
+ const artifactType = version.conversion.artifactType || version.conversion.targetFormat || "gltf";
+ return buildBeamViewerUrl({
+ name: version.originalFilename,
+ settingsSrc: version.conversion.sourceSrc || version.src,
+ src:
+ toBeamAbsoluteUrl(version.conversion.artifactSrc, version.conversion.updatedAt) || version.conversion.artifactSrc,
+ type: artifactType,
+ });
+};
+
export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachment): Promise => {
const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`);
statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
const response = await fetch(statusUrl.toString());
if (!response.ok) {
- throw new Error(`Beam conversion status failed: HTTP ${response.status}`);
+ throw new Error(`BIM conversion status failed: HTTP ${response.status}`);
}
const payload = (await response.json()) as TBeamConversionStatus;
@@ -180,16 +318,19 @@ export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachmen
export const uploadBeamModelFile = (
file: File,
options: {
+ assetId?: string;
issueId?: string;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
projectId?: string;
+ uploadedBy?: string;
+ version?: number;
workspaceSlug?: string;
} = {}
): Promise =>
new Promise((resolve, reject) => {
const type = getBeamModelTypeFromName(file.name);
if (!type) {
- reject(new Error("Формат модели не поддерживается Beam Viewer"));
+ reject(new Error("Формат модели не поддерживается BIM Viewer"));
return;
}
const directViewerType = getBeamDirectModelTypeFromName(file.name);
@@ -201,64 +342,81 @@ export const uploadBeamModelFile = (
"projectId",
getUploadGroupId([options.workspaceSlug, options.projectId, options.issueId])
);
+ if (options.assetId) uploadUrl.searchParams.set("assetId", options.assetId);
+ if (options.version) uploadUrl.searchParams.set("version", String(options.version));
const xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl.toString());
xhr.setRequestHeader("Content-Type", "application/octet-stream");
- xhr.upload.onprogress = (event) => {
+ xhr.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable || !options.onUploadProgress) return;
options.onUploadProgress({
loaded: event.loaded,
progress: event.loaded / event.total,
total: event.total,
} as AxiosProgressEvent);
- };
- xhr.onerror = () => reject(new Error("Не удалось загрузить модель в Beam Viewer"));
- xhr.onload = () => {
+ });
+ xhr.addEventListener("error", () => reject(new Error("Не удалось загрузить модель в BIM Viewer")));
+ xhr.addEventListener("load", () => {
if (xhr.status < 200 || xhr.status >= 300) {
- reject(new Error(xhr.responseText || `Beam Viewer upload failed: HTTP ${xhr.status}`));
+ reject(new Error(xhr.responseText || `BIM Viewer upload failed: HTTP ${xhr.status}`));
return;
}
try {
const response = JSON.parse(xhr.responseText) as {
+ assetId?: string;
conversion?: TBeamViewerAttachment["conversion"];
+ originalFilename?: string;
+ sha256?: string;
src?: string;
+ uploadedAt?: string;
+ version?: number;
+ versionId?: string;
};
if (!response.src) {
- reject(new Error("Beam Viewer не вернул путь к загруженной модели"));
+ reject(new Error("BIM Viewer не вернул путь к загруженной модели"));
return;
}
const downloadUrl = new URL(response.src.startsWith("/") ? response.src : `/${response.src}`, `${apiBaseUrl}/`);
const baseAttachment: TBeamViewerAttachment = {
+ assetId: response.assetId ?? options.assetId,
backend: "beam-viewer-local",
downloadUrl: downloadUrl.toString(),
- originalFilename: file.name,
+ originalFilename: response.originalFilename || file.name,
previewAvailable: !!directViewerType,
+ sha256: response.sha256,
src: downloadUrl.toString(),
type,
- uploadedAt: new Date().toISOString(),
+ uploadedBy: options.uploadedBy,
+ uploadedAt: response.uploadedAt || new Date().toISOString(),
+ version: response.version ?? options.version ?? 1,
+ versionId: response.versionId,
};
if (!directViewerType) {
- resolve({
+ const nextAttachment: TBeamViewerAttachment = {
...baseAttachment,
conversion: {
- ...(response.conversion ?? {}),
+ ...response.conversion,
componentTreeRequired: true,
message:
response.conversion?.message ??
- "Оригинальный STEP загружен. Preview появится после подготовки GLB и дерева компонентов.",
+ "Оригинальный STEP загружен. Просмотр появится после подготовки модели и дерева компонентов.",
sourceFormat: type,
status: response.conversion?.status ?? "conversion_required",
- targetFormat: response.conversion?.targetFormat ?? "glb",
+ targetFormat: response.conversion?.targetFormat ?? "xkt",
},
+ };
+ resolve({
+ ...nextAttachment,
+ versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
});
return;
}
- resolve({
+ const nextAttachment: TBeamViewerAttachment = {
...baseAttachment,
viewerUrl: buildBeamViewerUrl({
name: file.name,
@@ -266,10 +424,38 @@ export const uploadBeamModelFile = (
src: downloadUrl.toString(),
type: directViewerType,
}),
+ };
+
+ resolve({
+ ...nextAttachment,
+ versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
});
} catch (_error) {
- reject(new Error("Beam Viewer вернул некорректный ответ"));
+ reject(new Error("BIM Viewer вернул некорректный ответ"));
}
- };
+ });
xhr.send(file);
});
+
+const deleteBeamStorageResource = async (
+ endpoint: "/api/uploads/asset" | "/api/uploads/version",
+ payload: unknown
+): Promise => {
+ const response = await fetch(new URL(endpoint, `${getBeamApiBaseUrl()}/`).toString(), {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ throw new Error((await response.text()) || `BIM storage delete failed: HTTP ${response.status}`);
+ }
+};
+
+export const deleteBeamModelAsset = async (beamViewer: TBeamViewerAttachment): Promise =>
+ deleteBeamStorageResource("/api/uploads/asset", beamViewer);
+
+export const deleteBeamModelVersion = async (version: TBeamModelVersionRecord): Promise =>
+ deleteBeamStorageResource("/api/uploads/version", version);
diff --git a/plane-src/packages/types/src/issues/issue_attachment.ts b/plane-src/packages/types/src/issues/issue_attachment.ts
index fd02fb6..9966448 100644
--- a/plane-src/packages/types/src/issues/issue_attachment.ts
+++ b/plane-src/packages/types/src/issues/issue_attachment.ts
@@ -6,22 +6,53 @@
import type { TFileSignedURLResponse } from "../file";
-export type TBeamViewerAttachment = {
- backend: "beam-viewer-local" | "beam-viewer";
- conversion?: {
- componentTreeRequired: boolean;
- message?: string;
- sourceFormat: string;
- status: "conversion_required" | "processing" | "ready" | "failed";
- targetFormat: "glb" | "xkt";
- };
+export type TBeamViewerConversion = {
+ artifactSrc?: string;
+ artifactType?: string;
+ componentTreeRequired: boolean;
+ message?: string;
+ metadataSrc?: string;
+ size?: number;
+ sourceFormat: string;
+ sourceSrc?: string;
+ status: "conversion_required" | "processing" | "ready" | "failed";
+ targetFormat: "glb" | "xkt";
+ updatedAt?: string;
+};
+
+export type TBeamModelVersionRecord = {
+ assetId?: string;
+ conversion?: TBeamViewerConversion;
downloadUrl: string;
originalFilename: string;
previewAvailable: boolean;
+ sha256?: string;
+ size: number;
src: string;
type: string;
+ uploadedBy?: string;
uploadedAt: string;
- viewerUrl?: string;
+ version: number;
+ versionId?: string;
+ viewerUrl?: string | null;
+};
+
+export type TBeamViewerAttachment = {
+ assetId?: string;
+ backend: "beam-viewer-local" | "beam-viewer";
+ conversion?: TBeamViewerConversion;
+ downloadUrl: string;
+ originalFilename: string;
+ previewAvailable: boolean;
+ sha256?: string;
+ src: string;
+ type: string;
+ uploadedBy?: string;
+ uploadedAt: string;
+ version?: number;
+ versionId?: string;
+ versions?: TBeamModelVersionRecord[];
+ viewerUrl?: string | null;
};
export type TIssueAttachment = {