Compare commits

...

17 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS c3d2d78724 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: вкладка хранилища в модальных настройках 2026-04-27 15:38:23 +03:00
DCCONSTRUCTIONS d9f534efcd ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: API мониторинга хранилища воркспейса 2026-04-27 15:37:38 +03:00
DCCONSTRUCTIONS a7606f2e9a ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: retention cleanup файлового хранилища 2026-04-27 13:47:46 +03:00
DCCONSTRUCTIONS 3e328531ec ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: подтверждение файловых загрузок через внутренний storage 2026-04-27 13:47:03 +03:00
DCCONSTRUCTIONS 490aa1bc04 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: blob-дедупликация файлов 2026-04-27 09:45:16 +03:00
DCCONSTRUCTIONS b945bc4d31 АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: регламент ведения карточек Codex 2026-04-26 23:53:29 +03:00
DCCONSTRUCTIONS 9e2c2f065a UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: подэлементы внешнего контура и заглушка стикеров 2026-04-26 23:28:36 +03:00
DCCONSTRUCTIONS 86b17b23c9 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: карточка внешнего контура 2026-04-26 22:11:17 +03:00
DCCONSTRUCTIONS 347d95709c UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: отключение межстраничного лоудера 2026-04-26 22:09:49 +03:00
DCCONSTRUCTIONS 7ac9a3dbd3 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: фильтры активности и единый voice loader 2026-04-26 21:17:23 +03:00
DCCONSTRUCTIONS 9a91af372e ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Voice Tasker preview и трейс активности 2026-04-26 20:41:38 +03:00
DCCONSTRUCTIONS d867a89a1b UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: чистое закрытие записи Voice Tasker 2026-04-26 18:57:07 +03:00
DCCONSTRUCTIONS 5b1fca5356 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: живая визуализация записи Voice Tasker 2026-04-26 18:36:38 +03:00
DCCONSTRUCTIONS a3aedb7c5d UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: состояния записи и плеер Voice Tasker 2026-04-26 17:57:29 +03:00
DCCONSTRUCTIONS 323b4b964e ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: интерактивный предпросмотр Voice Tasker 2026-04-26 16:14:49 +03:00
DCCONSTRUCTIONS a13ff3b954 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: перестановка элементов верхней панели 2026-04-26 14:42:23 +03:00
DCCONSTRUCTIONS ee8b5123d8 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: корректная обработка русских Voice Tasker задач 2026-04-26 14:35:16 +03:00
55 changed files with 3955 additions and 690 deletions

View File

@ -79,6 +79,32 @@ UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переименован
- не переписывать историю
- не объединять несколько отдельных этапов в один коммит постфактум
## Ведение карточек задач Codex
Карточка задачи должна разделять постановку и ход работ.
Основное тело карточки:
- хранит общее описание задачи, цель, контекст, ограничения и критерии приемки
- не превращается в журнал работ
- остается читаемым входом в задачу после нескольких итераций
Подэлемент `Текущий статус работ`:
- создается как текстовый блок через `Добавить подэлемент`
- хранит фактический отчет по реализации
- обновляется после каждого осмысленного этапа
- фиксирует, что сделано, что проверено, какие файлы/модули затронуты, что осталось и какой следующий шаг
Подэлемент-чекер:
- используется для подзадач, которые можно проверить отдельно
- содержит короткие конкретные пункты без дублирования основного описания
- закрывается по факту реализации и проверки, а не по намерению
Статус карточки:
- `В работе` ставится только когда задача реально взята в исполнение
- `Готово` ставится после проверки результата
- `Отложено` используется для задач, которые больше не входят в текущий рабочий план
- backlog не должен хранить уже сделанные или сознательно отложенные задачи как активные
## Публикация фронта
После каждой правки интерфейса агент должен:

View File

@ -116,6 +116,7 @@ class FileAssetSerializer(BaseSerializer):
"page",
"draft_issue",
"user",
"blob",
"is_deleted",
"deleted_at",
"storage_metadata",

View File

@ -678,6 +678,7 @@ class IssueAttachmentSerializer(BaseSerializer):
"workspace",
"project",
"issue",
"blob",
"updated_by",
"updated_at",
]

View File

