Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9308539bf | ||
|
|
ca21a8f5bc | ||
|
|
9f641a5b5f | ||
|
|
12b595b1ed | ||
|
|
d595e43fb8 | ||
|
|
ad04910209 | ||
|
|
24481ea432 | ||
|
|
70559910b1 | ||
|
|
43e5f570e5 |
@@ -1,4 +1,11 @@
|
||||
services:
|
||||
api:
|
||||
image: nodedc/plane-backend:bim-gateway-test
|
||||
environment:
|
||||
PLANE_NODEDC_BIM_ACCESS_TOKEN: local-tasker-bim-gateway-test
|
||||
PLANE_NODEDC_BIM_EMBED_SECRET: local-tasker-bim-gateway-test
|
||||
PLANE_NODEDC_BIM_INTERNAL_URL: http://host.docker.internal:8080
|
||||
PLANE_NODEDC_BIM_PUBLIC_URL: http://localhost:8080
|
||||
web:
|
||||
volumes:
|
||||
- ./.local-web-root:/usr/share/nginx/html:ro
|
||||
|
||||
@@ -10,6 +10,7 @@ from plane.app.views import (
|
||||
AIWorkspaceExecutorEventsEndpoint,
|
||||
AIWorkspaceExecutorListEndpoint,
|
||||
AIWorkspaceExecutorSelectEndpoint,
|
||||
AIWorkspaceExecutorSetupCommandEndpoint,
|
||||
AIWorkspaceExecutorWindowsAgentEndpoint,
|
||||
AIWorkspaceSettingsEndpoint,
|
||||
AIWorkspaceThreadDispatchEndpoint,
|
||||
@@ -55,6 +56,11 @@ urlpatterns = [
|
||||
AIWorkspaceExecutorWindowsAgentEndpoint.as_view(),
|
||||
name="ai-workspace-executor-windows-agent",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/ai-workspace/executors/<uuid:executor_id>/agent/setup-command/",
|
||||
AIWorkspaceExecutorSetupCommandEndpoint.as_view(),
|
||||
name="ai-workspace-executor-setup-command",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/ai-workspace/threads/",
|
||||
AIWorkspaceThreadListEndpoint.as_view(),
|
||||
|
||||
@@ -26,6 +26,11 @@ from plane.app.views import (
|
||||
IssuePaginatedViewSet,
|
||||
IssueDetailEndpoint,
|
||||
IssueAttachmentV2Endpoint,
|
||||
IssueBimAttachmentStatusEndpoint,
|
||||
IssueBimAttachmentUploadEndpoint,
|
||||
IssueBimAttachmentVersionDetailEndpoint,
|
||||
IssueBimAttachmentVersionsEndpoint,
|
||||
IssueBimAttachmentViewerEndpoint,
|
||||
IssueBulkUpdateDateEndpoint,
|
||||
IssueVersionEndpoint,
|
||||
WorkItemDescriptionVersionEndpoint,
|
||||
@@ -144,6 +149,31 @@ urlpatterns = [
|
||||
IssueAttachmentV2Endpoint.as_view(),
|
||||
name="project-issue-attachments",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/bim-upload/",
|
||||
IssueBimAttachmentUploadEndpoint.as_view(),
|
||||
name="project-issue-bim-attachment-upload",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-status/",
|
||||
IssueBimAttachmentStatusEndpoint.as_view(),
|
||||
name="project-issue-bim-attachment-status",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-versions/",
|
||||
IssueBimAttachmentVersionsEndpoint.as_view(),
|
||||
name="project-issue-bim-attachment-versions",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-versions/<str:version_id>/",
|
||||
IssueBimAttachmentVersionDetailEndpoint.as_view(),
|
||||
name="project-issue-bim-attachment-version-detail",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-viewer/",
|
||||
IssueBimAttachmentViewerEndpoint.as_view(),
|
||||
name="project-issue-bim-attachment-viewer",
|
||||
),
|
||||
## End Issues
|
||||
## Issue Activity
|
||||
path(
|
||||
|
||||
@@ -145,6 +145,14 @@ from .issue.attachment import (
|
||||
IssueAttachmentV2Endpoint,
|
||||
)
|
||||
|
||||
from .issue.bim_attachment import (
|
||||
IssueBimAttachmentStatusEndpoint,
|
||||
IssueBimAttachmentUploadEndpoint,
|
||||
IssueBimAttachmentVersionDetailEndpoint,
|
||||
IssueBimAttachmentVersionsEndpoint,
|
||||
IssueBimAttachmentViewerEndpoint,
|
||||
)
|
||||
|
||||
from .issue.comment import IssueCommentViewSet, CommentReactionViewSet
|
||||
|
||||
from .issue.label import LabelViewSet, BulkCreateIssueLabelsEndpoint
|
||||
@@ -180,6 +188,7 @@ from .ai_workspace import (
|
||||
AIWorkspaceExecutorEventsEndpoint,
|
||||
AIWorkspaceExecutorListEndpoint,
|
||||
AIWorkspaceExecutorSelectEndpoint,
|
||||
AIWorkspaceExecutorSetupCommandEndpoint,
|
||||
AIWorkspaceExecutorWindowsAgentEndpoint,
|
||||
AIWorkspaceSettingsEndpoint,
|
||||
AIWorkspaceThreadDispatchEndpoint,
|
||||
|
||||
@@ -505,6 +505,51 @@ def ops_settings_payload(raw_payload, slug):
|
||||
return payload
|
||||
|
||||
|
||||
def sync_ops_installer_context(request, slug, source):
|
||||
request_data = request.data if isinstance(request.data, dict) else {}
|
||||
ops_project_id = str(
|
||||
request_data.get("opsProjectId")
|
||||
or request_data.get("ops_project_id")
|
||||
or request.query_params.get("ops_project_id")
|
||||
or ""
|
||||
).strip()
|
||||
if not ops_project_id:
|
||||
return None
|
||||
|
||||
ops_workspace_slug = str(
|
||||
request_data.get("opsWorkspaceSlug")
|
||||
or request_data.get("ops_workspace_slug")
|
||||
or request.query_params.get("ops_workspace_slug")
|
||||
or slug
|
||||
or ""
|
||||
).strip()
|
||||
active_context = {
|
||||
"surface": "ops",
|
||||
"workspaceSlug": ops_workspace_slug,
|
||||
"opsWorkspaceSlug": ops_workspace_slug,
|
||||
"opsRouteWorkspaceSlug": slug,
|
||||
"opsProjectId": ops_project_id,
|
||||
}
|
||||
sync_payload = ops_settings_payload(
|
||||
{
|
||||
"activeContext": active_context,
|
||||
"enabledToolPacks": ["ops", "engine"],
|
||||
"metadata": {
|
||||
"source": source,
|
||||
"updatedAt": timezone.now().isoformat(),
|
||||
},
|
||||
},
|
||||
slug,
|
||||
)
|
||||
sync_payload, ops_grant_error = attach_ops_grant_for_active_context(request, slug, sync_payload)
|
||||
if ops_grant_error is not None:
|
||||
return ops_grant_error
|
||||
sync_response = ai_workspace_request(request, "PATCH", "/settings", sync_payload)
|
||||
if sync_response.status_code >= 400:
|
||||
return sync_response
|
||||
return None
|
||||
|
||||
|
||||
class AIWorkspaceSettingsEndpoint(BaseAPIView):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request, slug):
|
||||
@@ -584,33 +629,9 @@ class AIWorkspaceExecutorWindowsAgentEndpoint(BaseAPIView):
|
||||
params = {}
|
||||
if request.query_params.get("port"):
|
||||
params["port"] = request.query_params.get("port")
|
||||
ops_project_id = (request.query_params.get("ops_project_id") or "").strip()
|
||||
if ops_project_id:
|
||||
ops_workspace_slug = (request.query_params.get("ops_workspace_slug") or slug or "").strip()
|
||||
active_context = {
|
||||
"surface": "ops",
|
||||
"workspaceSlug": ops_workspace_slug,
|
||||
"opsWorkspaceSlug": ops_workspace_slug,
|
||||
"opsRouteWorkspaceSlug": slug,
|
||||
"opsProjectId": ops_project_id,
|
||||
}
|
||||
sync_payload = ops_settings_payload(
|
||||
{
|
||||
"activeContext": active_context,
|
||||
"enabledToolPacks": ["ops", "engine"],
|
||||
"metadata": {
|
||||
"source": "ops-ai-workspace-installer",
|
||||
"updatedAt": timezone.now().isoformat(),
|
||||
},
|
||||
},
|
||||
slug,
|
||||
)
|
||||
sync_payload, ops_grant_error = attach_ops_grant_for_active_context(request, slug, sync_payload)
|
||||
if ops_grant_error is not None:
|
||||
return ops_grant_error
|
||||
sync_response = ai_workspace_request(request, "PATCH", "/settings", sync_payload)
|
||||
if sync_response.status_code >= 400:
|
||||
return sync_response
|
||||
sync_error = sync_ops_installer_context(request, slug, "ops-ai-workspace-installer")
|
||||
if sync_error is not None:
|
||||
return sync_error
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
@@ -643,6 +664,27 @@ class AIWorkspaceExecutorWindowsAgentEndpoint(BaseAPIView):
|
||||
return output
|
||||
|
||||
|
||||
class AIWorkspaceExecutorSetupCommandEndpoint(BaseAPIView):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug, executor_id):
|
||||
sync_error = sync_ops_installer_context(request, slug, "ops-ai-workspace-npm-setup")
|
||||
if sync_error is not None:
|
||||
return sync_error
|
||||
|
||||
request_data = request.data if isinstance(request.data, dict) else {}
|
||||
payload = {}
|
||||
port = request_data.get("port") or request.query_params.get("port")
|
||||
if port:
|
||||
payload["port"] = port
|
||||
return ai_workspace_request(
|
||||
request,
|
||||
"POST",
|
||||
f"/executors/{executor_id}/agent/setup-command",
|
||||
payload,
|
||||
timeout_override=15,
|
||||
)
|
||||
|
||||
|
||||
class AIWorkspaceThreadListEndpoint(BaseAPIView):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request, slug):
|
||||
|
||||
@@ -27,6 +27,7 @@ from plane.utils.host import base_host
|
||||
from plane.utils.upload_limits import get_project_storage_quota_response, resolve_workspace_upload_size_limit
|
||||
from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response
|
||||
from plane.utils.file_dedup import finalize_uploaded_file_asset, release_file_asset_blob, UploadedObjectMissing
|
||||
from plane.utils.nodedc_bim_gateway import BimGatewayError, bim_gateway_request, get_bim_registry_identity
|
||||
|
||||
|
||||
class IssueAttachmentEndpoint(BaseAPIView):
|
||||
@@ -109,12 +110,6 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||
name = request.data.get("name")
|
||||
type = request.data.get("type", False)
|
||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
||||
beam_viewer = request.data.get("beamViewer")
|
||||
is_beam_viewer_reference = (
|
||||
isinstance(beam_viewer, dict)
|
||||
and beam_viewer.get("src")
|
||||
and beam_viewer.get("downloadUrl")
|
||||
)
|
||||
|
||||
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
|
||||
return Response(
|
||||
@@ -126,45 +121,6 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
project = Project.objects.get(id=project_id, workspace=workspace)
|
||||
|
||||
if is_beam_viewer_reference:
|
||||
asset = FileAsset.objects.create(
|
||||
attributes={
|
||||
"name": name,
|
||||
"type": type,
|
||||
"size": size,
|
||||
"beamViewer": beam_viewer,
|
||||
},
|
||||
asset=f"{workspace.id}/beam-viewer/{uuid.uuid4().hex}-{name}",
|
||||
size=0,
|
||||
workspace_id=workspace.id,
|
||||
created_by=request.user,
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
is_uploaded=True,
|
||||
external_source="beam-viewer",
|
||||
)
|
||||
serializer = IssueAttachmentSerializer(asset)
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.created",
|
||||
requested_data=None,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("issue_id", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"asset_id": str(asset.id),
|
||||
"attachment": serializer.data,
|
||||
"asset_url": asset.asset_url,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
# asset key
|
||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||
|
||||
@@ -205,7 +161,32 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN], creator=True, model=FileAsset)
|
||||
def delete(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
||||
issue_attachment = FileAsset.objects.select_related("created_by").get(
|
||||
pk=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
)
|
||||
attributes = issue_attachment.attributes if isinstance(issue_attachment.attributes, dict) else {}
|
||||
beam_viewer = attributes.get("beamViewer")
|
||||
if issue_attachment.external_source == "beam-viewer" and isinstance(beam_viewer, dict):
|
||||
try:
|
||||
bim_gateway_request(
|
||||
"DELETE",
|
||||
"/api/uploads/asset",
|
||||
identity=get_bim_registry_identity(issue_attachment, beam_viewer),
|
||||
json_payload=beam_viewer,
|
||||
)
|
||||
except BimGatewayError as error:
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": error.code,
|
||||
"message": error.message,
|
||||
**({"upstream_status": error.upstream_status} if error.upstream_status else {}),
|
||||
},
|
||||
status=error.status_code,
|
||||
)
|
||||
if not issue_attachment.is_uploaded:
|
||||
release_file_asset_blob(issue_attachment, request=request, delete_untracked_object=True)
|
||||
issue_attachment.is_deleted = True
|
||||
@@ -256,29 +237,23 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def patch(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
||||
issue_attachment = FileAsset.objects.get(
|
||||
pk=pk,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
beam_viewer = request.data.get("beamViewer")
|
||||
if isinstance(beam_viewer, dict):
|
||||
attributes = issue_attachment.attributes or {}
|
||||
existing_beam_viewer = attributes.get("beamViewer")
|
||||
if not isinstance(existing_beam_viewer, dict):
|
||||
return Response(
|
||||
{"error": "The attachment is not a BIM Viewer reference.", "status": False},
|
||||
{
|
||||
"error": "BIM attachments are managed by the Ops BIM gateway.",
|
||||
"status": False,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
attributes["beamViewer"] = {
|
||||
**existing_beam_viewer,
|
||||
**beam_viewer,
|
||||
}
|
||||
for attribute_key in ("name", "size", "type", "version"):
|
||||
if attribute_key in request.data:
|
||||
attributes[attribute_key] = request.data.get(attribute_key)
|
||||
issue_attachment.attributes = attributes
|
||||
issue_attachment.is_uploaded = True
|
||||
issue_attachment.save(update_fields=["attributes", "is_uploaded", "updated_at"])
|
||||
return Response(IssueAttachmentSerializer(issue_attachment).data, status=status.HTTP_200_OK)
|
||||
|
||||
serializer = IssueAttachmentSerializer(issue_attachment)
|
||||
if not attachment_object_exists(issue_attachment):
|
||||
return Response(
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.app.serializers import IssueAttachmentSerializer
|
||||
from plane.app.views import BaseAPIView
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import FileAsset, Project, Workspace
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.nodedc_bim_gateway import (
|
||||
BimGatewayError,
|
||||
bim_gateway_request,
|
||||
build_bim_attachment,
|
||||
build_bim_embed_url,
|
||||
find_bim_version,
|
||||
get_bim_model_mime_type,
|
||||
get_bim_model_type,
|
||||
get_bim_registry_identity,
|
||||
make_bim_registry_owner_id,
|
||||
merge_bim_versions,
|
||||
resolve_bim_viewer_model,
|
||||
to_bim_public_url,
|
||||
to_bim_relative_asset_url,
|
||||
)
|
||||
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _error_response(error):
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": error.code,
|
||||
"message": error.message,
|
||||
**({"upstream_status": error.upstream_status} if error.upstream_status else {}),
|
||||
},
|
||||
status=error.status_code,
|
||||
)
|
||||
|
||||
|
||||
def _get_attachment(slug, project_id, issue_id, attachment_id):
|
||||
return (
|
||||
FileAsset.objects.select_related("created_by")
|
||||
.filter(
|
||||
id=attachment_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
is_uploaded=True,
|
||||
external_source="beam-viewer",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _get_beam_viewer(attachment):
|
||||
attributes = attachment.attributes if isinstance(attachment.attributes, dict) else {}
|
||||
beam_viewer = attributes.get("beamViewer")
|
||||
return beam_viewer if isinstance(beam_viewer, dict) else None
|
||||
|
||||
|
||||
def _upload_group_id(slug, project_id, issue_id):
|
||||
value = "{}_{}_{}".format(slug, project_id, issue_id)
|
||||
return re.sub(r"[^a-zA-Z0-9_-]", "_", value) or "tasker"
|
||||
|
||||
|
||||
def _validate_upload(workspace, uploaded_file):
|
||||
if uploaded_file is None:
|
||||
return Response(
|
||||
{"ok": False, "error": "bim_file_required", "message": "A BIM model file is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if not get_bim_model_type(uploaded_file.name):
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "bim_format_not_supported",
|
||||
"message": "This BIM model format is not supported.",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
size_limit = resolve_workspace_upload_size_limit(workspace, uploaded_file.size)
|
||||
if uploaded_file.size > size_limit:
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "bim_file_too_large",
|
||||
"message": "The BIM model exceeds the workspace upload limit.",
|
||||
"limit": size_limit,
|
||||
},
|
||||
status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _activity_created(request, serializer, issue_id, project_id):
|
||||
issue_activity.delay(
|
||||
type="attachment.activity.created",
|
||||
requested_data=None,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
|
||||
def _delete_uploaded_asset(beam_viewer, identity):
|
||||
try:
|
||||
bim_gateway_request(
|
||||
"DELETE",
|
||||
"/api/uploads/asset",
|
||||
identity=identity,
|
||||
json_payload=beam_viewer,
|
||||
)
|
||||
except BimGatewayError:
|
||||
logger.exception("Failed to clean up BIM asset after an OPS attachment failure")
|
||||
|
||||
|
||||
def _delete_uploaded_version(version, identity):
|
||||
try:
|
||||
bim_gateway_request(
|
||||
"DELETE",
|
||||
"/api/uploads/version",
|
||||
identity=identity,
|
||||
json_payload=version,
|
||||
)
|
||||
except BimGatewayError:
|
||||
logger.exception("Failed to clean up BIM version after an OPS attachment failure")
|
||||
|
||||
|
||||
def _public_status_payload(payload):
|
||||
result = dict(payload)
|
||||
result["artifactUrl"] = to_bim_public_url(payload.get("artifactSrc"))
|
||||
result["metadataUrl"] = to_bim_public_url(payload.get("metadataSrc"))
|
||||
return result
|
||||
|
||||
|
||||
def _public_version(version):
|
||||
result = dict(version)
|
||||
for key in ("src", "sourceSrc", "downloadUrl"):
|
||||
if result.get(key):
|
||||
result[key] = to_bim_public_url(result[key])
|
||||
result["viewerUrl"] = None
|
||||
return result
|
||||
|
||||
|
||||
class IssueBimAttachmentUploadEndpoint(BaseAPIView):
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def post(self, request, slug, project_id, issue_id):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
Project.objects.get(id=project_id, workspace=workspace)
|
||||
uploaded_file = request.FILES.get("file")
|
||||
validation_error = _validate_upload(workspace, uploaded_file)
|
||||
if validation_error is not None:
|
||||
return validation_error
|
||||
|
||||
attachment_id = uuid.uuid4()
|
||||
registry_owner_id = make_bim_registry_owner_id(attachment_id)
|
||||
identity = {"id": registry_owner_id, "email": request.user.email or ""}
|
||||
uploaded_file.seek(0)
|
||||
try:
|
||||
upload_payload = bim_gateway_request(
|
||||
"POST",
|
||||
"/api/uploads",
|
||||
identity=identity,
|
||||
params={
|
||||
"filename": uploaded_file.name,
|
||||
"projectId": _upload_group_id(slug, project_id, issue_id),
|
||||
"assetId": str(attachment_id),
|
||||
"version": "1",
|
||||
},
|
||||
data=uploaded_file.file,
|
||||
content_length=uploaded_file.size,
|
||||
)
|
||||
beam_viewer = build_bim_attachment(
|
||||
upload_payload,
|
||||
filename=uploaded_file.name,
|
||||
size=uploaded_file.size,
|
||||
uploaded_by=request.user.id,
|
||||
registry_owner_id=registry_owner_id,
|
||||
)
|
||||
except BimGatewayError as error:
|
||||
return _error_response(error)
|
||||
|
||||
try:
|
||||
asset = FileAsset.objects.create(
|
||||
id=attachment_id,
|
||||
attributes={
|
||||
"name": uploaded_file.name,
|
||||
"type": get_bim_model_mime_type(uploaded_file.name),
|
||||
"size": uploaded_file.size,
|
||||
"version": beam_viewer.get("version") or 1,
|
||||
"beamViewer": beam_viewer,
|
||||
},
|
||||
asset="{}/beam-viewer/{}-{}".format(workspace.id, attachment_id.hex, uploaded_file.name),
|
||||
size=0,
|
||||
workspace_id=workspace.id,
|
||||
created_by=request.user,
|
||||
issue_id=issue_id,
|
||||
project_id=project_id,
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
is_uploaded=True,
|
||||
external_source="beam-viewer",
|
||||
)
|
||||
serializer = IssueAttachmentSerializer(asset)
|
||||
_activity_created(request, serializer, issue_id, project_id)
|
||||
except Exception:
|
||||
_delete_uploaded_asset(beam_viewer, identity)
|
||||
raise
|
||||
|
||||
return Response(
|
||||
{
|
||||
"asset_id": str(asset.id),
|
||||
"attachment": serializer.data,
|
||||
"asset_url": asset.asset_url,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class IssueBimAttachmentStatusEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, issue_id, pk):
|
||||
attachment = _get_attachment(slug, project_id, issue_id, pk)
|
||||
if attachment is None:
|
||||
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
beam_viewer = _get_beam_viewer(attachment)
|
||||
if beam_viewer is None:
|
||||
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
|
||||
source = to_bim_relative_asset_url(beam_viewer.get("src"))
|
||||
if not source:
|
||||
return Response({"error": "BIM attachment source is invalid."}, status=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
try:
|
||||
payload = bim_gateway_request(
|
||||
"GET",
|
||||
"/api/conversions/status",
|
||||
identity=get_bim_registry_identity(attachment, beam_viewer),
|
||||
params={"src": source},
|
||||
)
|
||||
except BimGatewayError as error:
|
||||
return _error_response(error)
|
||||
return Response(_public_status_payload(payload), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueBimAttachmentVersionsEndpoint(BaseAPIView):
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, issue_id, pk):
|
||||
attachment = _get_attachment(slug, project_id, issue_id, pk)
|
||||
if attachment is None:
|
||||
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
beam_viewer = _get_beam_viewer(attachment)
|
||||
if beam_viewer is None:
|
||||
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
|
||||
params = {
|
||||
"projectId": beam_viewer.get("projectId"),
|
||||
"assetId": beam_viewer.get("assetId"),
|
||||
}
|
||||
if not all(params.values()):
|
||||
params = {"src": to_bim_relative_asset_url(beam_viewer.get("src"))}
|
||||
try:
|
||||
payload = bim_gateway_request(
|
||||
"GET",
|
||||
"/api/uploads/versions",
|
||||
identity=get_bim_registry_identity(attachment, beam_viewer),
|
||||
params=params,
|
||||
)
|
||||
except BimGatewayError as error:
|
||||
return _error_response(error)
|
||||
result = dict(payload)
|
||||
result["versions"] = [_public_version(version) for version in payload.get("versions") or []]
|
||||
return Response(result, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def post(self, request, slug, project_id, issue_id, pk):
|
||||
attachment = _get_attachment(slug, project_id, issue_id, pk)
|
||||
if attachment is None:
|
||||
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
beam_viewer = _get_beam_viewer(attachment)
|
||||
if beam_viewer is None:
|
||||
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
uploaded_file = request.FILES.get("file")
|
||||
validation_error = _validate_upload(workspace, uploaded_file)
|
||||
if validation_error is not None:
|
||||
return validation_error
|
||||
|
||||
versions = beam_viewer.get("versions") if isinstance(beam_viewer.get("versions"), list) else []
|
||||
current_versions = [int(beam_viewer.get("version") or 1)]
|
||||
current_versions.extend(int(version.get("version") or 0) for version in versions)
|
||||
next_version = max(current_versions) + 1
|
||||
identity = get_bim_registry_identity(attachment, beam_viewer)
|
||||
uploaded_file.seek(0)
|
||||
try:
|
||||
upload_payload = bim_gateway_request(
|
||||
"POST",
|
||||
"/api/uploads",
|
||||
identity=identity,
|
||||
params={
|
||||
"filename": uploaded_file.name,
|
||||
"projectId": beam_viewer.get("projectId") or _upload_group_id(slug, project_id, issue_id),
|
||||
"assetId": beam_viewer.get("assetId") or str(attachment.id),
|
||||
"version": str(next_version),
|
||||
},
|
||||
data=uploaded_file.file,
|
||||
content_length=uploaded_file.size,
|
||||
)
|
||||
next_beam_viewer = build_bim_attachment(
|
||||
upload_payload,
|
||||
filename=uploaded_file.name,
|
||||
size=uploaded_file.size,
|
||||
uploaded_by=request.user.id,
|
||||
registry_owner_id=identity["id"],
|
||||
)
|
||||
next_beam_viewer["versions"] = merge_bim_versions(beam_viewer, next_beam_viewer)
|
||||
except BimGatewayError as error:
|
||||
return _error_response(error)
|
||||
|
||||
next_record = find_bim_version(next_beam_viewer, next_beam_viewer.get("versionId"))
|
||||
try:
|
||||
attributes = dict(attachment.attributes or {})
|
||||
attributes.update(
|
||||
{
|
||||
"name": uploaded_file.name,
|
||||
"type": get_bim_model_mime_type(uploaded_file.name),
|
||||
"size": uploaded_file.size,
|
||||
"version": next_beam_viewer.get("version") or next_version,
|
||||
"beamViewer": next_beam_viewer,
|
||||
}
|
||||
)
|
||||
attachment.attributes = attributes
|
||||
attachment.save(update_fields=["attributes", "updated_at"])
|
||||
except Exception:
|
||||
if next_record:
|
||||
_delete_uploaded_version(next_record, identity)
|
||||
raise
|
||||
return Response(IssueAttachmentSerializer(attachment).data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueBimAttachmentVersionDetailEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def delete(self, request, slug, project_id, issue_id, pk, version_id):
|
||||
attachment = _get_attachment(slug, project_id, issue_id, pk)
|
||||
if attachment is None:
|
||||
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
beam_viewer = _get_beam_viewer(attachment)
|
||||
if beam_viewer is None:
|
||||
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
|
||||
version = find_bim_version(beam_viewer, version_id)
|
||||
if version is None:
|
||||
return Response({"error": "BIM version not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
current_version_id = beam_viewer.get("versionId")
|
||||
if (current_version_id and current_version_id == version.get("versionId")) or (
|
||||
not current_version_id and beam_viewer.get("version") == version.get("version")
|
||||
):
|
||||
return Response(
|
||||
{"error": "The current BIM version cannot be deleted."},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
try:
|
||||
bim_gateway_request(
|
||||
"DELETE",
|
||||
"/api/uploads/version",
|
||||
identity=get_bim_registry_identity(attachment, beam_viewer),
|
||||
json_payload=version,
|
||||
)
|
||||
except BimGatewayError as error:
|
||||
return _error_response(error)
|
||||
|
||||
versions = beam_viewer.get("versions") if isinstance(beam_viewer.get("versions"), list) else []
|
||||
beam_viewer = {
|
||||
**beam_viewer,
|
||||
"versions": [
|
||||
candidate
|
||||
for candidate in versions
|
||||
if not (
|
||||
(version.get("versionId") and candidate.get("versionId") == version.get("versionId"))
|
||||
or (not version.get("versionId") and candidate.get("version") == version.get("version"))
|
||||
)
|
||||
],
|
||||
}
|
||||
attributes = dict(attachment.attributes or {})
|
||||
attributes["beamViewer"] = beam_viewer
|
||||
attachment.attributes = attributes
|
||||
attachment.save(update_fields=["attributes", "updated_at"])
|
||||
return Response(IssueAttachmentSerializer(attachment).data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueBimAttachmentViewerEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def post(self, request, slug, project_id, issue_id, pk):
|
||||
attachment = _get_attachment(slug, project_id, issue_id, pk)
|
||||
if attachment is None:
|
||||
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
beam_viewer = _get_beam_viewer(attachment)
|
||||
if beam_viewer is None:
|
||||
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
|
||||
|
||||
version_id = request.data.get("versionId")
|
||||
version = find_bim_version(beam_viewer, version_id)
|
||||
if version is None:
|
||||
return Response({"error": "BIM version not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
live_status = None
|
||||
conversion = version.get("conversion") if isinstance(version.get("conversion"), dict) else {}
|
||||
if conversion and conversion.get("status") != "ready":
|
||||
source = to_bim_relative_asset_url(version.get("sourceSrc") or version.get("src"))
|
||||
if source:
|
||||
try:
|
||||
live_status = _public_status_payload(
|
||||
bim_gateway_request(
|
||||
"GET",
|
||||
"/api/conversions/status",
|
||||
identity=get_bim_registry_identity(attachment, beam_viewer),
|
||||
params={"src": source},
|
||||
)
|
||||
)
|
||||
except BimGatewayError as error:
|
||||
return _error_response(error)
|
||||
try:
|
||||
model = resolve_bim_viewer_model(
|
||||
beam_viewer,
|
||||
version_id=version_id,
|
||||
live_status=live_status,
|
||||
)
|
||||
viewer_url, expires_at = build_bim_embed_url(
|
||||
model,
|
||||
request.user,
|
||||
slug,
|
||||
project_id,
|
||||
issue_id,
|
||||
attachment.id,
|
||||
)
|
||||
except BimGatewayError as error:
|
||||
return _error_response(error)
|
||||
return Response(
|
||||
{
|
||||
"ok": True,
|
||||
"viewerUrl": viewer_url,
|
||||
"expiresAt": expires_at,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
@@ -128,6 +128,8 @@ def serialize_comment(comment):
|
||||
"body": comment.comment_html,
|
||||
"actor_id": str(comment.actor_id) if comment.actor_id else None,
|
||||
"created_at": comment.created_at.isoformat(),
|
||||
"updated_at": comment.updated_at.isoformat(),
|
||||
"edited_at": comment.edited_at.isoformat() if comment.edited_at else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -986,6 +988,53 @@ class NodeDCAgentIssueCommentEndpoint(View):
|
||||
comment.save(created_by_id=actor.id)
|
||||
return JsonResponse({"ok": True, "comment": serialize_comment(comment)}, status=201)
|
||||
|
||||
def patch(self, request, issue_id, comment_id):
|
||||
error_response = validate_internal_request(request)
|
||||
if error_response is not None:
|
||||
return error_response
|
||||
|
||||
payload = parse_json_body(request)
|
||||
if payload is None:
|
||||
return invalid_json_response()
|
||||
|
||||
project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
|
||||
if project is None:
|
||||
return validation_error("project_not_found", status=404)
|
||||
if issue is None:
|
||||
return validation_error("issue_not_found", status=404)
|
||||
|
||||
project_access_error = validate_agent_project_access(request, project)
|
||||
if project_access_error is not None:
|
||||
return project_access_error
|
||||
|
||||
body = payload.get("body")
|
||||
if not isinstance(body, str) or not body.strip():
|
||||
return validation_error("body_required")
|
||||
|
||||
actor = ensure_agent_actor(request, project.workspace, project, payload)
|
||||
if actor is None:
|
||||
return validation_error("missing_agent_headers")
|
||||
|
||||
comment = IssueComment.objects.filter(
|
||||
id=comment_id,
|
||||
issue=issue,
|
||||
project=project,
|
||||
actor=actor,
|
||||
deleted_at__isnull=True,
|
||||
).first()
|
||||
if comment is None:
|
||||
return validation_error("comment_not_found_or_not_owned", status=404)
|
||||
|
||||
comment.comment_html = html_from_text(body)
|
||||
comment.comment_json = {}
|
||||
comment.edited_at = timezone.now()
|
||||
comment.updated_by = actor
|
||||
comment.save(
|
||||
update_fields=["comment_html", "comment_json", "edited_at", "updated_by", "updated_at"],
|
||||
disable_auto_set_user=True,
|
||||
)
|
||||
return JsonResponse({"ok": True, "comment": serialize_comment(comment)})
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class NodeDCAgentIssueLabelsEndpoint(View):
|
||||
|
||||
@@ -461,8 +461,14 @@ ATTACHMENT_MIME_TYPES = [
|
||||
"text/css",
|
||||
"text/javascript",
|
||||
"application/json",
|
||||
"application/x-ndjson",
|
||||
"application/yaml",
|
||||
"application/x-yaml",
|
||||
"text/yaml",
|
||||
"application/toml",
|
||||
"text/xml",
|
||||
"text/csv",
|
||||
"text/tab-separated-values",
|
||||
"application/xml",
|
||||
# SQL
|
||||
"application/x-sql",
|
||||
@@ -470,6 +476,7 @@ ATTACHMENT_MIME_TYPES = [
|
||||
"application/x-gzip",
|
||||
# Markdown
|
||||
"text/markdown",
|
||||
"text/x-markdown",
|
||||
]
|
||||
|
||||
# Seed directory path
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import base64
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
|
||||
from plane.utils.nodedc_bim_gateway import (
|
||||
BimGatewayError,
|
||||
build_bim_embed_url,
|
||||
get_bim_direct_model_type,
|
||||
get_bim_model_type,
|
||||
resolve_bim_viewer_model,
|
||||
to_bim_relative_asset_url,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cad_extensions_are_normalized():
|
||||
assert get_bim_model_type("part.STEP") == "step"
|
||||
assert get_bim_model_type("part.stp") == "step"
|
||||
assert get_bim_model_type("part.IGES") == "iges"
|
||||
assert get_bim_model_type("part.igs") == "iges"
|
||||
assert get_bim_direct_model_type("part.glb") == "gltf"
|
||||
assert get_bim_direct_model_type("part.step") is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_only_managed_public_bim_sources_are_accepted(monkeypatch):
|
||||
monkeypatch.setenv("PLANE_NODEDC_BIM_PUBLIC_URL", "https://bim.example")
|
||||
|
||||
assert (
|
||||
to_bim_relative_asset_url("https://bim.example/uploads/project/model.glb?version=2")
|
||||
== "/uploads/project/model.glb?version=2"
|
||||
)
|
||||
assert to_bim_relative_asset_url("/data/project/model.xkt") == "/data/project/model.xkt"
|
||||
assert to_bim_relative_asset_url("https://attacker.example/uploads/model.glb") is None
|
||||
assert to_bim_relative_asset_url("/api/auth/session") is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_embed_grant_is_short_lived_and_bound_to_ops_context(monkeypatch):
|
||||
monkeypatch.setenv("PLANE_NODEDC_BIM_PUBLIC_URL", "https://bim.example")
|
||||
monkeypatch.setenv("PLANE_NODEDC_BIM_EMBED_SECRET", "test-embed-secret")
|
||||
monkeypatch.setenv("PLANE_NODEDC_BIM_EMBED_TTL_SECONDS", "180")
|
||||
model = {
|
||||
"url": "/uploads/project/model.glb",
|
||||
"settingsSrc": "/uploads/project/model.glb",
|
||||
"type": "gltf",
|
||||
"name": "model.glb",
|
||||
}
|
||||
|
||||
viewer_url, expires_at = build_bim_embed_url(
|
||||
model,
|
||||
SimpleNamespace(id="user-1"),
|
||||
"workspace",
|
||||
"project-1",
|
||||
"issue-1",
|
||||
"attachment-1",
|
||||
)
|
||||
|
||||
query = parse_qs(urlsplit(viewer_url).query)
|
||||
encoded_payload = query["token"][0].split(".", 1)[0]
|
||||
encoded_payload += "=" * (-len(encoded_payload) % 4)
|
||||
claims = json.loads(base64.urlsafe_b64decode(encoded_payload).decode("utf-8"))
|
||||
assert urlsplit(viewer_url).path == "/embed/tasker"
|
||||
assert claims["sub"] == "user-1"
|
||||
assert claims["workspace"] == "workspace"
|
||||
assert claims["project"] == "project-1"
|
||||
assert claims["issue"] == "issue-1"
|
||||
assert claims["attachment"] == "attachment-1"
|
||||
assert claims["model"] == model
|
||||
assert expires_at - claims["iat"] == 180
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_viewer_model_rejects_unmanaged_or_unready_sources(monkeypatch):
|
||||
monkeypatch.setenv("PLANE_NODEDC_BIM_PUBLIC_URL", "https://bim.example")
|
||||
|
||||
with pytest.raises(BimGatewayError, match="still being prepared"):
|
||||
resolve_bim_viewer_model(
|
||||
{
|
||||
"originalFilename": "part.step",
|
||||
"previewAvailable": False,
|
||||
"src": "https://bim.example/uploads/project/part.step",
|
||||
"type": "step",
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(BimGatewayError, match="outside the managed BIM storage"):
|
||||
resolve_bim_viewer_model(
|
||||
{
|
||||
"originalFilename": "model.glb",
|
||||
"previewAvailable": True,
|
||||
"src": "https://attacker.example/uploads/model.glb",
|
||||
"type": "gltf",
|
||||
}
|
||||
)
|
||||
@@ -120,6 +120,11 @@ urlpatterns = [
|
||||
NodeDCAgentIssueCommentEndpoint.as_view(),
|
||||
name="nodedc-agent-issue-comment",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/agent/issues/<uuid:issue_id>/comments/<uuid:comment_id>",
|
||||
NodeDCAgentIssueCommentEndpoint.as_view(),
|
||||
name="nodedc-agent-issue-comment-detail",
|
||||
),
|
||||
path(
|
||||
"api/internal/nodedc/agent/issues/<uuid:issue_id>/labels",
|
||||
NodeDCAgentIssueLabelsEndpoint.as_view(),
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from urllib.parse import urlencode, urljoin, urlsplit
|
||||
|
||||
import requests
|
||||
|
||||
BIM_EMBED_AUDIENCE = "nodedc-bim-tasker-embed"
|
||||
BIM_EMBED_ISSUER = "nodedc-tasker"
|
||||
BIM_SUPPORTED_MODEL_TYPES = {
|
||||
"bim": "bim",
|
||||
"glb": "gltf",
|
||||
"gltf": "gltf",
|
||||
"iges": "iges",
|
||||
"igs": "iges",
|
||||
"las": "las",
|
||||
"laz": "las",
|
||||
"obj": "obj",
|
||||
"step": "step",
|
||||
"stl": "stl",
|
||||
"stp": "step",
|
||||
"xkt": "xkt",
|
||||
}
|
||||
BIM_DIRECT_MODEL_TYPES = {
|
||||
"bim": "bim",
|
||||
"glb": "gltf",
|
||||
"gltf": "gltf",
|
||||
"las": "las",
|
||||
"laz": "las",
|
||||
"obj": "obj",
|
||||
"stl": "stl",
|
||||
"xkt": "xkt",
|
||||
}
|
||||
|
||||
|
||||
class BimGatewayError(Exception):
|
||||
def __init__(self, code, message, status_code=502, upstream_status=None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.upstream_status = upstream_status
|
||||
|
||||
|
||||
def get_bim_gateway_config():
|
||||
public_base_url = (
|
||||
os.environ.get("PLANE_NODEDC_BIM_PUBLIC_URL", "").strip()
|
||||
or os.environ.get("NODEDC_BIM_PUBLIC_URL", "").strip()
|
||||
or "https://bim.nodedc.tech"
|
||||
).rstrip("/")
|
||||
internal_base_url = (
|
||||
os.environ.get("PLANE_NODEDC_BIM_INTERNAL_URL", "").strip()
|
||||
or os.environ.get("PLANE_NODEDC_BIM_URL", "").strip()
|
||||
or public_base_url
|
||||
).rstrip("/")
|
||||
token = (
|
||||
os.environ.get("PLANE_NODEDC_BIM_ACCESS_TOKEN", "").strip()
|
||||
or os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip()
|
||||
or os.environ.get("NODEDC_INTERNAL_ACCESS_TOKEN", "").strip()
|
||||
)
|
||||
embed_secret = (
|
||||
os.environ.get("PLANE_NODEDC_BIM_EMBED_SECRET", "").strip()
|
||||
or os.environ.get("NODEDC_BIM_EMBED_SECRET", "").strip()
|
||||
or token
|
||||
)
|
||||
connect_timeout = float(os.environ.get("PLANE_NODEDC_BIM_CONNECT_TIMEOUT_SECONDS", "5") or "5")
|
||||
read_timeout = float(os.environ.get("PLANE_NODEDC_BIM_READ_TIMEOUT_SECONDS", "180") or "180")
|
||||
embed_ttl = max(30, int(os.environ.get("PLANE_NODEDC_BIM_EMBED_TTL_SECONDS", "180") or "180"))
|
||||
return {
|
||||
"public_base_url": public_base_url,
|
||||
"internal_base_url": internal_base_url,
|
||||
"token": token,
|
||||
"embed_secret": embed_secret,
|
||||
"timeout": (connect_timeout, read_timeout),
|
||||
"embed_ttl": embed_ttl,
|
||||
}
|
||||
|
||||
|
||||
def get_bim_model_type(filename):
|
||||
extension = os.path.splitext(str(filename or ""))[1].lower().lstrip(".")
|
||||
return BIM_SUPPORTED_MODEL_TYPES.get(extension)
|
||||
|
||||
|
||||
def get_bim_direct_model_type(filename):
|
||||
extension = os.path.splitext(str(filename or ""))[1].lower().lstrip(".")
|
||||
return BIM_DIRECT_MODEL_TYPES.get(extension)
|
||||
|
||||
|
||||
def get_bim_model_mime_type(filename):
|
||||
extension = os.path.splitext(str(filename or ""))[1].lower().lstrip(".")
|
||||
if extension == "glb":
|
||||
return "model/gltf-binary"
|
||||
if extension == "gltf":
|
||||
return "model/gltf+json"
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
def make_bim_registry_owner_id(attachment_id):
|
||||
return "tasker-attachment:{}".format(attachment_id)
|
||||
|
||||
|
||||
def get_bim_registry_identity(attachment, beam_viewer=None):
|
||||
beam_viewer = beam_viewer if isinstance(beam_viewer, dict) else {}
|
||||
registry_owner_id = beam_viewer.get("registryOwnerId")
|
||||
if isinstance(registry_owner_id, str) and registry_owner_id.strip():
|
||||
return {
|
||||
"id": registry_owner_id.strip(),
|
||||
"email": "",
|
||||
}
|
||||
created_by = getattr(attachment, "created_by", None)
|
||||
created_by_id = getattr(attachment, "created_by_id", None)
|
||||
return {
|
||||
"id": str(created_by_id or getattr(attachment, "id", "")),
|
||||
"email": getattr(created_by, "email", "") or "",
|
||||
}
|
||||
|
||||
|
||||
def _gateway_headers(identity, content_type=None, content_length=None):
|
||||
config = get_bim_gateway_config()
|
||||
if not config["token"]:
|
||||
raise BimGatewayError(
|
||||
"bim_gateway_not_configured",
|
||||
"NODE.DC BIM gateway token is not configured.",
|
||||
status_code=503,
|
||||
)
|
||||
identity = identity or {}
|
||||
headers = {
|
||||
"Authorization": "Bearer {}".format(config["token"]),
|
||||
"Accept": "application/json",
|
||||
"X-NODEDC-Service": "tasker",
|
||||
"X-NODEDC-User-Id": str(identity.get("id") or "tasker"),
|
||||
"X-NODEDC-User-Email": str(identity.get("email") or ""),
|
||||
}
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
if content_length is not None:
|
||||
headers["Content-Length"] = str(max(0, int(content_length)))
|
||||
return headers
|
||||
|
||||
|
||||
def bim_gateway_request(method, path, identity=None, params=None, json_payload=None, data=None, content_length=None):
|
||||
config = get_bim_gateway_config()
|
||||
try:
|
||||
response = requests.request(
|
||||
method,
|
||||
"{}{}".format(config["internal_base_url"], path),
|
||||
params=params,
|
||||
json=json_payload,
|
||||
data=data,
|
||||
headers=_gateway_headers(
|
||||
identity,
|
||||
content_type="application/octet-stream"
|
||||
if data is not None
|
||||
else ("application/json" if json_payload is not None else None),
|
||||
content_length=content_length,
|
||||
),
|
||||
timeout=config["timeout"],
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise BimGatewayError(
|
||||
"bim_gateway_unavailable",
|
||||
"NODE.DC BIM gateway is unavailable.",
|
||||
status_code=502,
|
||||
) from exc
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
payload = None
|
||||
|
||||
if response.status_code >= 400:
|
||||
upstream_message = payload.get("message") if isinstance(payload, dict) else None
|
||||
upstream_error = payload.get("error") if isinstance(payload, dict) else None
|
||||
status_code = response.status_code if response.status_code in {400, 404, 409, 413, 422} else 502
|
||||
raise BimGatewayError(
|
||||
upstream_error or "bim_gateway_rejected",
|
||||
upstream_message or "NODE.DC BIM gateway rejected the request.",
|
||||
status_code=status_code,
|
||||
upstream_status=response.status_code,
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
raise BimGatewayError(
|
||||
"bim_gateway_invalid_response",
|
||||
"NODE.DC BIM gateway returned an invalid response.",
|
||||
status_code=502,
|
||||
upstream_status=response.status_code,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def to_bim_public_url(value):
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
config = get_bim_gateway_config()
|
||||
raw_value = value.strip()
|
||||
parsed = urlsplit(raw_value)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
raw_value = parsed.path
|
||||
if parsed.query:
|
||||
raw_value = "{}?{}".format(raw_value, parsed.query)
|
||||
return urljoin("{}/".format(config["public_base_url"]), raw_value.lstrip("/"))
|
||||
|
||||
|
||||
def to_bim_relative_asset_url(value):
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
config = get_bim_gateway_config()
|
||||
raw_value = value.strip()
|
||||
parsed = urlsplit(raw_value)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
public_origin = urlsplit(config["public_base_url"])
|
||||
if (parsed.scheme, parsed.netloc) != (public_origin.scheme, public_origin.netloc):
|
||||
return None
|
||||
relative = parsed.path
|
||||
if parsed.query:
|
||||
relative = "{}?{}".format(relative, parsed.query)
|
||||
else:
|
||||
relative = "/{}".format(raw_value.lstrip("/"))
|
||||
path_only = urlsplit(relative).path
|
||||
if not (path_only.startswith("/uploads/") or path_only.startswith("/data/")):
|
||||
return None
|
||||
return relative
|
||||
|
||||
|
||||
def _model_version_record(beam_viewer, size=0):
|
||||
conversion = beam_viewer.get("conversion") if isinstance(beam_viewer.get("conversion"), dict) else None
|
||||
return {
|
||||
"assetId": beam_viewer.get("assetId"),
|
||||
"conversion": conversion,
|
||||
"downloadUrl": beam_viewer.get("downloadUrl"),
|
||||
"originalFilename": beam_viewer.get("originalFilename"),
|
||||
"previewAvailable": bool(beam_viewer.get("previewAvailable")),
|
||||
"projectId": beam_viewer.get("projectId"),
|
||||
"sha256": beam_viewer.get("sha256"),
|
||||
"size": int(size or (conversion or {}).get("size") or 0),
|
||||
"sourceSrc": (conversion or {}).get("sourceSrc") or beam_viewer.get("src"),
|
||||
"src": beam_viewer.get("src"),
|
||||
"status": (conversion or {}).get("status") or "ready",
|
||||
"type": beam_viewer.get("type"),
|
||||
"uploadedBy": beam_viewer.get("uploadedBy"),
|
||||
"uploadedAt": beam_viewer.get("uploadedAt"),
|
||||
"version": int(beam_viewer.get("version") or 1),
|
||||
"versionId": beam_viewer.get("versionId"),
|
||||
"viewerUrl": None,
|
||||
}
|
||||
|
||||
|
||||
def build_bim_attachment(upload_payload, filename, size, uploaded_by, registry_owner_id):
|
||||
source_src = upload_payload.get("src")
|
||||
if not isinstance(source_src, str) or not source_src:
|
||||
raise BimGatewayError(
|
||||
"bim_gateway_invalid_response",
|
||||
"NODE.DC BIM gateway did not return an uploaded model path.",
|
||||
status_code=502,
|
||||
)
|
||||
model_type = get_bim_model_type(filename)
|
||||
direct_type = get_bim_direct_model_type(filename)
|
||||
conversion = upload_payload.get("conversion")
|
||||
if isinstance(conversion, dict):
|
||||
conversion = {
|
||||
**conversion,
|
||||
"componentTreeRequired": True,
|
||||
"message": conversion.get("message")
|
||||
or "Оригинальная CAD-модель загружена. Просмотр появится после подготовки модели и дерева компонентов.",
|
||||
"sourceFormat": conversion.get("sourceFormat") or model_type,
|
||||
"status": conversion.get("status") or "conversion_required",
|
||||
"targetFormat": conversion.get("targetFormat") or "xkt",
|
||||
}
|
||||
else:
|
||||
conversion = None
|
||||
source_url = to_bim_public_url(source_src)
|
||||
beam_viewer = {
|
||||
"assetId": upload_payload.get("assetId"),
|
||||
"backend": "beam-viewer-ops",
|
||||
"downloadUrl": source_url,
|
||||
"originalFilename": upload_payload.get("originalFilename") or filename,
|
||||
"previewAvailable": bool(direct_type),
|
||||
"projectId": upload_payload.get("projectId"),
|
||||
"registryOwnerId": registry_owner_id,
|
||||
"sha256": upload_payload.get("sha256"),
|
||||
"src": source_url,
|
||||
"type": model_type,
|
||||
"uploadedBy": str(uploaded_by) if uploaded_by else None,
|
||||
"uploadedAt": upload_payload.get("uploadedAt"),
|
||||
"version": int(upload_payload.get("version") or 1),
|
||||
"versionId": upload_payload.get("versionId"),
|
||||
"viewerUrl": None,
|
||||
}
|
||||
if conversion:
|
||||
beam_viewer["conversion"] = conversion
|
||||
beam_viewer["versions"] = [_model_version_record(beam_viewer, size=size)]
|
||||
return beam_viewer
|
||||
|
||||
|
||||
def merge_bim_versions(previous_beam_viewer, next_beam_viewer):
|
||||
versions = previous_beam_viewer.get("versions")
|
||||
versions = list(versions) if isinstance(versions, list) else []
|
||||
if not versions:
|
||||
versions.append(_model_version_record(previous_beam_viewer))
|
||||
next_versions = next_beam_viewer.get("versions")
|
||||
next_record = (
|
||||
next_versions[0]
|
||||
if isinstance(next_versions, list) and next_versions
|
||||
else _model_version_record(next_beam_viewer)
|
||||
)
|
||||
next_version_id = next_record.get("versionId")
|
||||
next_version = next_record.get("version")
|
||||
versions = [
|
||||
version
|
||||
for version in versions
|
||||
if not (
|
||||
(next_version_id and version.get("versionId") == next_version_id)
|
||||
or (not next_version_id and version.get("version") == next_version)
|
||||
)
|
||||
]
|
||||
versions.append(next_record)
|
||||
return sorted(versions, key=lambda version: int(version.get("version") or 0))
|
||||
|
||||
|
||||
def find_bim_version(beam_viewer, version_id):
|
||||
versions = beam_viewer.get("versions")
|
||||
if not isinstance(versions, list):
|
||||
versions = []
|
||||
if version_id:
|
||||
for version in versions:
|
||||
if version.get("versionId") == version_id or str(version.get("version")) == str(version_id):
|
||||
return version
|
||||
return None
|
||||
return _model_version_record(beam_viewer)
|
||||
|
||||
|
||||
def resolve_bim_viewer_model(beam_viewer, version_id=None, live_status=None):
|
||||
record = find_bim_version(beam_viewer, version_id)
|
||||
if not isinstance(record, dict):
|
||||
raise BimGatewayError("bim_version_not_found", "BIM model version was not found.", status_code=404)
|
||||
conversion = record.get("conversion") if isinstance(record.get("conversion"), dict) else {}
|
||||
if live_status and isinstance(live_status, dict):
|
||||
conversion = {**conversion, **live_status}
|
||||
|
||||
if conversion.get("status") == "ready":
|
||||
source = conversion.get("artifactUrl") or conversion.get("artifactSrc")
|
||||
model_type = conversion.get("artifactType") or conversion.get("targetFormat") or "gltf"
|
||||
settings_source = conversion.get("sourceSrc") or record.get("sourceSrc") or record.get("src")
|
||||
elif record.get("previewAvailable"):
|
||||
source = record.get("src")
|
||||
model_type = record.get("type")
|
||||
settings_source = record.get("sourceSrc") or record.get("src")
|
||||
else:
|
||||
raise BimGatewayError(
|
||||
"bim_model_not_ready",
|
||||
"BIM model preview is still being prepared.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
source = to_bim_relative_asset_url(source)
|
||||
settings_source = to_bim_relative_asset_url(settings_source)
|
||||
if not source or not settings_source:
|
||||
raise BimGatewayError(
|
||||
"bim_model_source_invalid",
|
||||
"BIM model source is outside the managed BIM storage.",
|
||||
status_code=422,
|
||||
)
|
||||
return {
|
||||
"url": source,
|
||||
"settingsSrc": settings_source,
|
||||
"type": model_type,
|
||||
"name": record.get("originalFilename") or beam_viewer.get("originalFilename") or "model",
|
||||
}
|
||||
|
||||
|
||||
def _base64url(value):
|
||||
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def build_bim_embed_url(model, user, workspace_slug, project_id, issue_id, attachment_id):
|
||||
config = get_bim_gateway_config()
|
||||
if not config["embed_secret"]:
|
||||
raise BimGatewayError(
|
||||
"bim_embed_not_configured",
|
||||
"NODE.DC BIM embed signing secret is not configured.",
|
||||
status_code=503,
|
||||
)
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"v": 1,
|
||||
"aud": BIM_EMBED_AUDIENCE,
|
||||
"iss": BIM_EMBED_ISSUER,
|
||||
"iat": now,
|
||||
"exp": now + config["embed_ttl"],
|
||||
"sub": str(getattr(user, "id", "")),
|
||||
"workspace": str(workspace_slug),
|
||||
"project": str(project_id),
|
||||
"issue": str(issue_id),
|
||||
"attachment": str(attachment_id),
|
||||
"model": model,
|
||||
}
|
||||
encoded_payload = _base64url(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8"))
|
||||
signature = hmac.new(
|
||||
config["embed_secret"].encode("utf-8"),
|
||||
encoded_payload.encode("ascii"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
token = "{}.{}".format(encoded_payload, _base64url(signature))
|
||||
query = urlencode(
|
||||
{
|
||||
"token": token,
|
||||
"url": model["url"],
|
||||
"settingsSrc": model["settingsSrc"],
|
||||
"type": model["type"],
|
||||
"name": model["name"],
|
||||
}
|
||||
)
|
||||
return "{}/embed/tasker?{}".format(config["public_base_url"], query), payload["exp"]
|
||||
@@ -4,7 +4,16 @@
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export function MaintenanceMessage() {
|
||||
export type TMaintenanceReason = "starting" | "offline" | "unavailable";
|
||||
|
||||
type TMaintenanceMessageProps = {
|
||||
autoRetry?: boolean;
|
||||
reason?: TMaintenanceReason;
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
export function MaintenanceMessage(props: TMaintenanceMessageProps) {
|
||||
const { autoRetry = false, reason = "unavailable", statusCode } = props;
|
||||
const linkMap = [
|
||||
{
|
||||
key: "mail_to",
|
||||
@@ -13,24 +22,33 @@ export function MaintenanceMessage() {
|
||||
},
|
||||
];
|
||||
|
||||
const title =
|
||||
reason === "starting"
|
||||
? "Сервисы NODE.DC запускаются."
|
||||
: reason === "offline"
|
||||
? "Нет связи с сервером NODE.DC."
|
||||
: "NODE.DC временно недоступен.";
|
||||
|
||||
const description =
|
||||
reason === "starting"
|
||||
? "Сервер ещё не готов после обновления или перезапуска. NODE.DC подключится автоматически сразу после завершения запуска. Перезагружать страницу не нужно."
|
||||
: reason === "offline"
|
||||
? "Не удалось связаться с сервером. NODE.DC проверяет подключение автоматически и продолжит работу после восстановления связи."
|
||||
: autoRetry
|
||||
? "Сервер временно не может обработать запрос. NODE.DC повторяет подключение автоматически и продолжит работу после восстановления."
|
||||
: "Сервер вернул ошибку, которую нельзя исправить автоматическим повтором. Попробуйте ещё раз или обратитесь в службу поддержки.";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h1 className="text-left text-18 font-semibold text-primary">🚧 NODE.DC запустился с ошибкой.</h1>
|
||||
<span className="text-left text-14 font-medium text-secondary">
|
||||
Часть сервисов могла не подняться. Проверьте логи контейнеров и устраните причину. Если нужна помощь,
|
||||
переходите в службу поддержки.
|
||||
</span>
|
||||
<h1 className="text-left text-18 font-semibold text-primary">🚧 {title}</h1>
|
||||
<span className="text-left text-14 font-medium text-secondary">{description}</span>
|
||||
{statusCode && <span className="text-left text-12 text-tertiary">Код ответа сервера: {statusCode}</span>}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-start gap-6">
|
||||
{linkMap.map((link) => (
|
||||
<div key={link.key}>
|
||||
<a
|
||||
href={link.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="nodedc-error-link text-13"
|
||||
>
|
||||
<a href={link.value} target="_blank" rel="noopener noreferrer" className="nodedc-error-link text-13">
|
||||
{link.label}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -24,8 +24,7 @@ function ProjectAttributes(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<IProject>();
|
||||
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CREATE, isMobile);
|
||||
const projectAttributeChipClassName =
|
||||
"nodedc-modal-chip !h-10 !rounded-[1.25rem] !px-4 !py-2 !text-13";
|
||||
const projectAttributeChipClassName = "nodedc-modal-chip !h-10 !rounded-[1.25rem] !px-4 !py-2 !text-13";
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Controller
|
||||
@@ -86,6 +85,7 @@ function ProjectAttributes(props: Props) {
|
||||
buttonVariant="border-with-text"
|
||||
buttonClassName={projectAttributeChipClassName}
|
||||
buttonContainerClassName="!h-10"
|
||||
optionsClassName="!z-[190]"
|
||||
showUserDetails
|
||||
tabIndex={getIndex("lead")}
|
||||
/>
|
||||
|
||||
@@ -1547,6 +1547,29 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW
|
||||
[opsWorkspaceSlug, runRegistryAction, selectedProjectId, updateRegistry, workspaceSlug]
|
||||
);
|
||||
|
||||
const copySetupCommand = useCallback(
|
||||
async (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => {
|
||||
await runRegistryAction(
|
||||
"setup-command",
|
||||
async () => {
|
||||
const payload = await workspaceAIWorkspaceService.updateExecutor(workspaceSlug, executorId, input);
|
||||
await updateRegistry(payload);
|
||||
const setup = await workspaceAIWorkspaceService.createAgentSetupCommand(workspaceSlug, executorId, {
|
||||
port,
|
||||
opsWorkspaceSlug,
|
||||
opsProjectId: selectedProjectId,
|
||||
});
|
||||
const command = String(setup.install?.command || "").trim();
|
||||
if (!command) throw new Error("setup_command_empty");
|
||||
if (!navigator.clipboard?.writeText) throw new Error("clipboard_unavailable");
|
||||
await navigator.clipboard.writeText(command);
|
||||
},
|
||||
"npm setup command скопирована"
|
||||
);
|
||||
},
|
||||
[opsWorkspaceSlug, runRegistryAction, selectedProjectId, updateRegistry, workspaceSlug]
|
||||
);
|
||||
|
||||
const createThread = async () => {
|
||||
try {
|
||||
setThreadError("");
|
||||
@@ -2236,6 +2259,7 @@ export function AIWorkspaceProductConsole({ open, workspaceSlug, onClose }: TAIW
|
||||
onDelete={deleteExecutor}
|
||||
onOpsWorkspaceChange={setSelectedOpsWorkspaceSlug}
|
||||
onProjectChange={setSelectedProjectId}
|
||||
onCopySetupCommand={copySetupCommand}
|
||||
onDownloadAgent={downloadAgent}
|
||||
getWindowsAgentInstallerUrl={(executorId, options) =>
|
||||
workspaceAIWorkspaceService.getWindowsAgentInstallerUrl(workspaceSlug, executorId, {
|
||||
|
||||
@@ -68,6 +68,7 @@ type TAIWorkspaceProductSettingsModalProps = {
|
||||
onDelete: (executorId: string) => Promise<TAIWorkspaceExecutorListResponse | null>;
|
||||
onOpsWorkspaceChange: (workspaceSlug: string) => void;
|
||||
onProjectChange: (projectId: string) => void;
|
||||
onCopySetupCommand: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise<void>;
|
||||
onDownloadAgent: (executorId: string, input: TAIWorkspaceExecutorInput, port: string) => Promise<void>;
|
||||
getWindowsAgentInstallerUrl: (
|
||||
executorId: string,
|
||||
@@ -324,6 +325,7 @@ export function AIWorkspaceProductSettingsModal({
|
||||
onDelete,
|
||||
onOpsWorkspaceChange,
|
||||
onProjectChange,
|
||||
onCopySetupCommand,
|
||||
onDownloadAgent,
|
||||
getWindowsAgentInstallerUrl,
|
||||
}: TAIWorkspaceProductSettingsModalProps) {
|
||||
@@ -450,6 +452,11 @@ export function AIWorkspaceProductSettingsModal({
|
||||
await onDownloadAgent(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
|
||||
};
|
||||
|
||||
const copySetupCommand = async () => {
|
||||
if (!editingExecutor) return;
|
||||
await onCopySetupCommand(editingExecutor.id, toInput(draft), draft.agentPort || DEFAULT_WINDOWS_AGENT_PORT);
|
||||
};
|
||||
|
||||
const activeCheckDetails = activeExecutor
|
||||
? [formatCheckTime(activeExecutor.lastSeenAt), activeExecutor.statusDetail].filter(Boolean).join(" · ")
|
||||
: "";
|
||||
@@ -696,18 +703,26 @@ export function AIWorkspaceProductSettingsModal({
|
||||
placeholder="XXXX-XXXX-XXXX"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-btn ai-settings-download ai-settings-inline-download btn-primary"
|
||||
disabled={isBusy}
|
||||
onClick={() => void copySetupCommand()}
|
||||
>
|
||||
Copy npx
|
||||
</button>
|
||||
<a
|
||||
className="modal-btn ai-settings-download ai-settings-inline-download"
|
||||
href={getWindowsAgentInstallerUrl(editingExecutor.id)}
|
||||
onClick={downloadAgent}
|
||||
download
|
||||
>
|
||||
Скачать агент
|
||||
Legacy .ps1
|
||||
</a>
|
||||
</div>
|
||||
) : draft.connectionMode === "hub" ? (
|
||||
<div className="ai-settings-check-note">
|
||||
После сохранения общий AI Workspace создаст pairing code и installer агента.
|
||||
После сохранения общий AI Workspace создаст pairing code и npm setup command.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -125,9 +125,9 @@ export const CycleOptions = observer(function CycleOptions(props: CycleOptionsPr
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
return (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<Combobox.Options data-prevent-outside-click className="nodedc-external-popup-anchor fixed z-[760]" static>
|
||||
<div
|
||||
className="nodedc-dropdown-surface z-30 my-1 w-52"
|
||||
className="nodedc-dropdown-surface nodedc-external-popup-anchor z-[760] my-1 w-52"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
|
||||
@@ -232,9 +232,9 @@ export const EstimateDropdown = observer(function EstimateDropdown(props: Props)
|
||||
renderByDefault={renderByDefault}
|
||||
>
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<Combobox.Options data-prevent-outside-click className="nodedc-external-popup-anchor fixed z-[760]" static>
|
||||
<div
|
||||
className="nodedc-dropdown-surface z-30 my-1 w-52"
|
||||
className="nodedc-dropdown-surface nodedc-external-popup-anchor z-[760] my-1 w-52"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
@@ -269,14 +269,11 @@ export const EstimateDropdown = observer(function EstimateDropdown(props: Props)
|
||||
<Combobox.Option key={option.value} value={option.value}>
|
||||
{({ active, selected }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"nodedc-dropdown-option",
|
||||
{
|
||||
className={cn("nodedc-dropdown-option", {
|
||||
"bg-white/6": active,
|
||||
"text-primary": selected,
|
||||
"text-secondary": !selected,
|
||||
}
|
||||
)}
|
||||
})}
|
||||
>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
|
||||
@@ -128,10 +128,7 @@ export const MemberOptions = observer(function MemberOptions(props: Props) {
|
||||
return createPortal(
|
||||
<Combobox.Options data-prevent-outside-click static>
|
||||
<div
|
||||
className={cn(
|
||||
"nodedc-dropdown-surface z-30 my-1 w-52",
|
||||
optionsClassName
|
||||
)}
|
||||
className={cn("nodedc-dropdown-surface nodedc-external-popup-anchor z-[760] my-1 w-52", optionsClassName)}
|
||||
ref={setPopperElement}
|
||||
style={{
|
||||
...styles.popper,
|
||||
@@ -143,7 +140,7 @@ export const MemberOptions = observer(function MemberOptions(props: Props) {
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder outline-none focus:outline-none"
|
||||
className="w-full bg-transparent py-0 text-12 text-secondary outline-none placeholder:text-placeholder focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("search")}
|
||||
|
||||
@@ -113,9 +113,9 @@ export const ModuleOptions = observer(function ModuleOptions(props: Props) {
|
||||
);
|
||||
|
||||
return (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<Combobox.Options data-prevent-outside-click className="nodedc-external-popup-anchor fixed z-[760]" static>
|
||||
<div
|
||||
className="nodedc-dropdown-surface z-30 my-1 w-52"
|
||||
className="nodedc-dropdown-surface nodedc-external-popup-anchor z-[760] my-1 w-52"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
@@ -141,14 +141,11 @@ export const ModuleOptions = observer(function ModuleOptions(props: Props) {
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
cn(
|
||||
"nodedc-dropdown-option",
|
||||
{
|
||||
cn("nodedc-dropdown-option", {
|
||||
"bg-white/6": active,
|
||||
"text-primary": selected,
|
||||
"text-secondary": !selected,
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
|
||||
@@ -139,7 +139,7 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"clickable block h-full w-full rounded-full border-0 bg-transparent shadow-none outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0",
|
||||
"clickable block h-full w-full rounded-full border-0 bg-transparent shadow-none outline-none focus:outline-none focus-visible:ring-0 focus-visible:outline-none",
|
||||
buttonContainerClassName
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
@@ -154,7 +154,7 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
"clickable block h-full max-w-full rounded-full border-0 bg-transparent shadow-none outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0",
|
||||
"clickable block h-full max-w-full rounded-full border-0 bg-transparent shadow-none outline-none focus:outline-none focus-visible:ring-0 focus-visible:outline-none",
|
||||
{
|
||||
"cursor-not-allowed text-secondary": disabled,
|
||||
"cursor-pointer": !disabled,
|
||||
@@ -189,10 +189,7 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown
|
||||
<span className="flex-grow truncate text-left">{selectedState?.name ?? t("state")}</span>
|
||||
)}
|
||||
{dropdownArrow && (
|
||||
<ChevronDownIcon
|
||||
className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -214,9 +211,9 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown
|
||||
>
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
|
||||
<Combobox.Options data-prevent-outside-click className="nodedc-external-popup-anchor fixed z-[760]" static>
|
||||
<div
|
||||
className="nodedc-dropdown-surface z-30 my-1 w-52"
|
||||
className="nodedc-dropdown-surface nodedc-external-popup-anchor z-[760] my-1 w-52"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
@@ -226,7 +223,7 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder outline-none focus:outline-none"
|
||||
className="w-full bg-transparent py-0 text-12 text-secondary outline-none placeholder:text-placeholder focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("common.search.label")}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@plane/propel/button";
|
||||
// assets
|
||||
import maintenanceModeDarkModeImage from "@/app/assets/instance/maintenance-mode-dark.svg?url";
|
||||
import maintenanceModeLightModeImage from "@/app/assets/instance/maintenance-mode-light.svg?url";
|
||||
@@ -12,8 +13,18 @@ import maintenanceModeLightModeImage from "@/app/assets/instance/maintenance-mod
|
||||
import DefaultLayout from "@/layouts/default-layout";
|
||||
// components
|
||||
import { MaintenanceMessage } from "@/plane-web/components/instance";
|
||||
import type { TMaintenanceReason } from "@/plane-web/components/instance";
|
||||
|
||||
export function MaintenanceView() {
|
||||
type TMaintenanceViewProps = {
|
||||
autoRetry?: boolean;
|
||||
isRetrying?: boolean;
|
||||
reason?: TMaintenanceReason;
|
||||
statusCode?: number;
|
||||
onRetry?: () => void;
|
||||
};
|
||||
|
||||
export function MaintenanceView(props: TMaintenanceViewProps) {
|
||||
const { autoRetry = false, isRetrying = false, reason = "unavailable", statusCode, onRetry } = props;
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
// derived values
|
||||
@@ -31,7 +42,20 @@ export function MaintenanceView() {
|
||||
/>
|
||||
</div>
|
||||
<div className="relative mt-4 flex w-full flex-col gap-4">
|
||||
<MaintenanceMessage />
|
||||
<MaintenanceMessage autoRetry={autoRetry} reason={reason} statusCode={statusCode} />
|
||||
{onRetry && (
|
||||
<div className="flex justify-start">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="nodedc-error-primary"
|
||||
loading={isRetrying}
|
||||
onClick={onRetry}
|
||||
>
|
||||
Проверить сейчас
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
|
||||
@@ -19,24 +19,21 @@ import { EIssueServiceType } from "@plane/types";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore, Spinner } from "@plane/ui";
|
||||
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
|
||||
import { AlertTriangle, Box, Download, Eye, History, ImageIcon, Play, UploadCloud, X } from "lucide-react";
|
||||
import { AlertTriangle, Box, Download, Eye, FileText, History, ImageIcon, Play, UploadCloud, X } from "lucide-react";
|
||||
// components
|
||||
//
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { getFileIcon } from "@/components/icons";
|
||||
import { MarkdownRenderer } from "@/components/ui/markdown-to-component";
|
||||
import {
|
||||
buildBeamViewerUrl,
|
||||
dispatchBeamViewerOpenEvent,
|
||||
fetchBeamConversionStatus,
|
||||
fetchBeamModelVersions,
|
||||
getBeamModelVersionRecords,
|
||||
getBeamVersionViewerUrl,
|
||||
getBeamViewerAttachment,
|
||||
isBeamModelFile,
|
||||
mergeBeamModelVersionRecordLists,
|
||||
syncCurrentBeamVersionRecord,
|
||||
type TBeamModelVersionRecord,
|
||||
type TBeamViewerAttachment,
|
||||
type TBeamConversionStatus,
|
||||
} from "@/helpers/beam-viewer";
|
||||
import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview";
|
||||
@@ -61,6 +58,26 @@ type TIssueAttachmentsListItem = {
|
||||
const IMAGE_EXTENSIONS = new Set(["apng", "avif", "bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"]);
|
||||
const VIDEO_EXTENSIONS = new Set(["avi", "m4v", "mov", "mp4", "mpeg", "mpg", "ogv", "webm"]);
|
||||
const PDF_EXTENSIONS = new Set(["pdf"]);
|
||||
const MARKDOWN_EXTENSIONS = new Set(["markdown", "md", "mdown", "mkd", "mkdn"]);
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
"cfg",
|
||||
"conf",
|
||||
"csv",
|
||||
"ini",
|
||||
"json",
|
||||
"jsonl",
|
||||
"log",
|
||||
"ndjson",
|
||||
"toml",
|
||||
"tsv",
|
||||
"txt",
|
||||
"xml",
|
||||
"yaml",
|
||||
"yml",
|
||||
]);
|
||||
const MAX_INLINE_TEXT_PREVIEW_SIZE = 8 * 1024 * 1024;
|
||||
|
||||
type TAttachmentPreviewType = "image" | "video" | "pdf" | "markdown" | "text" | "file";
|
||||
|
||||
const appendSearchParam = (url: string | undefined, key: string, value: string): string => {
|
||||
if (!url) return "";
|
||||
@@ -98,11 +115,13 @@ const withBeamViewerSettingsSrc = (
|
||||
}
|
||||
};
|
||||
|
||||
const getPreviewType = (extension: string) => {
|
||||
const getPreviewType = (extension: string): TAttachmentPreviewType => {
|
||||
const normalizedExtension = extension.toLowerCase();
|
||||
if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image";
|
||||
if (VIDEO_EXTENSIONS.has(normalizedExtension)) return "video";
|
||||
if (PDF_EXTENSIONS.has(normalizedExtension)) return "pdf";
|
||||
if (MARKDOWN_EXTENSIONS.has(normalizedExtension)) return "markdown";
|
||||
if (TEXT_EXTENSIONS.has(normalizedExtension)) return "text";
|
||||
return "file";
|
||||
};
|
||||
|
||||
@@ -125,46 +144,6 @@ const sanitizeBeamStatusMessage = (message: string | undefined): string | undefi
|
||||
const getBeamVersionRecordKey = (version: TBeamModelVersionRecord): string =>
|
||||
version.versionId || `version-${version.version}`;
|
||||
|
||||
const buildSyncedBeamViewerAttachment = (
|
||||
beamViewer: TBeamViewerAttachment,
|
||||
status: TBeamConversionStatus,
|
||||
fullFileName: string
|
||||
): TBeamViewerAttachment | null => {
|
||||
if (status.status !== "ready" || !status.artifactUrl) return null;
|
||||
|
||||
const artifactType = status.artifactType || status.targetFormat || "gltf";
|
||||
const targetFormat: "glb" | "xkt" = status.targetFormat || (artifactType === "xkt" ? "xkt" : "glb");
|
||||
|
||||
return syncCurrentBeamVersionRecord({
|
||||
...beamViewer,
|
||||
assetId: beamViewer.assetId || status.assetId,
|
||||
previewAvailable: true,
|
||||
sha256: beamViewer.sha256 || status.sha256,
|
||||
version: beamViewer.version || status.version,
|
||||
versionId: beamViewer.versionId || status.versionId,
|
||||
viewerUrl: buildBeamViewerUrl({
|
||||
name: fullFileName,
|
||||
settingsSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc || beamViewer.src,
|
||||
src: status.artifactUrl,
|
||||
type: artifactType,
|
||||
}),
|
||||
conversion: {
|
||||
...beamViewer.conversion,
|
||||
artifactSrc: status.artifactSrc,
|
||||
artifactType,
|
||||
componentTreeRequired: status.componentTreeRequired ?? beamViewer.conversion?.componentTreeRequired ?? true,
|
||||
message: status.message || beamViewer.conversion?.message,
|
||||
metadataSrc: status.metadataSrc,
|
||||
size: status.size ?? beamViewer.conversion?.size,
|
||||
sourceFormat: status.sourceFormat || beamViewer.conversion?.sourceFormat || beamViewer.type,
|
||||
sourceSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc,
|
||||
status: "ready",
|
||||
targetFormat,
|
||||
updatedAt: status.updatedAt || beamViewer.conversion?.updatedAt,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
|
||||
const { t } = useTranslation();
|
||||
// props
|
||||
@@ -213,6 +192,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
type: beamConversionStatus?.artifactType || "gltf",
|
||||
})
|
||||
: undefined) || storedModelViewerUrlWithSettings;
|
||||
const canOpenModelViewer =
|
||||
!!beamViewer && (beamViewer.previewAvailable || beamEffectiveStatus === "ready" || !!modelViewerUrl);
|
||||
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
|
||||
const previewDownloadUrl = modelDownloadUrl || fileURL;
|
||||
const isBeamModel = isBeamModelFile(fullFileName);
|
||||
@@ -224,7 +205,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
? "BIM Viewer вернул статус готовности, но не вернул viewer-артефакт."
|
||||
: null);
|
||||
const isBeamConversionFailed = beamEffectiveStatus === "failed" || !!beamStatusErrorMessage;
|
||||
const isBeamAwaitingPreview = !!beamViewer && !modelViewerUrl && !isBeamConversionFailed;
|
||||
const isBeamAwaitingPreview = !!beamViewer && !canOpenModelViewer && !isBeamConversionFailed;
|
||||
const rawBeamStatusTooltipContent = isBeamConversionFailed
|
||||
? beamStatusErrorMessage || beamConversionStatus?.message || "Ошибка подготовки дерева компонентов."
|
||||
: beamConversionStatus?.message ||
|
||||
@@ -251,7 +232,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
() => mergeBeamModelVersionRecordLists(localBeamVersions, liveBeamVersions),
|
||||
[localBeamVersions, liveBeamVersions]
|
||||
);
|
||||
const rawVersion = beamViewer?.version ?? (attachment?.attributes as { version?: number | string } | undefined)?.version;
|
||||
const rawVersion =
|
||||
beamViewer?.version ?? (attachment?.attributes as { version?: number | string } | undefined)?.version;
|
||||
const versionLabel =
|
||||
typeof rawVersion === "number"
|
||||
? `v${rawVersion}`
|
||||
@@ -268,21 +250,41 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
if (!userId) return "—";
|
||||
return getUserDetails(userId)?.display_name ?? "—";
|
||||
};
|
||||
const openModelViewer = () => {
|
||||
if (!modelViewerUrl) return;
|
||||
const openModelViewer = async () => {
|
||||
if (!canOpenModelViewer || !attachment) return;
|
||||
try {
|
||||
const viewerUrl = await attachmentService.getBeamIssueAttachmentViewerUrl(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
attachment.id
|
||||
);
|
||||
dispatchBeamViewerOpenEvent({
|
||||
downloadUrl: modelDownloadUrl,
|
||||
fileExtension,
|
||||
fileName: fullFileName,
|
||||
fileSize: attachment?.attributes.size ?? 0,
|
||||
fileSize: attachment.attributes.size ?? 0,
|
||||
issueId,
|
||||
viewerUrl: modelViewerUrl,
|
||||
viewerUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Модель не открыта",
|
||||
message: error instanceof Error ? error.message : "Не удалось запустить BIM Viewer через Ops.",
|
||||
});
|
||||
}
|
||||
};
|
||||
const openBeamVersionViewer = (version: TBeamModelVersionRecord) => {
|
||||
const viewerUrl = getBeamVersionViewerUrl(version);
|
||||
if (!viewerUrl) return;
|
||||
|
||||
const openBeamVersionViewer = async (version: TBeamModelVersionRecord) => {
|
||||
if (!attachment || !getBeamVersionViewerUrl(version)) return;
|
||||
try {
|
||||
const viewerUrl = await attachmentService.getBeamIssueAttachmentViewerUrl(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
attachment.id,
|
||||
version.versionId || String(version.version)
|
||||
);
|
||||
setIsVersionHistoryOpen(false);
|
||||
dispatchBeamViewerOpenEvent({
|
||||
downloadUrl: version.downloadUrl,
|
||||
@@ -292,6 +294,13 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
issueId,
|
||||
viewerUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Версия не открыта",
|
||||
message: error instanceof Error ? error.message : "Не удалось запустить версию BIM-модели.",
|
||||
});
|
||||
}
|
||||
};
|
||||
const startVersionUpload = () => {
|
||||
versionUploadInputRef.current?.click();
|
||||
@@ -362,7 +371,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
}
|
||||
};
|
||||
const menuItems: TContextMenuItem[] = [
|
||||
...(modelViewerUrl
|
||||
...(canOpenModelViewer
|
||||
? [
|
||||
{
|
||||
key: "view-model",
|
||||
@@ -429,7 +438,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
fetchBeamModelVersions(beamViewer)
|
||||
attachmentService
|
||||
.getBeamIssueAttachmentVersions(workspaceSlug, projectId, issueId, attachmentId)
|
||||
.then((history) => {
|
||||
if (!isMounted) return;
|
||||
setLiveBeamVersions(Array.isArray(history.versions) ? history.versions : []);
|
||||
@@ -443,7 +453,17 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [beamViewer?.assetId, beamViewer?.projectId, beamViewer?.src, beamViewer?.versionId]);
|
||||
}, [
|
||||
attachmentId,
|
||||
attachmentService,
|
||||
beamViewer?.assetId,
|
||||
beamViewer?.projectId,
|
||||
beamViewer?.src,
|
||||
beamViewer?.versionId,
|
||||
issueId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!beamViewer || !beamViewer.src) return;
|
||||
@@ -454,17 +474,15 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
|
||||
const pollStatus = async () => {
|
||||
try {
|
||||
const status = await fetchBeamConversionStatus(beamViewer);
|
||||
const status = await attachmentService.getBeamIssueAttachmentStatus(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
attachmentId
|
||||
);
|
||||
if (!isMounted) return;
|
||||
setBeamConversionError(null);
|
||||
setBeamConversionStatus(status);
|
||||
const syncedBeamViewer = buildSyncedBeamViewerAttachment(beamViewer, status, fullFileName);
|
||||
if (syncedBeamViewer && syncedBeamViewer.viewerUrl !== beamViewer.viewerUrl) {
|
||||
attachmentService
|
||||
.updateBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, attachmentId, syncedBeamViewer)
|
||||
.then(() => fetchAttachments(workspaceSlug, projectId, issueId))
|
||||
.catch((error) => console.error("Error in syncing Beam attachment metadata:", error));
|
||||
}
|
||||
if (status.status !== "ready" && status.status !== "failed") {
|
||||
timeoutId = window.setTimeout(pollStatus, 5000);
|
||||
}
|
||||
@@ -481,17 +499,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
isMounted = false;
|
||||
if (timeoutId) window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [
|
||||
attachmentId,
|
||||
attachmentService,
|
||||
beamViewer,
|
||||
fetchAttachments,
|
||||
fullFileName,
|
||||
issueId,
|
||||
projectId,
|
||||
storedModelViewerUrl,
|
||||
workspaceSlug,
|
||||
]);
|
||||
}, [attachmentId, attachmentService, beamViewer, issueId, projectId, storedModelViewerUrl, workspaceSlug]);
|
||||
|
||||
if (!attachment) return <></>;
|
||||
|
||||
@@ -506,7 +514,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (modelViewerUrl) openModelViewer();
|
||||
if (canOpenModelViewer) void openModelViewer();
|
||||
else setIsPreviewOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -530,6 +538,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
) : (
|
||||
<Box className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
|
||||
)
|
||||
) : previewType === "markdown" || previewType === "text" ? (
|
||||
<FileText className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
|
||||
) : previewType === "file" ? (
|
||||
getFileIcon(fileExtension, 18)
|
||||
) : (
|
||||
@@ -586,9 +596,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(modelViewerUrl || previewURL) && (
|
||||
{(canOpenModelViewer || previewURL) && (
|
||||
<Tooltip
|
||||
tooltipContent={modelViewerUrl ? "Посмотреть модель" : "Открыть предпросмотр"}
|
||||
tooltipContent={canOpenModelViewer ? "Посмотреть модель" : "Открыть предпросмотр"}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<button
|
||||
@@ -597,7 +607,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (modelViewerUrl) openModelViewer();
|
||||
if (canOpenModelViewer) void openModelViewer();
|
||||
else setIsPreviewOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -683,7 +693,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (modelViewerUrl) openModelViewer();
|
||||
if (canOpenModelViewer) void openModelViewer();
|
||||
else setIsPreviewOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -727,6 +737,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
) : (
|
||||
<Box className="size-9 text-[rgb(var(--nodedc-accent-rgb))]" />
|
||||
)
|
||||
) : previewType === "markdown" || previewType === "text" ? (
|
||||
<FileText className="size-9 text-[rgb(var(--nodedc-accent-rgb))]" />
|
||||
) : previewType === "file" ? (
|
||||
fileIcon
|
||||
) : (
|
||||
@@ -819,7 +831,7 @@ type TAttachmentPreviewContent = {
|
||||
isBeamConversionFailed: boolean;
|
||||
modelDownloadUrl: string | undefined;
|
||||
previewDownloadUrl: string | undefined;
|
||||
previewType: "image" | "video" | "pdf" | "file";
|
||||
previewType: TAttachmentPreviewType;
|
||||
previewURL: string;
|
||||
setIsPreviewOpen: (isOpen: boolean) => void;
|
||||
size: number;
|
||||
@@ -883,6 +895,13 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
|
||||
</div>
|
||||
) : previewType === "pdf" && previewURL ? (
|
||||
<IssueAttachmentPdfPreview fileURL={previewURL} />
|
||||
) : (previewType === "markdown" || previewType === "text") && previewURL ? (
|
||||
<AttachmentTextPreview
|
||||
fileName={fullFileName}
|
||||
fileURL={previewURL}
|
||||
isMarkdown={previewType === "markdown"}
|
||||
size={size}
|
||||
/>
|
||||
) : isBeamAwaitingPreview || isBeamConversionFailed ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<div
|
||||
@@ -938,6 +957,93 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
|
||||
);
|
||||
};
|
||||
|
||||
type TAttachmentTextPreview = {
|
||||
fileName: string;
|
||||
fileURL: string;
|
||||
isMarkdown: boolean;
|
||||
size: number;
|
||||
};
|
||||
|
||||
const AttachmentTextPreview = (props: TAttachmentTextPreview) => {
|
||||
const { fileName, fileURL, isMarkdown, size } = props;
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (size > MAX_INLINE_TEXT_PREVIEW_SIZE) {
|
||||
setContent(null);
|
||||
setError(`Файл больше ${convertBytesToSize(MAX_INLINE_TEXT_PREVIEW_SIZE)}. Скачайте его для просмотра.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setContent(null);
|
||||
setError(null);
|
||||
|
||||
fetch(fileURL, {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.text();
|
||||
})
|
||||
.then((text) => setContent(text))
|
||||
.catch((fetchError) => {
|
||||
if (controller.signal.aborted) return;
|
||||
console.error("Error in loading text attachment preview:", fetchError);
|
||||
setError("Не удалось загрузить содержимое файла для предпросмотра.");
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [fileURL, size]);
|
||||
|
||||
const renderedContent = useMemo(() => {
|
||||
if (content === null || isMarkdown) return content;
|
||||
const extension = getFileExtension(fileName).toLowerCase();
|
||||
if (extension !== "json") return content;
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(content), null, 2);
|
||||
} catch (_error) {
|
||||
return content;
|
||||
}
|
||||
}, [content, fileName, isMarkdown]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8 text-center text-14 text-secondary">{error}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (renderedContent === null) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner
|
||||
height="34px"
|
||||
width="34px"
|
||||
className="fill-[rgb(var(--nodedc-accent-rgb))] text-[rgba(var(--nodedc-accent-rgb),0.18)]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vertical-scrollbar h-full overflow-auto bg-surface-1 px-6 py-5 sm:px-10 sm:py-8">
|
||||
{isMarkdown ? (
|
||||
<article className="nodedc-attachment-markdown-preview mx-auto max-w-4xl">
|
||||
<MarkdownRenderer markdown={renderedContent} />
|
||||
</article>
|
||||
) : (
|
||||
<pre className="font-mono m-0 min-h-full text-13 leading-6 break-words whitespace-pre-wrap text-secondary">
|
||||
{renderedContent}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type TBeamVersionHistoryModal = {
|
||||
currentVersionId: string | undefined;
|
||||
deletingVersionKey: string | null;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local imports
|
||||
import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns";
|
||||
import { IssueMarkdownExportButton } from "./markdown-export-button";
|
||||
import { IssueSubscription } from "./subscription";
|
||||
|
||||
type Props = {
|
||||
@@ -150,6 +151,7 @@ export const IssueDetailQuickActions = observer(function IssueDetailQuickActions
|
||||
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
|
||||
<IconButton variant="secondary" size="lg" onClick={handleCopyText} icon={CopyLinkIcon} />
|
||||
</Tooltip>
|
||||
<IssueMarkdownExportButton workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
|
||||
<WorkItemDetailQuickActions
|
||||
parentRef={parentRef}
|
||||
issue={issue}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState, type MouseEvent } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { generateWorkItemLink } from "@plane/utils";
|
||||
// hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local imports
|
||||
import { buildIssueMarkdownExport, downloadIssueMarkdownExport } from "./markdown-export";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
isArchived?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const IssueMarkdownExportButton = observer(function IssueMarkdownExportButton(props: Props) {
|
||||
const { workspaceSlug, projectId, issueId, isArchived = false, className } = props;
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { getProjectById, getProjectIdentifierById } = useProject();
|
||||
const { getUserDetails } = useMember();
|
||||
const { fetchProjectLabels, getLabelById } = useLabel();
|
||||
const { fetchModules, getModuleNameById } = useModule();
|
||||
const { fetchAllCycles, getCycleNameById } = useCycle();
|
||||
const { fetchProjectStates, getStateById } = useProjectState();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
link,
|
||||
attachment,
|
||||
subIssues,
|
||||
relation,
|
||||
comment,
|
||||
activity,
|
||||
reaction,
|
||||
fetchIssue,
|
||||
fetchLinks,
|
||||
fetchAttachments,
|
||||
fetchSubIssues,
|
||||
fetchRelations,
|
||||
fetchComments,
|
||||
fetchActivities,
|
||||
fetchReactions,
|
||||
} = useIssueDetail();
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
if (!issue) return <></>;
|
||||
|
||||
const issueProjectId = issue.project_id ?? projectId;
|
||||
const projectIdentifier = getProjectIdentifierById(issueProjectId);
|
||||
const project = getProjectById(issueProjectId);
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issueProjectId,
|
||||
issueId,
|
||||
projectIdentifier,
|
||||
sequenceId: issue.sequence_id,
|
||||
isArchived,
|
||||
});
|
||||
|
||||
const getDisplayName = (userId: string | null | undefined) => {
|
||||
if (!userId) return undefined;
|
||||
const user = getUserDetails(userId);
|
||||
return user?.display_name || user?.first_name || user?.email || userId;
|
||||
};
|
||||
|
||||
const getIssueListByIds = (issueIds: string[] | undefined) =>
|
||||
(issueIds ?? []).flatMap((currentIssueId) => {
|
||||
const currentIssue = getIssueById(currentIssueId);
|
||||
return currentIssue ? [currentIssue] : [];
|
||||
});
|
||||
|
||||
const refreshExportData = async () => {
|
||||
await fetchIssue(workspaceSlug, issueProjectId, issueId);
|
||||
await Promise.allSettled([
|
||||
fetchLinks(workspaceSlug, issueProjectId, issueId),
|
||||
fetchAttachments(workspaceSlug, issueProjectId, issueId),
|
||||
fetchSubIssues(workspaceSlug, issueProjectId, issueId),
|
||||
fetchRelations(workspaceSlug, issueProjectId, issueId),
|
||||
fetchComments(workspaceSlug, issueProjectId, issueId),
|
||||
fetchActivities(workspaceSlug, issueProjectId, issueId),
|
||||
fetchReactions(workspaceSlug, issueProjectId, issueId),
|
||||
fetchProjectStates(workspaceSlug, issueProjectId),
|
||||
fetchProjectLabels(workspaceSlug, issueProjectId),
|
||||
fetchModules(workspaceSlug, issueProjectId),
|
||||
fetchAllCycles(workspaceSlug, issueProjectId),
|
||||
]);
|
||||
};
|
||||
|
||||
const buildExportData = () => {
|
||||
const exportIssue = getIssueById(issueId) ?? issue;
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
const relationMap = relation.getRelationsByIssueId(issueId) ?? {};
|
||||
const reactionsByEmoji = reaction.getReactionsByIssueId(issueId) ?? {};
|
||||
|
||||
return buildIssueMarkdownExport({
|
||||
workspaceSlug,
|
||||
projectName: project?.name,
|
||||
projectIdentifier,
|
||||
workItemUrl: `${originURL}${workItemLink}`,
|
||||
issue: exportIssue,
|
||||
subIssues: getIssueListByIds(subIssues.subIssuesByIssueId(issueId)),
|
||||
relations: Object.fromEntries(
|
||||
Object.entries(relationMap).map(([relationType, relatedIssueIds]) => [
|
||||
relationType,
|
||||
getIssueListByIds(Array.isArray(relatedIssueIds) ? relatedIssueIds : []),
|
||||
])
|
||||
),
|
||||
links: (link.getLinksByIssueId(issueId) ?? []).flatMap((linkId) => {
|
||||
const issueLink = link.getLinkById(linkId);
|
||||
return issueLink ? [issueLink] : [];
|
||||
}),
|
||||
attachments: (attachment.getAttachmentsByIssueId(issueId) ?? []).flatMap((attachmentId) => {
|
||||
const issueAttachment = attachment.getAttachmentById(attachmentId);
|
||||
return issueAttachment ? [issueAttachment] : [];
|
||||
}),
|
||||
comments: (comment.getCommentsByIssueId(issueId) ?? []).flatMap((commentId) => {
|
||||
const issueComment = comment.getCommentById(commentId);
|
||||
return issueComment ? [issueComment] : [];
|
||||
}),
|
||||
activities: (activity.getActivitiesByIssueId(issueId) ?? []).flatMap((activityId) => {
|
||||
const issueActivity = activity.getActivityById(activityId);
|
||||
return issueActivity ? [issueActivity] : [];
|
||||
}),
|
||||
reactions: Object.entries(reactionsByEmoji).map(([emoji, reactionIds]) => {
|
||||
const actors = reactionIds
|
||||
.map((reactionId) => {
|
||||
const issueReaction = reaction.getReactionById(reactionId);
|
||||
return issueReaction?.display_name || getDisplayName(issueReaction?.actor);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return `${emoji}: ${actors.length > 0 ? actors.join(", ") : reactionIds.length}`;
|
||||
}),
|
||||
getIssueById: (currentIssueId) => (currentIssueId ? getIssueById(currentIssueId) : undefined),
|
||||
getUserDisplayName: getDisplayName,
|
||||
getStateName: (stateId) => (stateId ? getStateById(stateId)?.name : undefined),
|
||||
getLabelName: (labelId) => (labelId ? getLabelById(labelId)?.name : undefined),
|
||||
getModuleName: (moduleId) => (moduleId ? getModuleNameById(moduleId) : undefined),
|
||||
getCycleName: (cycleId) => (cycleId ? getCycleNameById(cycleId) : undefined),
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadMarkdown = async (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (isExporting) return;
|
||||
setIsExporting(true);
|
||||
|
||||
try {
|
||||
await refreshExportData();
|
||||
const exportResult = buildExportData();
|
||||
downloadIssueMarkdownExport(exportResult);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Markdown скачан",
|
||||
message: exportResult.fileName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error exporting work item markdown:", error);
|
||||
setToast({
|
||||
title: "Не удалось скачать Markdown",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip tooltipContent="Скачать Markdown" isMobile={isMobile}>
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={handleDownloadMarkdown}
|
||||
icon={Download}
|
||||
loading={isExporting}
|
||||
className={className}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { TIssue, TIssueActivity, TIssueAttachment, TIssueComment, TIssueLink } from "@plane/types";
|
||||
import { orderBy } from "lodash-es";
|
||||
// local imports
|
||||
import {
|
||||
extractIssueStructuredContent,
|
||||
type TIssueStructuredBlock,
|
||||
} from "../issue-detail-widgets/structured-content.helpers";
|
||||
|
||||
type TIssueLookup = (issueId: string | null | undefined) => TIssue | Partial<TIssue> | undefined;
|
||||
type TEntityNameLookup = (entityId: string | null | undefined) => string | undefined;
|
||||
|
||||
export type TIssueMarkdownExportContext = {
|
||||
workspaceSlug: string;
|
||||
projectName?: string;
|
||||
projectIdentifier?: string;
|
||||
workItemUrl?: string;
|
||||
issue: TIssue;
|
||||
subIssues: TIssue[];
|
||||
relations: Record<string, TIssue[]>;
|
||||
links: TIssueLink[];
|
||||
attachments: TIssueAttachment[];
|
||||
comments: TIssueComment[];
|
||||
activities: TIssueActivity[];
|
||||
reactions: string[];
|
||||
getIssueById: TIssueLookup;
|
||||
getUserDisplayName: TEntityNameLookup;
|
||||
getStateName: TEntityNameLookup;
|
||||
getLabelName: TEntityNameLookup;
|
||||
getModuleName: TEntityNameLookup;
|
||||
getCycleName: TEntityNameLookup;
|
||||
};
|
||||
|
||||
type TMarkdownExportResult = {
|
||||
fileName: string;
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
const emptyValue = "Нет";
|
||||
|
||||
const relationLabels: Record<string, string> = {
|
||||
blocking: "Блокирует",
|
||||
blocked_by: "Заблокировано",
|
||||
duplicate: "Дубликат",
|
||||
relates_to: "Связано",
|
||||
related: "Связано",
|
||||
};
|
||||
|
||||
const normalizeText = (value: unknown) => `${value ?? ""}`.trim();
|
||||
|
||||
const removeControlCharacters = (value: string) =>
|
||||
Array.from(value)
|
||||
.map((character) => (character.charCodeAt(0) < 32 ? " " : character))
|
||||
.join("");
|
||||
|
||||
const valueOrEmpty = (value: unknown) => {
|
||||
const normalized = normalizeText(value);
|
||||
return normalized || emptyValue;
|
||||
};
|
||||
|
||||
const listValue = (values: Array<string | undefined>) => {
|
||||
const normalizedValues = values.map((value) => normalizeText(value)).filter(Boolean);
|
||||
return normalizedValues.length > 0 ? normalizedValues.join(", ") : emptyValue;
|
||||
};
|
||||
|
||||
const formatDate = (value: string | Date | null | undefined) => {
|
||||
if (!value) return emptyValue;
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return `${value}`;
|
||||
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const formatBytes = (value: number | null | undefined) => {
|
||||
if (!value || value < 0) return emptyValue;
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = value;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const normalizeMarkdownWhitespace = (value: string) =>
|
||||
value
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.trim();
|
||||
|
||||
const plainTextFromHtml = (html: string) =>
|
||||
normalizeMarkdownWhitespace(
|
||||
html
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/(p|div|li|h[1-6]|blockquote)>/gi, "\n")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
);
|
||||
|
||||
const nodeChildrenToMarkdown = (node: Node, depth = 0): string =>
|
||||
Array.from(node.childNodes)
|
||||
.map((childNode) => nodeToMarkdown(childNode, depth))
|
||||
.join("");
|
||||
|
||||
const normalizeListItem = (value: string) => value.trim().replace(/\n+/g, "\n ");
|
||||
|
||||
const nodeToMarkdown = (node: Node, depth = 0): string => {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? "";
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||||
|
||||
const element = node as HTMLElement;
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const childMarkdown = () => nodeChildrenToMarkdown(element, depth);
|
||||
|
||||
if (tagName === "br") return "\n";
|
||||
|
||||
if (tagName === "p" || tagName === "div") {
|
||||
const body = normalizeMarkdownWhitespace(childMarkdown());
|
||||
return body ? `${body}\n\n` : "";
|
||||
}
|
||||
|
||||
if (/^h[1-6]$/.test(tagName)) {
|
||||
const level = Number(tagName.slice(1));
|
||||
const body = normalizeMarkdownWhitespace(childMarkdown());
|
||||
return body ? `${"#".repeat(level)} ${body}\n\n` : "";
|
||||
}
|
||||
|
||||
if (tagName === "strong" || tagName === "b") {
|
||||
const body = childMarkdown().trim();
|
||||
return body ? `**${body}**` : "";
|
||||
}
|
||||
|
||||
if (tagName === "em" || tagName === "i") {
|
||||
const body = childMarkdown().trim();
|
||||
return body ? `_${body}_` : "";
|
||||
}
|
||||
|
||||
if (tagName === "code") {
|
||||
const body = childMarkdown().trim();
|
||||
return body ? `\`${body}\`` : "";
|
||||
}
|
||||
|
||||
if (tagName === "pre") {
|
||||
const body = element.textContent?.trim() ?? "";
|
||||
return body ? `\n\`\`\`\n${body}\n\`\`\`\n\n` : "";
|
||||
}
|
||||
|
||||
if (tagName === "blockquote") {
|
||||
const body = normalizeMarkdownWhitespace(childMarkdown());
|
||||
return body
|
||||
? `${body
|
||||
.split("\n")
|
||||
.map((line) => `> ${line}`)
|
||||
.join("\n")}\n\n`
|
||||
: "";
|
||||
}
|
||||
|
||||
if (tagName === "a") {
|
||||
const body = normalizeMarkdownWhitespace(childMarkdown()) || element.getAttribute("href") || "";
|
||||
const href = element.getAttribute("href");
|
||||
return href ? `[${body}](${href})` : body;
|
||||
}
|
||||
|
||||
if (tagName === "img") {
|
||||
const src = element.getAttribute("src");
|
||||
if (!src) return "";
|
||||
return ``;
|
||||
}
|
||||
|
||||
if (tagName === "ul") {
|
||||
return `${Array.from(element.children)
|
||||
.filter((childElement) => childElement.tagName.toLowerCase() === "li")
|
||||
.map(
|
||||
(childElement) => `${" ".repeat(depth)}- ${normalizeListItem(nodeChildrenToMarkdown(childElement, depth + 1))}`
|
||||
)
|
||||
.join("\n")}\n\n`;
|
||||
}
|
||||
|
||||
if (tagName === "ol") {
|
||||
return `${Array.from(element.children)
|
||||
.filter((childElement) => childElement.tagName.toLowerCase() === "li")
|
||||
.map(
|
||||
(childElement, index) =>
|
||||
`${" ".repeat(depth)}${index + 1}. ${normalizeListItem(nodeChildrenToMarkdown(childElement, depth + 1))}`
|
||||
)
|
||||
.join("\n")}\n\n`;
|
||||
}
|
||||
|
||||
if (tagName === "li") return `${" ".repeat(depth)}- ${normalizeListItem(childMarkdown())}\n`;
|
||||
|
||||
return childMarkdown();
|
||||
};
|
||||
|
||||
const htmlToMarkdown = (html: string | null | undefined) => {
|
||||
const normalizedHtml = normalizeText(html);
|
||||
if (!normalizedHtml || normalizedHtml === "<p></p>") return "";
|
||||
|
||||
if (typeof document === "undefined") return plainTextFromHtml(normalizedHtml);
|
||||
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = normalizedHtml;
|
||||
|
||||
return normalizeMarkdownWhitespace(nodeChildrenToMarkdown(template.content));
|
||||
};
|
||||
|
||||
const absoluteUrl = (url: string | null | undefined) => {
|
||||
const normalizedUrl = normalizeText(url);
|
||||
if (!normalizedUrl) return undefined;
|
||||
if (/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
|
||||
if (typeof window !== "undefined" && normalizedUrl.startsWith("/"))
|
||||
return `${window.location.origin}${normalizedUrl}`;
|
||||
return normalizedUrl;
|
||||
};
|
||||
|
||||
const issueIdentifier = (issue: Partial<TIssue>, projectIdentifier?: string) =>
|
||||
projectIdentifier && issue.sequence_id ? `${projectIdentifier}-${issue.sequence_id}` : valueOrEmpty(issue.id);
|
||||
|
||||
const issueLine = (issue: Partial<TIssue>, context: TIssueMarkdownExportContext) => {
|
||||
const projectIdentifier =
|
||||
issue.project_id === context.issue.project_id
|
||||
? context.projectIdentifier
|
||||
: context.getIssueById(issue.id)?.project_id === context.issue.project_id
|
||||
? context.projectIdentifier
|
||||
: undefined;
|
||||
|
||||
return `${issueIdentifier(issue, projectIdentifier)} — ${valueOrEmpty(issue.name)}`;
|
||||
};
|
||||
|
||||
const userName = (userId: string | null | undefined, context: TIssueMarkdownExportContext) =>
|
||||
context.getUserDisplayName(userId) ?? valueOrEmpty(userId);
|
||||
|
||||
const namesByIds = (ids: string[] | null | undefined, lookup: TEntityNameLookup) =>
|
||||
listValue((ids ?? []).map((id) => lookup(id) ?? id));
|
||||
|
||||
const issueStateName = (stateId: string | null | undefined, context: TIssueMarkdownExportContext) =>
|
||||
context.getStateName(stateId) ?? valueOrEmpty(stateId);
|
||||
|
||||
const issueAssignees = (issue: Partial<TIssue>, context: TIssueMarkdownExportContext) =>
|
||||
namesByIds(issue.assignee_ids ?? [], context.getUserDisplayName);
|
||||
|
||||
const formatIssueFacts = (issue: Partial<TIssue>, context: TIssueMarkdownExportContext) => [
|
||||
` - Статус: ${issueStateName(issue.state_id, context)}`,
|
||||
` - Приоритет: ${valueOrEmpty(issue.priority)}`,
|
||||
` - Исполнители: ${issueAssignees(issue, context)}`,
|
||||
` - Старт: ${formatDate(issue.start_date)}`,
|
||||
` - Дедлайн: ${formatDate(issue.target_date)}`,
|
||||
` - Завершено: ${formatDate(issue.completed_at)}`,
|
||||
];
|
||||
|
||||
const renderStructuredBlocks = (blocks: TIssueStructuredBlock[]) => {
|
||||
if (blocks.length === 0) return emptyValue;
|
||||
|
||||
return blocks
|
||||
.map((block, index) => {
|
||||
const title = valueOrEmpty(block.title) === emptyValue ? `Блок ${index + 1}` : block.title.trim();
|
||||
|
||||
if (block.type === "text") {
|
||||
return [`### ${title}`, "", normalizeMarkdownWhitespace(block.body) || emptyValue].join("\n");
|
||||
}
|
||||
|
||||
const items =
|
||||
block.items.length > 0
|
||||
? block.items.map((item) => `- [${item.checked ? "x" : " "}] ${valueOrEmpty(item.text)}`).join("\n")
|
||||
: emptyValue;
|
||||
|
||||
return [`### ${title}`, "", items].join("\n");
|
||||
})
|
||||
.join("\n\n");
|
||||
};
|
||||
|
||||
const renderSubIssues = (context: TIssueMarkdownExportContext) => {
|
||||
if (context.subIssues.length === 0) return emptyValue;
|
||||
|
||||
return context.subIssues
|
||||
.map((subIssue) =>
|
||||
[
|
||||
`- ${issueLine(subIssue, context)}`,
|
||||
...formatIssueFacts(subIssue, context),
|
||||
` - Project UUID: ${valueOrEmpty(subIssue.project_id)}`,
|
||||
` - Issue UUID: ${valueOrEmpty(subIssue.id)}`,
|
||||
].join("\n")
|
||||
)
|
||||
.join("\n\n");
|
||||
};
|
||||
|
||||
const renderRelations = (context: TIssueMarkdownExportContext) => {
|
||||
const relationSections = Object.entries(context.relations).filter(([, issues]) => issues.length > 0);
|
||||
if (relationSections.length === 0) return emptyValue;
|
||||
|
||||
return relationSections
|
||||
.map(([relationType, issues]) => {
|
||||
const relationTitle = relationLabels[relationType] ?? relationType;
|
||||
const relationIssueList = issues.map((issue) => `- ${issueLine(issue, context)}`).join("\n");
|
||||
|
||||
return [`### ${relationTitle}`, "", relationIssueList].join("\n");
|
||||
})
|
||||
.join("\n\n");
|
||||
};
|
||||
|
||||
const renderLinks = (links: TIssueLink[]) => {
|
||||
if (links.length === 0) return emptyValue;
|
||||
|
||||
return links
|
||||
.map((link) => {
|
||||
const url = valueOrEmpty(link.url);
|
||||
const title = valueOrEmpty(link.title);
|
||||
|
||||
return [`- ${title}: ${url}`, ` - ID: ${link.id}`, ` - Created: ${formatDate(link.created_at)}`].join("\n");
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const renderAttachments = (attachments: TIssueAttachment[]) => {
|
||||
if (attachments.length === 0) return emptyValue;
|
||||
|
||||
return attachments
|
||||
.map((attachment) => {
|
||||
const beamViewer = attachment.attributes?.beamViewer;
|
||||
const lines = [
|
||||
`- ${valueOrEmpty(attachment.attributes?.name)}`,
|
||||
` - ID: ${attachment.id}`,
|
||||
` - Type: ${valueOrEmpty(attachment.attributes?.type)}`,
|
||||
` - Size: ${formatBytes(attachment.attributes?.size)}`,
|
||||
` - URL: ${valueOrEmpty(absoluteUrl(attachment.asset_url))}`,
|
||||
` - Created: ${formatDate(attachment.created_at)}`,
|
||||
` - Updated: ${formatDate(attachment.updated_at)}`,
|
||||
` - Created by: ${valueOrEmpty(attachment.created_by)}`,
|
||||
` - Updated by: ${valueOrEmpty(attachment.updated_by)}`,
|
||||
];
|
||||
|
||||
if (beamViewer) {
|
||||
lines.push(
|
||||
` - BIM viewer: ${valueOrEmpty(beamViewer.viewerUrl)}`,
|
||||
` - BIM download: ${valueOrEmpty(beamViewer.downloadUrl)}`,
|
||||
` - BIM source: ${valueOrEmpty(beamViewer.conversion?.sourceSrc)}`,
|
||||
` - BIM status: ${valueOrEmpty(beamViewer.conversion?.status)}`,
|
||||
` - BIM version: ${valueOrEmpty(beamViewer.version)}`
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
})
|
||||
.join("\n\n");
|
||||
};
|
||||
|
||||
const renderComments = (comments: TIssueComment[]) => {
|
||||
if (comments.length === 0) return emptyValue;
|
||||
|
||||
return orderBy(comments, (comment) => new Date(comment.created_at).getTime(), "asc")
|
||||
.map((comment) => {
|
||||
const author = comment.actor_detail?.display_name || comment.actor_detail?.first_name || comment.actor;
|
||||
const body = htmlToMarkdown(comment.comment_html) || valueOrEmpty(comment.comment_stripped);
|
||||
const attachments =
|
||||
comment.attachments && comment.attachments.length > 0
|
||||
? `\n\nAttachments:\n\`\`\`json\n${JSON.stringify(comment.attachments, null, 2)}\n\`\`\``
|
||||
: "";
|
||||
|
||||
return [`### ${valueOrEmpty(author)} — ${formatDate(comment.created_at)}`, "", body, attachments].join("\n");
|
||||
})
|
||||
.join("\n\n");
|
||||
};
|
||||
|
||||
const formatActivityValue = (
|
||||
field: string | undefined,
|
||||
value: string | undefined,
|
||||
context: TIssueMarkdownExportContext
|
||||
) => {
|
||||
if (!value) return emptyValue;
|
||||
|
||||
const resolveSingleValue = (item: string) => {
|
||||
const normalizedItem = item.trim();
|
||||
if (!normalizedItem) return undefined;
|
||||
|
||||
if (field === "state") return context.getStateName(normalizedItem) ?? normalizedItem;
|
||||
if (field === "assignees") return context.getUserDisplayName(normalizedItem) ?? normalizedItem;
|
||||
if (field === "labels") return context.getLabelName(normalizedItem) ?? normalizedItem;
|
||||
if (field === "modules") return context.getModuleName(normalizedItem) ?? normalizedItem;
|
||||
if (field === "cycle") return context.getCycleName(normalizedItem) ?? normalizedItem;
|
||||
|
||||
return normalizedItem;
|
||||
};
|
||||
|
||||
return listValue(value.split(",").map(resolveSingleValue));
|
||||
};
|
||||
|
||||
const renderActivities = (activities: TIssueActivity[], context: TIssueMarkdownExportContext) => {
|
||||
if (activities.length === 0) return emptyValue;
|
||||
|
||||
return orderBy(activities, (activity) => new Date(activity.created_at).getTime(), "asc")
|
||||
.map((activity) => {
|
||||
const actor = activity.actor_detail?.display_name || activity.actor_detail?.first_name || activity.actor;
|
||||
const field = valueOrEmpty(activity.field);
|
||||
const oldValue = formatActivityValue(activity.field, activity.old_value, context);
|
||||
const newValue = formatActivityValue(activity.field, activity.new_value, context);
|
||||
const base = `- ${formatDate(activity.created_at)} — ${valueOrEmpty(actor)} — ${valueOrEmpty(activity.verb)} — ${field}: ${oldValue} -> ${newValue}`;
|
||||
const comment = normalizeText(activity.comment);
|
||||
|
||||
return comment ? `${base}\n - Comment: ${comment}` : base;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const renderReactions = (reactions: string[]) =>
|
||||
reactions.length > 0 ? reactions.map((reaction) => `- ${reaction}`).join("\n") : emptyValue;
|
||||
|
||||
const buildRawSnapshot = (context: TIssueMarkdownExportContext) => ({
|
||||
issue: context.issue,
|
||||
sub_issues: context.subIssues,
|
||||
relations: context.relations,
|
||||
links: context.links,
|
||||
attachments: context.attachments,
|
||||
comments: context.comments,
|
||||
activities: context.activities,
|
||||
reactions: context.reactions,
|
||||
});
|
||||
|
||||
const sanitizeFileNameSegment = (value: unknown) =>
|
||||
removeControlCharacters(valueOrEmpty(value))
|
||||
.replace(/[\\/:*?"<>|]+/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const buildFileName = (context: TIssueMarkdownExportContext) => {
|
||||
const projectName = sanitizeFileNameSegment(context.projectName || context.projectIdentifier || "TASKER");
|
||||
const sequence = context.issue.sequence_id
|
||||
? `${context.issue.sequence_id}`
|
||||
: sanitizeFileNameSegment(context.issue.id);
|
||||
const title = sanitizeFileNameSegment(context.issue.name);
|
||||
const baseName = `${projectName} ${sequence}_${title}`.slice(0, 180).trim();
|
||||
|
||||
return `${baseName || "tasker-card"}.md`;
|
||||
};
|
||||
|
||||
export const buildIssueMarkdownExport = (context: TIssueMarkdownExportContext): TMarkdownExportResult => {
|
||||
const { issue } = context;
|
||||
const parsedContent = extractIssueStructuredContent(issue.detail_layout, issue.description_html);
|
||||
const parentIssue = issue.parent_id ? context.getIssueById(issue.parent_id) || issue.parent : undefined;
|
||||
const projectDisplayName = context.projectName || context.projectIdentifier || issue.project_id;
|
||||
const labels = namesByIds(issue.label_ids, context.getLabelName);
|
||||
const modules = namesByIds(issue.module_ids ?? [], context.getModuleName);
|
||||
const cycle = context.getCycleName(issue.cycle_id) ?? valueOrEmpty(issue.cycle_id);
|
||||
const description = htmlToMarkdown(parsedContent.bodyHtml) || emptyValue;
|
||||
const identifier = issueIdentifier(issue, context.projectIdentifier);
|
||||
|
||||
const markdown = normalizeMarkdownWhitespace(
|
||||
[
|
||||
`# ${identifier} — ${valueOrEmpty(issue.name)}`,
|
||||
"",
|
||||
"## Карточка",
|
||||
"",
|
||||
`- Workspace: ${context.workspaceSlug}`,
|
||||
`- Project: ${valueOrEmpty(projectDisplayName)}`,
|
||||
`- Project ID: ${valueOrEmpty(issue.project_id)}`,
|
||||
`- Project identifier: ${valueOrEmpty(context.projectIdentifier)}`,
|
||||
`- Issue ID: ${issue.id}`,
|
||||
`- Sequence: ${valueOrEmpty(issue.sequence_id)}`,
|
||||
`- Link: ${valueOrEmpty(context.workItemUrl)}`,
|
||||
`- Status: ${issueStateName(issue.state_id, context)}`,
|
||||
`- Priority: ${valueOrEmpty(issue.priority)}`,
|
||||
`- Estimate: ${valueOrEmpty(issue.estimate_point)}`,
|
||||
`- Assignees: ${issueAssignees(issue, context)}`,
|
||||
`- Labels: ${labels}`,
|
||||
`- Modules: ${modules}`,
|
||||
`- Cycle: ${cycle}`,
|
||||
`- Parent: ${parentIssue ? issueLine(parentIssue, context) : emptyValue}`,
|
||||
`- Created: ${formatDate(issue.created_at)}`,
|
||||
`- Created by: ${userName(issue.created_by, context)}`,
|
||||
`- Updated: ${formatDate(issue.updated_at)}`,
|
||||
`- Updated by: ${userName(issue.updated_by, context)}`,
|
||||
`- Start date: ${formatDate(issue.start_date)}`,
|
||||
`- Target date: ${formatDate(issue.target_date)}`,
|
||||
`- Completed: ${formatDate(issue.completed_at)}`,
|
||||
`- Archived: ${formatDate(issue.archived_at)}`,
|
||||
`- External source: ${valueOrEmpty(issue.external_source)}`,
|
||||
`- External ID: ${valueOrEmpty(issue.external_id)}`,
|
||||
"",
|
||||
"## Описание",
|
||||
"",
|
||||
description,
|
||||
"",
|
||||
"## Структурные блоки",
|
||||
"",
|
||||
renderStructuredBlocks(parsedContent.blocks),
|
||||
"",
|
||||
"## Подзадачи",
|
||||
"",
|
||||
renderSubIssues(context),
|
||||
"",
|
||||
"## Связи",
|
||||
"",
|
||||
renderRelations(context),
|
||||
"",
|
||||
"## Ссылки",
|
||||
"",
|
||||
renderLinks(context.links),
|
||||
"",
|
||||
"## Файлы",
|
||||
"",
|
||||
renderAttachments(context.attachments),
|
||||
"",
|
||||
"## Комментарии",
|
||||
"",
|
||||
renderComments(context.comments),
|
||||
"",
|
||||
"## Реакции",
|
||||
"",
|
||||
renderReactions(context.reactions),
|
||||
"",
|
||||
"## История изменений",
|
||||
"",
|
||||
renderActivities(context.activities, context),
|
||||
"",
|
||||
"## Raw data snapshot",
|
||||
"",
|
||||
"```json",
|
||||
JSON.stringify(buildRawSnapshot(context), null, 2),
|
||||
"```",
|
||||
].join("\n")
|
||||
);
|
||||
|
||||
return {
|
||||
fileName: buildFileName(context),
|
||||
markdown,
|
||||
};
|
||||
};
|
||||
|
||||
export const downloadIssueMarkdownExport = ({ fileName, markdown }: TMarkdownExportResult) => {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
+3
-3
@@ -252,9 +252,9 @@ export function LabelDropdown(props: ILabelDropdownProps) {
|
||||
multiple
|
||||
>
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<Combobox.Options data-prevent-outside-click className="nodedc-external-popup-anchor fixed z-[760]" static>
|
||||
<div
|
||||
className={`nodedc-dropdown-surface z-10 my-1 h-auto w-52 whitespace-nowrap ${optionsClassName}`}
|
||||
className={`nodedc-dropdown-surface nodedc-external-popup-anchor z-[760] my-1 h-auto w-52 whitespace-nowrap ${optionsClassName}`}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
@@ -263,7 +263,7 @@ export function LabelDropdown(props: ILabelDropdownProps) {
|
||||
<SearchIcon className="h-3.5 w-3.5 text-tertiary" />
|
||||
<Combobox.Input
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent px-0 py-0 text-12 text-secondary placeholder:text-placeholder outline-none focus:outline-none"
|
||||
className="w-full bg-transparent px-0 py-0 text-12 text-secondary outline-none placeholder:text-placeholder focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("common.search.label")}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { IssueSubscription } from "../issue-detail/subscription";
|
||||
import { WorkItemDetailQuickActions } from "../issue-layouts/quick-action-dropdowns";
|
||||
import { NameDescriptionUpdateStatus } from "../issue-update-status";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
import { IssueMarkdownExportButton } from "../issue-detail/markdown-export-button";
|
||||
|
||||
export type TPeekModes = "side-peek" | "modal" | "full-screen";
|
||||
|
||||
@@ -104,25 +105,31 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
|
||||
isArchived,
|
||||
});
|
||||
|
||||
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const handleCopyText = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
copyUrlToClipboard(workItemLink).then(() => {
|
||||
|
||||
try {
|
||||
await copyUrlToClipboard(workItemLink);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("common.link_copied_to_clipboard"),
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
title: t("toast.error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteIssue = async () => {
|
||||
try {
|
||||
const deleteIssue = issueDetails?.archived_at ? removeArchivedIssue : removeIssue;
|
||||
|
||||
return deleteIssue(workspaceSlug, projectId, issueId).then(() => {
|
||||
await deleteIssue(workspaceSlug, projectId, issueId);
|
||||
setPeekIssue(undefined);
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
title: t("toast.error"),
|
||||
@@ -173,6 +180,15 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
|
||||
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
|
||||
{actionSlot}
|
||||
{issueDetails?.project_id && (
|
||||
<IssueMarkdownExportButton
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issueDetails.project_id}
|
||||
issueId={issueId}
|
||||
isArchived={isArchived}
|
||||
className="size-10 rounded-[18px] border-transparent bg-layer-2/80 shadow-none backdrop-blur-xl hover:bg-layer-2-active focus-visible:outline-none"
|
||||
/>
|
||||
)}
|
||||
{showSubscription && currentUser && !isArchived && (
|
||||
<IssueSubscription
|
||||
workspaceSlug={workspaceSlug}
|
||||
|
||||
@@ -10,14 +10,16 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type DragEvent as ReactDragEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Download, Maximize2, Minimize2, X } from "lucide-react";
|
||||
import { Download, Maximize2, Minimize2, UploadCloud, X } from "lucide-react";
|
||||
// plane imports
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TNameDescriptionLoader } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
import { cn, convertBytesToSize } from "@plane/utils";
|
||||
@@ -26,6 +28,7 @@ import { BEAM_VIEWER_OPEN_EVENT, type TBeamViewerOpenEventDetail } from "@/helpe
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import usePeekOverviewOutsideClickDetector from "@/hooks/use-peek-overview-outside-click";
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
// local imports
|
||||
import type { TIssueOperations } from "../issue-detail";
|
||||
import { IssueActivity } from "../issue-detail/issue-activity";
|
||||
@@ -40,6 +43,8 @@ import { PeekOverviewProperties } from "./properties";
|
||||
const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:issue-peek-width";
|
||||
const BEAM_VIEWER_CLOSING_CLASS_NAME = "nodedc-beam-viewer-closing";
|
||||
|
||||
const isFileDragEvent = (event: ReactDragEvent<HTMLElement>) => Array.from(event.dataTransfer.types).includes("Files");
|
||||
|
||||
interface IIssueView {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
@@ -96,6 +101,8 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||
const [beamPeekViewer, setBeamPeekViewer] = useState<TBeamViewerOpenEventDetail | null>(null);
|
||||
const [isBeamPeekFullscreen, setIsBeamPeekFullscreen] = useState(false);
|
||||
const [isBeamPeekClosing, setIsBeamPeekClosing] = useState(false);
|
||||
const [isCardAttachmentUploading, setIsCardAttachmentUploading] = useState(false);
|
||||
const [isCardAttachmentDragActive, setIsCardAttachmentDragActive] = useState(false);
|
||||
const [sidePeekWidth, setSidePeekWidth] = useState<number>(() => {
|
||||
if (typeof window === "undefined") return 720;
|
||||
|
||||
@@ -114,17 +121,158 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||
const initialMouseXRef = useRef<number>(0);
|
||||
const livePeekWidthRef = useRef<number>(sidePeekWidth);
|
||||
const beamCloseTimeoutRef = useRef<number | null>(null);
|
||||
const cardAttachmentDragDepthRef = useRef(0);
|
||||
// store hooks
|
||||
const {
|
||||
setPeekIssue,
|
||||
isAnyModalOpen,
|
||||
createAttachment,
|
||||
fetchActivities,
|
||||
fetchAttachments,
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS);
|
||||
const { fileSizeLimitEnabled, maxFileSize } = useFileSize();
|
||||
const issue = getIssueById(issueId);
|
||||
const shouldUseInteractiveEmbeddedLayout = embedIssue && interactiveEmbeddedLayout;
|
||||
const shouldRenderPeekSurface = !embedIssue || shouldUseInteractiveEmbeddedLayout;
|
||||
const shouldAllowPeekResize = !embedIssue || shouldUseInteractiveEmbeddedLayout;
|
||||
const isCardAttachmentDropDisabled =
|
||||
disabled || is_archived || !!isLoading || !!isError || !issue || isCardAttachmentUploading;
|
||||
|
||||
const handleCardAttachmentDrop = useCallback(
|
||||
async (acceptedFiles: File[], rejectedFileCount = 0) => {
|
||||
if (acceptedFiles.length === 0) {
|
||||
if (rejectedFileCount > 0) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Файлы не прикреплены",
|
||||
message: fileSizeLimitEnabled
|
||||
? `Проверьте размер файлов: максимум ${Math.round(maxFileSize / 1024 / 1024)} МБ на файл.`
|
||||
: "Не удалось принять выбранные файлы.",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCardAttachmentUploading(true);
|
||||
try {
|
||||
const uploadResults = await Promise.allSettled(
|
||||
acceptedFiles.map((file) => createAttachment(workspaceSlug, projectId, issueId, file))
|
||||
);
|
||||
const failedUploads = uploadResults.filter((result) => result.status === "rejected");
|
||||
|
||||
await Promise.allSettled([
|
||||
fetchAttachments(workspaceSlug, projectId, issueId),
|
||||
fetchActivities(workspaceSlug, projectId, issueId),
|
||||
]);
|
||||
|
||||
if (failedUploads.length > 0 || rejectedFileCount > 0) {
|
||||
const failedCount = failedUploads.length + rejectedFileCount;
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title:
|
||||
failedCount === acceptedFiles.length + rejectedFileCount ? "Файлы не прикреплены" : "Загружено не всё",
|
||||
message: `${failedCount} ${failedCount === 1 ? "файл не удалось прикрепить" : "файла не удалось прикрепить"}.`,
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: acceptedFiles.length === 1 ? "Файл прикреплён" : "Файлы прикреплены",
|
||||
message:
|
||||
acceptedFiles.length === 1
|
||||
? (acceptedFiles[0]?.name ?? "Вложение добавлено в карточку.")
|
||||
: `${acceptedFiles.length} файлов добавлено в карточку.`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsCardAttachmentUploading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
createAttachment,
|
||||
fetchActivities,
|
||||
fetchAttachments,
|
||||
fileSizeLimitEnabled,
|
||||
issueId,
|
||||
maxFileSize,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
]
|
||||
);
|
||||
|
||||
const resetCardAttachmentDrag = useCallback(() => {
|
||||
cardAttachmentDragDepthRef.current = 0;
|
||||
setIsCardAttachmentDragActive(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("dragend", resetCardAttachmentDrag, true);
|
||||
window.addEventListener("drop", resetCardAttachmentDrag, true);
|
||||
window.addEventListener("blur", resetCardAttachmentDrag);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("dragend", resetCardAttachmentDrag, true);
|
||||
window.removeEventListener("drop", resetCardAttachmentDrag, true);
|
||||
window.removeEventListener("blur", resetCardAttachmentDrag);
|
||||
};
|
||||
}, [resetCardAttachmentDrag]);
|
||||
|
||||
const handleCardDragEnterCapture = useCallback(
|
||||
(event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (isCardAttachmentDropDisabled || !isFileDragEvent(event)) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cardAttachmentDragDepthRef.current += 1;
|
||||
setIsCardAttachmentDragActive(true);
|
||||
},
|
||||
[isCardAttachmentDropDisabled]
|
||||
);
|
||||
|
||||
const handleCardDragOverCapture = useCallback(
|
||||
(event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (isCardAttachmentDropDisabled || !isFileDragEvent(event)) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
setIsCardAttachmentDragActive(true);
|
||||
},
|
||||
[isCardAttachmentDropDisabled]
|
||||
);
|
||||
|
||||
const handleCardDragLeaveCapture = useCallback(
|
||||
(event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (isCardAttachmentDropDisabled || !isFileDragEvent(event)) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cardAttachmentDragDepthRef.current = Math.max(0, cardAttachmentDragDepthRef.current - 1);
|
||||
if (cardAttachmentDragDepthRef.current === 0) setIsCardAttachmentDragActive(false);
|
||||
},
|
||||
[isCardAttachmentDropDisabled]
|
||||
);
|
||||
|
||||
const handleCardDropCapture = useCallback(
|
||||
(event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!isFileDragEvent(event)) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resetCardAttachmentDrag();
|
||||
if (isCardAttachmentDropDisabled) return;
|
||||
|
||||
const droppedFiles = Array.from(event.dataTransfer.files);
|
||||
const acceptedFiles = fileSizeLimitEnabled
|
||||
? droppedFiles.filter((file) => file.size <= maxFileSize)
|
||||
: droppedFiles;
|
||||
const rejectedFileCount = droppedFiles.length - acceptedFiles.length;
|
||||
|
||||
void handleCardAttachmentDrop(acceptedFiles, rejectedFileCount);
|
||||
},
|
||||
[fileSizeLimitEnabled, handleCardAttachmentDrop, isCardAttachmentDropDisabled, maxFileSize, resetCardAttachmentDrag]
|
||||
);
|
||||
// remove peek id
|
||||
const removeRoutePeekId = () => {
|
||||
setPeekIssue(undefined);
|
||||
@@ -407,7 +555,32 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||
};
|
||||
|
||||
const issuePanel = issueId ? (
|
||||
<div ref={issuePeekOverviewRef} className={peekOverviewIssueClassName} style={issuePanelStyle}>
|
||||
<div
|
||||
ref={issuePeekOverviewRef}
|
||||
className={peekOverviewIssueClassName}
|
||||
style={issuePanelStyle}
|
||||
data-card-attachment-drop-active={isCardAttachmentDragActive ? "true" : "false"}
|
||||
onDragEnterCapture={handleCardDragEnterCapture}
|
||||
onDragOverCapture={handleCardDragOverCapture}
|
||||
onDragLeaveCapture={handleCardDragLeaveCapture}
|
||||
onDropCapture={handleCardDropCapture}
|
||||
>
|
||||
{isCardAttachmentDragActive && (
|
||||
<div className="pointer-events-none absolute inset-0 z-[96] flex items-center justify-center overflow-hidden rounded-[inherit] bg-surface-2/88 p-6 backdrop-blur-md">
|
||||
<div className="absolute inset-3 rounded-[22px] border border-dashed border-[#303432] bg-white/[0.01]" />
|
||||
<div className="relative flex max-w-md flex-col items-center text-center">
|
||||
<div className="grid size-16 place-items-center rounded-3xl bg-[rgba(var(--nodedc-accent-rgb),0.14)] text-[rgb(var(--nodedc-accent-rgb))]">
|
||||
<UploadCloud className="size-7" />
|
||||
</div>
|
||||
<div className="text-17 mt-5 font-semibold text-primary">Отпустите файлы, чтобы прикрепить</div>
|
||||
<div className="mt-2 text-13 text-secondary">
|
||||
{fileSizeLimitEnabled
|
||||
? `Можно несколько файлов за раз, до ${Math.round(maxFileSize / 1024 / 1024)} МБ каждый`
|
||||
: "Можно прикрепить несколько файлов за раз"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{shouldAllowPeekResize && peekMode === "side-peek" && (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-[81] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
|
||||
|
||||
@@ -11,7 +11,7 @@ import { NETWORK_CHOICES } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// plane imports
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import { LockIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
@@ -32,6 +32,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { ProjectService } from "@/services/project";
|
||||
// local imports
|
||||
import { ProjectNetworkIcon } from "./project-network-icon";
|
||||
import { ProjectLogoPickerModal } from "./project-logo-picker-modal";
|
||||
|
||||
export interface IProjectDetailsForm {
|
||||
project: IProject;
|
||||
@@ -45,7 +46,7 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
|
||||
const { project, workspaceSlug, projectId, isAdmin } = props;
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLogoPickerOpen, setIsLogoPickerOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// store hooks
|
||||
const { updateProject } = useProject();
|
||||
@@ -192,6 +193,13 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectLogoPickerModal
|
||||
isOpen={isLogoPickerOpen}
|
||||
logo={watch("logo_props")}
|
||||
onChange={(logo) => setValue("logo_props", logo, { shouldDirty: true })}
|
||||
onClose={() => setIsLogoPickerOpen(false)}
|
||||
/>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="relative h-44 w-full">
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
@@ -199,42 +207,15 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
|
||||
<div className="absolute bottom-4 z-5 flex w-full items-end justify-between gap-3 px-4">
|
||||
<div className="flex min-w-0 flex-grow gap-3 truncate">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 rounded-[1.2rem] bg-white/10 px-2.5 py-2.5 backdrop-blur-2xl">
|
||||
<Controller
|
||||
control={control}
|
||||
name="logo_props"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<EmojiPicker
|
||||
iconType="material"
|
||||
closeOnSelect={false}
|
||||
isOpen={isOpen}
|
||||
handleToggle={(val: boolean) => setIsOpen(val)}
|
||||
className="flex items-center justify-center"
|
||||
buttonClassName="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-lg bg-white/[0.06]"
|
||||
label={<Logo logo={value} size={28} />}
|
||||
// TODO: fix types
|
||||
onChange={(val: any) => {
|
||||
let logoValue = {};
|
||||
|
||||
if (val?.type === "emoji")
|
||||
logoValue = {
|
||||
value: val.value,
|
||||
};
|
||||
else if (val?.type === "icon") logoValue = val.value;
|
||||
|
||||
onChange({
|
||||
in_use: val?.type,
|
||||
[val?.type]: logoValue,
|
||||
});
|
||||
setIsOpen(false);
|
||||
}}
|
||||
defaultIconColor={value?.in_use && value.in_use === "icon" ? value?.icon?.color : undefined}
|
||||
defaultOpen={
|
||||
value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLogoPickerOpen(true)}
|
||||
className="flex h-[52px] w-[52px] flex-shrink-0 items-center justify-center rounded-lg bg-white/[0.06] transition-colors hover:bg-white/[0.12] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-label="Изменить иконку проекта"
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
<Logo logo={watch("logo_props")} size={28} />
|
||||
</button>
|
||||
<div className="flex flex-col gap-1 truncate text-on-color">
|
||||
<span className="truncate text-16 font-semibold">{watch("name")}</span>
|
||||
<span className="flex items-center gap-2 text-13">
|
||||
@@ -448,5 +429,6 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { EmojiIconPickerTypes, EmojiRoot, IconRoot, emojiToString } from "@plane/propel/emoji-icon-picker";
|
||||
import { CloseIcon } from "@plane/propel/icons";
|
||||
import type { TLogoProps } from "@plane/types";
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
logo: TLogoProps;
|
||||
onChange: (logo: TLogoProps) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function ProjectLogoPickerModal(props: Props) {
|
||||
const { isOpen, logo, onChange, onClose } = props;
|
||||
const defaultTab =
|
||||
logo?.in_use === EmojiIconPickerTypes.EMOJI ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON;
|
||||
const defaultIconColor = logo?.in_use === EmojiIconPickerTypes.ICON ? (logo.icon?.color ?? "#6d7b8a") : "#6d7b8a";
|
||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) setActiveTab(defaultTab);
|
||||
}, [defaultTab, isOpen]);
|
||||
|
||||
const handleEmojiChange = (emoji: string) => {
|
||||
onChange({
|
||||
in_use: EmojiIconPickerTypes.EMOJI,
|
||||
emoji: { value: emojiToString(emoji) },
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleIconChange = (icon: { name: string; color: string }) => {
|
||||
onChange({
|
||||
in_use: EmojiIconPickerTypes.ICON,
|
||||
icon,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
handleClose={onClose}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.XL}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-subtle px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-16 font-semibold text-primary">Изменить иконку проекта</h3>
|
||||
<p className="mt-1 text-12 text-secondary">Выберите emoji или иконку с цветом.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="grid size-8 place-items-center rounded-lg text-tertiary transition-colors hover:bg-layer-1 hover:text-primary"
|
||||
aria-label="Закрыть выбор иконки"
|
||||
>
|
||||
<CloseIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 px-5 pt-4" role="tablist" aria-label="Тип иконки">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === EmojiIconPickerTypes.EMOJI}
|
||||
onClick={() => setActiveTab(EmojiIconPickerTypes.EMOJI)}
|
||||
className={
|
||||
activeTab === EmojiIconPickerTypes.EMOJI
|
||||
? "rounded-md border border-strong bg-surface-1 py-2 text-13 text-primary"
|
||||
: "rounded-md border border-subtle bg-layer-1 py-2 text-13 text-placeholder hover:bg-layer-1/60 hover:text-tertiary"
|
||||
}
|
||||
>
|
||||
Emoji
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === EmojiIconPickerTypes.ICON}
|
||||
onClick={() => setActiveTab(EmojiIconPickerTypes.ICON)}
|
||||
className={
|
||||
activeTab === EmojiIconPickerTypes.ICON
|
||||
? "rounded-md border border-strong bg-surface-1 py-2 text-13 text-primary"
|
||||
: "rounded-md border border-subtle bg-layer-1 py-2 text-13 text-placeholder hover:bg-layer-1/60 hover:text-tertiary"
|
||||
}
|
||||
>
|
||||
Иконка
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === EmojiIconPickerTypes.EMOJI && (
|
||||
<div className="h-[25rem] overflow-hidden" role="tabpanel">
|
||||
<EmojiRoot onChange={handleEmojiChange} searchPlaceholder="Поиск emoji" />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === EmojiIconPickerTypes.ICON && (
|
||||
<div className="h-[25rem] overflow-y-auto" role="tabpanel">
|
||||
<IconRoot iconType="material" defaultColor={defaultIconColor} onChange={handleIconChange} />
|
||||
</div>
|
||||
)}
|
||||
</ModalCore>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { isAxiosError } from "axios";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
@@ -17,16 +18,44 @@ type TInstanceWrapper = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const INSTANCE_RETRY_BASE_INTERVAL = 2_000;
|
||||
const INSTANCE_RETRY_MAX_INTERVAL = 30_000;
|
||||
|
||||
export const getInstanceRetryInterval = (retryCount: number) =>
|
||||
Math.min(INSTANCE_RETRY_MAX_INTERVAL, INSTANCE_RETRY_BASE_INTERVAL * 2 ** Math.min(Math.max(retryCount - 1, 0), 4));
|
||||
|
||||
export const getInstanceErrorStatus = (error: unknown) => (isAxiosError(error) ? error.response?.status : undefined);
|
||||
|
||||
export const shouldRetryInstanceRequest = (error: unknown) => {
|
||||
if (!isAxiosError(error) || !error.response) return true;
|
||||
|
||||
const { status } = error.response;
|
||||
return status === 408 || status === 429 || status >= 500;
|
||||
};
|
||||
|
||||
const InstanceWrapper = observer(function InstanceWrapper(props: TInstanceWrapper) {
|
||||
const { children } = props;
|
||||
// store
|
||||
const { isLoading, instance, error, fetchInstanceInfo } = useInstance();
|
||||
|
||||
const { isLoading: isInstanceSWRLoading, error: instanceSWRError } = useSWR(
|
||||
"INSTANCE_INFORMATION",
|
||||
async () => await fetchInstanceInfo(),
|
||||
{ revalidateOnFocus: false }
|
||||
);
|
||||
const {
|
||||
isLoading: isInstanceSWRLoading,
|
||||
isValidating: isInstanceSWRValidating,
|
||||
error: instanceSWRError,
|
||||
mutate: retryInstanceInfo,
|
||||
} = useSWR("INSTANCE_INFORMATION", async () => await fetchInstanceInfo(), {
|
||||
revalidateOnFocus: true,
|
||||
revalidateOnReconnect: true,
|
||||
shouldRetryOnError: true,
|
||||
onErrorRetry: (requestError, _key, _config, revalidate, { retryCount }) => {
|
||||
if (typeof navigator !== "undefined" && !navigator.onLine) return;
|
||||
if (!shouldRetryInstanceRequest(requestError)) return;
|
||||
|
||||
setTimeout(() => {
|
||||
void revalidate({ retryCount });
|
||||
}, getInstanceRetryInterval(retryCount));
|
||||
},
|
||||
});
|
||||
|
||||
// loading state
|
||||
if ((isLoading || isInstanceSWRLoading) && !instance)
|
||||
@@ -36,7 +65,27 @@ const InstanceWrapper = observer(function InstanceWrapper(props: TInstanceWrappe
|
||||
</div>
|
||||
);
|
||||
|
||||
if (instanceSWRError) return <MaintenanceView />;
|
||||
if (instanceSWRError) {
|
||||
const statusCode = getInstanceErrorStatus(instanceSWRError);
|
||||
const reason =
|
||||
statusCode === 502 || statusCode === 503 || statusCode === 504
|
||||
? "starting"
|
||||
: statusCode === undefined
|
||||
? "offline"
|
||||
: "unavailable";
|
||||
|
||||
return (
|
||||
<MaintenanceView
|
||||
autoRetry={shouldRetryInstanceRequest(instanceSWRError)}
|
||||
isRetrying={isInstanceSWRValidating}
|
||||
reason={reason}
|
||||
statusCode={statusCode}
|
||||
onRetry={() => {
|
||||
void retryInstanceInfo();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// something went wrong while in the request
|
||||
if (error && error?.status === "error") return <>{children}</>;
|
||||
|
||||
@@ -9,7 +9,6 @@ import { API_BASE_URL } from "@plane/constants";
|
||||
// plane types
|
||||
import { getFileMetaDataForUpload, generateFileUploadPayload } from "@plane/services";
|
||||
import type {
|
||||
TFileMetaDataLite,
|
||||
TIssueAttachment,
|
||||
TIssueAttachmentUploadResponse,
|
||||
TIssueServiceType,
|
||||
@@ -17,17 +16,11 @@ import type {
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// services
|
||||
import {
|
||||
deleteBeamModelAsset,
|
||||
deleteBeamModelVersion,
|
||||
getBeamModelVersionRecords,
|
||||
getBeamModelMimeType,
|
||||
getBeamViewerAttachment,
|
||||
getBeamViewerVersionNumber,
|
||||
isBeamModelFile,
|
||||
mergeBeamModelVersionRecords,
|
||||
type TBeamModelVersionRecord,
|
||||
type TBeamViewerAttachment,
|
||||
uploadBeamModelFile,
|
||||
type TBeamConversionStatus,
|
||||
type TBeamVersionHistoryResponse,
|
||||
} from "@/helpers/beam-viewer";
|
||||
import { APIService } from "@/services/api.service";
|
||||
import { FileUploadService } from "@/services/file-upload.service";
|
||||
@@ -49,6 +42,15 @@ export class IssueAttachmentService extends APIService {
|
||||
this.serviceType = serviceType;
|
||||
}
|
||||
|
||||
private attachmentBasePath(workspaceSlug: string, projectId: string, issueId: string): string {
|
||||
return `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments`;
|
||||
}
|
||||
|
||||
private toBeamRequestError(error: unknown, fallback: string): Error {
|
||||
const responseData = (error as { response?: { data?: { message?: string; error?: string } } })?.response?.data;
|
||||
return new Error(responseData?.message || responseData?.error || fallback);
|
||||
}
|
||||
|
||||
private async updateIssueAttachmentUploadStatus(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
@@ -64,43 +66,6 @@ export class IssueAttachmentService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
private async createBeamIssueAttachmentReference(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
payload: TFileMetaDataLite & { beamViewer: TBeamViewerAttachment }
|
||||
): Promise<TIssueAttachment> {
|
||||
return this.post(
|
||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,
|
||||
payload
|
||||
)
|
||||
.then((response) => {
|
||||
const createResponse: TIssueAttachmentCreateResponse = response?.data;
|
||||
return createResponse.attachment;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data ?? error;
|
||||
});
|
||||
}
|
||||
|
||||
async updateBeamIssueAttachmentReference(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
attachmentId: string,
|
||||
beamViewer: TBeamViewerAttachment,
|
||||
attributes: Record<string, unknown> = {}
|
||||
): Promise<TIssueAttachment> {
|
||||
return this.patch(
|
||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${attachmentId}/`,
|
||||
{ ...attributes, beamViewer }
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data ?? error;
|
||||
});
|
||||
}
|
||||
|
||||
async uploadBeamIssueAttachmentVersion(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
@@ -108,60 +73,26 @@ export class IssueAttachmentService extends APIService {
|
||||
attachment: TIssueAttachment,
|
||||
file: File,
|
||||
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"],
|
||||
uploadedBy?: string
|
||||
_uploadedBy?: string
|
||||
): Promise<TIssueAttachment> {
|
||||
const previousBeamViewer = getBeamViewerAttachment(attachment);
|
||||
if (!previousBeamViewer) {
|
||||
if (!getBeamViewerAttachment(attachment)) {
|
||||
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 formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return this.post(
|
||||
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachment.id}/bim-versions/`,
|
||||
formData,
|
||||
{ onUploadProgress: uploadProgressHandler }
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw this.toBeamRequestError(error, "Не удалось загрузить новую версию BIM-модели.");
|
||||
});
|
||||
const nextVersions = mergeBeamModelVersionRecords(
|
||||
{ ...previousBeamViewer, assetId, versions: previousVersions },
|
||||
nextBeamViewer,
|
||||
attachment
|
||||
);
|
||||
|
||||
return this.updateBeamIssueAttachmentReference(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
attachment.id,
|
||||
{
|
||||
...nextBeamViewer,
|
||||
assetId,
|
||||
projectId: nextBeamViewer.projectId || previousBeamViewer.projectId,
|
||||
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(
|
||||
@@ -171,35 +102,68 @@ export class IssueAttachmentService extends APIService {
|
||||
attachment: TIssueAttachment,
|
||||
versionToDelete: TBeamModelVersionRecord
|
||||
): Promise<TIssueAttachment> {
|
||||
const beamViewer = getBeamViewerAttachment(attachment);
|
||||
if (!beamViewer) {
|
||||
if (!getBeamViewerAttachment(attachment)) {
|
||||
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 versionKey = encodeURIComponent(versionToDelete.versionId || String(versionToDelete.version));
|
||||
return this.delete(
|
||||
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachment.id}/bim-versions/${versionKey}/`
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw this.toBeamRequestError(error, "Не удалось удалить версию BIM-модели.");
|
||||
});
|
||||
}
|
||||
|
||||
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("Версия не найдена.");
|
||||
async getBeamIssueAttachmentStatus(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
attachmentId: string
|
||||
): Promise<TBeamConversionStatus> {
|
||||
return this.get(
|
||||
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachmentId}/bim-status/`
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw this.toBeamRequestError(error, "Не удалось получить статус BIM-модели.");
|
||||
});
|
||||
}
|
||||
|
||||
await deleteBeamModelVersion(versionToDelete);
|
||||
async getBeamIssueAttachmentVersions(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
attachmentId: string
|
||||
): Promise<TBeamVersionHistoryResponse> {
|
||||
return this.get(
|
||||
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachmentId}/bim-versions/`
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw this.toBeamRequestError(error, "Не удалось получить историю BIM-модели.");
|
||||
});
|
||||
}
|
||||
|
||||
return this.updateBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, attachment.id, {
|
||||
...beamViewer,
|
||||
versions: nextVersions,
|
||||
async getBeamIssueAttachmentViewerUrl(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
attachmentId: string,
|
||||
versionId?: string
|
||||
): Promise<string> {
|
||||
return this.post(
|
||||
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachmentId}/bim-viewer/`,
|
||||
versionId ? { versionId } : {}
|
||||
)
|
||||
.then((response) => {
|
||||
const viewerUrl = response?.data?.viewerUrl;
|
||||
if (!viewerUrl) throw new Error("Ops не вернул ссылку запуска BIM Viewer.");
|
||||
return viewerUrl as string;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof Error && !("response" in error)) throw error;
|
||||
throw this.toBeamRequestError(error, "Не удалось открыть BIM-модель.");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,18 +175,19 @@ export class IssueAttachmentService extends APIService {
|
||||
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
|
||||
): Promise<TIssueAttachment> {
|
||||
if (isBeamModelFile(file.name)) {
|
||||
const beamViewer = await uploadBeamModelFile(file, {
|
||||
issueId,
|
||||
onUploadProgress: uploadProgressHandler,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
});
|
||||
|
||||
return this.createBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, {
|
||||
beamViewer,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: getBeamModelMimeType(file),
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return this.post(
|
||||
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/bim-upload/`,
|
||||
formData,
|
||||
{ onUploadProgress: uploadProgressHandler }
|
||||
)
|
||||
.then((response) => {
|
||||
const createResponse: TIssueAttachmentCreateResponse = response?.data;
|
||||
return createResponse.attachment;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw this.toBeamRequestError(error, "Не удалось загрузить модель через Ops.");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -278,15 +243,8 @@ export class IssueAttachmentService extends APIService {
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
assetId: string,
|
||||
attachment?: TIssueAttachment
|
||||
_attachment?: TIssueAttachment
|
||||
): Promise<TIssueAttachment> {
|
||||
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}/`
|
||||
)
|
||||
|
||||
@@ -139,6 +139,21 @@ export type TAIWorkspaceBridgeEventsResponse = {
|
||||
events: TAIWorkspaceBridgeEvent[];
|
||||
};
|
||||
|
||||
export type TAIWorkspaceAgentSetupCommandResponse = {
|
||||
ok: boolean;
|
||||
executorId?: string;
|
||||
install?: {
|
||||
command?: string;
|
||||
packageName?: string;
|
||||
gatewayUrl?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
setupCode?: {
|
||||
suffix?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TAIWorkspaceOpsProject = {
|
||||
id: string;
|
||||
identifier?: string | null;
|
||||
@@ -284,6 +299,22 @@ export class WorkspaceAIWorkspaceService extends APIService {
|
||||
return this.listExecutors(workspaceSlug);
|
||||
}
|
||||
|
||||
async createAgentSetupCommand(
|
||||
workspaceSlug: string,
|
||||
executorId: string,
|
||||
options: { port?: string | number; opsWorkspaceSlug?: string; opsProjectId?: string } = {}
|
||||
): Promise<TAIWorkspaceAgentSetupCommandResponse> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/ai-workspace/executors/${executorId}/agent/setup-command/`, {
|
||||
port: String(options.port || "").trim() || undefined,
|
||||
opsWorkspaceSlug: String(options.opsWorkspaceSlug || "").trim() || undefined,
|
||||
opsProjectId: String(options.opsProjectId || "").trim() || undefined,
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data ?? error;
|
||||
});
|
||||
}
|
||||
|
||||
getWindowsAgentInstallerUrl(
|
||||
workspaceSlug: string,
|
||||
executorId: string,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEFAULT_BEAM_VIEWER_BASE_URL,
|
||||
getBeamDirectModelTypeFromFileName,
|
||||
getBeamModelTypeFromFileName,
|
||||
// @ts-expect-error Node's strip-types test runner requires the explicit TypeScript extension.
|
||||
} from "./beam-viewer-config.ts";
|
||||
|
||||
test("routes STEP and IGES variants through the converter", () => {
|
||||
assert.equal(getBeamModelTypeFromFileName("model.step"), "step");
|
||||
assert.equal(getBeamModelTypeFromFileName("model.STP"), "step");
|
||||
assert.equal(getBeamModelTypeFromFileName("model.iges"), "iges");
|
||||
assert.equal(getBeamModelTypeFromFileName("model.IGS"), "iges");
|
||||
assert.equal(getBeamDirectModelTypeFromFileName("model.igs"), null);
|
||||
});
|
||||
|
||||
test("uses the production BIM service when build args are absent", () => {
|
||||
assert.equal(DEFAULT_BEAM_VIEWER_BASE_URL, "https://bim.nodedc.tech");
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
export const DEFAULT_BEAM_VIEWER_BASE_URL = "https://bim.nodedc.tech";
|
||||
|
||||
const BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
bim: "bim",
|
||||
glb: "gltf",
|
||||
gltf: "gltf",
|
||||
las: "las",
|
||||
laz: "las",
|
||||
obj: "obj",
|
||||
stl: "stl",
|
||||
xkt: "xkt",
|
||||
};
|
||||
|
||||
const BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
iges: "iges",
|
||||
igs: "iges",
|
||||
step: "step",
|
||||
stp: "step",
|
||||
};
|
||||
|
||||
export const getBeamFileExtension = (name: string): string => {
|
||||
const parts = name.split(".");
|
||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
|
||||
};
|
||||
|
||||
export const getBeamModelTypeFromFileName = (name: string | undefined): string | null => {
|
||||
if (!name) return null;
|
||||
const extension = getBeamFileExtension(name);
|
||||
return BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION[extension] ?? BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION[extension] ?? null;
|
||||
};
|
||||
|
||||
export const getBeamDirectModelTypeFromFileName = (name: string | undefined): string | null => {
|
||||
if (!name) return null;
|
||||
return BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION[getBeamFileExtension(name)] ?? null;
|
||||
};
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type { TIssueAttachment } from "@plane/types";
|
||||
import {
|
||||
DEFAULT_BEAM_VIEWER_BASE_URL,
|
||||
getBeamDirectModelTypeFromFileName,
|
||||
getBeamFileExtension,
|
||||
getBeamModelTypeFromFileName,
|
||||
} from "./beam-viewer-config";
|
||||
|
||||
export type TBeamViewerConversion = {
|
||||
artifactSrc?: string;
|
||||
@@ -37,12 +42,13 @@ export type TBeamModelVersionRecord = {
|
||||
|
||||
export type TBeamViewerAttachment = {
|
||||
assetId?: string;
|
||||
backend: "beam-viewer-local" | "beam-viewer";
|
||||
backend: "beam-viewer-local" | "beam-viewer-ops" | "beam-viewer";
|
||||
conversion?: TBeamViewerConversion;
|
||||
downloadUrl: string;
|
||||
originalFilename: string;
|
||||
previewAvailable: boolean;
|
||||
projectId?: string;
|
||||
registryOwnerId?: string;
|
||||
sha256?: string;
|
||||
src: string;
|
||||
type: string;
|
||||
@@ -101,24 +107,6 @@ export const dispatchBeamViewerOpenEvent = (detail: TBeamViewerOpenEventDetail)
|
||||
window.dispatchEvent(new CustomEvent<TBeamViewerOpenEventDetail>(BEAM_VIEWER_OPEN_EVENT, { detail }));
|
||||
};
|
||||
|
||||
const DEFAULT_BEAM_VIEWER_BASE_URL = "http://localhost:8080";
|
||||
|
||||
const BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
bim: "bim",
|
||||
glb: "gltf",
|
||||
gltf: "gltf",
|
||||
las: "las",
|
||||
laz: "las",
|
||||
obj: "obj",
|
||||
stl: "stl",
|
||||
xkt: "xkt",
|
||||
};
|
||||
|
||||
const BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
step: "step",
|
||||
stp: "step",
|
||||
};
|
||||
|
||||
const MIME_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
glb: "model/gltf-binary",
|
||||
gltf: "model/gltf+json",
|
||||
@@ -127,35 +115,13 @@ const MIME_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||
const normalizeBaseUrl = (url: string | undefined): string =>
|
||||
(url && url.trim() ? url.trim() : DEFAULT_BEAM_VIEWER_BASE_URL).replace(/\/+$/, "");
|
||||
|
||||
const getFileExtension = (name: string): string => {
|
||||
const parts = name.split(".");
|
||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
|
||||
};
|
||||
|
||||
const getUploadGroupId = (parts: Array<string | undefined>): string => {
|
||||
const value = parts
|
||||
.filter(Boolean)
|
||||
.join("_")
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
return value || "tasker";
|
||||
};
|
||||
|
||||
const toBeamRelativeUploadSrc = (src: string): string => {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
return url.pathname.replace(/^\/+/, "");
|
||||
} catch (_error) {
|
||||
return src.replace(/^\/+/, "");
|
||||
}
|
||||
};
|
||||
|
||||
const toBeamAbsoluteUrl = (src: string | undefined, cacheKey?: string): string | undefined => {
|
||||
if (!src) return undefined;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(src);
|
||||
} catch (_error) {
|
||||
url = new URL(src.startsWith("/") ? src : `/${src}`, `${getBeamApiBaseUrl()}/`);
|
||||
url = new URL(src.startsWith("/") ? src : `/${src}`, `${getBeamViewerBaseUrl()}/`);
|
||||
}
|
||||
if (cacheKey) url.searchParams.set("v", cacheKey);
|
||||
return url.toString();
|
||||
@@ -163,22 +129,16 @@ const toBeamAbsoluteUrl = (src: string | undefined, cacheKey?: string): string |
|
||||
|
||||
export const getBeamViewerBaseUrl = (): string => normalizeBaseUrl(process.env.VITE_BEAM_VIEWER_BASE_URL);
|
||||
|
||||
export const getBeamApiBaseUrl = (): string =>
|
||||
normalizeBaseUrl(process.env.VITE_BEAM_API_BASE_URL || process.env.VITE_BEAM_VIEWER_BASE_URL);
|
||||
|
||||
export const getBeamModelTypeFromName = (name: string | undefined): string | null => {
|
||||
if (!name) return null;
|
||||
const extension = getFileExtension(name);
|
||||
return BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION[extension] ?? BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION[extension] ?? null;
|
||||
return getBeamModelTypeFromFileName(name);
|
||||
};
|
||||
|
||||
export const getBeamDirectModelTypeFromName = (name: string | undefined): string | null => {
|
||||
if (!name) return null;
|
||||
return BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION[getFileExtension(name)] ?? null;
|
||||
return getBeamDirectModelTypeFromFileName(name);
|
||||
};
|
||||
|
||||
export const getBeamModelMimeType = (file: File): string =>
|
||||
MIME_TYPE_BY_EXTENSION[getFileExtension(file.name)] || "application/octet-stream";
|
||||
MIME_TYPE_BY_EXTENSION[getBeamFileExtension(file.name)] || "application/octet-stream";
|
||||
|
||||
export const isBeamModelFile = (name: string | undefined): boolean => !!getBeamModelTypeFromName(name);
|
||||
|
||||
@@ -337,188 +297,3 @@ export const getBeamVersionViewerUrl = (version: TBeamModelVersionRecord): strin
|
||||
type: artifactType,
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachment): Promise<TBeamConversionStatus> => {
|
||||
const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`);
|
||||
statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
|
||||
|
||||
const response = await fetch(statusUrl.toString(), { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`BIM conversion status failed: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as TBeamConversionStatus;
|
||||
return {
|
||||
...payload,
|
||||
artifactUrl: toBeamAbsoluteUrl(payload.artifactSrc, payload.updatedAt),
|
||||
metadataUrl: toBeamAbsoluteUrl(payload.metadataSrc),
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchBeamModelVersions = async (
|
||||
beamViewer: TBeamViewerAttachment
|
||||
): Promise<TBeamVersionHistoryResponse> => {
|
||||
const versionsUrl = new URL("/api/uploads/versions", `${getBeamApiBaseUrl()}/`);
|
||||
if (beamViewer.projectId && beamViewer.assetId) {
|
||||
versionsUrl.searchParams.set("projectId", beamViewer.projectId);
|
||||
versionsUrl.searchParams.set("assetId", beamViewer.assetId);
|
||||
} else {
|
||||
versionsUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
|
||||
}
|
||||
|
||||
const response = await fetch(versionsUrl.toString(), { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`BIM version history failed: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as TBeamVersionHistoryResponse;
|
||||
};
|
||||
|
||||
export const uploadBeamModelFile = (
|
||||
file: File,
|
||||
options: {
|
||||
assetId?: string;
|
||||
issueId?: string;
|
||||
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
|
||||
projectId?: string;
|
||||
uploadedBy?: string;
|
||||
version?: number;
|
||||
workspaceSlug?: string;
|
||||
} = {}
|
||||
): Promise<TBeamViewerAttachment> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const type = getBeamModelTypeFromName(file.name);
|
||||
if (!type) {
|
||||
reject(new Error("Формат модели не поддерживается BIM Viewer"));
|
||||
return;
|
||||
}
|
||||
const directViewerType = getBeamDirectModelTypeFromName(file.name);
|
||||
|
||||
const apiBaseUrl = getBeamApiBaseUrl();
|
||||
const uploadUrl = new URL("/api/uploads", `${apiBaseUrl}/`);
|
||||
uploadUrl.searchParams.set("filename", file.name);
|
||||
uploadUrl.searchParams.set(
|
||||
"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.withCredentials = true;
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
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.addEventListener("error", () => reject(new Error("Не удалось загрузить модель в BIM Viewer")));
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
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;
|
||||
projectId?: string;
|
||||
sha256?: string;
|
||||
src?: string;
|
||||
uploadedAt?: string;
|
||||
version?: number;
|
||||
versionId?: string;
|
||||
};
|
||||
if (!response.src) {
|
||||
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: response.originalFilename || file.name,
|
||||
previewAvailable: !!directViewerType,
|
||||
projectId: response.projectId,
|
||||
sha256: response.sha256,
|
||||
src: downloadUrl.toString(),
|
||||
type,
|
||||
uploadedBy: options.uploadedBy,
|
||||
uploadedAt: response.uploadedAt || new Date().toISOString(),
|
||||
version: response.version ?? options.version ?? 1,
|
||||
versionId: response.versionId,
|
||||
};
|
||||
|
||||
if (!directViewerType) {
|
||||
const nextAttachment: TBeamViewerAttachment = {
|
||||
...baseAttachment,
|
||||
conversion: {
|
||||
...response.conversion,
|
||||
componentTreeRequired: true,
|
||||
message:
|
||||
response.conversion?.message ??
|
||||
"Оригинальный STEP загружен. Просмотр появится после подготовки модели и дерева компонентов.",
|
||||
sourceFormat: type,
|
||||
status: response.conversion?.status ?? "conversion_required",
|
||||
targetFormat: response.conversion?.targetFormat ?? "xkt",
|
||||
},
|
||||
};
|
||||
resolve({
|
||||
...nextAttachment,
|
||||
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextAttachment: TBeamViewerAttachment = {
|
||||
...baseAttachment,
|
||||
viewerUrl: buildBeamViewerUrl({
|
||||
name: file.name,
|
||||
settingsSrc: downloadUrl.toString(),
|
||||
src: downloadUrl.toString(),
|
||||
type: directViewerType,
|
||||
}),
|
||||
};
|
||||
|
||||
resolve({
|
||||
...nextAttachment,
|
||||
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
|
||||
});
|
||||
} catch (_error) {
|
||||
reject(new Error("BIM Viewer вернул некорректный ответ"));
|
||||
}
|
||||
});
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
const deleteBeamStorageResource = async (
|
||||
endpoint: "/api/uploads/asset" | "/api/uploads/version",
|
||||
payload: unknown
|
||||
): Promise<void> => {
|
||||
const response = await fetch(new URL(endpoint, `${getBeamApiBaseUrl()}/`).toString(), {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
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<void> =>
|
||||
deleteBeamStorageResource("/api/uploads/asset", beamViewer);
|
||||
|
||||
export const deleteBeamModelVersion = async (version: TBeamModelVersionRecord): Promise<void> =>
|
||||
deleteBeamStorageResource("/api/uploads/version", version);
|
||||
|
||||
@@ -1163,7 +1163,7 @@
|
||||
|
||||
.ai-settings-field-row--download {
|
||||
align-items: end;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(96px, 112px) auto;
|
||||
}
|
||||
|
||||
.ai-settings-field > span {
|
||||
@@ -1409,7 +1409,7 @@
|
||||
|
||||
.ai-settings-actions .modal-btn:hover:not(:disabled),
|
||||
.ai-settings-empty-context .modal-btn:hover:not(:disabled),
|
||||
.ai-settings-inline-download:hover {
|
||||
.ai-settings-inline-download:hover:not(:disabled) {
|
||||
background: var(--nodedc-glass-control-hover);
|
||||
color: #ffffff;
|
||||
}
|
||||
@@ -1420,11 +1420,21 @@
|
||||
color: var(--brand-contrast);
|
||||
}
|
||||
|
||||
.ai-settings-inline-download.btn-primary {
|
||||
background: var(--brand);
|
||||
color: var(--brand-contrast);
|
||||
}
|
||||
|
||||
.ai-settings-actions .modal-btn:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.ai-settings-inline-download:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.ai-settings-context-pane > .ai-settings-actions,
|
||||
.ai-settings-details-pane > .ai-settings-actions {
|
||||
margin-top: auto;
|
||||
@@ -2393,7 +2403,8 @@
|
||||
var(--nodedc-list-property-icon-size)
|
||||
var(--nodedc-list-property-icon-size)
|
||||
var(--nodedc-list-property-icon-size);
|
||||
grid-auto-flow: row;
|
||||
grid-template-rows: var(--nodedc-list-property-chip-height);
|
||||
grid-auto-flow: column;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
column-gap: 0.28rem !important;
|
||||
@@ -2409,6 +2420,7 @@
|
||||
height: var(--nodedc-list-property-chip-height) !important;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
grid-row: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -2725,6 +2737,7 @@
|
||||
.nodedc-list-property-attachments,
|
||||
.nodedc-list-property-links {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@@ -7510,6 +7523,81 @@
|
||||
color: var(--text-color-primary) !important;
|
||||
}
|
||||
|
||||
.nodedc-attachment-markdown-preview {
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.nodedc-attachment-markdown-preview > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-attachment-markdown-preview h1,
|
||||
.nodedc-attachment-markdown-preview h2,
|
||||
.nodedc-attachment-markdown-preview h3,
|
||||
.nodedc-attachment-markdown-preview h4 {
|
||||
color: var(--text-color-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.nodedc-attachment-markdown-preview h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.nodedc-attachment-markdown-preview h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.nodedc-attachment-markdown-preview h3,
|
||||
.nodedc-attachment-markdown-preview h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.nodedc-attachment-markdown-preview blockquote {
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid rgba(var(--nodedc-accent-rgb), 0.48);
|
||||
color: var(--text-color-tertiary);
|
||||
}
|
||||
|
||||
.nodedc-attachment-markdown-preview code {
|
||||
padding: 0.12rem 0.35rem;
|
||||
border-radius: 0.35rem;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--text-color-primary);
|
||||
font-family: var(--font-code);
|
||||
}
|
||||
|
||||
.nodedc-attachment-markdown-preview pre {
|
||||
overflow-x: auto;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 0.85rem;
|
||||
background: rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.nodedc-attachment-markdown-preview pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nodedc-attachment-markdown-preview table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.nodedc-attachment-markdown-preview th,
|
||||
.nodedc-attachment-markdown-preview td {
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nodedc-attachment-markdown-preview a {
|
||||
color: rgb(var(--nodedc-accent-rgb));
|
||||
}
|
||||
|
||||
.nodedc-attachments-panel[data-view-mode="list"] {
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
*/
|
||||
|
||||
export * from "./emoji-picker";
|
||||
export * from "./emoji";
|
||||
export * from "./helper";
|
||||
export * from "./icon";
|
||||
export * from "./logo";
|
||||
export * from "./lucide-icons";
|
||||
export * from "./material-icons";
|
||||
|
||||
@@ -10,6 +10,30 @@ import { fileTypeFromBuffer } from "file-type";
|
||||
import type { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
|
||||
import { DANGEROUS_EXTENSIONS } from "@plane/constants";
|
||||
|
||||
const TEXT_MIME_TYPES_BY_EXTENSION: Readonly<Record<string, string>> = {
|
||||
cfg: "text/plain",
|
||||
conf: "text/plain",
|
||||
csv: "text/csv",
|
||||
ini: "text/plain",
|
||||
json: "application/json",
|
||||
jsonl: "application/x-ndjson",
|
||||
log: "text/plain",
|
||||
markdown: "text/markdown",
|
||||
md: "text/markdown",
|
||||
mdown: "text/markdown",
|
||||
mkd: "text/markdown",
|
||||
mkdn: "text/markdown",
|
||||
ndjson: "application/x-ndjson",
|
||||
toml: "application/toml",
|
||||
tsv: "text/tab-separated-values",
|
||||
txt: "text/plain",
|
||||
xml: "application/xml",
|
||||
yaml: "application/yaml",
|
||||
yml: "application/yaml",
|
||||
};
|
||||
|
||||
const getFileExtension = (filename: string): string => filename.split(".").pop()?.trim().toLowerCase() ?? "";
|
||||
|
||||
/**
|
||||
* @description Filename validation - checks for double extensions and dangerous patterns
|
||||
* @param {string} filename
|
||||
@@ -82,8 +106,10 @@ const detectMimeTypeFromSignature = async (file: File): Promise<string> => {
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Validate and detect the MIME type of a file using signature detection
|
||||
* Also performs basic security checks on filename
|
||||
* @description Validate and detect the MIME type of a file.
|
||||
* Binary signatures take precedence. Text formats are resolved from a conservative
|
||||
* extension map because they do not have a binary signature. Browser MIME metadata
|
||||
* and application/octet-stream provide compatibility for other safe attachments.
|
||||
* @param {File} file
|
||||
* @returns {Promise<string>} validated and detected MIME type
|
||||
*/
|
||||
@@ -91,7 +117,7 @@ const validateAndDetectFileType = async (file: File): Promise<string> => {
|
||||
// Basic filename validation
|
||||
const filenameError = validateFilename(file.name);
|
||||
if (filenameError) {
|
||||
console.warn(`File validation warning: ${filenameError}`);
|
||||
throw new Error(filenameError);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -103,8 +129,15 @@ const validateAndDetectFileType = async (file: File): Promise<string> => {
|
||||
console.warn("Error detecting file type from signature:", _error);
|
||||
}
|
||||
|
||||
// fallback for unknown files
|
||||
return "";
|
||||
const extensionMimeType = TEXT_MIME_TYPES_BY_EXTENSION[getFileExtension(file.name)];
|
||||
if (extensionMimeType) return extensionMimeType;
|
||||
|
||||
const browserMimeType = file.type.split(";", 1)[0]?.trim().toLowerCase();
|
||||
if (browserMimeType) return browserMimeType;
|
||||
|
||||
// Preserve generic attachment support when neither the file signature nor the
|
||||
// browser can identify a safe filename. The API still enforces its MIME allowlist.
|
||||
return "application/octet-stream";
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -142,7 +142,7 @@ export function MultiSelectDropdown(props: IMultiSelectDropdown) {
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
|
||||
<Combobox.Options data-prevent-outside-click className="nodedc-external-popup-anchor fixed z-[760]" static>
|
||||
<div
|
||||
className={cn("nodedc-dropdown-surface my-1 w-56", optionsContainerClassName)}
|
||||
ref={setPopperElement}
|
||||
|
||||
@@ -141,7 +141,7 @@ export function Dropdown(props: ISingleSelectDropdown) {
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
|
||||
<Combobox.Options data-prevent-outside-click className="nodedc-external-popup-anchor fixed z-[760]" static>
|
||||
<div
|
||||
className={cn("nodedc-dropdown-surface my-1 w-56", optionsContainerClassName)}
|
||||
ref={setPopperElement}
|
||||
|
||||
Reference in New Issue
Block a user