@ -15,7 +15,6 @@ from rest_framework.response import Response
from drf_spectacular.utils import OpenApiExample, OpenApiRequest
# Module Imports
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.settings.storage import S3Storage
from plane.db.models import FileAsset, User, Workspace
from plane.api.views.base import BaseAPIView
@ -44,6 +43,11 @@ from plane.utils.openapi import (
)
from plane.utils.exception_logger import log_exception
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
from plane.utils.file_dedup import (
UploadedObjectMissing,
confirm_uploaded_file_asset,
release_file_asset_blob,
)
class UserAssetEndpoint(BaseAPIView):
@ -53,9 +57,11 @@ class UserAssetEndpoint(BaseAPIView):
asset = FileAsset.objects.filter(id=asset_id).first()
if asset is None:
return
if not asset.is_uploaded:
release_file_asset_blob(asset, delete_untracked_object=True)
asset.is_deleted = True
asset.deleted_at = timezone.now()
asset.save(update_fields=["is_deleted", "deleted_at"])
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
return
def entity_asset_delete(self, entity_type, asset, request):
@ -209,15 +215,17 @@ class UserAssetEndpoint(BaseAPIView):
"""
# get the asset id
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(asset_id))
# update the attributes
asset.attributes = request.data.get("attributes", asset.attributes)
# save the asset
asset.save(update_fields=["is_uploaded", "attributes"])
try:
confirm_uploaded_file_asset(
asset,
request=request,
attributes=request.data.get("attributes", asset.attributes),
)
except UploadedObjectMissing:
return Response(
{"error": "The uploaded asset object was not found.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@asset_docs(
@ -236,11 +244,13 @@ class UserAssetEndpoint(BaseAPIView):
This performs a soft delete by marking the asset as deleted and updating the user's profile.
"""
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
if not asset.is_uploaded:
release_file_asset_blob(asset, request=request, delete_untracked_object=True)
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
asset.save(update_fields=["is_deleted", "deleted_at"])
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@ -251,9 +261,11 @@ class UserServerAssetEndpoint(BaseAPIView):
asset = FileAsset.objects.filter(id=asset_id).first()
if asset is None:
return
if not asset.is_uploaded:
release_file_asset_blob(asset, delete_untracked_object=True)
asset.is_deleted = True
asset.deleted_at = timezone.now()
asset.save(update_fields=["is_deleted", "deleted_at"])
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
return
def entity_asset_delete(self, entity_type, asset, request):
@ -365,15 +377,17 @@ class UserServerAssetEndpoint(BaseAPIView):
"""
# get the asset id
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(asset_id))
# update the attributes
asset.attributes = request.data.get("attributes", asset.attributes)
# save the asset
asset.save(update_fields=["is_uploaded", "attributes"])
try:
confirm_uploaded_file_asset(
asset,
request=request,
attributes=request.data.get("attributes", asset.attributes),
)
except UploadedObjectMissing:
return Response(
{"error": "The uploaded asset object was not found.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@asset_docs(
@ -393,11 +407,13 @@ class UserServerAssetEndpoint(BaseAPIView):
asset as deleted and updating the user's profile.
"""
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
if not asset.is_uploaded:
release_file_asset_blob(asset, request=request, delete_untracked_object=True)
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
asset.save(update_fields=["is_deleted", "deleted_at"])
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@ -604,15 +620,21 @@ class GenericAssetEndpoint(BaseAPIView):
try:
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug, is_deleted=False)
# Update is_uploaded status
asset.is_uploaded = request.data.get("is_uploaded", asset.is_uploaded)
# Update storage metadata if not present
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(asset_id))
asset.save(update_fields=["is_uploaded"])
if request.data.get("is_uploaded", asset.is_uploaded):
confirm_uploaded_file_asset(
asset,
request=request,
attributes=request.data.get("attributes"),
)
else:
asset.is_uploaded = False
asset.save(update_fields=["is_uploaded", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
except FileAsset.DoesNotExist:
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
except UploadedObjectMissing:
return Response(
{"error": "The uploaded asset object was not found.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -82,11 +82,11 @@ from plane.db.models import (
Workspace,
)
from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.utils.issue_relation_mapper import get_actual_relation
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.upload_limits import resolve_workspace_upload_size_limit
from plane.bgtasks.webhook_task import model_activity
from plane.app.permissions import ROLE
@ -2020,9 +2020,11 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
)
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
if not issue_attachment.is_uploaded:
release_file_asset_blob(issue_attachment, request=request, delete_untracked_object=True)
issue_attachment.is_deleted = True
issue_attachment.deleted_at = timezone.now()
issue_attachment.save()
issue_attachment.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
issue_activity.delay(
type="attachment.activity.deleted",
@ -2036,10 +2038,6 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
origin=base_host(request=request, is_app=True),
)
# Get the storage metadata
if not issue_attachment.storage_metadata:
get_asset_object_metadata.delay(str(issue_attachment.id))
issue_attachment.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@issue_attachment_docs(
@ -2174,14 +2172,16 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
origin=base_host(request=request, is_app=True),
)
# Update the attachment
issue_attachment.is_uploaded = True
issue_attachment.created_by = request.user
issue_attachment.save(update_fields=["created_by", "updated_at"])
# Get the storage metadata
if not issue_attachment.storage_metadata:
get_asset_object_metadata.delay(str(issue_attachment.id))
issue_attachment.save()
try:
finalize_uploaded_file_asset(issue_attachment, request=request)
except UploadedObjectMissing:
return Response(
{"error": "The uploaded attachment object was not found.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -10,4 +10,4 @@ class FileAssetSerializer(BaseSerializer):
class Meta:
model = FileAsset
fields = "__all__"
read_only_fields = ["created_by", "updated_by", "created_at", "updated_at"]
read_only_fields = ["created_by", "updated_by", "created_at", "updated_at", "blob"]

View File

@ -630,6 +630,7 @@ class IssueAttachmentSerializer(BaseSerializer):
"workspace",
"project",
"issue",
"blob",
]

View File

@ -17,6 +17,7 @@ from plane.app.views import (
UserLastProjectWithWorkspaceEndpoint,
WorkspaceThemeViewSet,
WorkspaceUserProfileStatsEndpoint,
WorkspaceActivityEndpoint,
WorkspaceUserActivityEndpoint,
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
@ -35,6 +36,7 @@ from plane.app.views import (
UserRecentVisitViewSet,
WorkspaceHomePreferenceViewSet,
WorkspaceStickyViewSet,
WorkspaceStorageSummaryEndpoint,
WorkspaceUserPreferenceViewSet,
)
@ -134,6 +136,11 @@ urlpatterns = [
WorkspaceUserProfileStatsEndpoint.as_view(),
name="workspace-user-stats",
),
path(
"workspaces/<str:slug>/activity/",
WorkspaceActivityEndpoint.as_view(),
name="workspace-activity",
),
path(
"workspaces/<str:slug>/user-activity/<uuid:user_id>/",
WorkspaceUserActivityEndpoint.as_view(),
@ -251,6 +258,11 @@ urlpatterns = [
WorkspaceStickyViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
name="workspace-sticky",
),
path(
"workspaces/<str:slug>/storage/summary/",
WorkspaceStorageSummaryEndpoint.as_view(),
name="workspace-storage-summary",
),
# User Preference
path(
"workspaces/<str:slug>/sidebar-preferences/",

View File

@ -69,6 +69,7 @@ from .workspace.label import WorkspaceLabelsEndpoint
from .workspace.state import WorkspaceStatesEndpoint
from .workspace.user import (
UserLastProjectWithWorkspaceEndpoint,
WorkspaceActivityEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceUserPropertiesEndpoint,
WorkspaceUserProfileEndpoint,
@ -82,6 +83,7 @@ from .workspace.module import WorkspaceModulesEndpoint
from .workspace.cycle import WorkspaceCyclesEndpoint
from .workspace.quick_link import QuickLinkViewSet
from .workspace.sticky import WorkspaceStickyViewSet
from .workspace.storage import WorkspaceStorageSummaryEndpoint
from .state.base import StateViewSet, IntakeStateEndpoint
from .view.base import (

View File

@ -11,6 +11,7 @@ from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from ..base import BaseAPIView, BaseViewSet
from plane.db.models import FileAsset, Workspace
from plane.app.serializers import FileAssetSerializer
from plane.utils.file_dedup import UploadedObjectMissing, confirm_uploaded_file_asset, release_file_asset_blob
class FileAssetEndpoint(BaseAPIView):
@ -37,15 +38,24 @@ class FileAssetEndpoint(BaseAPIView):
if serializer.is_valid():
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
serializer.save(workspace_id=workspace.id)
asset = serializer.save(workspace_id=workspace.id)
try:
confirm_uploaded_file_asset(asset, request=request)
except UploadedObjectMissing:
return Response(
{"error": "The uploaded asset object was not found.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, workspace_id, asset_key):
asset_key = str(workspace_id) + "/" + asset_key
file_asset = FileAsset.objects.get(asset=asset_key)
if not file_asset.is_uploaded:
release_file_asset_blob(file_asset, request=request, delete_untracked_object=True)
file_asset.is_deleted = True
file_asset.save(update_fields=["is_deleted"])
file_asset.save(update_fields=["is_deleted", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@ -75,12 +85,21 @@ class UserAssetsEndpoint(BaseAPIView):
def post(self, request):
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
asset = serializer.save()
try:
confirm_uploaded_file_asset(asset, request=request)
except UploadedObjectMissing:
return Response(
{"error": "The uploaded asset object was not found.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, asset_key):
file_asset = FileAsset.objects.get(asset=asset_key, created_by=request.user)
if not file_asset.is_uploaded:
release_file_asset_blob(file_asset, request=request, delete_untracked_object=True)
file_asset.is_deleted = True
file_asset.save(update_fields=["is_deleted"])
file_asset.save(update_fields=["is_deleted", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -22,9 +22,14 @@ from plane.db.models import FileAsset, Workspace, Project, User
from plane.settings.storage import S3Storage
from plane.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.throttles.asset import AssetRateThrottle
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
from plane.utils.file_dedup import (
UploadedObjectMissing,
attach_existing_blob_to_file_asset,
confirm_uploaded_file_asset,
release_file_asset_blob,
)
class UserAssetsV2Endpoint(BaseAPIView):
@ -34,9 +39,11 @@ class UserAssetsV2Endpoint(BaseAPIView):
asset = FileAsset.objects.filter(id=asset_id).first()
if asset is None:
return
if not asset.is_uploaded:
release_file_asset_blob(asset, delete_untracked_object=True)
asset.is_deleted = True
asset.deleted_at = timezone.now()
asset.save(update_fields=["is_deleted", "deleted_at"])
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
return
def entity_asset_save(self, asset_id, entity_type, asset, request):
@ -171,11 +178,17 @@ class UserAssetsV2Endpoint(BaseAPIView):
def patch(self, request, asset_id):
# get the asset id
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(asset_id))
try:
asset = confirm_uploaded_file_asset(
asset,
request=request,
attributes=request.data.get("attributes", asset.attributes),
)
except UploadedObjectMissing:
return Response(
{"error": "The uploaded asset object was not found.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# get the entity and save the asset id for the request field
self.entity_asset_save(
asset_id=asset_id,
@ -183,19 +196,17 @@ class UserAssetsV2Endpoint(BaseAPIView):
asset=asset,
request=request,
)
# update the attributes
asset.attributes = request.data.get("attributes", asset.attributes)
# save the asset
asset.save(update_fields=["is_uploaded", "attributes"])
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, asset_id):
asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id)
if not asset.is_uploaded:
release_file_asset_blob(asset, request=request, delete_untracked_object=True)
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
asset.save(update_fields=["is_deleted", "deleted_at"])
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@ -239,10 +250,12 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
# Check if the asset exists
if asset is None:
return
if not asset.is_uploaded:
release_file_asset_blob(asset, delete_untracked_object=True)
# Mark the asset as deleted
asset.is_deleted = True
asset.deleted_at = timezone.now()
asset.save(update_fields=["is_deleted", "deleted_at"])
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
return
def entity_asset_save(self, asset_id, entity_type, asset, request):
@ -380,11 +393,17 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
def patch(self, request, slug, asset_id):
# get the asset id
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(asset_id))
try:
asset = confirm_uploaded_file_asset(
asset,
request=request,
attributes=request.data.get("attributes", asset.attributes),
)
except UploadedObjectMissing:
return Response(
{"error": "The uploaded asset object was not found.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# get the entity and save the asset id for the request field
self.entity_asset_save(
asset_id=asset_id,
@ -392,19 +411,17 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
asset=asset,
request=request,
)
# update the attributes
asset.attributes = request.data.get("attributes", asset.attributes)
# save the asset
asset.save(update_fields=["is_uploaded", "attributes"])
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, slug, asset_id):
asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug)
if not asset.is_uploaded:
release_file_asset_blob(asset, request=request, delete_untracked_object=True)
asset.is_deleted = True
asset.deleted_at = timezone.now()
# get the entity and save the asset id for the request field
self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request)
asset.save(update_fields=["is_deleted", "deleted_at"])
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
def get(self, request, slug, asset_id):
@ -581,27 +598,30 @@ class ProjectAssetEndpoint(BaseAPIView):
def patch(self, request, slug, project_id, pk):
# get the asset id
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(asset_id=str(pk))
# update the attributes
asset.attributes = request.data.get("attributes", asset.attributes)
# save the asset
asset.save(update_fields=["is_uploaded", "attributes"])
try:
confirm_uploaded_file_asset(
asset,
request=request,
attributes=request.data.get("attributes", asset.attributes),
)
except UploadedObjectMissing:
return Response(
{"error": "The uploaded asset object was not found.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def delete(self, request, slug, project_id, pk):
# Get the asset
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
if not asset.is_uploaded:
release_file_asset_blob(asset, request=request, delete_untracked_object=True)
# Check deleted assets
asset.is_deleted = True
asset.deleted_at = timezone.now()
# Save the asset
asset.save(update_fields=["is_deleted", "deleted_at"])
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@ -753,7 +773,7 @@ class DuplicateAssetEndpoint(BaseAPIView):
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
storage = S3Storage(request=request)
original_asset = FileAsset.objects.filter(id=asset_id, is_uploaded=True).first()
original_asset = FileAsset.objects.filter(id=asset_id, workspace=workspace, is_uploaded=True).first()
if not original_asset:
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
@ -774,9 +794,15 @@ class DuplicateAssetEndpoint(BaseAPIView):
storage_metadata=original_asset.storage_metadata,
**self.get_entity_id_field(entity_type=entity_type, entity_id=entity_id),
)
storage.copy_object(original_asset.asset, destination_key)
# Update the is_uploaded field for all newly created assets
FileAsset.objects.filter(id=duplicated_asset.id).update(is_uploaded=True)
if not attach_existing_blob_to_file_asset(original_asset, duplicated_asset):
storage.copy_object(original_asset.asset, destination_key)
try:
confirm_uploaded_file_asset(duplicated_asset, request=request)
except UploadedObjectMissing:
return Response(
{"error": "The source asset object was not found.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
return Response({"asset_id": str(duplicated_asset.id)}, status=status.HTTP_200_OK)

View File

@ -23,10 +23,10 @@ from plane.db.models import FileAsset, Workspace
from plane.bgtasks.issue_activities_task import issue_activity
from plane.app.permissions import allow_permission, ROLE
from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.utils.host import base_host
from plane.utils.upload_limits import 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
class IssueAttachmentEndpoint(BaseAPIView):
@ -69,7 +69,9 @@ class IssueAttachmentEndpoint(BaseAPIView):
{"error": "Issue attachment not found."},
status=status.HTTP_404_NOT_FOUND,
)
issue_attachment.asset.delete(save=False)
release_result = release_file_asset_blob(issue_attachment, request=request)
if not release_result.had_blob:
issue_attachment.asset.delete(save=False)
issue_attachment.delete()
issue_activity.delay(
type="attachment.activity.deleted",
@ -155,9 +157,11 @@ 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)
if not issue_attachment.is_uploaded:
release_file_asset_blob(issue_attachment, request=request, delete_untracked_object=True)
issue_attachment.is_deleted = True
issue_attachment.deleted_at = timezone.now()
issue_attachment.save()
issue_attachment.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
issue_activity.delay(
type="attachment.activity.deleted",
@ -225,12 +229,14 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
origin=base_host(request=request, is_app=True),
)
# Update the attachment
issue_attachment.is_uploaded = True
issue_attachment.created_by = request.user
issue_attachment.save(update_fields=["created_by", "updated_at"])
# Get the storage metadata
if not issue_attachment.storage_metadata:
get_asset_object_metadata.delay(str(issue_attachment.id))
issue_attachment.save()
try:
finalize_uploaded_file_asset(issue_attachment, request=request)
except UploadedObjectMissing:
return Response(
{"error": "The uploaded attachment object was not found.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -186,6 +186,39 @@ VOICE_TASK_ABSOLUTE_MONTH_DATE_PATTERN = re.compile(
VOICE_TASK_NUMERIC_DATE_PATTERN = re.compile(
r"(?<!\d)(?P<day>[0-3]?\d)[./-](?P<month>[01]?\d)(?:[./-](?P<year>\d{2,4}))?(?!\d)"
)
VOICE_TASK_WEEKDAYS = {
"понедельник": 0,
"понедельника": 0,
"понедельнику": 0,
"monday": 0,
"вторник": 1,
"вторника": 1,
"вторнику": 1,
"tuesday": 1,
"среда": 2,
"среду": 2,
"среды": 2,
"wednesday": 2,
"четверг": 3,
"четверга": 3,
"четвергу": 3,
"thursday": 3,
"пятница": 4,
"пятницу": 4,
"пятницы": 4,
"friday": 4,
"суббота": 5,
"субботу": 5,
"субботы": 5,
"saturday": 5,
"воскресенье": 6,
"воскресенья": 6,
"воскресенью": 6,
"sunday": 6,
}
VOICE_TASK_WEEKDAY_PATTERN = re.compile(
rf"(?<![0-9a-zа-я])(?P<weekday>{'|'.join(sorted(VOICE_TASK_WEEKDAYS.keys(), key=len, reverse=True))})(?![0-9a-zа-я])"
)
def normalize_audio_content_type(content_type):
@ -358,6 +391,11 @@ class VoiceTaskParserService:
"For create_task, title must be a compact but meaning-preserving task name, not a 2-word summary. "
"description should be a detailed structured summary that preserves the user's meaning; "
"checklist should contain actionable bullet decomposition when the transcript includes multiple steps. "
"Return title, description, labels, checklist, and questions in the same natural language as "
"the transcript. If the transcript is Russian, all human-facing text must be Russian; never "
"translate Russian task text into English. "
"If the user says to assign the task to all employees/team members of a project/department, "
"set assignee_hint to all_project_members. "
"Use state_hint only for explicit status/state phrases like в работе, в реализации, active, backlog, done. "
"Do not infer state_hint from project names. If no status is requested, return null. "
"Never classify delete/remove/cancel-last-task commands as create_task. "
@ -570,9 +608,91 @@ def derive_voice_task_title_from_text(value):
return title or None
def voice_task_text_has_cyrillic(value):
return bool(normalize_string(value) and re.search(r"[А-Яа-яЁё]", value))
def voice_task_text_looks_latin(value):
normalized = normalize_string(value)
if not normalized:
return False
latin_count = len(re.findall(r"[A-Za-z]", normalized))
cyrillic_count = len(re.findall(r"[А-Яа-яЁё]", normalized))
return latin_count >= 12 and latin_count > cyrillic_count * 2
def derive_voice_task_title_from_transcript(transcript):
normalized = normalize_string(transcript, 1200)
if not normalized:
return None
action_verbs = [
"отправить",
"сдать",
"подготовить",
"закрыть",
"согласовать",
"проверить",
"добавить",
"создать",
"передать",
"позвонить",
"написать",
"сделать",
"разобрать",
"обновить",
"исправить",
"сверить",
"выгрузить",
"загрузить",
"оформить",
]
sentences = [sentence.strip(" :-,;") for sentence in re.split(r"[.!?\n]+", normalized) if sentence.strip()]
for sentence in sentences:
sentence_lower = sentence.lower().replace("ё", "е")
if "задач" not in sentence_lower:
continue
best_index = None
for verb in action_verbs:
match = re.search(rf"(?<![а-яa-z]){verb}(?![а-яa-z])", sentence_lower)
if match and (best_index is None or match.start() < best_index):
best_index = match.start()
if best_index is not None:
title = sentence[best_index:].strip(" :-,;")
return derive_voice_task_title_from_text(title)
return derive_voice_task_title_from_text(normalized)
def enforce_voice_task_output_language(parsed, transcript):
if parsed.get("intent") != "create_task" or not voice_task_text_has_cyrillic(transcript):
return parsed
if voice_task_text_looks_latin(parsed.get("title")):
parsed["title"] = derive_voice_task_title_from_transcript(transcript) or parsed.get("title")
parsed["confidence"]["task"] = min(parsed["confidence"].get("task", 1.0), 0.9)
if voice_task_text_looks_latin(parsed.get("description")):
parsed["description"] = normalize_string(transcript) or parsed.get("description")
parsed["confidence"]["task"] = min(parsed["confidence"].get("task", 1.0), 0.9)
parsed["checklist"] = [
item for item in parsed.get("checklist", []) if not voice_task_text_looks_latin(item)
]
parsed["questions"] = [
question for question in parsed.get("questions", []) if not voice_task_text_looks_latin(question)
]
return parsed
def harden_voice_task_intent(parsed, transcript):
parsed = enforce_voice_task_output_language(parsed, transcript)
if parsed.get("intent") != "update_task":
return parsed
if parsed.get("target_task_id"):
return parsed
target_memory_ref = normalize_string(parsed.get("target_memory_ref"), 80)
has_explicit_issue_ref = bool(parse_issue_key_reference(target_memory_ref)) or transcript_has_issue_key_reference(
@ -598,6 +718,9 @@ def harden_voice_task_intent(parsed, transcript):
def voice_task_has_safe_existing_task_anchor(draft, transcript):
if draft.get("target_task_id"):
return True
target_memory_ref = normalize_string(draft.get("target_memory_ref"), 80)
return bool(
parse_issue_key_reference(target_memory_ref)
@ -738,6 +861,14 @@ def serialize_resolved_assignee(user, confidence=0.0, source=None):
}
def get_voice_task_project_assignable_members(project):
return (
ProjectMember.objects.filter(project=project, is_active=True, role__gte=ROLE.MEMBER.value, member__is_active=True)
.select_related("member")
.order_by("member__display_name", "member__email")
)
def serialize_resolved_state(state, confidence=0.0, source=None):
if not state:
return None
@ -820,11 +951,7 @@ def resolve_voice_task_assignee(project, draft):
if not assignee_hint:
return None
project_members = (
ProjectMember.objects.filter(project=project, is_active=True, role__gte=ROLE.MEMBER.value, member__is_active=True)
.select_related("member")
.order_by("member__display_name", "member__email")
)
project_members = get_voice_task_project_assignable_members(project)
best_member = None
best_score = 0.0
for project_member in project_members:
@ -849,6 +976,57 @@ def resolve_voice_task_assignee(project, draft):
return serialize_resolved_assignee(best_member, best_score, "assignee_hint")
def resolve_explicit_voice_task_assignees(project, draft):
if "assignee_ids" not in draft:
return None
assignee_ids = draft.get("assignee_ids") if isinstance(draft.get("assignee_ids"), list) else []
if not assignee_ids:
return []
member_by_id = {
str(project_member.member_id): project_member.member
for project_member in get_voice_task_project_assignable_members(project)
}
return [
serialize_resolved_assignee(member_by_id[assignee_id], 1.0, "explicit_assignee_ids")
for assignee_id in assignee_ids
if assignee_id in member_by_id
]
def transcript_requests_all_project_assignees(transcript, draft=None):
normalized = normalize_match_value(transcript)
assignee_hint = normalize_match_value((draft or {}).get("assignee_hint"))
if assignee_hint in {"all project members", "all_project_members", "all members", "all employees"}:
return True
if not normalized:
return False
patterns = [
r"\b(всех|всем|кажд\w*|all|everyone|everybody)\b.{0,48}\b(сотрудник\w*|участник\w*|исполнитель\w*|ответственн\w*|employees|members|assignees)\b",
r"\b(назнач\w*|ответственн\w*|исполнитель\w*)\b.{0,48}\b(всех|всем|кажд\w*|all|everyone|everybody)\b",
r"\b(сотрудник\w*|участник\w*|employees|members)\b.{0,24}\b(всех|всем|all|everyone|everybody)\b",
]
return any(re.search(pattern, normalized) for pattern in patterns)
def resolve_voice_task_assignees(project, draft, transcript=None):
explicit_assignees = resolve_explicit_voice_task_assignees(project, draft)
if explicit_assignees is not None:
return explicit_assignees
if transcript_requests_all_project_assignees(transcript, draft):
return [
serialize_resolved_assignee(project_member.member, 1.0, "all_project_members")
for project_member in get_voice_task_project_assignable_members(project)
]
assignee = resolve_voice_task_assignee(project, draft)
return [assignee] if assignee else []
def resolve_voice_task_labels(project, draft):
label_names = draft.get("labels") if isinstance(draft.get("labels"), list) else []
if not label_names:
@ -932,8 +1110,14 @@ def resolve_voice_task_state(project, draft, allow_default=True):
if not project:
return None
state_hint = draft.get("state_hint")
states = list(State.objects.filter(project=project).order_by("sequence"))
explicit_state_id = normalize_uuid_string(draft.get("state_id"))
if explicit_state_id:
explicit_state = next((state for state in states if str(state.id) == explicit_state_id), None)
if explicit_state:
return serialize_resolved_state(explicit_state, 1.0, "explicit_state_id")
state_hint = draft.get("state_hint")
if state_hint:
best_state = None
best_score = 0.0
@ -1059,6 +1243,33 @@ def infer_voice_task_absolute_due_date(transcript, current_date):
return None
def infer_voice_task_weekday_due_date(transcript, current_date):
normalized = normalize_match_value(transcript)
if not normalized:
return None
has_due_context = bool(
re.search(
r"\b(срок|дедлайн|deadline|выполнен\w*|выполнить|закончить|завершить|до|к|конц\w*|by|end)\b",
normalized,
)
)
for match in VOICE_TASK_WEEKDAY_PATTERN.finditer(normalized):
window_start = max(0, match.start() - 48)
window_end = min(len(normalized), match.end() + 32)
window = normalized[window_start:window_end]
if not has_due_context and not re.search(r"\b(до|к|на|в|во|by)\b", window):
continue
target_weekday = VOICE_TASK_WEEKDAYS[match.group("weekday")]
days_ahead = (target_weekday - current_date.weekday()) % 7
if days_ahead == 0 and re.search(r"\b(следующ\w*|next)\b", window):
days_ahead = 7
return (current_date + timedelta(days=days_ahead)).isoformat()
return None
def infer_voice_task_relative_due_date(transcript, current_date, target_issue=None):
normalized = normalize_match_value(transcript)
if not normalized:
@ -1155,6 +1366,11 @@ def hydrate_voice_task_due_date(draft, transcript, client_context, user, workspa
draft["due_date"] = absolute_due_date
return
weekday_due_date = infer_voice_task_weekday_due_date(transcript, current_date=current_date)
if weekday_due_date:
draft["due_date"] = weekday_due_date
return
inferred_due_date = infer_voice_task_relative_due_date(
transcript=transcript,
current_date=current_date,
@ -1271,9 +1487,22 @@ def find_latest_voice_task_issue(memory_sessions, project_id=None):
def resolve_voice_task_memory_target(workspace, user, draft, current_session=None, client_context=None, transcript=None):
target_memory_ref = normalize_string(draft.get("target_memory_ref"), 80)
explicit_target_task_id = normalize_uuid_string(draft.get("target_task_id"))
memory_sessions = get_committed_voice_task_memory_sessions(workspace, user, current_session=current_session)
generic_memory_reference = transcript_has_generic_memory_reference(transcript)
if explicit_target_task_id:
target_issue = (
Issue.issue_objects.filter(workspace=workspace, id=explicit_target_task_id)
.select_related("project")
.first()
)
if (
is_voice_task_issue_available(target_issue)
and get_accessible_projects(workspace, user).filter(id=target_issue.project_id).exists()
):
return target_issue, "explicit_target_task_id", None
if target_memory_ref:
target_uuid = None
try:
@ -1342,6 +1571,8 @@ def voice_task_has_update_fields(draft, resolution):
or draft.get("due_time")
or (draft.get("priority") and draft.get("priority") != "none")
or draft.get("checklist")
or "assignee_ids" in draft
or draft.get("state_id")
or (resolution.get("assignee") and resolution["assignee"]["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD)
or (draft.get("state_hint") and resolution.get("state") and resolution["state"]["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD)
or resolution.get("labels")
@ -1444,10 +1675,12 @@ def build_voice_task_resolution(workspace, user, ai_settings, draft, client_cont
warnings.append("project_hint_not_in_transcript")
resolved_assignee = None
resolved_assignees = []
resolved_labels = []
resolved_state = None
if project:
resolved_assignee = resolve_voice_task_assignee(project, draft)
resolved_assignees = resolve_voice_task_assignees(project, draft, transcript)
resolved_assignee = resolved_assignees[0] if len(resolved_assignees) == 1 else None
if resolved_assignee and resolved_assignee["confidence"] < VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
warnings.append("low_assignee_confidence")
resolved_labels = resolve_voice_task_labels(project, draft)
@ -1490,6 +1723,7 @@ def build_voice_task_resolution(workspace, user, ai_settings, draft, client_cont
resolution = {
"project": resolved_project,
"assignee": resolved_assignee,
"assignees": resolved_assignees,
"labels": resolved_labels,
"state": resolved_state,
"target_task": serialize_voice_task_target(target_issue, target_source, target_session),
@ -1616,10 +1850,17 @@ def append_voice_task_description(existing_html, update_html):
def build_voice_task_issue_payload(draft, resolution, transcript=None):
project = resolution.get("project")
assignee = resolution.get("assignee")
assignees = resolution.get("assignees") or []
state = resolution.get("state")
labels = resolution.get("labels") or []
assignee_ids = []
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
if assignees:
assignee_ids = [
assignee["id"]
for assignee in assignees
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD
]
elif assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
assignee_ids = [assignee["id"]]
return {
@ -1636,6 +1877,7 @@ def build_voice_task_issue_payload(draft, resolution, transcript=None):
def build_voice_task_issue_update_payload(issue, draft, resolution, transcript=None):
assignee = resolution.get("assignee")
assignees = resolution.get("assignees") or []
state = resolution.get("state")
labels = resolution.get("labels") or []
project_change = resolution.get("project_change")
@ -1654,10 +1896,22 @@ def build_voice_task_issue_update_payload(issue, draft, resolution, transcript=N
if draft.get("priority") and draft["priority"] != "none":
payload["priority"] = draft["priority"]
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
if "assignee_ids" in draft:
payload["assignee_ids"] = [
assignee["id"]
for assignee in assignees
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD
]
elif assignees:
payload["assignee_ids"] = [
assignee["id"]
for assignee in assignees
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD
]
elif assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
payload["assignee_ids"] = [assignee["id"]]
if (draft.get("state_hint") or project_change) and state and state["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD:
if (draft.get("state_hint") or draft.get("state_id") or project_change) and state and state["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD:
payload["state_id"] = state["id"]
if labels:
@ -1845,6 +2099,29 @@ def normalize_string(value, max_length=None):
return normalized[:max_length] if max_length else normalized
def normalize_uuid_string(value):
normalized = normalize_string(value, 80)
if not normalized:
return None
try:
return str(uuid.UUID(normalized))
except (TypeError, ValueError):
return None
def normalize_uuid_list(value, limit=50):
if not isinstance(value, list):
return []
result = []
for item in value[:limit]:
normalized = normalize_uuid_string(item)
if normalized and normalized not in result:
result.append(normalized)
return result
def normalize_string_list(value, limit=20, item_max_length=120):
if not isinstance(value, list):
return []
@ -1899,6 +2176,9 @@ def normalize_voice_task_parse(parsed):
normalized = {
"intent": intent,
"target_memory_ref": normalize_string(parsed.get("target_memory_ref"), 80),
"project_id": normalize_uuid_string(parsed.get("project_id")),
"state_id": normalize_uuid_string(parsed.get("state_id")),
"target_task_id": normalize_uuid_string(parsed.get("target_task_id") or parsed.get("target_issue_id")),
"project_hint": normalize_string(parsed.get("project_hint"), 255),
"state_hint": normalize_string(parsed.get("state_hint"), 120),
"assignee_hint": normalize_string(parsed.get("assignee_hint"), 255),
@ -1917,6 +2197,8 @@ def normalize_voice_task_parse(parsed):
},
"questions": normalize_string_list(parsed.get("questions"), limit=10, item_max_length=255),
}
if "assignee_ids" in parsed:
normalized["assignee_ids"] = normalize_uuid_list(parsed.get("assignee_ids"))
return normalized
@ -2307,66 +2589,145 @@ class VoiceTaskCommitEndpoint(BaseAPIView):
)
if action == "create_task":
project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace)
payload = build_voice_task_issue_payload(draft, resolution, voice_session.transcript)
payload_without_project = {key: value for key, value in payload.items() if key != "project_id"}
payload_without_project["external_source"] = VOICE_TASK_EXTERNAL_SOURCE
payload_without_project["external_id"] = str(voice_session.id)
existing_issue = voice_session.created_task
if is_voice_task_issue_available(existing_issue):
issue = existing_issue
if not can_user_update_voice_task_issue(request.user, workspace, issue):
return Response(
{
"ok": False,
"code": "issue_permission_denied",
"error": "Voice Task draft could not update the created work item.",
},
status=status.HTTP_403_FORBIDDEN,
)
serializer = IssueCreateSerializer(
data=payload_without_project,
context={
"project_id": project.id,
"workspace_id": workspace.id,
"default_assignee_id": project.default_assignee_id,
},
)
if not serializer.is_valid():
return Response(
{
"ok": False,
"code": "issue_validation_failed",
"error": "Voice Task draft could not be converted to a work item.",
"details": serializer.errors,
},
status=status.HTTP_400_BAD_REQUEST,
project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace)
payload = build_voice_task_issue_payload(draft, resolution, voice_session.transcript)
payload_without_project = {key: value for key, value in payload.items() if key != "project_id"}
payload_without_project["external_source"] = VOICE_TASK_EXTERNAL_SOURCE
payload_without_project["external_id"] = str(voice_session.id)
current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
requested_data = json.dumps(
{**payload_without_project, "project_id": str(project.id)},
cls=DjangoJSONEncoder,
)
issue = serializer.save(created_by_id=request.user.id)
voice_session.created_task = issue
voice_session.parsed_json = draft
voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"])
if issue.project_id != project.id:
issue = move_voice_task_issue_to_project(issue, project, resolution["state"], request.user)
requested_data = json.dumps(payload_without_project, cls=DjangoJSONEncoder)
issue_activity.delay(
type="issue.activity.created",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project.id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
model_activity.delay(
model_name="issue",
model_id=str(issue.id),
requested_data=payload_without_project,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
issue_description_version_task.delay(
updated_issue=requested_data,
issue_id=str(issue.id),
user_id=request.user.id,
is_creating=True,
)
serializer = IssueCreateSerializer(
issue,
data=payload_without_project,
partial=True,
context={"project_id": project.id},
)
if not serializer.is_valid():
return Response(
{
"ok": False,
"code": "issue_validation_failed",
"error": "Voice Task draft could not update the created work item.",
"details": serializer.errors,
},
status=status.HTTP_400_BAD_REQUEST,
)
response_status = status.HTTP_201_CREATED
commit_status = "created"
serializer.save()
issue.refresh_from_db()
voice_session.created_task = issue
voice_session.parsed_json = draft
voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"])
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project.id),
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
model_activity.delay(
model_name="issue",
model_id=str(issue.id),
requested_data=json.loads(requested_data),
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
issue_description_version_task.delay(
updated_issue=current_instance,
issue_id=str(issue.id),
user_id=request.user.id,
)
response_status = status.HTTP_200_OK
commit_status = "updated"
else:
project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace)
payload = build_voice_task_issue_payload(draft, resolution, voice_session.transcript)
payload_without_project = {key: value for key, value in payload.items() if key != "project_id"}
payload_without_project["external_source"] = VOICE_TASK_EXTERNAL_SOURCE
payload_without_project["external_id"] = str(voice_session.id)
serializer = IssueCreateSerializer(
data=payload_without_project,
context={
"project_id": project.id,
"workspace_id": workspace.id,
"default_assignee_id": project.default_assignee_id,
},
)
if not serializer.is_valid():
return Response(
{
"ok": False,
"code": "issue_validation_failed",
"error": "Voice Task draft could not be converted to a work item.",
"details": serializer.errors,
},
status=status.HTTP_400_BAD_REQUEST,
)
issue = serializer.save(created_by_id=request.user.id)
voice_session.created_task = issue
voice_session.parsed_json = draft
voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"])
requested_data = json.dumps(payload_without_project, cls=DjangoJSONEncoder)
issue_activity.delay(
type="issue.activity.created",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project.id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
model_activity.delay(
model_name="issue",
model_id=str(issue.id),
requested_data=payload_without_project,
current_instance=None,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
issue_description_version_task.delay(
updated_issue=requested_data,
issue_id=str(issue.id),
user_id=request.user.id,
is_creating=True,
)
response_status = status.HTTP_201_CREATED
commit_status = "created"
elif action == "update_task":
target_task = resolution.get("target_task") or {}

View File

@ -0,0 +1,121 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from datetime import timedelta
from django.db.models import Sum
from django.utils import timezone
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import ROLE, allow_permission
from plane.app.views.base import BaseAPIView
from plane.db.models import FileAsset, Project, StoredBlob, Workspace
def _int_size(value):
return int(value or 0)
def _sum_asset_size(queryset):
return _int_size(queryset.aggregate(total=Sum("size")).get("total"))
def _sum_blob_size(blob_ids):
if not blob_ids:
return 0
return _int_size(StoredBlob.objects.filter(id__in=blob_ids).aggregate(total=Sum("size")).get("total"))
def _dedup_savings(logical_size, physical_size):
return max(_int_size(logical_size) - _int_size(physical_size), 0)
class WorkspaceStorageSummaryEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
stale_cutoff = timezone.now() - timedelta(days=1)
active_assets = FileAsset.objects.filter(workspace=workspace, is_uploaded=True)
active_blob_ids = list(active_assets.exclude(blob__isnull=True).values_list("blob_id", flat=True).distinct())
workspace_logical_size = _sum_asset_size(active_assets)
workspace_physical_size = _sum_blob_size(active_blob_ids)
failed_uploads = FileAsset.all_objects.filter(
workspace=workspace,
is_uploaded=False,
deleted_at__isnull=True,
)
stale_unuploaded = failed_uploads.filter(created_at__lt=stale_cutoff)
soft_deleted_assets = FileAsset.all_objects.filter(workspace=workspace, deleted_at__isnull=False)
orphaned_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.ORPHANED)
missing_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.MISSING)
uploaded_without_blob = active_assets.filter(blob__isnull=True)
project_rows = []
projects = Project.objects.filter(workspace=workspace).order_by("name")
for project in projects:
project_assets = active_assets.filter(project=project)
project_blob_ids = list(
project_assets.exclude(blob__isnull=True).values_list("blob_id", flat=True).distinct()
)
project_logical_size = _sum_asset_size(project_assets)
project_physical_size = _sum_blob_size(project_blob_ids)
project_failed_uploads = failed_uploads.filter(project=project)
project_soft_deleted = soft_deleted_assets.filter(project=project)
project_uploaded_without_blob = uploaded_without_blob.filter(project=project)
project_rows.append(
{
"id": str(project.id),
"name": project.name,
"identifier": project.identifier,
"file_count": project_assets.count(),
"blob_count": len(project_blob_ids),
"logical_size": project_logical_size,
"physical_size": project_physical_size,
"dedup_savings": _dedup_savings(project_logical_size, project_physical_size),
"failed_upload_count": project_failed_uploads.count(),
"failed_upload_size": _sum_asset_size(project_failed_uploads),
"soft_deleted_count": project_soft_deleted.count(),
"soft_deleted_size": _sum_asset_size(project_soft_deleted),
"uploaded_without_blob_count": project_uploaded_without_blob.count(),
}
)
data = {
"workspace": {
"id": str(workspace.id),
"name": workspace.name,
"slug": workspace.slug,
"upload_file_size_limit_enabled": workspace.storage_file_size_limit_enabled,
"upload_file_size_limit": workspace.storage_file_size_limit,
},
"summary": {
"file_count": active_assets.count(),
"blob_count": len(active_blob_ids),
"logical_size": workspace_logical_size,
"physical_size": workspace_physical_size,
"dedup_savings": _dedup_savings(workspace_logical_size, workspace_physical_size),
"uploaded_without_blob_count": uploaded_without_blob.count(),
},
"diagnostics": {
"failed_upload_count": failed_uploads.count(),
"failed_upload_size": _sum_asset_size(failed_uploads),
"stale_unuploaded_count": stale_unuploaded.count(),
"stale_unuploaded_size": _sum_asset_size(stale_unuploaded),
"soft_deleted_count": soft_deleted_assets.count(),
"soft_deleted_size": _sum_asset_size(soft_deleted_assets),
"orphaned_blob_count": orphaned_blobs.count(),
"orphaned_blob_size": _sum_blob_size(list(orphaned_blobs.values_list("id", flat=True))),
"missing_blob_count": missing_blobs.count(),
"missing_blob_size": _sum_blob_size(list(missing_blobs.values_list("id", flat=True))),
},
"projects": project_rows,
}
return Response(data, status=status.HTTP_200_OK)

View File

@ -406,6 +406,35 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
)
class WorkspaceActivityEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
def get(self, request, slug):
projects = request.query_params.getlist("project", [])
queryset = (
IssueActivity.objects.filter(
~Q(field__in=["comment", "vote", "reaction", "draft"]),
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related("actor", "workspace", "issue", "project")
.distinct()
)
if projects:
queryset = queryset.filter(project__in=projects)
return self.paginate(
order_by=request.GET.get("order_by", "-created_at"),
request=request,
queryset=queryset,
on_results=lambda issue_activities: IssueActivitySerializer(issue_activities, many=True).data,
)
class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
def get(self, request, slug, user_id):
filters = issue_filters(request.query_params, "GET")

View File

@ -17,6 +17,7 @@ from plane.utils.exception_logger import log_exception
from plane.settings.storage import S3Storage
from celery import shared_task
from plane.utils.url import normalize_url_path
from plane.utils.file_dedup import attach_existing_blob_to_file_asset, confirm_uploaded_file_asset
def get_entity_id_field(entity_type, entity_id):
@ -108,15 +109,16 @@ def copy_assets(entity, entity_identifier, project_id, asset_ids, user_id):
storage_metadata=original_asset.storage_metadata,
**get_entity_id_field(original_asset.entity_type, entity_identifier),
)
storage.copy_object(original_asset.asset, destination_key)
if not attach_existing_blob_to_file_asset(original_asset, duplicated_asset):
storage.copy_object(original_asset.asset, destination_key)
confirm_uploaded_file_asset(duplicated_asset)
duplicated_assets.append(
{
"new_asset_id": str(duplicated_asset.id),
"old_asset_id": str(original_asset.id),
}
)
if duplicated_assets:
FileAsset.objects.filter(pk__in=[item["new_asset_id"] for item in duplicated_assets]).update(is_uploaded=True)
return duplicated_assets

View File

@ -112,6 +112,7 @@ def restore_related_objects(app_label, model_name, instance_pk, using=None):
@shared_task
def hard_delete():
from plane.bgtasks.file_asset_task import delete_expired_file_asset
from plane.db.models import (
Workspace,
Project,
@ -134,6 +135,9 @@ def hard_delete():
)
days = settings.HARD_DELETE_AFTER_DAYS
delete_expired_file_asset()
# check delete workspace
_ = Workspace.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete()
@ -185,6 +189,9 @@ def hard_delete():
# Iterate through all models
for model in all_models:
if model._meta.label_lower in {"db.fileasset", "db.storedblob"}:
continue
# Check if the model has a 'deleted_at' field
if hasattr(model, "deleted_at"):
# Get all instances where 'deleted_at' is greater than 30 days ago

View File

@ -7,6 +7,7 @@ import os
from datetime import timedelta
# Django imports
from django.conf import settings
from django.utils import timezone
from django.db.models import Q
@ -14,13 +15,63 @@ from django.db.models import Q
from celery import shared_task
# Module imports
from plane.db.models import FileAsset
from plane.db.models import FileAsset, StoredBlob
from plane.settings.storage import S3Storage
from plane.utils.file_dedup import release_file_asset_blob
def _asset_object_key(asset):
return str(asset.asset.name or asset.asset)
def _delete_legacy_asset_object(asset, storage):
object_key = _asset_object_key(asset)
if not object_key:
return False
has_other_reference = FileAsset.all_objects.filter(asset=object_key).exclude(pk=asset.pk).exists()
if has_other_reference:
return False
return storage.delete_files(object_names=[object_key])
def _hard_delete_file_asset(asset, storage=None):
blob_id = asset.blob_id
if blob_id:
release_file_asset_blob(asset)
StoredBlob.all_objects.filter(
pk=blob_id,
ref_count=0,
status=StoredBlob.Status.ORPHANED,
).delete()
else:
storage = storage or S3Storage()
_delete_legacy_asset_object(asset, storage)
asset.delete(soft=False)
@shared_task
def delete_unuploaded_file_asset():
"""This task deletes unuploaded file assets older than a certain number of days."""
FileAsset.objects.filter(
stale_assets = FileAsset.objects.filter(
Q(created_at__lt=timezone.now() - timedelta(days=int(os.environ.get("UNUPLOADED_ASSET_DELETE_DAYS", "7"))))
& Q(is_uploaded=False)
).delete()
)
for asset in stale_assets.iterator():
release_file_asset_blob(asset, delete_untracked_object=True)
asset.delete(soft=False)
@shared_task
def delete_expired_file_asset():
"""Hard delete soft-deleted file assets after the restore retention window."""
days = int(os.environ.get("FILE_ASSET_HARD_DELETE_AFTER_DAYS", settings.HARD_DELETE_AFTER_DAYS))
cutoff = timezone.now() - timedelta(days=days)
storage = S3Storage()
expired_assets = FileAsset.all_objects.filter(deleted_at__lt=cutoff)
for asset in expired_assets.iterator():
_hard_delete_file_asset(asset, storage=storage)

View File

@ -0,0 +1,112 @@
# Generated by Codex on 2026-04-26
import uuid
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("db", "0127_issue_detail_layout"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="StoredBlob",
fields=[
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created At")),
("updated_at", models.DateTimeField(auto_now=True, verbose_name="Last Modified At")),
("deleted_at", models.DateTimeField(blank=True, null=True, verbose_name="Deleted At")),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("sha256", models.CharField(db_index=True, max_length=64)),
("size", models.PositiveBigIntegerField(default=0)),
("mime_type", models.CharField(blank=True, default="", max_length=255)),
("canonical_object_key", models.CharField(max_length=800)),
(
"status",
models.CharField(
choices=[("active", "Active"), ("orphaned", "Orphaned"), ("missing", "Missing")],
default="active",
max_length=32,
),
),
("ref_count", models.PositiveIntegerField(default=0)),
("storage_metadata", models.JSONField(blank=True, default=dict, null=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="stored_blobs",
to="db.workspace",
),
),
],
options={
"verbose_name": "Stored Blob",
"verbose_name_plural": "Stored Blobs",
"db_table": "stored_blobs",
"ordering": ("-created_at",),
},
),
migrations.AddField(
model_name="fileasset",
name="blob",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="assets",
to="db.storedblob",
),
),
migrations.AddIndex(
model_name="storedblob",
index=models.Index(fields=["workspace", "sha256"], name="stored_blob_workspace_sha_idx"),
),
migrations.AddIndex(
model_name="storedblob",
index=models.Index(fields=["status"], name="stored_blob_status_idx"),
),
migrations.AddConstraint(
model_name="storedblob",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True), ("status", "active")),
fields=("workspace", "sha256", "size"),
name="stored_blob_unique_active_hash_size",
),
),
]

View File

@ -4,7 +4,7 @@
from .analytic import AnalyticView
from .api import APIActivityLog, APIToken
from .asset import FileAsset
from .asset import FileAsset, StoredBlob
from .base import BaseModel
from .cycle import Cycle, CycleIssue, CycleUserProperties
from .deploy_board import DeployBoard

View File

@ -60,6 +60,13 @@ class FileAsset(BaseModel):
size = models.FloatField(default=0)
is_uploaded = models.BooleanField(default=False)
storage_metadata = models.JSONField(default=dict, null=True, blank=True)
blob = models.ForeignKey(
"db.StoredBlob",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="assets",
)
class Meta:
verbose_name = "File Asset"
@ -98,3 +105,43 @@ class FileAsset(BaseModel):
return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/{self.id}/"
return None
class StoredBlob(BaseModel):
"""
Canonical stored object shared by one or more FileAsset records.
"""
class Status(models.TextChoices):
ACTIVE = "active", "Active"
ORPHANED = "orphaned", "Orphaned"
MISSING = "missing", "Missing"
workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="stored_blobs")
sha256 = models.CharField(max_length=64, db_index=True)
size = models.PositiveBigIntegerField(default=0)
mime_type = models.CharField(max_length=255, blank=True, default="")
canonical_object_key = models.CharField(max_length=800)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.ACTIVE)
ref_count = models.PositiveIntegerField(default=0)
storage_metadata = models.JSONField(default=dict, null=True, blank=True)
class Meta:
verbose_name = "Stored Blob"
verbose_name_plural = "Stored Blobs"
db_table = "stored_blobs"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["workspace", "sha256"], name="stored_blob_workspace_sha_idx"),
models.Index(fields=["status"], name="stored_blob_status_idx"),
]
constraints = [
models.UniqueConstraint(
fields=["workspace", "sha256", "size"],
condition=models.Q(deleted_at__isnull=True, status="active"),
name="stored_blob_unique_active_hash_size",
)
]
def __str__(self):
return self.canonical_object_key

View File

@ -22,7 +22,7 @@ class S3Storage(S3Boto3Storage):
"""S3 storage class to generate presigned URLs for S3 objects"""
def __init__(self, request=None):
def __init__(self, request=None, is_server=False, use_internal_endpoint=False, **kwargs):
# Get the AWS credentials and bucket name from the environment
self.aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID")
# Use the AWS_SECRET_ACCESS_KEY environment variable for the secret key
@ -36,19 +36,26 @@ class S3Storage(S3Boto3Storage):
# Use the SIGNED_URL_EXPIRATION environment variable for the expiration time (default: 3600 seconds)
self.signed_url_expiration = int(os.environ.get("SIGNED_URL_EXPIRATION", "3600"))
self.use_internal_endpoint = bool(is_server or use_internal_endpoint)
if os.environ.get("USE_MINIO") == "1":
# Determine protocol based on environment variable
if os.environ.get("MINIO_ENDPOINT_SSL") == "1":
endpoint_protocol = "https"
else:
endpoint_protocol = request.scheme if request else "http"
endpoint_protocol = request.scheme if request and not self.use_internal_endpoint else "http"
endpoint_url = self.aws_s3_endpoint_url
if request and not self.use_internal_endpoint:
endpoint_url = f"{endpoint_protocol}://{request.get_host()}"
# Create an S3 client for MinIO
self.s3_client = boto3.client(
"s3",
aws_access_key_id=self.aws_access_key_id,
aws_secret_access_key=self.aws_secret_access_key,
region_name=self.aws_region,
endpoint_url=(f"{endpoint_protocol}://{request.get_host()}" if request else self.aws_s3_endpoint_url),
endpoint_url=endpoint_url,
config=boto3.session.Config(signature_version="s3v4"),
)
else:

View File

@ -15,9 +15,9 @@ from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.db.models import DeployBoard, FileAsset
from plane.settings.storage import S3Storage
from plane.utils.file_dedup import UploadedObjectMissing, confirm_uploaded_file_asset, release_file_asset_blob
# Module imports
from .base import BaseAPIView
@ -141,16 +141,17 @@ class EntityAssetEndpoint(BaseAPIView):
# get the asset id
asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace)
# get the storage metadata
asset.is_uploaded = True
# get the storage metadata
if not asset.storage_metadata:
get_asset_object_metadata.delay(str(asset.id))
# update the attributes
asset.attributes = request.data.get("attributes", asset.attributes)
# save the asset
asset.save(update_fields=["attributes", "is_uploaded"])
try:
confirm_uploaded_file_asset(
asset,
request=request,
attributes=request.data.get("attributes", asset.attributes),
)
except UploadedObjectMissing:
return Response(
{"error": "The uploaded asset object was not found.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(status=status.HTTP_204_NO_CONTENT)
def delete(self, request, anchor, pk):
@ -161,11 +162,13 @@ class EntityAssetEndpoint(BaseAPIView):
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
# Get the asset
asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace, project_id=deploy_board.project_id)
if not asset.is_uploaded:
release_file_asset_blob(asset, request=request, delete_untracked_object=True)
# Check deleted assets
asset.is_deleted = True
asset.deleted_at = timezone.now()
# Save the asset
asset.save(update_fields=["is_deleted", "deleted_at"])
asset.save(update_fields=["is_deleted", "deleted_at", "updated_at"])
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -204,3 +204,49 @@ class TestS3StorageSignedURLExpiration:
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
assert call_kwargs["ExpiresIn"] == 120
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
"AWS_S3_ENDPOINT_URL": "http://plane-minio:9000",
"USE_MINIO": "1",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_minio_request_uses_request_host_for_browser_urls(self, mock_boto3):
"""Test that browser-facing MinIO URLs keep using the request host"""
request = Mock(scheme="http")
request.get_host.return_value = "localhost:8090"
S3Storage(request=request)
call_kwargs = mock_boto3.client.call_args[1]
assert call_kwargs["endpoint_url"] == "http://localhost:8090"
@patch.dict(
os.environ,
{
"AWS_ACCESS_KEY_ID": "test-key",
"AWS_SECRET_ACCESS_KEY": "test-secret",
"AWS_S3_BUCKET_NAME": "test-bucket",
"AWS_REGION": "us-east-1",
"AWS_S3_ENDPOINT_URL": "http://plane-minio:9000",
"USE_MINIO": "1",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_minio_server_mode_uses_internal_endpoint_with_request(self, mock_boto3):
"""Test that server-side MinIO operations use the internal endpoint"""
request = Mock(scheme="http")
request.get_host.return_value = "localhost:8090"
S3Storage(request=request, is_server=True)
call_kwargs = mock_boto3.client.call_args[1]
assert call_kwargs["endpoint_url"] == "http://plane-minio:9000"

View File

@ -0,0 +1,177 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from unittest.mock import patch
import pytest
from django.utils import timezone
from plane.db.models import FileAsset, StoredBlob
from plane.bgtasks.file_asset_task import delete_expired_file_asset
from plane.utils.file_dedup import attach_existing_blob_to_file_asset, finalize_uploaded_file_asset, release_file_asset_blob
class FakeStreamingBody:
def __init__(self, payload):
self.payload = payload
self.closed = False
def iter_chunks(self, chunk_size):
for index in range(0, len(self.payload), chunk_size):
yield self.payload[index : index + chunk_size]
def close(self):
self.closed = True
class FakeS3Client:
def __init__(self, objects):
self.objects = objects
def get_object(self, Bucket, Key):
return {"Body": FakeStreamingBody(self.objects[Key]["body"])}
class FakeStorage:
def __init__(self, objects):
self.aws_storage_bucket_name = "uploads"
self.s3_client = FakeS3Client(objects)
self.deleted = []
self.objects = objects
def get_object_metadata(self, object_name):
item = self.objects.get(object_name)
if item is None:
return None
return {
"ContentType": item["type"],
"ContentLength": len(item["body"]),
"ETag": item.get("etag", "etag"),
"Metadata": {},
}
def delete_files(self, object_names):
self.deleted.extend(object_names)
for object_name in object_names:
self.objects.pop(object_name, None)
return True
@pytest.fixture
def project(workspace):
from plane.db.models import Project, ProjectMember
project = Project.objects.create(name="Dedup Project", identifier="DEDUP", workspace=workspace)
ProjectMember.objects.create(project=project, member=workspace.owner)
return project
@pytest.fixture
def fake_storage():
objects = {
"workspace/a.txt": {"body": b"same payload", "type": "text/plain"},
"workspace/b.txt": {"body": b"same payload", "type": "text/plain"},
}
return FakeStorage(objects)
def create_asset(workspace, project, object_key):
return FileAsset.objects.create(
workspace=workspace,
project=project,
asset=object_key,
size=12,
attributes={"name": object_key.rsplit("/", 1)[-1], "type": "text/plain", "size": 12},
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
@pytest.mark.django_db
def test_finalize_uploaded_file_asset_reuses_existing_blob(workspace, project, fake_storage):
first_asset = create_asset(workspace, project, "workspace/a.txt")
duplicate_asset = create_asset(workspace, project, "workspace/b.txt")
with patch("plane.utils.file_dedup.S3Storage", return_value=fake_storage):
first_result = finalize_uploaded_file_asset(first_asset)
duplicate_result = finalize_uploaded_file_asset(duplicate_asset)
first_asset.refresh_from_db()
duplicate_asset.refresh_from_db()
blob = StoredBlob.objects.get()
assert first_result.deduplicated is False
assert duplicate_result.deduplicated is True
assert duplicate_result.deleted_duplicate_object is True
assert first_asset.blob_id == blob.id
assert duplicate_asset.blob_id == blob.id
assert first_asset.asset.name == "workspace/a.txt"
assert duplicate_asset.asset.name == "workspace/a.txt"
assert blob.ref_count == 2
assert fake_storage.deleted == ["workspace/b.txt"]
@pytest.mark.django_db
def test_release_file_asset_blob_deletes_canonical_object_after_last_reference(workspace, project, fake_storage):
first_asset = create_asset(workspace, project, "workspace/a.txt")
duplicate_asset = create_asset(workspace, project, "workspace/b.txt")
with patch("plane.utils.file_dedup.S3Storage", return_value=fake_storage):
finalize_uploaded_file_asset(first_asset)
finalize_uploaded_file_asset(duplicate_asset)
first_release = release_file_asset_blob(first_asset)
second_release = release_file_asset_blob(duplicate_asset)
blob = StoredBlob.objects.get()
assert first_release.had_blob is True
assert first_release.deleted_object is False
assert second_release.had_blob is True
assert second_release.deleted_object is True
assert second_release.object_key == "workspace/a.txt"
assert blob.ref_count == 0
assert blob.status == StoredBlob.Status.ORPHANED
@pytest.mark.django_db
def test_attach_existing_blob_reuses_canonical_object_without_copy(workspace, project, fake_storage):
source_asset = create_asset(workspace, project, "workspace/a.txt")
target_asset = create_asset(workspace, project, "workspace/c.txt")
with patch("plane.utils.file_dedup.S3Storage", return_value=fake_storage):
finalize_uploaded_file_asset(source_asset)
attached = attach_existing_blob_to_file_asset(source_asset, target_asset)
source_asset.refresh_from_db()
target_asset.refresh_from_db()
blob = StoredBlob.objects.get()
assert attached is True
assert source_asset.blob_id == blob.id
assert target_asset.blob_id == blob.id
assert target_asset.asset.name == "workspace/a.txt"
assert blob.ref_count == 2
assert fake_storage.deleted == []
@pytest.mark.django_db
def test_delete_expired_file_asset_releases_blob_and_hard_deletes_rows(workspace, project, fake_storage):
first_asset = create_asset(workspace, project, "workspace/a.txt")
duplicate_asset = create_asset(workspace, project, "workspace/b.txt")
with patch("plane.utils.file_dedup.S3Storage", return_value=fake_storage):
finalize_uploaded_file_asset(first_asset)
finalize_uploaded_file_asset(duplicate_asset)
deleted_at = timezone.now() - timezone.timedelta(days=90)
FileAsset.all_objects.filter(pk__in=[first_asset.pk, duplicate_asset.pk]).update(deleted_at=deleted_at)
with (
patch("plane.utils.file_dedup.S3Storage", return_value=fake_storage),
patch("plane.bgtasks.file_asset_task.S3Storage", return_value=fake_storage),
):
delete_expired_file_asset()
assert FileAsset.all_objects.filter(pk__in=[first_asset.pk, duplicate_asset.pk]).count() == 0
assert StoredBlob.all_objects.count() == 0
assert fake_storage.deleted == ["workspace/b.txt", "workspace/a.txt"]

View File

@ -0,0 +1,321 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import hashlib
from dataclasses import dataclass
from botocore.exceptions import ClientError
from django.db import IntegrityError, transaction
from plane.db.models import FileAsset, StoredBlob
from plane.settings.storage import S3Storage
from plane.utils.exception_logger import log_exception
class UploadedObjectMissing(Exception):
"""Raised when a FileAsset points to a storage object that is not available."""
@dataclass(frozen=True)
class FileBlobFinalizeResult:
asset: FileAsset
blob: StoredBlob
sha256: str
deduplicated: bool
deleted_duplicate_object: bool
@dataclass(frozen=True)
class FileBlobReleaseResult:
had_blob: bool
deleted_object: bool
object_key: str | None = None
def supports_blob_dedup(asset: FileAsset) -> bool:
return bool(asset.workspace_id)
def _object_key(asset: FileAsset) -> str:
return str(asset.asset.name or asset.asset)
def _server_storage(request=None) -> S3Storage:
return S3Storage(request=request, is_server=True)
def _get_object_metadata(storage: S3Storage, object_key: str) -> dict:
metadata = storage.get_object_metadata(object_name=object_key)
if not metadata:
raise UploadedObjectMissing(f"Storage object not found: {object_key}")
return metadata
def _calculate_object_sha256(storage: S3Storage, object_key: str) -> str:
try:
response = storage.s3_client.get_object(Bucket=storage.aws_storage_bucket_name, Key=object_key)
except ClientError as exc:
raise UploadedObjectMissing(f"Storage object not found: {object_key}") from exc
digest = hashlib.sha256()
body = response["Body"]
try:
for chunk in body.iter_chunks(chunk_size=1024 * 1024):
if chunk:
digest.update(chunk)
finally:
body.close()
return digest.hexdigest()
def _resolve_size(asset: FileAsset, metadata: dict) -> int:
metadata_size = metadata.get("ContentLength")
if metadata_size is not None:
return int(metadata_size)
attr_size = (asset.attributes or {}).get("size")
if attr_size is not None:
return int(float(attr_size))
return int(asset.size or 0)
def _resolve_mime_type(asset: FileAsset, metadata: dict) -> str:
return str((asset.attributes or {}).get("type") or metadata.get("ContentType") or "")
def finalize_uploaded_file_asset(asset: FileAsset, request=None) -> FileBlobFinalizeResult:
"""
Attach a confirmed upload to a canonical StoredBlob.
The uploaded object is treated as a temporary candidate until its SHA-256 is
known. If the same blob already exists in the workspace, the FileAsset is
repointed to the canonical object and the duplicate candidate object is
removed from storage.
"""
if not asset.workspace_id:
raise ValueError("FileAsset workspace is required for blob deduplication.")
storage = _server_storage(request=request)
candidate_key = _object_key(asset)
candidate_metadata = _get_object_metadata(storage, candidate_key)
candidate_sha256 = _calculate_object_sha256(storage, candidate_key)
candidate_size = _resolve_size(asset, candidate_metadata)
candidate_mime_type = _resolve_mime_type(asset, candidate_metadata)
duplicate_object_key = None
released_object_key = None
with transaction.atomic():
locked_asset = FileAsset.objects.select_for_update().get(pk=asset.pk)
previous_blob_id = locked_asset.blob_id
blob = (
StoredBlob.objects.select_for_update()
.filter(
workspace_id=locked_asset.workspace_id,
sha256=candidate_sha256,
size=candidate_size,
status=StoredBlob.Status.ACTIVE,
deleted_at__isnull=True,
)
.first()
)
if blob is None:
try:
with transaction.atomic():
blob = StoredBlob.objects.create(
workspace_id=locked_asset.workspace_id,
sha256=candidate_sha256,
size=candidate_size,
mime_type=candidate_mime_type,
canonical_object_key=candidate_key,
status=StoredBlob.Status.ACTIVE,
ref_count=0,
storage_metadata=candidate_metadata,
)
except IntegrityError:
blob = (
StoredBlob.objects.select_for_update()
.filter(
workspace_id=locked_asset.workspace_id,
sha256=candidate_sha256,
size=candidate_size,
status=StoredBlob.Status.ACTIVE,
deleted_at__isnull=True,
)
.get()
)
deduplicated = blob.canonical_object_key != candidate_key
if deduplicated:
duplicate_object_key = candidate_key
if previous_blob_id and previous_blob_id != blob.id:
previous_blob = StoredBlob.objects.select_for_update().filter(pk=previous_blob_id).first()
if previous_blob:
previous_blob.ref_count = max(previous_blob.ref_count - 1, 0)
if previous_blob.ref_count == 0 and previous_blob.status == StoredBlob.Status.ACTIVE:
previous_blob.status = StoredBlob.Status.ORPHANED
released_object_key = previous_blob.canonical_object_key
previous_blob.save(update_fields=["ref_count", "status", "updated_at"])
if previous_blob_id != blob.id:
blob.ref_count += 1
blob.save(update_fields=["ref_count", "updated_at"])
attributes = dict(locked_asset.attributes or {})
attributes["size"] = candidate_size
attributes["type"] = candidate_mime_type
attributes["sha256"] = candidate_sha256
attributes["blob_id"] = str(blob.id)
attributes["deduplicated"] = deduplicated
locked_asset.blob = blob
locked_asset.asset = blob.canonical_object_key
locked_asset.size = candidate_size
locked_asset.attributes = attributes
locked_asset.storage_metadata = blob.storage_metadata or candidate_metadata
locked_asset.is_uploaded = True
locked_asset.save(
update_fields=[
"blob",
"asset",
"size",
"attributes",
"storage_metadata",
"is_uploaded",
"updated_at",
]
)
deleted_duplicate_object = False
object_keys_to_delete = [key for key in {duplicate_object_key, released_object_key} if key]
if object_keys_to_delete:
deleted_duplicate_object = storage.delete_files(object_names=object_keys_to_delete)
locked_asset.refresh_from_db()
asset.blob_id = locked_asset.blob_id
asset.asset = locked_asset.asset
asset.size = locked_asset.size
asset.attributes = locked_asset.attributes
asset.storage_metadata = locked_asset.storage_metadata
asset.is_uploaded = locked_asset.is_uploaded
return FileBlobFinalizeResult(
asset=locked_asset,
blob=blob,
sha256=candidate_sha256,
deduplicated=deduplicated,
deleted_duplicate_object=deleted_duplicate_object,
)
def confirm_uploaded_file_asset(asset: FileAsset, request=None, attributes: dict | None = None) -> FileAsset:
if attributes is not None:
asset.attributes = attributes
asset.save(update_fields=["attributes", "updated_at"])
if supports_blob_dedup(asset):
return finalize_uploaded_file_asset(asset, request=request).asset
storage = _server_storage(request=request)
asset.storage_metadata = storage.get_object_metadata(object_name=_object_key(asset)) or asset.storage_metadata
asset.is_uploaded = True
asset.save(update_fields=["storage_metadata", "is_uploaded", "updated_at"])
return asset
def release_file_asset_blob(
asset: FileAsset,
request=None,
delete_untracked_object: bool = False,
) -> FileBlobReleaseResult:
"""
Release the blob reference held by a FileAsset.
The canonical object is deleted only when the last blob-backed FileAsset
releases it. Legacy non-blob assets are left intact unless the caller marks
them as temporary/untracked.
"""
storage = _server_storage(request=request)
delete_object_key = None
had_blob = False
with transaction.atomic():
locked_asset = FileAsset.all_objects.select_for_update().get(pk=asset.pk)
current_key = _object_key(locked_asset)
if locked_asset.blob_id:
had_blob = True
blob = StoredBlob.objects.select_for_update().get(pk=locked_asset.blob_id)
blob.ref_count = max(blob.ref_count - 1, 0)
if blob.ref_count == 0 and blob.status == StoredBlob.Status.ACTIVE:
blob.status = StoredBlob.Status.ORPHANED
delete_object_key = blob.canonical_object_key
blob.save(update_fields=["ref_count", "status", "updated_at"])
locked_asset.blob = None
locked_asset.save(update_fields=["blob", "updated_at"])
elif delete_untracked_object and current_key:
delete_object_key = current_key
if had_blob:
asset.blob_id = None
deleted_object = False
if delete_object_key:
try:
deleted_object = storage.delete_files(object_names=[delete_object_key])
except Exception as exc:
log_exception(exc)
deleted_object = False
return FileBlobReleaseResult(had_blob=had_blob, deleted_object=deleted_object, object_key=delete_object_key)
def attach_existing_blob_to_file_asset(source: FileAsset, target: FileAsset) -> bool:
if not source.blob_id:
source.refresh_from_db(fields=["blob"])
if not source.blob_id:
return False
with transaction.atomic():
source_blob = StoredBlob.objects.select_for_update().get(pk=source.blob_id)
locked_target = FileAsset.objects.select_for_update().get(pk=target.pk)
if locked_target.blob_id == source_blob.id:
return True
source_blob.ref_count += 1
source_blob.save(update_fields=["ref_count", "updated_at"])
attributes = dict(locked_target.attributes or {})
attributes["sha256"] = source_blob.sha256
attributes["blob_id"] = str(source_blob.id)
attributes["deduplicated"] = True
locked_target.blob = source_blob
locked_target.asset = source_blob.canonical_object_key
locked_target.size = source_blob.size
locked_target.attributes = attributes
locked_target.storage_metadata = source_blob.storage_metadata
locked_target.is_uploaded = True
locked_target.save(
update_fields=[
"blob",
"asset",
"size",
"attributes",
"storage_metadata",
"is_uploaded",
"updated_at",
]
)
target.blob_id = source.blob_id
target.asset = source_blob.canonical_object_key
return True

View File

@ -117,7 +117,9 @@ const ProjectsToolbarMenu = observer(function ProjectsToolbarMenu() {
>
<span
className={`nodedc-toolbar-icon-active-dot ${
pathname.includes("/projects/") ? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]" : ""
pathname.includes("/projects/")
? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]"
: ""
}`}
>
<ProjectIcon className="size-4" />
@ -126,7 +128,7 @@ const ProjectsToolbarMenu = observer(function ProjectsToolbarMenu() {
<Menu.Items className="absolute top-full -right-2 z-[170] mt-2 origin-top-right">
<div className="nodedc-glass-modal nodedc-glass-popup-surface flex max-h-[70vh] min-w-[26rem] flex-col overflow-hidden rounded-[1.5rem] border-0 p-2 shadow-none outline-none">
<div className="vertical-scrollbar scrollbar-sm flex max-h-[70vh] flex-col gap-0.5 overflow-y-auto pr-1">
<div className="vertical-scrollbar flex scrollbar-sm max-h-[70vh] flex-col gap-0.5 overflow-y-auto pr-1">
{joinedProjectIds.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
@ -258,14 +260,9 @@ export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar()
<div className="nodedc-glass-modal flex w-full flex-wrap items-center justify-between gap-4 rounded-[1.6rem] px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">
<ToolbarIconButton
label={t("app_header.add_task")}
onClick={() => toggleCreateIssueModal(true)}
disabled={!canCreateIssue || joinedProjectIds.length === 0}
>
<PlusIcon className="size-4" />
</ToolbarIconButton>
<WorkspaceMenuRoot variant="toolbar" />
<TopNavPowerK variant="sidebar" />
<UserMenuRoot variant="toolbar" />
<Tooltip tooltipContent={t("notification.label")} position="bottom">
<Link
href={`/${workspaceSlug?.toString()}/notifications/`}
@ -281,8 +278,13 @@ export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar()
)}
</Link>
</Tooltip>
<UserMenuRoot variant="toolbar" />
<WorkspaceMenuRoot variant="toolbar" />
<ToolbarIconButton
label={t("app_header.add_task")}
onClick={() => toggleCreateIssueModal(true)}
disabled={!canCreateIssue || joinedProjectIds.length === 0}
>
<PlusIcon className="size-4" />
</ToolbarIconButton>
</div>
</div>

View File

@ -0,0 +1,13 @@
import { redirect } from "react-router";
import type { Route } from "./+types/page";
export function clientLoader({ params }: Route.ClientLoaderArgs) {
const { workspaceSlug } = params;
throw redirect(`/${workspaceSlug}/?workspaceSettings=storage`);
}
function StorageWorkspaceSettingsPage() {
return null;
}
export default StorageWorkspaceSettingsPage;

View File

@ -281,6 +281,10 @@ export const coreRoutes: RouteConfigEntry[] = [
":workspaceSlug/settings/exports",
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx"
),
route(
":workspaceSlug/settings/storage",
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/storage/page.tsx"
),
route(
":workspaceSlug/settings/webhooks",
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx"

View File

@ -7,16 +7,13 @@
import { useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { MoveDiagonal, MoveRight } from "lucide-react";
import { Check, MoveDiagonal, MoveRight, X } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { IconButton } from "@plane/propel/icon-button";
import { Button } from "@plane/propel/button";
import {
CenterPanelIcon,
CheckCircleFilledIcon,
ChevronDownIcon,
ChevronUpIcon,
CloseCircleFilledIcon,
CopyLinkIcon,
FullScreenPanelIcon,
SidePanelIcon,
@ -100,6 +97,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
contourRequest.capabilities?.can_source_decide ??
(contourRequest.status === "closed" && contourRequest.source_decision !== "accepted");
const isSourceAccepted = contourRequest.source_decision === "accepted";
const isDecisionSubmitting = loader === "mutation-loading";
const redirectToRelativeIssue = useCallback(
(direction: "next" | "prev") => {
@ -291,16 +289,30 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
<div className="flex flex-wrap items-center gap-2">
{canReviewClosedRequest && (
<>
<Button variant="primary" size="lg" onClick={() => handleDecision("accept")} className="nodedc-external-primary-button">
<CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" />
{t("external_contours_page.actions.accept")}
</Button>
<Button variant="secondary" size="lg" onClick={() => setIsDeclineModalOpen(true)} className="nodedc-external-action-button">
<CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" />
{t("external_contours_page.actions.decline")}
</Button>
</>
<div className="nodedc-external-decision-cluster">
<Tooltip tooltipContent={t("external_contours_page.actions.accept")}>
<button
type="button"
aria-label={t("external_contours_page.actions.accept")}
onClick={() => handleDecision("accept")}
disabled={isDecisionSubmitting}
className="nodedc-external-decision-button nodedc-external-decision-button-accept"
>
<Check className="size-4" strokeWidth={2.6} />
</button>
</Tooltip>
<Tooltip tooltipContent={t("external_contours_page.actions.decline")}>
<button
type="button"
aria-label={t("external_contours_page.actions.decline")}
onClick={() => setIsDeclineModalOpen(true)}
disabled={isDecisionSubmitting}
className="nodedc-external-decision-button nodedc-external-decision-button-decline"
>
<X className="size-4" strokeWidth={2.5} />
</button>
</Tooltip>
</div>
)}
{isSourceAccepted && (

View File

@ -13,14 +13,14 @@ import { useTranslation } from "@plane/i18n";
import { LabelPropertyIcon, PriorityIcon, PriorityPropertyIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TExternalContourRequest, TIssue, TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType } from "@plane/types";
import { EFileAssetType, EIssueServiceType } from "@plane/types";
import { getTextContent } from "@plane/utils";
import { DescriptionVersionsRoot } from "@/components/core/description-versions";
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
import { DescriptionInputLoader } from "@/components/editor/rich-text/description-input/loader";
import { IssueAttachmentRoot } from "@/components/issues/attachment";
import type { TIssueOperations } from "@/components/issues/issue-detail";
import { IssueActivity } from "@/components/issues/issue-detail/issue-activity";
import { IssueDetailWidgets } from "@/components/issues/issue-detail-widgets";
import { IssueReaction } from "@/components/issues/issue-detail/reactions";
import { IssueTitleInput } from "@/components/issues/title-input";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
@ -329,8 +329,16 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
<ExternalContoursRequestTraceability contourRequest={contourRequest} />
<div className="nodedc-external-section overflow-visible px-4 py-4">
<IssueAttachmentRoot workspaceSlug={workspaceSlug} projectId={targetProjectId} issueId={issue.id} disabled={!isEditable} />
<div className="nodedc-external-section nodedc-external-detail-widgets overflow-visible px-4 py-4">
<IssueDetailWidgets
workspaceSlug={workspaceSlug}
projectId={targetProjectId}
issueId={issue.id}
disabled={!isEditable}
issueOperations={issueOperations}
issueServiceType={EIssueServiceType.ISSUES}
compactView
/>
</div>
<div className="nodedc-external-section overflow-visible px-4 py-4">

View File

@ -14,6 +14,7 @@ import usePeekOverviewOutsideClickDetector from "@/hooks/use-peek-overview-outsi
import { ExternalContoursIssueActionsHeader, type TExternalContourPeekMode } from "./issue-header";
const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:external-contour-peek-width";
const SIDE_PEEK_MIN_WIDTH = 780;
type Props = {
workspaceSlug: string;
@ -41,9 +42,9 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
} = props;
const [peekMode, setPeekMode] = useState<TExternalContourPeekMode>("side-peek");
const [sidePeekWidth, setSidePeekWidth] = useState<number>(() => {
if (typeof window === "undefined") return 720;
if (typeof window === "undefined") return SIDE_PEEK_MIN_WIDTH;
const fallbackWidth = Math.max(640, Math.floor(window.innerWidth * 0.5));
const fallbackWidth = Math.max(SIDE_PEEK_MIN_WIDTH, Math.floor(window.innerWidth * 0.54));
const storedWidth = window.localStorage.getItem(SIDE_PEEK_WIDTH_STORAGE_KEY);
const parsedWidth = storedWidth ? parseInt(storedWidth, 10) : NaN;
@ -72,10 +73,9 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
(event: MouseEvent) => {
if (!isResizingPeek) return;
const maxWidth = Math.max(720, window.innerWidth - 48);
const minWidth = 640;
const maxWidth = Math.max(SIDE_PEEK_MIN_WIDTH, window.innerWidth - 48);
const deltaX = event.clientX - initialMouseXRef.current;
const nextWidth = Math.min(Math.max(initialPeekWidthRef.current - deltaX, minWidth), maxWidth);
const nextWidth = Math.min(Math.max(initialPeekWidthRef.current - deltaX, SIDE_PEEK_MIN_WIDTH), maxWidth);
setSidePeekWidth(nextWidth);
},
[isResizingPeek]
@ -94,7 +94,7 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
useEffect(() => {
const handleWindowResize = () => {
const maxWidth = Math.max(720, window.innerWidth - 48);
const maxWidth = Math.max(SIDE_PEEK_MIN_WIDTH, window.innerWidth - 48);
setSidePeekWidth((currentWidth) => Math.min(currentWidth, maxWidth));
};
@ -105,8 +105,8 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
useEffect(() => {
if (typeof window === "undefined") return;
const maxWidth = Math.max(720, window.innerWidth - 48);
const clampedWidth = Math.min(Math.max(sidePeekWidth, 640), maxWidth);
const maxWidth = Math.max(SIDE_PEEK_MIN_WIDTH, window.innerWidth - 48);
const clampedWidth = Math.min(Math.max(sidePeekWidth, SIDE_PEEK_MIN_WIDTH), maxWidth);
window.localStorage.setItem(SIDE_PEEK_WIDTH_STORAGE_KEY, String(clampedWidth));
@ -151,7 +151,7 @@ export const ExternalContoursPeekShell = observer(function ExternalContoursPeekS
? "absolute z-[25] flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
: "h-full w-full",
!embedIssue && {
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[780px] md:max-w-[calc(100vw-1.5rem)]":
peekMode === "side-peek",
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",

View File

@ -15,8 +15,8 @@ export function LogoSpinner() {
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
return (
<div className="flex items-center justify-center">
<img src={logoSrc} alt="logo" className="h-6 w-auto object-contain sm:h-11" />
<div className="pointer-events-none flex items-center justify-center opacity-0" aria-hidden="true">
<img src={logoSrc} alt="" className="h-6 w-auto object-contain sm:h-11" />
</div>
);
}

View File

@ -0,0 +1,19 @@
import { cn } from "@plane/utils";
type TNodedcProcessingLoaderProps = {
className?: string;
label?: string;
tone?: "accent" | "white";
};
export function NodedcProcessingLoader({ className, label, tone = "accent" }: TNodedcProcessingLoaderProps) {
return (
<div className={cn("flex flex-col items-center justify-center gap-5 text-center", className)}>
<span
className={cn("nodedc-processing-loader", tone === "white" && "nodedc-processing-loader-white")}
aria-hidden="true"
/>
{label && <div className="text-18 font-semibold text-primary">{label}</div>}
</div>
);
}

View File

@ -238,19 +238,19 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
multiple={multiple}
>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<Combobox.Options className="fixed z-[1000]" static>
<div
className="my-1 w-48 rounded-sm border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 text-11 shadow-raised-200 focus:outline-none"
className="nodedc-dropdown-surface my-1 w-56 focus:outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded-sm border border-subtle bg-surface-2 px-2">
<div className="nodedc-dropdown-search">
<SearchIcon className="h-3.5 w-3.5 text-placeholder" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-11 text-secondary placeholder:text-placeholder focus:outline-none"
className="w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("search")}
@ -268,9 +268,9 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none ${
active ? "bg-layer-transparent-hover" : ""
} ${selected ? "text-primary" : "text-secondary"}`
`nodedc-dropdown-option cursor-pointer ${active ? "bg-white/[0.06]" : ""} ${
selected ? "text-primary" : "text-secondary"
}`
}
>
{({ selected }) => (
@ -283,10 +283,10 @@ export const ProjectDropdownBase = observer(function ProjectDropdownBase(props:
);
})
) : (
<p className="px-1.5 py-1 text-placeholder italic">{t("no_matching_results")}</p>
<p className="px-3 py-2 text-12 text-placeholder italic">{t("no_matching_results")}</p>
)
) : (
<p className="px-1.5 py-1 text-placeholder italic">{t("loading")}</p>
<p className="px-3 py-2 text-12 text-placeholder italic">{t("loading")}</p>
)}
</div>
</div>

View File

@ -6,13 +6,18 @@
import { useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import { ArrowDownUp, Check, Filter } from "lucide-react";
import useSWR from "swr";
import { useTranslation } from "@plane/i18n";
// plane types
import { PageIcon, ProjectIcon, WorkItemsIcon } from "@plane/propel/icons";
import type { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
import { Avatar } from "@plane/propel/avatar";
import type { IIssueActivity, TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
import { calculateTimeAgo, cn, generateWorkItemLink, getFileURL, renderFormattedDate } from "@plane/utils";
import { ActivityIcon } from "@/components/core/activity";
// plane web services
import { WorkspaceService } from "@/services/workspace.service";
import { useUser } from "@/hooks/store/user";
import { getActivityProjectId } from "../../home.utils";
import { RecentsEmptyState } from "../empty-states";
import { EWidgetKeys, WidgetLoader } from "../loaders";
@ -30,6 +35,117 @@ const filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode; i18n_k
{ name: "project", icon: <ProjectIcon height={16} width={16} />, i18n_key: "home.recents.filters.projects" },
];
type TActivityTraceFilterKey =
| "archive"
| "assignees"
| "content"
| "created"
| "dates"
| "labels"
| "mine"
| "priority"
| "state";
type TActivityTraceSortMode = "newest" | "oldest";
const ACTIVITY_TRACE_FILTER_OPTIONS: { key: TActivityTraceFilterKey; label: string }[] = [
{ key: "created", label: "Новые задачи" },
{ key: "assignees", label: "Исполнители" },
{ key: "state", label: "Статусы" },
{ key: "priority", label: "Приоритеты" },
{ key: "dates", label: "Сроки и даты" },
{ key: "content", label: "Название и описание" },
{ key: "labels", label: "Метки" },
{ key: "archive", label: "Архив и восстановление" },
{ key: "mine", label: "Только мои действия" },
];
const ACTIVITY_TRACE_SORT_OPTIONS: { key: TActivityTraceSortMode; label: string }[] = [
{ key: "newest", label: "Новые сверху" },
{ key: "oldest", label: "Старые сверху" },
];
const PRIORITY_LABELS: Record<string, string> = {
urgent: "Срочный",
high: "Высокий",
medium: "Средний",
low: "Низкий",
none: "Без приоритета",
};
const activityValue = (value: string | null | undefined) => {
if (!value) return null;
return PRIORITY_LABELS[value] ?? value;
};
const activityDate = (value: string | null | undefined) => {
if (!value) return null;
return renderFormattedDate(value);
};
const activityMessage = (activity: IIssueActivity) => {
if (!activity.field && activity.verb === "created") return "создал рабочий элемент";
switch (activity.field) {
case "assignees":
return activity.old_value === ""
? `добавил исполнителя ${activityValue(activity.new_value) ?? ""}`.trim()
: `убрал исполнителя ${activityValue(activity.old_value) ?? ""}`.trim();
case "state":
return `изменил статус на ${activityValue(activity.new_value) ?? "новое значение"}`;
case "priority":
return `изменил приоритет на ${activityValue(activity.new_value) ?? "новое значение"}`;
case "target_date":
return `изменил срок на ${activityDate(activity.new_value) ?? "новую дату"}`;
case "start_date":
return `изменил дату начала на ${activityDate(activity.new_value) ?? "новую дату"}`;
case "name":
return "изменил название";
case "description":
return "обновил описание";
case "labels":
return "изменил метки";
case "estimate_point":
return "изменил оценку";
case "archived_at":
return activity.new_value === "restore" ? "восстановил рабочий элемент" : "архивировал рабочий элемент";
default:
return "обновил рабочий элемент";
}
};
const getActivityTraceFilterKeys = (activity: IIssueActivity): TActivityTraceFilterKey[] => {
if (!activity.field && activity.verb === "created") return ["created"];
switch (activity.field) {
case "assignees":
return ["assignees"];
case "state":
return ["state"];
case "priority":
return ["priority"];
case "start_date":
case "target_date":
return ["dates"];
case "name":
case "description":
return ["content"];
case "labels":
return ["labels"];
case "archived_at":
return ["archive"];
default:
return [];
}
};
const sortActivityTrace = (activities: IIssueActivity[], sortMode: TActivityTraceSortMode) =>
[...activities].sort((firstActivity, secondActivity) => {
const firstTime = new Date(firstActivity.created_at).getTime();
const secondTime = new Date(secondActivity.created_at).getTime();
return sortMode === "oldest" ? firstTime - secondTime : secondTime - firstTime;
});
type TRecentWidgetProps = THomeWidgetProps & {
presetFilter?: TRecentActivityFilterKeys;
showFilterSelect?: boolean;
@ -37,16 +153,105 @@ type TRecentWidgetProps = THomeWidgetProps & {
recents?: TActivityEntityData[];
};
function RecentActivityTraceItem({
activity,
currentUserId,
workspaceSlug,
}: {
activity: IIssueActivity;
currentUserId?: string;
workspaceSlug: string;
}) {
const actorName =
currentUserId === activity.actor_detail?.id ? "Вы" : activity.actor_detail?.display_name || "Пользователь";
const issueIdentifier =
activity.project_detail?.identifier && activity.issue_detail?.sequence_id
? `${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`
: null;
const issueTitle = activity.issue_detail?.name ?? "рабочем элементе";
const issueLink =
activity.issue && activity.issue_detail
? generateWorkItemLink({
workspaceSlug,
projectId: activity.project,
issueId: activity.issue,
projectIdentifier: activity.project_detail?.identifier,
sequenceId: activity.issue_detail?.sequence_id,
})
: null;
const content = (
<div className="group flex min-w-0 items-start gap-3 rounded-[18px] px-3 py-2.5 transition-colors hover:bg-white/[0.03]">
<Avatar
name={activity.actor_detail?.display_name}
src={getFileURL(activity.actor_detail?.avatar_url)}
size="sm"
shape="circle"
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 text-13 leading-5 text-secondary">
<span className="font-medium text-primary">{actorName}</span>{" "}
<span>{activityMessage(activity)}</span>{" "}
<span className="font-medium text-primary">
{issueIdentifier ? `${issueIdentifier}: ${issueTitle}` : issueTitle}
</span>
</div>
<div className="flex shrink-0 items-center gap-2 pt-0.5">
<span className="text-11 whitespace-nowrap text-placeholder">{calculateTimeAgo(activity.created_at)}</span>
<span className="grid size-6 place-items-center rounded-full bg-white/[0.04] text-tertiary">
{activity.field ? <ActivityIcon activity={activity} /> : <WorkItemsIcon className="size-3" />}
</span>
</div>
</div>
</div>
</div>
);
if (!issueLink) return content;
return (
<a href={issueLink} className="block">
{content}
</a>
);
}
export const RecentActivityWidget = observer(function RecentActivityWidget(props: TRecentWidgetProps) {
const { presetFilter, showFilterSelect = true, workspaceSlug, projectId, recents: preloadedRecents } = props;
// states
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(presetFilter ?? filters[0].name);
const [activeTraceFilters, setActiveTraceFilters] = useState<TActivityTraceFilterKey[]>([]);
const [isTraceFilterOpen, setIsTraceFilterOpen] = useState(false);
const [traceSortMode, setTraceSortMode] = useState<TActivityTraceSortMode>("newest");
const { t } = useTranslation();
const { data: currentUser } = useUser();
// ref
const ref = useRef<HTMLDivElement>(null);
const shouldUseActivityTrace = filter === filters[0].name || filter === "issue";
const { data: fetchedActivity, isLoading: isActivityLoading } = useSWR(
workspaceSlug && shouldUseActivityTrace
? `WORKSPACE_ACTIVITY_TRACE_${workspaceSlug}_${projectId ?? "all"}`
: null,
workspaceSlug
? () =>
workspaceService.fetchWorkspaceActivity(workspaceSlug.toString(), {
per_page: 60,
...(projectId ? { project: projectId } : {}),
})
: null,
{
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
const { data: fetchedRecents, isLoading } = useSWR(
workspaceSlug && !preloadedRecents ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null,
workspaceSlug && !preloadedRecents && !shouldUseActivityTrace
? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}`
: null,
workspaceSlug
? () =>
workspaceService.fetchWorkspaceRecents(
@ -61,6 +266,21 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
}
);
const activityTrace = useMemo(() => {
const source = fetchedActivity?.results ?? [];
const onlyMine = activeTraceFilters.includes("mine");
const fieldFilters = activeTraceFilters.filter((filterKey) => filterKey !== "mine");
const filteredTrace = source.filter((activity) => {
if (onlyMine && currentUser?.id !== activity.actor_detail?.id) return false;
if (fieldFilters.length === 0) return true;
const activityFilterKeys = getActivityTraceFilterKeys(activity);
return fieldFilters.some((filterKey) => activityFilterKeys.includes(filterKey));
});
return sortActivityTrace(filteredTrace, traceSortMode);
}, [activeTraceFilters, currentUser?.id, fetchedActivity?.results, traceSortMode]);
const recents = useMemo(() => {
const source = preloadedRecents ?? fetchedRecents ?? [];
const filteredByType = source.filter((activity) =>
@ -74,6 +294,109 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
});
}, [fetchedRecents, filter, preloadedRecents, projectId]);
const isWidgetLoading = shouldUseActivityTrace ? isActivityLoading : isLoading;
const isWidgetEmpty = shouldUseActivityTrace ? activityTrace.length === 0 : recents.length === 0;
const activeTraceFilterCount = activeTraceFilters.length + (traceSortMode === "newest" ? 0 : 1);
const toggleTraceFilter = (filterKey: TActivityTraceFilterKey) =>
setActiveTraceFilters((currentFilters) =>
currentFilters.includes(filterKey)
? currentFilters.filter((currentFilter) => currentFilter !== filterKey)
: [...currentFilters, filterKey]
);
const resetTraceFilters = () => {
setActiveTraceFilters([]);
setTraceSortMode("newest");
};
const headerActions = (
<div className="flex items-center gap-2">
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
{shouldUseActivityTrace && (
<div className="nodedc-home-gantt-action-group">
<button
type="button"
className={cn("nodedc-home-gantt-round-button", {
"nodedc-home-gantt-round-button-active": activeTraceFilterCount > 0 || isTraceFilterOpen,
"nodedc-home-gantt-filter-button-has-count": activeTraceFilterCount > 0,
})}
aria-expanded={isTraceFilterOpen}
aria-label="Фильтры недавних действий"
aria-pressed={activeTraceFilterCount > 0}
onClick={() => setIsTraceFilterOpen((isOpen) => !isOpen)}
>
<Filter className="size-4" />
{activeTraceFilterCount > 0 && (
<span className="nodedc-home-gantt-filter-count">{activeTraceFilterCount}</span>
)}
</button>
{isTraceFilterOpen && (
<div className="nodedc-home-gantt-popover nodedc-home-gantt-popover-wide">
<div className="nodedc-home-gantt-popover-section">
<div className="nodedc-home-gantt-popover-title">Тип действия</div>
{ACTIVITY_TRACE_FILTER_OPTIONS.map((option) => {
const isActive = activeTraceFilters.includes(option.key);
return (
<button
key={option.key}
type="button"
className={cn("nodedc-home-gantt-popover-option", {
"nodedc-home-gantt-popover-option-active": isActive,
})}
onClick={() => toggleTraceFilter(option.key)}
>
<span className="nodedc-home-gantt-popover-option-left">
<span className="nodedc-home-gantt-popover-check">
{isActive && <Check className="size-3" />}
</span>
<span>{option.label}</span>
</span>
</button>
);
})}
</div>
<div className="nodedc-home-gantt-popover-section">
<div className="nodedc-home-gantt-popover-title">Сортировка</div>
{ACTIVITY_TRACE_SORT_OPTIONS.map((option) => {
const isActive = traceSortMode === option.key;
return (
<button
key={option.key}
type="button"
className={cn("nodedc-home-gantt-popover-option", {
"nodedc-home-gantt-popover-option-active": isActive,
})}
onClick={() => setTraceSortMode(option.key)}
>
<span className="nodedc-home-gantt-popover-option-left">
<span className="nodedc-home-gantt-popover-check">
{isActive && <Check className="size-3" />}
</span>
<span>{option.label}</span>
</span>
<ArrowDownUp className="size-3 text-tertiary" />
</button>
);
})}
</div>
{activeTraceFilterCount > 0 && (
<button type="button" className="nodedc-home-gantt-popover-reset" onClick={resetTraceFilters}>
Сбросить фильтры
</button>
)}
</div>
)}
</div>
)}
</div>
);
const resolveRecent = (activity: TActivityEntityData) => {
switch (activity.entity_name) {
case "page":
@ -88,12 +411,12 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
}
};
if (!isLoading && recents.length === 0)
if (!isWidgetLoading && isWidgetEmpty)
return (
<div ref={ref} className="max-h-[500px] overflow-y-scroll">
<div className="mb-4 flex items-center justify-between">
<div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div>
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
{headerActions}
</div>
<div className="flex flex-col items-center justify-center">
<RecentsEmptyState type={filter} />
@ -105,11 +428,23 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
<div className="box-border min-h-[250px]">
<div className="mb-2 flex items-center justify-between">
<div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div>
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
{headerActions}
</div>
<div className="flex max-h-[415px] min-h-[250px] flex-col overflow-y-auto">
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
{!isLoading && recents.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
{isWidgetLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
{!isWidgetLoading &&
shouldUseActivityTrace &&
activityTrace.map((activity) => (
<RecentActivityTraceItem
key={activity.id}
activity={activity}
currentUserId={currentUser?.id}
workspaceSlug={workspaceSlug}
/>
))}
{!isWidgetLoading &&
!shouldUseActivityTrace &&
recents.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
</div>
</div>
);

View File

@ -49,8 +49,6 @@ export const SubIssuesActionButton = observer(function SubIssuesActionButton(pro
// derived values
const issue = getIssueById(issueId);
if (!issue) return <></>;
// handlers
const handleIssueCrudState = (
key: "create" | "existing",
@ -74,7 +72,7 @@ export const SubIssuesActionButton = observer(function SubIssuesActionButton(pro
const handleAddExisting = () => {
handleIssueCrudState("existing", issueId, null);
toggleSubIssuesModal(issue.id);
toggleSubIssuesModal(issue?.id ?? issueId);
};
// options

View File

@ -15,6 +15,7 @@ import { cn } from "@plane/utils";
// components
import { AppSidebarItem } from "@/components/sidebar/sidebar-item";
import {
getWorkspaceSettingsModalTabFromSearch,
openWorkspaceSettingsModal,
WORKSPACE_SETTINGS_MODAL_EVENT,
} from "@/components/workspace/settings/workspace-settings-modal.utils";
@ -35,13 +36,13 @@ export const AppRailRoot = observer(() => {
const { preferences, updateDisplayMode } = useAppRailPreferences();
const { isCollapsed, toggleAppRail } = useAppRailVisibility();
const [isWorkspaceSettingsModalOpen, setIsWorkspaceSettingsModalOpen] = useState(
searchParams?.get("workspaceSettings") === "general" || searchParams?.get("workspaceSettings") === "ai-voice-tasker"
Boolean(getWorkspaceSettingsModalTabFromSearch(searchParams?.toString() ?? ""))
);
// derived values
const workspaceSettingsModalTab = getWorkspaceSettingsModalTabFromSearch(searchParams?.toString() ?? "");
const isWorkspaceSettingsPath =
(pathname.includes(`/${workspaceSlug}/settings`) && !projectId) ||
searchParams?.get("workspaceSettings") === "general" ||
searchParams?.get("workspaceSettings") === "ai-voice-tasker" ||
Boolean(workspaceSettingsModalTab) ||
isWorkspaceSettingsModalOpen;
const showLabel = preferences.displayMode === "icon_with_label";
const railWidth = showLabel ? "3.75rem" : "3rem";

View File

@ -5,7 +5,7 @@
*/
import type { LucideIcon } from "lucide-react";
import { ArrowUpToLine, Building, CreditCard, Mic, Users, Webhook } from "lucide-react";
import { ArrowUpToLine, Building, CreditCard, Database, Mic, Users, Webhook } from "lucide-react";
// plane imports
import type { ISvgIcons } from "@plane/propel/icons";
import type { TWorkspaceSettingsTabs } from "@plane/types";
@ -15,6 +15,7 @@ export const WORKSPACE_SETTINGS_ICONS: Record<TWorkspaceSettingsTabs, LucideIcon
members: Users,
export: ArrowUpToLine,
"billing-and-plans": CreditCard,
storage: Database,
webhooks: Webhook,
"ai-voice-tasker": Mic,
};

View File

@ -12,22 +12,15 @@ import type {
import type { ElementDragPayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import Masonry from "react-masonry-component";
// plane imports
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { PlusIcon } from "@plane/propel/icons";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import { EUserWorkspaceRoles } from "@plane/types";
// assets
import darkStickiesAsset from "@/app/assets/empty-state/stickies/stickies-dark.webp?url";
import lightStickiesAsset from "@/app/assets/empty-state/stickies/stickies-light.webp?url";
import darkStickiesSearchAsset from "@/app/assets/empty-state/stickies/stickies-search-dark.webp?url";
import lightStickiesSearchAsset from "@/app/assets/empty-state/stickies/stickies-search-light.webp?url";
// components
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
import { StickiesEmptyState } from "@/components/home/widgets/empty-states/stickies";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
@ -51,8 +44,6 @@ export const StickiesList = observer(function StickiesList(props: TProps) {
const { workspaceSlug, intersectionElement, columnCount } = props;
// navigation
const pathname = usePathname();
// theme hook
const { resolvedTheme } = useTheme();
// plane hooks
const { t } = useTranslation();
// store hooks
@ -69,8 +60,6 @@ export const StickiesList = observer(function StickiesList(props: TProps) {
[EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
EUserPermissionsLevel.WORKSPACE
);
const stickiesResolvedPath = resolvedTheme === "light" ? lightStickiesAsset : darkStickiesAsset;
const stickiesSearchResolvedPath = resolvedTheme === "light" ? lightStickiesSearchAsset : darkStickiesSearchAsset;
const masonryRef = useRef<any>(null);
const handleLayout = () => {
@ -118,25 +107,36 @@ export const StickiesList = observer(function StickiesList(props: TProps) {
{isStickiesPage ? (
<>
{searchQuery ? (
<SimpleEmptyState
<EmptyStateDetailed
assetKey="search"
assetClassName="nodedc-stickies-empty-asset size-40"
rootClassName="nodedc-stickies-empty-root"
align="center"
title={t("stickies.empty_state.search.title")}
description={t("stickies.empty_state.search.description")}
assetPath={stickiesSearchResolvedPath}
/>
) : (
<DetailedEmptyState
<EmptyStateDetailed
assetKey="dashboard"
assetClassName="nodedc-stickies-empty-asset size-40"
rootClassName="nodedc-stickies-empty-root"
align="center"
title={t("stickies.empty_state.general.title")}
description={t("stickies.empty_state.general.description")}
assetPath={stickiesResolvedPath}
primaryButton={{
prependIcon: <PlusIcon className="size-4" />,
text: t("stickies.empty_state.general.primary_button.text"),
onClick: () => {
toggleShowNewSticky(true);
stickyOperations.create();
},
disabled: !hasGuestLevelPermissions,
}}
customButton={
<button
type="button"
className="nodedc-stickies-empty-primary-button"
onClick={() => {
toggleShowNewSticky(true);
stickyOperations.create();
}}
disabled={!hasGuestLevelPermissions}
>
<PlusIcon className="size-4" />
<span>{t("stickies.empty_state.general.primary_button.text")}</span>
</button>
}
/>
)}
</>

View File

@ -0,0 +1,216 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { AlertTriangle, Database, Files, HardDrive, Layers3, Recycle, UploadCloud } from "lucide-react";
import type { ElementType } from "react";
import useSWR from "swr";
// plane imports
import type { IWorkspaceStorageProjectSummary } from "@plane/types";
import { cn } from "@plane/utils";
// services
import { WorkspaceService } from "@/services/workspace.service";
const workspaceService = new WorkspaceService();
const formatBytes = (value: number) => {
const bytes = Number(value || 0);
if (bytes <= 0) return "0 Б";
const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const size = bytes / 1024 ** index;
return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`;
};
const formatCount = (value: number) => new Intl.NumberFormat("ru-RU").format(Number(value || 0));
const StatCard = (props: {
title: string;
value: string;
caption: string;
icon: ElementType;
tone?: "default" | "accent" | "warning";
}) => {
const { title, value, caption, icon: Icon, tone = "default" } = props;
return (
<div className="rounded-[28px] border border-white/5 bg-custom-background-80/85 p-5 shadow-[0_22px_80px_rgba(0,0,0,0.28)]">
<div className="mb-5 flex items-start justify-between gap-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{title}</div>
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-full bg-custom-background-90 text-secondary",
tone === "accent" && "bg-accent-primary/20 text-accent-primary",
tone === "warning" && "bg-red-500/15 text-red-300"
)}
>
<Icon className="size-4" />
</div>
</div>
<div className="text-3xl font-semibold tracking-normal text-primary">{value}</div>
<div className="mt-2 text-12 leading-5 text-secondary">{caption}</div>
</div>
);
};
const ProjectStorageRow = (props: { project: IWorkspaceStorageProjectSummary; maxSize: number }) => {
const { project, maxSize } = props;
const ratio = maxSize > 0 ? Math.max((project.logical_size / maxSize) * 100, project.logical_size > 0 ? 3 : 0) : 0;
return (
<tr className="border-b border-white/6 last:border-0">
<td className="py-4 pr-4 align-middle">
<div className="flex min-w-0 flex-col">
<span className="truncate text-14 font-semibold text-primary">{project.name}</span>
<span className="mt-1 text-11 uppercase tracking-[0.16em] text-tertiary">{project.identifier}</span>
</div>
</td>
<td className="px-4 py-4 align-middle text-13 text-secondary">{formatCount(project.file_count)}</td>
<td className="px-4 py-4 align-middle text-13 text-secondary">{formatCount(project.blob_count)}</td>
<td className="px-4 py-4 align-middle">
<div className="flex min-w-[12rem] items-center gap-3">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-custom-background-90">
<div className="h-full rounded-full bg-accent-primary" style={{ width: `${ratio}%` }} />
</div>
<span className="w-20 text-right text-13 font-medium text-primary">{formatBytes(project.logical_size)}</span>
</div>
</td>
<td className="px-4 py-4 align-middle text-13 text-secondary">{formatBytes(project.physical_size)}</td>
<td className="px-4 py-4 align-middle text-13 text-accent-primary">{formatBytes(project.dedup_savings)}</td>
<td className="px-4 py-4 align-middle text-13 text-secondary">{formatCount(project.failed_upload_count)}</td>
<td className="pl-4 py-4 align-middle text-13 text-secondary">{formatCount(project.soft_deleted_count)}</td>
</tr>
);
};
type TStorageSettingsContentProps = {
workspaceSlug: string;
};
export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsContentProps) {
const { data, error, isLoading } = useSWR(
workspaceSlug ? ["workspace-storage-summary", workspaceSlug] : null,
([, slug]) => workspaceService.fetchWorkspaceStorageSummary(slug)
);
const projects = [...(data?.projects ?? [])].sort((a, b) => b.logical_size - a.logical_size);
const maxProjectSize = Math.max(...projects.map((project) => project.logical_size), 0);
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-normal text-primary">Хранилище</h1>
<p className="mt-2 max-w-3xl text-14 leading-6 text-secondary">
Контроль объема файлов, дедупликации и кандидатов на очистку по workspace и проектам.
</p>
</div>
{isLoading && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-36 animate-pulse rounded-[28px] bg-custom-background-80/80" />
))}
</div>
)}
{error && (
<div className="rounded-[28px] border border-red-500/20 bg-red-500/10 px-5 py-4 text-14 text-red-200">
Не удалось загрузить данные хранилища.
</div>
)}
{data && (
<>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard
title="Логический объем"
value={formatBytes(data.summary.logical_size)}
caption={`${formatCount(data.summary.file_count)} файлов во всех проектах`}
icon={Files}
/>
<StatCard
title="Физический объем"
value={formatBytes(data.summary.physical_size)}
caption={`${formatCount(data.summary.blob_count)} уникальных blob`}
icon={HardDrive}
tone="accent"
/>
<StatCard
title="Экономия дедупа"
value={formatBytes(data.summary.dedup_savings)}
caption={`${formatCount(data.summary.uploaded_without_blob_count)} загруженных файлов без blob`}
icon={Layers3}
tone="accent"
/>
<StatCard
title="Проблемы загрузки"
value={formatCount(data.diagnostics.failed_upload_count)}
caption={`${formatBytes(data.diagnostics.failed_upload_size)} неподтвержденных файлов`}
icon={AlertTriangle}
tone={data.diagnostics.failed_upload_count > 0 ? "warning" : "default"}
/>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<StatCard
title="Зависшие загрузки"
value={formatCount(data.diagnostics.stale_unuploaded_count)}
caption={`${formatBytes(data.diagnostics.stale_unuploaded_size)} старше суток`}
icon={UploadCloud}
/>
<StatCard
title="Удаленные файлы"
value={formatCount(data.diagnostics.soft_deleted_count)}
caption={`${formatBytes(data.diagnostics.soft_deleted_size)} ожидают retention cleanup`}
icon={Recycle}
/>
<StatCard
title="Потерянные blob"
value={`${formatCount(data.diagnostics.orphaned_blob_count)} / ${formatCount(data.diagnostics.missing_blob_count)}`}
caption={`${formatBytes(data.diagnostics.orphaned_blob_size + data.diagnostics.missing_blob_size)} вне активных ссылок`}
icon={Database}
/>
</div>
<section className="rounded-[28px] border border-white/5 bg-custom-background-80/85 p-5 shadow-[0_22px_80px_rgba(0,0,0,0.28)]">
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold tracking-normal text-primary">Проекты</h2>
<p className="mt-1 text-13 text-secondary">Сортировка по логическому объему файлов.</p>
</div>
<div className="rounded-full bg-custom-background-90 px-4 py-2 text-12 font-medium text-secondary">
{formatCount(projects.length)} проектов
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[58rem] border-collapse">
<thead>
<tr className="border-b border-white/8 text-left text-[11px] font-semibold uppercase tracking-[0.16em] text-tertiary">
<th className="pb-3 pr-4">Проект</th>
<th className="px-4 pb-3">Файлы</th>
<th className="px-4 pb-3">Blob</th>
<th className="px-4 pb-3">Логический объем</th>
<th className="px-4 pb-3">Физический</th>
<th className="px-4 pb-3">Дедуп</th>
<th className="px-4 pb-3">Ошибки</th>
<th className="pb-3 pl-4">Удалено</th>
</tr>
</thead>
<tbody>
{projects.map((project) => (
<ProjectStorageRow key={project.id} project={project} maxSize={maxProjectSize} />
))}
</tbody>
</table>
</div>
</section>
</>
)}
</div>
);
}

View File

@ -23,6 +23,7 @@ import { SettingsSidebarItem } from "@/components/settings/sidebar/item";
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
import { WorkspaceSettingsSidebarHeader } from "@/components/settings/workspace/sidebar/header";
import { AIVoiceTaskerSettingsContent } from "@/components/workspace/settings/ai-voice-tasker-settings";
import { StorageSettingsContent } from "@/components/workspace/settings/storage-settings";
import { WorkspaceDetails } from "@/components/workspace/settings/workspace-details";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
@ -37,7 +38,7 @@ import {
} from "./workspace-settings-modal.utils";
const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set<TWorkspaceSettingsTabs>(["billing-and-plans"]);
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["general", "ai-voice-tasker"]);
const MODAL_TABS = new Set<TWorkspaceSettingsTabs>(["general", "storage", "ai-voice-tasker"]);
const getInitialTab = (): TWorkspaceSettingsModalTab => {
if (typeof window === "undefined") return "general";
@ -103,9 +104,16 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
return <AIVoiceTaskerSettingsContent workspaceSlug={currentWorkspace.slug} />;
}
if (activeTab === "storage" && currentWorkspace?.slug) {
return <StorageSettingsContent workspaceSlug={currentWorkspace.slug} />;
}
return <WorkspaceDetails />;
};
const activeTabLabel =
activeTab === "ai-voice-tasker" ? "AI / Voice Tasker" : activeTab === "storage" ? "хранилище" : "основные параметры";
return (
<ModalCore
isOpen={isOpen}
@ -130,7 +138,7 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal()
<div className="min-w-0">
<div className="text-18 font-semibold text-primary">Настройки workspace</div>
<div className="mt-1 truncate text-12 text-tertiary">
{currentWorkspace?.name ?? "Workspace"} / {activeTab === "ai-voice-tasker" ? "AI / Voice Tasker" : "основные параметры"}
{currentWorkspace?.name ?? "Workspace"} / {activeTabLabel}
</div>
</div>
<button
@ -188,7 +196,6 @@ function WorkspaceModalSidebar({ activeTab, allowPermissions, onSelectItem, work
{accessibleItems.map((item) => {
const Icon = WORKSPACE_SETTINGS_ICONS[item.key];
const isActive = item.key === activeTab;
const isModalTab = MODAL_TABS.has(item.key);
return (
<SettingsSidebarItem

View File

@ -1,7 +1,7 @@
export const WORKSPACE_SETTINGS_MODAL_QUERY_KEY = "workspaceSettings";
export const WORKSPACE_SETTINGS_MODAL_EVENT = "nodedc:workspace-settings-modal";
export type TWorkspaceSettingsModalTab = "general" | "ai-voice-tasker";
export type TWorkspaceSettingsModalTab = "general" | "storage" | "ai-voice-tasker";
type TWorkspaceSettingsModalEventDetail = {
isOpen: boolean;
@ -15,7 +15,7 @@ const dispatchWorkspaceSettingsModalEvent = (detail: TWorkspaceSettingsModalEven
export const getWorkspaceSettingsModalTabFromSearch = (search: string): TWorkspaceSettingsModalTab | undefined => {
const value = new URLSearchParams(search).get(WORKSPACE_SETTINGS_MODAL_QUERY_KEY);
if (value === "general" || value === "ai-voice-tasker") return value;
if (value === "general" || value === "storage" || value === "ai-voice-tasker") return value;
return undefined;
};

View File

@ -4,140 +4,6 @@
* See the LICENSE file for details.
*/
import { useEffect, useRef } from "react";
import { BProgress } from "@bprogress/core";
import { useNavigation } from "react-router";
import "@bprogress/core/css";
/**
* Progress bar configuration options
*/
interface ProgressConfig {
/** Whether to show the loading spinner */
showSpinner: boolean;
/** Minimum progress percentage (0-1) */
minimum: number;
/** Animation speed in milliseconds */
speed: number;
/** Auto-increment speed in milliseconds */
trickleSpeed: number;
/** CSS easing function */
easing: string;
/** Enable auto-increment */
trickle: boolean;
/** Delay before showing progress bar in milliseconds */
delay: number;
/** Whether to disable the progress bar */
isDisabled?: boolean;
}
/**
* Configuration for the progress bar
*/
const PROGRESS_CONFIG: Readonly<ProgressConfig> = {
showSpinner: false,
minimum: 0.1,
speed: 400,
trickleSpeed: 800,
easing: "ease",
trickle: true,
delay: 0,
} as const;
/**
* Navigation Progress Bar Component
*
* Automatically displays a progress bar at the top of the page during React Router navigation.
* Integrates with React Router's useNavigation hook to monitor route changes.
*
* Note: Progress bar is disabled in production builds.
*
* @returns null - This component doesn't render any visible elements
*
* @example
* ```tsx
* function App() {
* return (
* <>
* <AppProgressBar />
* <Outlet />
* </>
* );
* }
* ```
*/
export default function AppProgressBar(): null {
const navigation = useNavigation();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startedRef = useRef<boolean>(false);
// Initialize BProgress once on mount
useEffect(() => {
// Skip initialization in production builds
if (PROGRESS_CONFIG.isDisabled) {
return;
}
// Configure BProgress with our settings
BProgress.configure({
showSpinner: PROGRESS_CONFIG.showSpinner,
minimum: PROGRESS_CONFIG.minimum,
speed: PROGRESS_CONFIG.speed,
trickleSpeed: PROGRESS_CONFIG.trickleSpeed,
easing: PROGRESS_CONFIG.easing,
trickle: PROGRESS_CONFIG.trickle,
});
// Render the progress bar element in the DOM
BProgress.render(true);
// Cleanup on unmount
return () => {
if (BProgress.isStarted()) {
BProgress.done();
}
};
}, []);
// Handle navigation state changes
useEffect(() => {
// Skip navigation tracking in production builds
if (PROGRESS_CONFIG.isDisabled) {
return;
}
if (navigation.state === "idle") {
// Navigation complete - clear any pending timer
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
// Complete progress if it was started
if (startedRef.current) {
BProgress.done();
startedRef.current = false;
}
} else {
// Navigation in progress (loading or submitting)
// Only start if not already started and no timer pending
if (timerRef.current === null && !startedRef.current) {
timerRef.current = setTimeout((): void => {
if (!BProgress.isStarted()) {
BProgress.start();
startedRef.current = true;
}
timerRef.current = null;
}, PROGRESS_CONFIG.delay);
}
}
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
}
};
}, [navigation.state]);
return null;
}

View File

@ -0,0 +1 @@
export { LiveAudioVisualizer } from "./live-audio-visualizer";

View File

@ -0,0 +1,172 @@
/**
* Localized audio visualizer based on the public API shape of `react-audio-visualize`.
* It stays in-repo so the Voice Tasker recording UI can keep the package behavior
* while preserving NODEDC styling and silent-state dots.
*/
import { useCallback, useEffect, useRef } from "react";
type LiveAudioVisualizerProps = {
backgroundColor?: string;
barColor?: string;
barWidth?: number;
fftSize?: 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192 | 16384 | 32768;
gap?: number;
height?: number;
maxDecibels?: number;
mediaRecorder: MediaRecorder;
minDecibels?: number;
smoothingTimeConstant?: number;
width?: number;
};
const averageVoiceFrequencyBins = (data: Uint8Array, width: number, barWidth: number, gap: number) => {
let barsCount = Math.max(1, Math.floor(width / (barWidth + gap)));
const voiceBandBinCount = Math.max(barsCount, Math.floor(data.length * 0.32));
const voiceBand = data.slice(1, voiceBandBinCount);
let binWindow = Math.floor(voiceBand.length / barsCount);
if (barsCount > voiceBand.length) {
barsCount = voiceBand.length;
binWindow = 1;
}
return Array.from({ length: barsCount }, (_, index) => {
let peak = 0;
let sumSquares = 0;
let count = 0;
for (let offset = 0; offset < binWindow && index * binWindow + offset < voiceBand.length; offset++) {
const value = voiceBand[index * binWindow + offset] ?? 0;
peak = Math.max(peak, value);
sumSquares += value * value;
count++;
}
const rms = Math.sqrt(sumSquares / Math.max(1, count));
return Math.max(rms, peak * 0.72);
});
};
export function LiveAudioVisualizer(props: LiveAudioVisualizerProps) {
const {
backgroundColor = "transparent",
barColor = "rgb(160, 198, 255)",
barWidth = 2,
fftSize = 1024,
gap = 1,
height = 100,
maxDecibels = -10,
mediaRecorder,
minDecibels = -90,
smoothingTimeConstant = 0.4,
width = 300,
} = props;
const animationFrameRef = useRef<number | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const latestHeightsRef = useRef<number[]>([]);
const draw = useCallback(
(rawValues: number[]) => {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
const pixelRatio = window.devicePixelRatio || 1;
const canvasWidth = Math.max(1, Math.floor(width));
const canvasHeight = Math.max(1, Math.floor(height));
if (canvas.width !== canvasWidth * pixelRatio || canvas.height !== canvasHeight * pixelRatio) {
canvas.width = canvasWidth * pixelRatio;
canvas.height = canvasHeight * pixelRatio;
}
canvas.style.width = `${canvasWidth}px`;
canvas.style.height = `${canvasHeight}px`;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, canvasWidth, canvasHeight);
if (backgroundColor !== "transparent") {
context.fillStyle = backgroundColor;
context.fillRect(0, 0, canvasWidth, canvasHeight);
}
const previousHeights = latestHeightsRef.current;
const centerY = canvasHeight / 2;
const maxBarHeight = canvasHeight * 0.94;
const nextHeights = rawValues.map((value, index) => {
const normalized = Math.max(0, Math.min(1, (value - 7) / 118));
const shapedValue = Math.min(1, Math.pow(normalized, 0.54) * 1.18);
const targetHeight = barWidth + shapedValue * (maxBarHeight - barWidth);
const previousHeight = previousHeights[index] ?? barWidth;
const smoothing = targetHeight > previousHeight ? 0.88 : 0.46;
return previousHeight + (targetHeight - previousHeight) * smoothing;
});
latestHeightsRef.current = nextHeights;
context.fillStyle = barColor;
nextHeights.forEach((barHeight, index) => {
const x = index * (barWidth + gap);
const y = centerY - barHeight / 2;
const radius = barWidth / 2;
context.beginPath();
if (context.roundRect) context.roundRect(x, y, barWidth, barHeight, radius);
else context.rect(x, y, barWidth, barHeight);
context.fill();
});
},
[backgroundColor, barColor, barWidth, gap, height, width]
);
useEffect(() => {
const stream = mediaRecorder.stream;
if (!stream) return;
const AudioContextClass =
window.AudioContext ||
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextClass) return;
const audioContext = new AudioContextClass();
const analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(stream);
analyser.fftSize = fftSize;
analyser.minDecibels = minDecibels;
analyser.maxDecibels = maxDecibels;
analyser.smoothingTimeConstant = smoothingTimeConstant;
const frequencyData = new Uint8Array(analyser.frequencyBinCount);
source.connect(analyser);
void audioContext.resume();
const renderFrame = () => {
if (mediaRecorder.state === "recording") {
analyser.getByteFrequencyData(frequencyData);
draw(averageVoiceFrequencyBins(frequencyData, width, barWidth, gap));
animationFrameRef.current = window.requestAnimationFrame(renderFrame);
return;
}
draw(averageVoiceFrequencyBins(new Uint8Array(analyser.frequencyBinCount), width, barWidth, gap));
};
renderFrame();
return () => {
if (animationFrameRef.current) {
window.cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
source.disconnect();
analyser.disconnect();
if (audioContext.state !== "closed") void audioContext.close();
};
}, [barWidth, draw, fftSize, gap, maxDecibels, mediaRecorder, minDecibels, smoothingTimeConstant, width]);
return <canvas ref={canvasRef} aria-hidden="true" />;
}

View File

@ -23,9 +23,11 @@ import type {
TSearchEntityRequestPayload,
TWidgetEntityData,
TActivityEntityData,
IUserActivityResponse,
IWorkspaceSidebarNavigationItem,
IWorkspaceSidebarNavigation,
IWorkspaceUserPropertiesResponse,
IWorkspaceStorageSummaryResponse,
} from "@plane/types";
// services
import { APIService } from "@/services/api.service";
@ -51,6 +53,14 @@ export class WorkspaceService extends APIService {
});
}
async fetchWorkspaceStorageSummary(workspaceSlug: string): Promise<IWorkspaceStorageSummaryResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/storage/summary/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async createWorkspace(data: Partial<IWorkspace>): Promise<IWorkspace> {
return this.post("/api/workspaces/", data)
.then((response) => response?.data)
@ -353,6 +363,23 @@ export class WorkspaceService extends APIService {
});
}
async fetchWorkspaceActivity(
workspaceSlug: string,
params: {
per_page: number;
cursor?: string;
project?: string;
}
): Promise<IUserActivityResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/activity/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
// widgets
async fetchWorkspaceWidgets(workspaceSlug: string): Promise<TWidgetEntityData[]> {
return this.get(`/api/workspaces/${workspaceSlug}/home-preferences/`)

View File

@ -1525,6 +1525,56 @@
color: var(--text-color-primary) !important;
}
.nodedc-stickies-empty-root {
padding-bottom: calc(var(--nodedc-bottom-dock-height) + 2rem) !important;
}
.nodedc-stickies-empty-asset {
--illustration-fill-primary: rgba(255, 255, 255, 0.1);
--illustration-fill-secondary: rgba(255, 255, 255, 0.075);
--illustration-fill-tertiary: rgba(var(--nodedc-card-active-rgb), 0.16);
--illustration-fill-quaternary: rgba(var(--nodedc-card-active-rgb), 0.24);
--illustration-stroke-primary: rgba(var(--nodedc-card-active-rgb), 0.46);
--illustration-stroke-secondary: rgba(var(--nodedc-card-active-rgb), 0.62);
--illustration-stroke-tertiary: rgb(var(--nodedc-card-active-rgb));
color: rgb(var(--nodedc-card-active-rgb));
opacity: 0.92;
}
.nodedc-stickies-empty-primary-button {
display: inline-flex;
min-height: 3.05rem;
align-items: center;
justify-content: center;
gap: 0.55rem;
border: 0 !important;
outline: none !important;
border-radius: 1.2rem !important;
background: rgb(var(--nodedc-card-active-rgb)) !important;
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
padding: 0 1.45rem !important;
font-size: 0.875rem;
font-weight: 700;
line-height: 1;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 16px 32px rgba(var(--nodedc-card-active-rgb), 0.14) !important;
transition:
background 160ms ease,
transform 160ms ease,
opacity 160ms ease;
}
.nodedc-stickies-empty-primary-button:hover:not(:disabled) {
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 84%, white) !important;
transform: translateY(-1px);
}
.nodedc-stickies-empty-primary-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.nodedc-external-sidebar-shell {
border: 0 !important;
background:
@ -3603,11 +3653,16 @@
.nodedc-external-detail-toolbar {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
.nodedc-external-detail-toolbar > .flex {
flex-wrap: nowrap !important;
}
.nodedc-external-toolbar-cluster {
display: flex;
align-items: center;
@ -3618,6 +3673,85 @@
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.018) !important;
}
.nodedc-external-decision-cluster {
display: flex;
align-items: center;
gap: 0.45rem;
min-height: 3.05rem;
padding: 0.28rem !important;
border: 0 !important;
outline: none !important;
border-radius: 999px;
background: rgba(255, 255, 255, 0.055) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.018),
0 10px 28px rgba(0, 0, 0, 0.1) !important;
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
}
.nodedc-external-decision-button {
display: grid;
place-items: center;
width: 2.5rem;
min-width: 2.5rem;
height: 2.5rem;
border: 0 !important;
outline: none !important;
border-radius: 999px;
padding: 0 !important;
transition:
transform 160ms ease,
background 160ms ease,
color 160ms ease,
opacity 160ms ease;
}
.nodedc-external-decision-button:hover:not(:disabled) {
transform: translateY(-1px);
}
.nodedc-external-decision-button:disabled {
cursor: progress;
opacity: 0.58;
}
.nodedc-external-decision-button-accept {
background: rgb(var(--nodedc-card-active-rgb)) !important;
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 8px 18px rgba(var(--nodedc-card-active-rgb), 0.2) !important;
}
.nodedc-external-decision-button-accept:hover:not(:disabled) {
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 86%, white) !important;
}
.nodedc-external-decision-button-decline {
background: rgba(255, 255, 255, 0.12) !important;
color: rgba(255, 255, 255, 0.72) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04) !important;
}
.nodedc-external-decision-button-decline:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.18) !important;
color: rgba(255, 255, 255, 0.86) !important;
}
.nodedc-external-detail-widgets {
--nodedc-widget-surface-rgb: 255 255 255;
}
.nodedc-external-detail-widgets [data-slot="button"],
.nodedc-external-detail-widgets button {
outline: none !important;
}
.nodedc-external-detail-widgets .nodedc-attachment-upload {
min-height: 4.5rem;
}
.nodedc-external-priority-inline {
display: inline-flex;
align-items: center;
@ -3936,4 +4070,74 @@
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important;
color: var(--text-color-primary) !important;
}
.nodedc-processing-loader,
.nodedc-voice-task-processing-loader {
--nodedc-processing-loader-rgb: var(--nodedc-accent-rgb);
width: 5rem;
aspect-ratio: 1;
box-sizing: border-box;
position: relative;
display: block;
color: rgb(var(--nodedc-processing-loader-rgb));
}
.nodedc-processing-loader-white {
--nodedc-processing-loader-rgb: 255 255 255;
}
.nodedc-processing-loader::before,
.nodedc-processing-loader::after,
.nodedc-voice-task-processing-loader::before,
.nodedc-voice-task-processing-loader::after {
content: "";
position: absolute;
box-sizing: border-box;
display: block;
}
.nodedc-processing-loader::before,
.nodedc-voice-task-processing-loader::before {
inset: 1.125rem;
border: 0.5rem solid currentColor;
border-radius: 0.625rem;
box-shadow: 0 0 1.25rem rgba(var(--nodedc-processing-loader-rgb), 0.18);
}
.nodedc-processing-loader::after,
.nodedc-voice-task-processing-loader::after {
width: 1rem;
aspect-ratio: 1;
top: 0;
left: 0;
border-radius: 9999px;
background: currentColor;
box-shadow: 0 0 1.125rem rgba(var(--nodedc-processing-loader-rgb), 0.28);
offset-anchor: center;
offset-path: path("M 22 22 H 58 V 58 H 22 V 22");
animation: nodedc-voice-task-processing-loader 1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
@keyframes nodedc-voice-task-processing-loader {
0% {
offset-distance: 0%;
}
25% {
offset-distance: 25%;
}
50% {
offset-distance: 50%;
}
75% {
offset-distance: 75%;
}
100% {
offset-distance: 100%;
}
}
}

View File

@ -49,6 +49,13 @@ export const WORKSPACE_SETTINGS: Record<TWorkspaceSettingsTabs, TWorkspaceSettin
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports/`,
},
storage: {
key: "storage",
i18n_label: "workspace_settings.settings.storage.title",
href: `/settings/storage`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/storage/`,
},
webhooks: {
key: "webhooks",
i18n_label: "workspace_settings.settings.webhooks.title",
@ -75,6 +82,7 @@ export const GROUPED_WORKSPACE_SETTINGS: Record<WORKSPACE_SETTINGS_CATEGORY, TWo
WORKSPACE_SETTINGS["members"],
WORKSPACE_SETTINGS["billing-and-plans"],
WORKSPACE_SETTINGS["export"],
WORKSPACE_SETTINGS["storage"],
],
[WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [WORKSPACE_SETTINGS["ai-voice-tasker"]],
[WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]],

View File

@ -1730,6 +1730,9 @@ export default {
},
},
},
storage: {
title: "Storage",
},
webhooks: {
heading: "Webhooks",
description: "Automate notifications to external services when project events occur.",

View File

@ -1892,6 +1892,9 @@ export default {
},
},
},
storage: {
title: "Хранилище",
},
webhooks: {
heading: "Вебхуки",
description: "Автоматизируйте уведомления во внешние сервисы при событиях проекта.",

View File

@ -83,6 +83,9 @@ export type TVoiceTaskDraft = {
intent: TVoiceTaskIntent;
target_memory_ref: string | null;
project_id?: string | null;
state_id?: string | null;
assignee_ids?: string[];
target_task_id?: string | null;
project_hint: string | null;
state_hint: string | null;
assignee_hint: string | null;
@ -104,13 +107,8 @@ export type TVoiceTaskDraft = {
export type TVoiceTaskResolution = {
project: TVoiceTaskResolvedProject | null;
assignee: {
id: string;
name: string;
email: string | null;
confidence: number;
source: string | null;
} | null;
assignee: TVoiceTaskResolvedAssignee | null;
assignees?: TVoiceTaskResolvedAssignee[];
labels: {
id: string;
name: string;
@ -149,6 +147,14 @@ export type TVoiceTaskResolvedProject = {
source: string | null;
};
export type TVoiceTaskResolvedAssignee = {
id: string;
name: string;
email: string | null;
confidence: number;
source: string | null;
};
export type TVoiceTaskUploadResult = {
ok: boolean;
status?: "uploaded" | "parsed";

View File

@ -15,6 +15,7 @@ export type TWorkspaceSettingsTabs =
| "members"
| "billing-and-plans"
| "export"
| "storage"
| "webhooks"
| "ai-voice-tasker";
export type TWorkspaceSettingsItem = {

View File

@ -240,6 +240,53 @@ export interface IWorkspaceAnalyticsResponse {
completion_chart: Record<string, unknown>;
}
export interface IWorkspaceStorageProjectSummary {
id: string;
name: string;
identifier: string;
file_count: number;
blob_count: number;
logical_size: number;
physical_size: number;
dedup_savings: number;
failed_upload_count: number;
failed_upload_size: number;
soft_deleted_count: number;
soft_deleted_size: number;
uploaded_without_blob_count: number;
}
export interface IWorkspaceStorageSummaryResponse {
workspace: {
id: string;
name: string;
slug: string;
upload_file_size_limit_enabled: boolean;
upload_file_size_limit: number;
};
summary: {
file_count: number;
blob_count: number;
logical_size: number;
physical_size: number;
dedup_savings: number;
uploaded_without_blob_count: number;
};
diagnostics: {
failed_upload_count: number;
failed_upload_size: number;
stale_unuploaded_count: number;
stale_unuploaded_size: number;
soft_deleted_count: number;
soft_deleted_size: number;
orphaned_blob_count: number;
orphaned_blob_size: number;
missing_blob_count: number;
missing_blob_size: number;
};
projects: IWorkspaceStorageProjectSummary[];
}
export type TWorkspacePaginationInfo = TPaginationInfo & {
results: IWorkspace[];
};