Compare commits

..

1 Commits

Author SHA1 Message Date
Palanikannan M 202336dd9c fix: rendering node views reliably 2025-12-01 16:15:33 +05:30
632 changed files with 7912 additions and 15167 deletions
@@ -31,7 +31,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12.x"
python-version: "3.x"
- name: Install Pylint
run: python -m pip install ruff
- name: Install API Dependencies
@@ -43,9 +43,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Affected
run: pnpm turbo run build --affected
- name: Lint Affected
run: pnpm turbo run check:lint --affected
@@ -54,3 +51,6 @@ jobs:
- name: Check Affected types
run: pnpm turbo run check:types --affected
- name: Build Affected
run: pnpm turbo run build --affected
-1
View File
@@ -111,4 +111,3 @@ build/
.react-router/
AGENTS.md
temp/
scripts/
-1
View File
@@ -1 +0,0 @@
pnpm lint-staged
+25 -45
View File
@@ -1,54 +1,34 @@
# ------------------------------
# Core Workspace Behavior
# ------------------------------
# Enforce pnpm workspace behavior and allow Turbo's lifecycle hooks if scripts are disabled
# This repo uses pnpm with workspaces.
# Always prefer using local workspace packages when available
prefer-workspace-packages = true
# Prefer linking local workspace packages when available
prefer-workspace-packages=true
link-workspace-packages=true
shared-workspace-lockfile=true
# Symlink workspace packages instead of duplicating them
link-workspace-packages = true
# Make peer installs smoother across the monorepo
auto-install-peers=true
strict-peer-dependencies=false
# Use a single lockfile across the whole monorepo
shared-workspace-lockfile = true
# If scripts are disabled (e.g., CI with --ignore-scripts), allowlisted packages can still run their hooks
# Turbo occasionally performs postinstall tasks for optimal performance
# moved to pnpm-workspace.yaml: onlyBuiltDependencies (e.g., allow turbo)
# Ensure packages added from workspace save using workspace: protocol
save-workspace-protocol = true
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=prettier
public-hoist-pattern[]=typescript
# Reproducible installs across CI and dev
prefer-frozen-lockfile=true
# ------------------------------
# Dependency Resolution
# ------------------------------
# Prefer resolving to highest versions in monorepo to reduce duplication
resolution-mode=highest
# Choose the highest compatible version across the workspace
# → reduces fragmentation & node_modules bloat
resolution-mode = highest
# Speed up native module builds by caching side effects
side-effects-cache=true
# Automatically install peer dependencies instead of forcing every package to declare them
auto-install-peers = true
# Speed up local dev by reusing local store when possible
prefer-offline=true
# Don't break the install if peers are missing
strict-peer-dependencies = false
# ------------------------------
# Performance Optimizations
# ------------------------------
# Use cached artifacts for native modules (sharp, esbuild, etc.)
side-effects-cache = true
# Prefer local cached packages rather than hitting network
prefer-offline = true
# In CI, refuse to modify lockfile (prevents drift)
prefer-frozen-lockfile = true
# Use isolated linker (best compatibility with Node ecosystem tools)
node-linker = isolated
# Hoist commonly used tools to the root to prevent duplicates and speed up resolution
public-hoist-pattern[] = typescript
public-hoist-pattern[] = eslint
public-hoist-pattern[] = *@plane/*
public-hoist-pattern[] = vite
public-hoist-pattern[] = turbo
# Ensure workspace protocol is used when adding internal deps
save-workspace-protocol=true
-10
View File
@@ -1,10 +0,0 @@
.next/
.react-router/
.turbo/
.vite/
build/
dist/
node_modules/
out/
pnpm-lock.yaml
storybook-static/
+2 -11
View File
@@ -1,15 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"overrides": [
{
"files": ["packages/codemods/**/*"],
"options": {
"printWidth": 80
}
}
],
"plugins": ["@prettier/plugin-oxc"],
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
"trailingComma": "es5",
"plugins": ["@prettier/plugin-oxc"]
}
-1
View File
@@ -1 +0,0 @@
eslint.config.mjs @lifeiscontent
+8 -10
View File
@@ -91,7 +91,7 @@ If you would like to _implement_ it, an issue with your proposal must be submitt
To ensure consistency throughout the source code, please keep these rules in mind as you are working:
- All features or bug fixes must be tested by one or more specs (unit-tests).
- We lint with [ESLint 9](https://eslint.org/docs/latest/) using the shared `eslint.config.mjs` (type-aware via `typescript-eslint`) and format with [Prettier](https://prettier.io/) using `prettier.config.cjs`.
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
## Ways to contribute
@@ -187,19 +187,18 @@ Adding a new language involves several steps to ensure it integrates seamlessly
Add the new language to the TLanguage type in the language definitions file:
```ts
// packages/i18n/src/types/language.ts
export type TLanguage = "en" | "fr" | "your-lang";
// packages/i18n/src/types/language.ts
export type TLanguage = "en" | "fr" | "your-lang";
```
1. **Add language configuration**
Include the new language in the list of supported languages:
```ts
// packages/i18n/src/constants/language.ts
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "English", value: "en" },
{ label: "Your Language", value: "your-lang" },
];
// packages/i18n/src/constants/language.ts
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "English", value: "en" },
{ label: "Your Language", value: "your-lang" }
];
```
2. **Create translation files**
@@ -211,7 +210,6 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
3. **Update import logic**
Modify the language import logic to include your new language:
```ts
private importLanguageFile(language: TLanguage): Promise<any> {
switch (language) {
+14
View File
@@ -0,0 +1,14 @@
.next/*
.react-router/*
.vite/*
out/*
public/*
dist/*
node_modules/*
.turbo/*
.env*
.env
.env.local
.env.development
.env.production
.env.test
+18
View File
@@ -0,0 +1,18 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
ignorePatterns: ["build/**", "dist/**", ".vite/**"],
rules: {
"import/no-duplicates": ["error", { "prefer-inline": false }],
"import/consistent-type-specifier-style": ["error", "prefer-top-level"],
"@typescript-eslint/no-import-type-side-effects": "error",
"@typescript-eslint/consistent-type-imports": [
"error",
{
prefer: "type-imports",
fixStyle: "separate-type-imports",
disallowTypeAnnotations: false,
},
],
},
};
+7 -9
View File
@@ -1,10 +1,8 @@
.next/
.react-router/
.turbo/
.vite/
build/
dist/
node_modules/
.next
.react-router
.vite
.vercel
.tubro
out/
pnpm-lock.yaml
storybook-static/
dist/
build/
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5",
"plugins": ["@prettier/plugin-oxc"]
}
+4 -6
View File
@@ -13,7 +13,7 @@ RUN corepack enable pnpm
FROM base AS builder
RUN pnpm add -g turbo@2.6.3
RUN pnpm add -g turbo@2.5.8
COPY . .
@@ -62,12 +62,10 @@ COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
# Copy full directory structure before fetch to ensure all package.json files are available
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
# Fetch dependencies to cache store, then install offline with dev deps
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store --prod=false
# Build only the admin package
@@ -75,7 +73,7 @@ RUN pnpm turbo run build --filter=admin
# =========================================================================== #
FROM nginx:1.29-alpine AS production
FROM nginx:1.27-alpine AS production
COPY apps/admin/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=installer /app/apps/admin/build/client /usr/share/nginx/html/god-mode
@@ -163,6 +163,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
label={EMAIL_SECURITY_OPTIONS[emailSecurityKey]}
onChange={handleEmailSecurityChange}
buttonClassName="rounded-md border-custom-border-200"
optionsClassName="w-full"
input
>
{Object.entries(EMAIL_SECURITY_OPTIONS).map(([key, value]) => (
@@ -11,7 +11,7 @@ import { cn } from "@plane/utils";
// hooks
import { useTheme } from "@/hooks/store";
// assets
// eslint-disable-next-line import/order
import packageJson from "package.json";
const helpOptions = [
@@ -177,6 +177,7 @@ export function WorkspaceCreateForm() {
}
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
input
optionsClassName="w-full"
>
{ORGANIZATION_SIZE.map((item) => (
<CustomSelect.Option key={item} value={item}>
+6 -3
View File
@@ -26,7 +26,7 @@ export enum EErrorAlertType {
}
const errorCodeMessages: {
[key in EAdminAuthErrorCodes]: { title: string; message: (email?: string) => React.ReactNode };
[key in EAdminAuthErrorCodes]: { title: string; message: (email?: string | undefined) => React.ReactNode };
} = {
// admin
[EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: {
@@ -79,11 +79,14 @@ const errorCodeMessages: {
},
[EAdminAuthErrorCodes.ADMIN_USER_DEACTIVATED]: {
title: `User account deactivated`,
message: () => `User account deactivated. Please contact ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
message: () => `User account deactivated. Please contact ${!!SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`,
},
};
export const authErrorHandler = (errorCode: EAdminAuthErrorCodes, email?: string): TAdminAuthErrorInfo | undefined => {
export const authErrorHandler = (
errorCode: EAdminAuthErrorCodes,
email?: string | undefined
): TAdminAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable import/order */
import * as Sentry from "@sentry/react-router";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
@@ -11,7 +11,6 @@ export function UpgradeButton() {
href="https://plane.so/pricing?mode=self-hosted"
target="_blank"
className={cn(getButtonStyling("primary", "sm"))}
rel="noreferrer"
>
Upgrade
<SquareArrowOutUpRight className="h-3.5 w-3.5 p-0.5" />
@@ -24,7 +24,6 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
target="_blank"
className="group flex items-center justify-between p-4 gap-2.5 truncate border border-custom-border-200/70 hover:border-custom-border-200 hover:bg-custom-background-90 rounded-md"
rel="noreferrer"
>
<div className="flex items-start gap-4">
<span
+9 -7
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "1.2.1",
"version": "1.1.0",
"license": "AGPL-3.0",
"private": true,
"type": "module",
@@ -11,11 +11,11 @@
"preview": "react-router build && serve -s build/client -l 3001",
"start": "serve -s build/client -l 3001",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist && rm -rf build",
"check:lint": "eslint . --max-warnings=485",
"check:lint": "eslint . --max-warnings 19",
"check:types": "react-router typegen && tsc --noEmit",
"check:format": "prettier --check .",
"fix:lint": "eslint . --fix --max-warnings=485",
"fix:format": "prettier --write ."
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\""
},
"dependencies": {
"@bprogress/core": "catalog:",
@@ -37,7 +37,7 @@
"lucide-react": "catalog:",
"mobx": "catalog:",
"mobx-react": "catalog:",
"next-themes": "0.4.6",
"next-themes": "^0.2.1",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "7.51.5",
@@ -48,14 +48,16 @@
"uuid": "catalog:"
},
"devDependencies": {
"@dotenvx/dotenvx": "catalog:",
"@plane/eslint-config": "workspace:*",
"@plane/tailwind-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@prettier/plugin-oxc": "0.0.4",
"@react-router/dev": "catalog:",
"@types/lodash-es": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"dotenv": "^16.4.5",
"typescript": "catalog:",
"vite": "catalog:",
"vite-tsconfig-paths": "^5.1.4"
+1
View File
@@ -1 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
module.exports = require("@plane/tailwind-config/postcss.config.js");
-21
View File
@@ -296,10 +296,6 @@ body {
}
/* scrollbar style */
::-webkit-scrollbar {
display: none;
}
@-moz-document url-prefix() {
* {
scrollbar-width: none;
@@ -360,23 +356,6 @@ body {
margin-top: 44px;
}
/* scrollbar xs size */
.scrollbar-xs::-webkit-scrollbar {
height: 10px;
width: 10px;
}
.scrollbar-xs::-webkit-scrollbar-thumb {
border: 3px solid rgba(0, 0, 0, 0);
}
.shadow-custom {
box-shadow: 2px 2px 8px 2px rgba(234, 231, 250, 0.3); /* Convert #EAE7FA4D to rgba */
}
/* backdrop filter */
.backdrop-blur-custom {
@apply backdrop-filter blur-[9px];
}
/* scrollbar sm size */
.scrollbar-sm::-webkit-scrollbar {
height: 12px;
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const sharedConfig = require("@plane/tailwind-config/tailwind.config.js");
module.exports = {
+8 -13
View File
@@ -1,21 +1,16 @@
{
"extends": "@plane/typescript-config/react-router.json",
"compilerOptions": {
"noImplicitOverride": false,
"exactOptionalPropertyTypes": false,
"noUnusedParameters": false,
"noUnusedLocals": false,
"baseUrl": ".",
"rootDirs": [".", "./.react-router/types"],
"types": ["vite/client"],
"types": ["node", "vite/client"],
"paths": {
"package.json": ["./package.json"],
"ce/*": ["./ce/*"],
"@/app/*": ["./app/*"],
"@/*": ["./core/*"],
"@/plane-admin/*": ["./ce/*"],
"@/ce/*": ["./ce/*"],
"@/styles/*": ["./styles/*"]
}
"@/app/*": ["app/*"],
"@/*": ["core/*"],
"@/plane-admin/*": ["ce/*"],
"@/styles/*": ["styles/*"]
},
"strictNullChecks": true
},
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
"exclude": ["node_modules"]
+1 -1
View File
@@ -1,6 +1,6 @@
import path from "node:path";
import * as dotenv from "@dotenvx/dotenvx";
import { reactRouter } from "@react-router/dev/vite";
import dotenv from "dotenv";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { joinUrlPath } from "@plane/utils";
-3
View File
@@ -32,9 +32,6 @@ AWS_S3_ENDPOINT_URL="http://localhost:9000"
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
# Signed URL expiration time in seconds (default: 3600 = 1 hour)
# Set to 30 for 30 seconds, 300 for 5 minutes, etc.
SIGNED_URL_EXPIRATION=3600
# Settings related to Docker
DOCKERIZED=1 # deprecated
-10
View File
@@ -1,10 +0,0 @@
.next/
.react-router/
.turbo/
.vite/
build/
dist/
node_modules/
out/
pnpm-lock.yaml
storybook-static/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "1.2.1",
"version": "1.1.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+1 -2
View File
@@ -54,5 +54,4 @@ from .asset import (
FileAssetSerializer,
)
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
from .sticky import StickySerializer
from .member import ProjectMemberSerializer
+8 -13
View File
@@ -4,7 +4,7 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.db.models import Cycle, CycleIssue, User, Project
from plane.db.models import Cycle, CycleIssue, User
from plane.utils.timezone_converter import convert_to_utc
@@ -55,18 +55,6 @@ class CycleCreateSerializer(BaseSerializer):
]
def validate(self, data):
project_id = self.initial_data.get("project_id") or (
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
)
if not project_id:
raise serializers.ValidationError("Project ID is required")
project = Project.objects.filter(id=project_id).first()
if not project:
raise serializers.ValidationError("Project not found")
if not project.cycle_view:
raise serializers.ValidationError("Cycles are not enabled for this project")
if (
data.get("start_date", None) is not None
and data.get("end_date", None) is not None
@@ -75,6 +63,13 @@ class CycleCreateSerializer(BaseSerializer):
raise serializers.ValidationError("Start date cannot exceed end date")
if data.get("start_date", None) is not None and data.get("end_date", None) is not None:
project_id = self.initial_data.get("project_id") or (
self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None
)
if not project_id:
raise serializers.ValidationError("Project ID is required")
data["start_date"] = convert_to_utc(
date=str(data.get("start_date").date()),
project_id=project_id,
-9
View File
@@ -10,7 +10,6 @@ from plane.db.models import (
ModuleMember,
ModuleIssue,
ProjectMember,
Project,
)
@@ -54,14 +53,6 @@ class ModuleCreateSerializer(BaseSerializer):
]
def validate(self, data):
project_id = self.context.get("project_id")
if not project_id:
raise serializers.ValidationError("Project ID is required")
project = Project.objects.get(id=project_id)
if not project:
raise serializers.ValidationError("Project not found")
if not project.module_view:
raise serializers.ValidationError("Modules are not enabled for this project")
if (
data.get("start_date", None) is not None
and data.get("target_date", None) is not None
+2 -2
View File
@@ -17,7 +17,7 @@ from plane.utils.content_validator import (
from .base import BaseSerializer
class ProjectCreateSerializer(BaseSerializer):
class ProjectCreateSerializer(BaseSerializer):
"""
Serializer for creating projects with workspace validation.
@@ -171,7 +171,7 @@ class ProjectUpdateSerializer(ProjectCreateSerializer):
if (
validated_data.get("estimate", None) is not None
and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate").id).exists()
and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate")).exists()
):
# Check if the estimate is a estimate in the project
raise serializers.ValidationError("Estimate should be a estimate in the project")
-30
View File
@@ -1,30 +0,0 @@
from rest_framework import serializers
from .base import BaseSerializer
from plane.db.models import Sticky
from plane.utils.content_validator import validate_html_content, validate_binary_data
class StickySerializer(BaseSerializer):
class Meta:
model = Sticky
fields = "__all__"
read_only_fields = ["workspace", "owner"]
extra_kwargs = {"name": {"required": False}}
def validate(self, data):
# Validate description content for security
if "description_html" in data and data["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"])
if not is_valid:
raise serializers.ValidationError({"error": "html content is not valid"})
# Update the data with sanitized HTML if available
if sanitized_html is not None:
data["description_html"] = sanitized_html
if "description_binary" in data and data["description_binary"]:
is_valid, error_msg = validate_binary_data(data["description_binary"])
if not is_valid:
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
return data
-2
View File
@@ -9,7 +9,6 @@ from .state import urlpatterns as state_patterns
from .user import urlpatterns as user_patterns
from .work_item import urlpatterns as work_item_patterns
from .invite import urlpatterns as invite_patterns
from .sticky import urlpatterns as sticky_patterns
urlpatterns = [
*asset_patterns,
@@ -23,5 +22,4 @@ urlpatterns = [
*user_patterns,
*work_item_patterns,
*invite_patterns,
*sticky_patterns,
]
+1 -5
View File
@@ -1,10 +1,6 @@
from django.urls import path
from plane.api.views import (
ProjectMemberListCreateAPIEndpoint,
ProjectMemberDetailAPIEndpoint,
WorkspaceMemberAPIEndpoint,
)
from plane.api.views import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint
urlpatterns = [
# Project members
-12
View File
@@ -1,12 +0,0 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from plane.api.views import StickyViewSet
router = DefaultRouter()
router.register(r"stickies", StickyViewSet, basename="workspace-stickies")
urlpatterns = [
path("workspaces/<str:slug>/", include(router.urls)),
]
+1 -3
View File
@@ -54,6 +54,4 @@ from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpo
from .user import UserEndpoint
from .invite import WorkspaceInvitationsViewset
from .sticky import StickyViewSet
from .invite import WorkspaceInvitationsViewset
+2
View File
@@ -13,6 +13,8 @@ from django.utils import timezone
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import SearchFilter
from rest_framework.viewsets import ModelViewSet
from rest_framework.exceptions import APIException
from rest_framework.generics import GenericAPIView
+2
View File
@@ -65,7 +65,9 @@ from plane.utils.openapi import (
ADMIN_ONLY_RESPONSE,
REQUIRED_FIELDS_RESPONSE,
MODULE_ISSUE_NOT_FOUND_RESPONSE,
ARCHIVED_RESPONSE,
CANNOT_ARCHIVE_RESPONSE,
UNARCHIVED_RESPONSE,
)
-109
View File
@@ -1,109 +0,0 @@
from rest_framework.response import Response
from rest_framework import status
from plane.api.views.base import BaseViewSet
from plane.app.permissions import WorkspaceUserPermission
from plane.db.models import Sticky, Workspace
from plane.api.serializers import StickySerializer
# OpenAPI imports
from plane.utils.openapi.decorators import sticky_docs
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
from plane.utils.openapi import (
STICKY_EXAMPLE,
create_paginated_response,
DELETED_RESPONSE,
)
class StickyViewSet(BaseViewSet):
serializer_class = StickySerializer
model = Sticky
use_read_replica = True
permission_classes = [WorkspaceUserPermission]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(owner_id=self.request.user.id)
.distinct()
)
@sticky_docs(
operation_id="create_sticky",
summary="Create a new sticky",
description="Create a new sticky in the workspace",
request=OpenApiRequest(request=StickySerializer),
responses={
201: OpenApiResponse(description="Sticky created", response=StickySerializer, examples=[STICKY_EXAMPLE])
},
)
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = StickySerializer(data=request.data)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@sticky_docs(
operation_id="list_stickies",
summary="List stickies",
description="List all stickies in the workspace",
responses={
200: create_paginated_response(
StickySerializer, "Sticky", "List of stickies", example_name="List of stickies"
)
},
)
def list(self, request, slug):
query = request.query_params.get("query", False)
stickies = self.get_queryset().order_by("-created_at")
if query:
stickies = stickies.filter(description_stripped__icontains=query)
return self.paginate(
request=request,
queryset=(stickies),
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
default_per_page=20,
)
@sticky_docs(
operation_id="retrieve_sticky",
summary="Retrieve a sticky",
description="Retrieve a sticky by its ID",
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
)
def retrieve(self, request, slug, pk):
sticky = self.get_object()
return Response(StickySerializer(sticky).data)
@sticky_docs(
operation_id="update_sticky",
summary="Update a sticky",
description="Update a sticky by its ID",
request=OpenApiRequest(request=StickySerializer),
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
)
def partial_update(self, request, slug, pk):
sticky = self.get_object()
serializer = StickySerializer(sticky, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@sticky_docs(
operation_id="delete_sticky",
summary="Delete a sticky",
description="Delete a sticky by its ID",
responses={204: DELETED_RESPONSE},
)
def destroy(self, request, slug, pk):
sticky = self.get_object()
sticky.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+6
View File
@@ -11,6 +11,7 @@ from plane.app.views import (
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
UserProjectInvitationsViewset,
ProjectPublicCoverImagesEndpoint,
UserProjectRolesEndpoint,
ProjectArchiveUnarchiveEndpoint,
ProjectMemberPreferenceEndpoint,
@@ -105,6 +106,11 @@ urlpatterns = [
ProjectFavoritesViewSet.as_view({"delete": "destroy"}),
name="project-favorite",
),
path(
"project-covers/",
ProjectPublicCoverImagesEndpoint.as_view(),
name="project-covers",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/",
DeployBoardViewSet.as_view({"get": "list", "post": "create"}),
+1
View File
@@ -3,6 +3,7 @@ from .project.base import (
ProjectIdentifierEndpoint,
ProjectUserViewsEndpoint,
ProjectFavoritesViewSet,
ProjectPublicCoverImagesEndpoint,
DeployBoardViewSet,
ProjectArchiveUnarchiveEndpoint,
)
+19 -26
View File
@@ -322,9 +322,6 @@ class IntakeIssueViewSet(BaseViewSet):
@allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue)
def partial_update(self, request, slug, project_id, pk):
skip_activity = request.data.pop("skip_activity", False)
is_description_update = request.data.get("description_html") is not None
intake_id = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first()
intake_issue = IntakeIssue.objects.get(
issue_id=pk,
@@ -421,30 +418,26 @@ class IntakeIssueViewSet(BaseViewSet):
# Both serializers are valid, now save them
if issue_serializer:
issue_serializer.save()
# Check if the update is a migration description update
is_migration_description_update = skip_activity and is_description_update
# Log all the updates
if not is_migration_description_update:
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=issue_requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=issue_current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=issue_current_instance,
issue_id=str(pk),
user_id=request.user.id,
)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=issue_requested_data,
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=issue_current_instance,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
intake=str(intake_issue.id),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=issue_current_instance,
issue_id=str(pk),
user_id=request.user.id,
)
if intake_serializer:
intake_serializer.save()
+26 -34
View File
@@ -611,10 +611,6 @@ class IssueViewSet(BaseViewSet):
def partial_update(self, request, slug, project_id, pk=None):
queryset = self.get_queryset()
queryset = self.apply_annotations(queryset)
skip_activity = request.data.pop("skip_activity", False)
is_description_update = request.data.get("description_html") is not None
issue = (
queryset.annotate(
label_ids=Coalesce(
@@ -663,36 +659,32 @@ class IssueViewSet(BaseViewSet):
serializer = IssueCreateSerializer(issue, data=request.data, partial=True, context={"project_id": project_id})
if serializer.is_valid():
serializer.save()
# Check if the update is a migration description update
is_migration_description_update = skip_activity and is_description_update
# Log all the updates
if not is_migration_description_update:
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(pk),
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(serializer.data.get("id", None)),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=current_instance,
issue_id=str(serializer.data.get("id", None)),
user_id=request.user.id,
)
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
actor_id=str(request.user.id),
issue_id=str(pk),
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(serializer.data.get("id", None)),
requested_data=request.data,
current_instance=current_instance,
actor_id=request.user.id,
slug=slug,
origin=base_host(request=request, is_app=True),
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=current_instance,
issue_id=str(serializer.data.get("id", None)),
user_id=request.user.id,
)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+65 -29
View File
@@ -1,44 +1,43 @@
# Python imports
import boto3
from django.conf import settings
from django.utils import timezone
import json
import boto3
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
# Module imports
from plane.app.permissions import ROLE, ProjectMemberPermission, allow_permission
from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.app.serializers import (
DeployBoardSerializer,
ProjectListSerializer,
ProjectSerializer,
ProjectListSerializer,
DeployBoardSerializer,
)
from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE
from plane.db.models import (
UserFavorite,
DeployBoard,
Intake,
DeployBoard,
IssueUserProperty,
Project,
ProjectIdentifier,
ProjectMember,
ProjectNetwork,
State,
DEFAULT_STATES,
Workspace,
WorkspaceMember,
)
from plane.utils.cache import cache_response
from plane.bgtasks.webhook_task import model_activity, webhook_activity
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.exception_logger import log_exception
from plane.utils.host import base_host
@@ -211,25 +210,19 @@ class ProjectViewSet(BaseViewSet):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def retrieve(self, request, slug, pk):
project = self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk).first()
project = (
self.get_queryset()
.filter(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
.filter(archived_at__isnull=True)
.filter(pk=pk)
).first()
if project is None:
return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND)
member_ids = [str(project_member.member_id) for project_member in project.members_list]
if str(request.user.id) not in member_ids:
if project.network == ProjectNetwork.SECRET.value:
return Response(
{"error": "You do not have permission"},
status=status.HTTP_403_FORBIDDEN,
)
else:
return Response(
{"error": "You are not a member of this project"},
status=status.HTTP_409_CONFLICT,
)
recent_visited_task.delay(
slug=slug,
project_id=pk,
@@ -526,6 +519,49 @@ class ProjectFavoritesViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class ProjectPublicCoverImagesEndpoint(BaseAPIView):
permission_classes = [AllowAny]
# Cache the below api for 24 hours
@cache_response(60 * 60 * 24, user=False)
def get(self, request):
files = []
if settings.USE_MINIO:
s3 = boto3.client(
"s3",
endpoint_url=settings.AWS_S3_ENDPOINT_URL,
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
else:
s3 = boto3.client(
"s3",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
params = {
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
"Prefix": "static/project-cover/",
}
try:
response = s3.list_objects_v2(**params)
# Extracting file keys from the response
if "Contents" in response:
for content in response["Contents"]:
if not content["Key"].endswith(
"/"
): # This line ensures we're only getting files, not "sub-folders"
files.append(
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
)
return Response(files, status=status.HTTP_200_OK)
except Exception as e:
log_exception(e)
return Response([], status=status.HTTP_200_OK)
class DeployBoardViewSet(BaseViewSet):
permission_classes = [ProjectMemberPermission]
serializer_class = DeployBoardSerializer
@@ -164,40 +164,6 @@ class ProjectMemberViewSet(BaseViewSet):
serializer = ProjectMemberRoleSerializer(project_members, fields=("id", "member", "role"), many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve(self, request, slug, project_id, pk):
requesting_project_member = ProjectMember.objects.get(
project_id=project_id,
workspace__slug=slug,
member=request.user,
is_active=True,
)
project_member = (
ProjectMember.objects.filter(
pk=pk,
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
is_active=True,
)
.select_related("project", "member", "workspace")
.first()
)
if not project_member:
return Response(
{"error": "Project member not found"},
status=status.HTTP_404_NOT_FOUND,
)
if requesting_project_member.role > ROLE.GUEST.value:
serializer = ProjectMemberAdminSerializer(project_member)
else:
serializer = ProjectMemberRoleSerializer(project_member, fields=("id", "member", "role"))
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id, is_active=True)
+63 -26
View File
@@ -129,7 +129,9 @@ class GlobalSearchEndpoint(BaseAPIView):
return (
cycles.order_by("-created_at")
.distinct()
.values("name", "id", "project_id", "project__identifier", "workspace__slug")
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
)
def filter_modules(self, query, slug, project_id, workspace_search):
@@ -153,7 +155,9 @@ class GlobalSearchEndpoint(BaseAPIView):
return (
modules.order_by("-created_at")
.distinct()
.values("name", "id", "project_id", "project__identifier", "workspace__slug")
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
)
def filter_pages(self, query, slug, project_id, workspace_search):
@@ -173,7 +177,9 @@ class GlobalSearchEndpoint(BaseAPIView):
)
.annotate(
project_ids=Coalesce(
ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)),
ArrayAgg(
"projects__id", distinct=True, filter=~Q(projects__id=True)
),
Value([], output_field=ArrayField(UUIDField())),
)
)
@@ -190,16 +196,20 @@ class GlobalSearchEndpoint(BaseAPIView):
)
if workspace_search == "false" and project_id:
project_subquery = ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=project_id).values_list(
"project_id", flat=True
)[:1]
project_subquery = ProjectPage.objects.filter(
page_id=OuterRef("id"), project_id=project_id
).values_list("project_id", flat=True)[:1]
pages = pages.annotate(project_id=Subquery(project_subquery)).filter(project_id=project_id)
pages = pages.annotate(project_id=Subquery(project_subquery)).filter(
project_id=project_id
)
return (
pages.order_by("-created_at")
.distinct()
.values("name", "id", "project_ids", "project_identifiers", "workspace__slug")
.values(
"name", "id", "project_ids", "project_identifiers", "workspace__slug"
)
)
def filter_views(self, query, slug, project_id, workspace_search):
@@ -223,7 +233,9 @@ class GlobalSearchEndpoint(BaseAPIView):
return (
issue_views.order_by("-created_at")
.distinct()
.values("name", "id", "project_id", "project__identifier", "workspace__slug")
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
)
def filter_intakes(self, query, slug, project_id, workspace_search):
@@ -282,7 +294,9 @@ class GlobalSearchEndpoint(BaseAPIView):
# Determine which entities to search
if entities_param:
requested_entities = [e.strip() for e in entities_param.split(",") if e.strip()]
requested_entities = [
e.strip() for e in entities_param.split(",") if e.strip()
]
requested_entities = [e for e in requested_entities if e in MODELS_MAPPER]
else:
requested_entities = list(MODELS_MAPPER.keys())
@@ -292,7 +306,9 @@ class GlobalSearchEndpoint(BaseAPIView):
for entity in requested_entities:
func = MODELS_MAPPER.get(entity)
if func:
results[entity] = func(query or None, slug, project_id, workspace_search)
results[entity] = func(
query or None, slug, project_id, workspace_search
)
return Response({"results": results}, status=status.HTTP_200_OK)
@@ -304,6 +320,7 @@ class SearchEndpoint(BaseAPIView):
query_types = [qt.strip() for qt in query_types]
count = int(request.query_params.get("count", 5))
project_id = request.query_params.get("project_id", None)
issue_id = request.query_params.get("issue_id", None)
response_data = {}
@@ -350,10 +367,14 @@ class SearchEndpoint(BaseAPIView):
.order_by("-created_at")
)
users = users.distinct().values(
"member__avatar_url",
"member__display_name",
"member__id",
users = (
users
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
)
response_data["user_mention"] = list(users[:count])
@@ -368,12 +389,15 @@ class SearchEndpoint(BaseAPIView):
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user) | Q(network=2),
Q(project_projectmember__member=self.request.user)
| Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values("name", "id", "identifier", "logo_props", "workspace__slug")[:count]
.values(
"name", "id", "identifier", "logo_props", "workspace__slug"
)[:count]
)
response_data["project"] = list(projects)
@@ -432,16 +456,20 @@ class SearchEndpoint(BaseAPIView):
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING"),
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
@@ -559,7 +587,9 @@ class SearchEndpoint(BaseAPIView):
)
)
.order_by("-created_at")
.values("member__avatar_url", "member__display_name", "member__id")[:count]
.values(
"member__avatar_url", "member__display_name", "member__id"
)[:count]
)
response_data["user_mention"] = list(users)
@@ -573,12 +603,15 @@ class SearchEndpoint(BaseAPIView):
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user) | Q(network=2),
Q(project_projectmember__member=self.request.user)
| Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values("name", "id", "identifier", "logo_props", "workspace__slug")[:count]
.values(
"name", "id", "identifier", "logo_props", "workspace__slug"
)[:count]
)
response_data["project"] = list(projects)
@@ -635,16 +668,20 @@ class SearchEndpoint(BaseAPIView):
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING"),
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
+10 -2
View File
@@ -2,6 +2,8 @@
import uuid
import json
import logging
import random
import string
import secrets
# Django imports
@@ -149,7 +151,13 @@ class UserEndpoint(BaseViewSet):
# Include user ID to bind the code to the specific user
cache_key = f"magic_email_update_{user.id}_{new_email}"
## Generate a random token
token = str(secrets.randbelow(900000) + 100000)
token = (
"".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
+ "-"
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
+ "-"
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
)
# Store in cache with 10 minute expiration
cache_data = json.dumps({"token": token})
cache.set(cache_key, cache_data, timeout=600)
@@ -210,7 +218,7 @@ class UserEndpoint(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
except Exception:
except Exception as e:
return Response(
{"error": "Failed to verify code. Please try again."},
status=status.HTTP_400_BAD_REQUEST,
@@ -50,25 +50,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
serializer = WorkSpaceMemberSerializer(workspace_members, fields=("id", "member", "role"), many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def retrieve(self, request, slug, pk):
workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True)
try:
# Get the specific workspace member by pk
member = self.get_queryset().get(pk=pk)
except WorkspaceMember.DoesNotExist:
return Response(
{"error": "Workspace member not found"},
status=status.HTTP_404_NOT_FOUND,
)
if workspace_member.role > ROLE.GUEST.value:
serializer = WorkspaceMemberAdminSerializer(member, fields=("id", "member", "role"))
else:
serializer = WorkSpaceMemberSerializer(member, fields=("id", "member", "role"))
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def partial_update(self, request, slug, pk):
workspace_member = WorkspaceMember.objects.get(
+6 -106
View File
@@ -1,26 +1,22 @@
# Python imports
import os
import uuid
import requests
from io import BytesIO
# Django imports
from django.utils import timezone
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from django.conf import settings
# Third party imports
from zxcvbn import zxcvbn
# Module imports
from plane.db.models import Profile, User, WorkspaceMemberInvite, FileAsset
from plane.db.models import Profile, User, WorkspaceMemberInvite
from plane.license.utils.instance_value import get_configuration_value
from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES
from plane.bgtasks.user_activation_email_task import user_activation_email
from plane.utils.host import base_host
from plane.utils.ip_address import get_client_ip
from plane.utils.exception_logger import log_exception
class Adapter:
@@ -90,9 +86,9 @@ class Adapter:
"""Check if sign up is enabled or not and raise exception if not enabled"""
# Get configuration value
(ENABLE_SIGNUP,) = get_configuration_value([
{"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")}
])
(ENABLE_SIGNUP,) = get_configuration_value(
[{"key": "ENABLE_SIGNUP", "default": os.environ.get("ENABLE_SIGNUP", "1")}]
)
# Check if sign up is disabled and invite is present or not
if ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter(email=email).exists():
@@ -105,93 +101,6 @@ class Adapter:
return True
def get_avatar_download_headers(self):
return {}
def download_and_upload_avatar(self, avatar_url, user):
"""
Downloads avatar from OAuth provider and uploads to our storage.
Returns the uploaded file path or None if failed.
"""
if not avatar_url:
return None
try:
headers = self.get_avatar_download_headers()
# Download the avatar image
response = requests.get(avatar_url, timeout=10, headers=headers)
response.raise_for_status()
# Check content length before downloading
content_length = response.headers.get("Content-Length")
max_size = settings.DATA_UPLOAD_MAX_MEMORY_SIZE
if content_length and int(content_length) > max_size:
return None
# Get content type and determine file extension
content_type = response.headers.get("Content-Type", "image/jpeg")
extension_map = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
}
extension = extension_map.get(content_type)
if not extension:
return None
# Download with size limit
chunks = []
total_size = 0
for chunk in response.iter_content(chunk_size=8192):
total_size += len(chunk)
if total_size > max_size:
return None
chunks.append(chunk)
content = b"".join(chunks)
file_size = len(content)
# Generate unique filename
filename = f"{uuid.uuid4().hex}-user-avatar.{extension}"
# Upload to S3/MinIO storage
from plane.settings.storage import S3Storage
storage = S3Storage(request=self.request)
# Create file-like object
file_obj = BytesIO(response.content)
file_obj.seek(0)
# Upload using boto3 directly
upload_success = storage.upload_file(file_obj=file_obj, object_name=filename, content_type=content_type)
if not upload_success:
return None
# Get storage metadata
storage_metadata = storage.get_object_metadata(object_name=filename)
# Create FileAsset record
file_asset = FileAsset.objects.create(
attributes={"name": f"{self.provider}-avatar.{extension}", "type": content_type, "size": file_size},
asset=filename,
size=file_size,
user=user,
created_by=user,
entity_type=FileAsset.EntityTypeContext.USER_AVATAR,
is_uploaded=True,
storage_metadata=storage_metadata,
)
return file_asset
except Exception as e:
log_exception(e)
# Return None if upload fails, so original URL can be used as fallback
return None
def save_user_data(self, user):
# Update user details
user.last_login_medium = self.provider
@@ -242,23 +151,14 @@ class Adapter:
user.is_password_autoset = False
# Set user details
avatar = self.user_data.get("user", {}).get("avatar", "")
first_name = self.user_data.get("user", {}).get("first_name", "")
last_name = self.user_data.get("user", {}).get("last_name", "")
user.avatar = avatar if avatar else ""
user.first_name = first_name if first_name else ""
user.last_name = last_name if last_name else ""
user.save()
# Download and upload avatar
avatar = self.user_data.get("user", {}).get("avatar", "")
if avatar:
avatar_asset = self.download_and_upload_avatar(avatar_url=avatar, user=user)
if avatar_asset:
user.avatar_asset = avatar_asset
# If avatar upload fails, set the avatar to the original URL
else:
user.avatar = avatar
# Create profile
Profile.objects.create(user=user)
@@ -1,7 +1,8 @@
# Python imports
import json
import os
import secrets
import random
import string
# Module imports
@@ -49,7 +50,13 @@ class MagicCodeProvider(CredentialAdapter):
def initiate(self):
## Generate a random token
token = str(secrets.randbelow(900000) + 100000)
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
ri = redis_instance()
+1
View File
@@ -2,6 +2,7 @@
import io
import zipfile
from typing import List
from collections import defaultdict
import boto3
from botocore.client import Config
from uuid import UUID
+92 -120
View File
@@ -10,7 +10,6 @@ from datetime import timedelta
# Django imports
from django.conf import settings
from django.utils import timezone
from django.contrib.auth.hashers import make_password
# Third party imports
from celery import shared_task
@@ -35,8 +34,6 @@ from plane.db.models import (
CycleIssue,
ModuleIssue,
IssueView,
User,
BotTypeEnum,
)
logger = logging.getLogger("plane.worker")
@@ -64,7 +61,7 @@ def read_seed_file(filename):
return None
def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int, uuid.UUID]:
def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
"""Creates a project and associated members for a workspace.
Creates a new project using the workspace name and sets up all necessary
@@ -72,7 +69,7 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int,
Args:
workspace: The workspace to create the project in
bot_user: The bot user to use for creating the project
Returns:
A mapping of seed project IDs to actual project IDs
"""
@@ -99,7 +96,7 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int,
workspace=workspace,
name=workspace.name, # Use workspace name
identifier=project_identifier,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
# Enable all views in seed data
cycle_view=True,
module_view=True,
@@ -107,56 +104,60 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int,
)
# Create project members
ProjectMember.objects.bulk_create([
ProjectMember(
project=project,
member_id=workspace_member["member_id"],
role=workspace_member["role"],
workspace_id=workspace.id,
created_by_id=bot_user.id,
)
for workspace_member in workspace_members
])
ProjectMember.objects.bulk_create(
[
ProjectMember(
project=project,
member_id=workspace_member["member_id"],
role=workspace_member["role"],
workspace_id=workspace.id,
created_by_id=workspace.created_by_id,
)
for workspace_member in workspace_members
]
)
# Create issue user properties
IssueUserProperty.objects.bulk_create([
IssueUserProperty(
project=project,
user_id=workspace_member["member_id"],
workspace_id=workspace.id,
display_filters={
"layout": "list",
"calendar": {"layout": "month", "show_weekends": False},
"group_by": "state",
"order_by": "sort_order",
"sub_issue": True,
"sub_group_by": None,
"show_empty_groups": True,
},
display_properties={
"key": True,
"link": True,
"cycle": False,
"state": True,
"labels": False,
"modules": False,
"assignee": True,
"due_date": False,
"estimate": True,
"priority": True,
"created_on": True,
"issue_type": True,
"start_date": False,
"updated_on": True,
"customer_count": True,
"sub_issue_count": False,
"attachment_count": False,
"customer_request_count": True,
},
created_by_id=bot_user.id,
)
for workspace_member in workspace_members
])
IssueUserProperty.objects.bulk_create(
[
IssueUserProperty(
project=project,
user_id=workspace_member["member_id"],
workspace_id=workspace.id,
display_filters={
"layout": "list",
"calendar": {"layout": "month", "show_weekends": False},
"group_by": "state",
"order_by": "sort_order",
"sub_issue": True,
"sub_group_by": None,
"show_empty_groups": True,
},
display_properties={
"key": True,
"link": True,
"cycle": False,
"state": True,
"labels": False,
"modules": False,
"assignee": True,
"due_date": False,
"estimate": True,
"priority": True,
"created_on": True,
"issue_type": True,
"start_date": False,
"updated_on": True,
"customer_count": True,
"sub_issue_count": False,
"attachment_count": False,
"customer_request_count": True,
},
created_by_id=workspace.created_by_id,
)
for workspace_member in workspace_members
]
)
# update map
projects_map[project_id] = project.id
logger.info(f"Task: workspace_seed_task -> Project {project_id} created")
@@ -164,15 +165,13 @@ def create_project_and_member(workspace: Workspace, bot_user: User) -> Dict[int,
return projects_map
def create_project_states(
workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User
) -> Dict[int, uuid.UUID]:
def create_project_states(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]:
"""Creates states for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
bot_user: The bot user to use for creating the states
Returns:
A mapping of seed state IDs to actual state IDs
"""
@@ -191,7 +190,7 @@ def create_project_states(
**state_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
state_map[state_id] = state.id
@@ -199,15 +198,13 @@ def create_project_states(
return state_map
def create_project_labels(
workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User
) -> Dict[int, uuid.UUID]:
def create_project_labels(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]:
"""Creates labels for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
bot_user: The bot user to use for creating the labels
Returns:
A mapping of seed label IDs to actual label IDs
"""
@@ -224,7 +221,7 @@ def create_project_labels(
**label_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
label_map[label_id] = label.id
@@ -239,7 +236,6 @@ def create_project_issues(
labels_map: Dict[int, uuid.UUID],
cycles_map: Dict[int, uuid.UUID],
module_map: Dict[int, uuid.UUID],
bot_user: User,
) -> None:
"""Creates issues and their associated records for each project.
@@ -277,13 +273,13 @@ def create_project_issues(
state_id=states_map[state_id],
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
IssueSequence.objects.create(
issue=issue,
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
IssueActivity.objects.create(
@@ -292,7 +288,7 @@ def create_project_issues(
workspace_id=workspace.id,
comment="created the issue",
verb="created",
actor_id=bot_user.id,
actor_id=workspace.created_by_id,
epoch=time.time(),
)
@@ -303,7 +299,7 @@ def create_project_issues(
label_id=labels_map[label_id],
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
# Create cycle issues
@@ -313,7 +309,7 @@ def create_project_issues(
cycle_id=cycles_map[cycle_id],
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
# Create module issues
@@ -324,20 +320,19 @@ def create_project_issues(
module_id=module_map[module_id],
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
logger.info(f"Task: workspace_seed_task -> Issue {issue_id} created")
return
def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> None:
def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
"""Creates pages for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
bot_user: The bot user to use for creating the pages
"""
page_seeds = read_seed_file("pages.json")
@@ -356,9 +351,9 @@ def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_us
description_html=page_seed.get("description_html", "<p></p>"),
description_binary=page_seed.get("description_binary", None),
description_stripped=page_seed.get("description_stripped", None),
created_by_id=bot_user.id,
updated_by_id=bot_user.id,
owned_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
updated_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
)
logger.info(f"Task: workspace_seed_task -> Page {page_id} created")
@@ -367,27 +362,19 @@ def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_us
workspace_id=workspace.id,
project_id=project_map[page_seed.get("project_id")],
page_id=page.id,
created_by_id=bot_user.id,
updated_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
updated_by_id=workspace.created_by_id,
)
logger.info(f"Task: workspace_seed_task -> Project Page {page_id} created")
return
def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> Dict[int, uuid.UUID]:
"""Creates cycles for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
bot_user: The bot user to use for creating the cycles
Returns:
A mapping of seed cycle IDs to actual cycle IDs
"""
def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]:
# Create cycles
cycle_seeds = read_seed_file("cycles.json")
if not cycle_seeds:
return {}
return
cycle_map: Dict[int, uuid.UUID] = {}
@@ -416,8 +403,8 @@ def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_u
end_date=end_date,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
owned_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
)
cycle_map[cycle_id] = cycle.id
@@ -425,17 +412,16 @@ def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_u
return cycle_map
def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> None:
def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
"""Creates modules for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
bot_user: The bot user to use for creating the modules
"""
module_seeds = read_seed_file("modules.json")
if not module_seeds:
return {}
return
module_map: Dict[int, uuid.UUID] = {}
@@ -452,20 +438,19 @@ def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_
target_date=end_date,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
)
module_map[module_id] = module.id
logger.info(f"Task: workspace_seed_task -> Module {module_id} created")
return module_map
def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> None:
def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
"""Creates views for each project in the workspace.
Args:
workspace: The workspace containing the projects
project_map: Mapping of seed project IDs to actual project IDs
bot_user: The bot user to use for creating the views
"""
view_seeds = read_seed_file("views.json")
@@ -478,8 +463,8 @@ def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_us
**view_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=bot_user.id,
owned_by_id=bot_user.id,
created_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
)
@@ -501,42 +486,29 @@ def workspace_seed(workspace_id: uuid.UUID) -> None:
# Get the workspace
workspace = Workspace.objects.get(id=workspace_id)
# Create a bot user for creating all the workspace data
bot_user = User.objects.create(
username=f"bot_user_{workspace.id}",
display_name="Plane",
first_name="Plane",
last_name="",
is_bot=True,
bot_type=BotTypeEnum.WORKSPACE_SEED,
email=f"bot_user_{workspace.id}@plane.so",
password=make_password(uuid.uuid4().hex),
is_password_autoset=True,
)
# Create a project with the same name as workspace
project_map = create_project_and_member(workspace, bot_user)
project_map = create_project_and_member(workspace)
# Create project states
state_map = create_project_states(workspace, project_map, bot_user)
state_map = create_project_states(workspace, project_map)
# Create project labels
label_map = create_project_labels(workspace, project_map, bot_user)
label_map = create_project_labels(workspace, project_map)
# Create project cycles
cycle_map = create_cycles(workspace, project_map, bot_user)
cycle_map = create_cycles(workspace, project_map)
# Create project modules
module_map = create_modules(workspace, project_map, bot_user)
module_map = create_modules(workspace, project_map)
# create project issues
create_project_issues(workspace, project_map, state_map, label_map, cycle_map, module_map, bot_user)
create_project_issues(workspace, project_map, state_map, label_map, cycle_map, module_map)
# create project views
create_views(workspace, project_map, bot_user)
create_views(workspace, project_map)
# create project pages
create_pages(workspace, project_map, bot_user)
create_pages(workspace, project_map)
logger.info(f"Task: workspace_seed_task -> Workspace {workspace_id} seeded successfully")
return
+1 -2
View File
@@ -52,13 +52,12 @@ from .project import (
ProjectIdentifier,
ProjectMember,
ProjectMemberInvite,
ProjectNetwork,
ProjectPublicMember,
)
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State, StateGroup, DEFAULT_STATES
from .user import Account, Profile, User, BotTypeEnum
from .user import Account, Profile, User
from .view import IssueView
from .webhook import Webhook, WebhookLog
from .workspace import (
-4
View File
@@ -35,10 +35,6 @@ def get_mobile_default_onboarding():
}
class BotTypeEnum(models.TextChoices):
WORKSPACE_SEED = "WORKSPACE_SEED", "Workspace Seed"
class User(AbstractBaseUser, PermissionsMixin):
id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True)
username = models.CharField(max_length=128, unique=True)
@@ -175,7 +175,6 @@ class InstanceEndpoint(BaseAPIView):
data["app_base_url"] = settings.APP_BASE_URL
data["instance_changelog_url"] = settings.INSTANCE_CHANGELOG_URL
data["is_self_managed"] = settings.IS_SELF_MANAGED
instance_data = serializer.data
instance_data["workspaces_exist"] = Workspace.objects.count() >= 1
+3 -4
View File
@@ -25,9 +25,6 @@ SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get("DEBUG", "0"))
# Self-hosted mode
IS_SELF_MANAGED = True
# Allowed Hosts
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
@@ -72,7 +69,9 @@ MIDDLEWARE = [
# Rest Framework settings
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",),
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_THROTTLE_CLASSES": ("rest_framework.throttling.AnonRateThrottle",),
"DEFAULT_THROTTLE_RATES": {
"anon": "30/minute",
+3 -32
View File
@@ -29,8 +29,6 @@ class S3Storage(S3Boto3Storage):
self.aws_region = os.environ.get("AWS_REGION")
# Use the AWS_S3_ENDPOINT_URL environment variable for the endpoint URL
self.aws_s3_endpoint_url = os.environ.get("AWS_S3_ENDPOINT_URL") or os.environ.get("MINIO_ENDPOINT_URL")
# 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"))
if os.environ.get("USE_MINIO") == "1":
# Determine protocol based on environment variable
@@ -58,10 +56,8 @@ class S3Storage(S3Boto3Storage):
config=boto3.session.Config(signature_version="s3v4"),
)
def generate_presigned_post(self, object_name, file_type, file_size, expiration=None):
def generate_presigned_post(self, object_name, file_type, file_size, expiration=3600):
"""Generate a presigned URL to upload an S3 object"""
if expiration is None:
expiration = self.signed_url_expiration
fields = {"Content-Type": file_type}
conditions = [
@@ -108,15 +104,13 @@ class S3Storage(S3Boto3Storage):
def generate_presigned_url(
self,
object_name,
expiration=None,
expiration=3600,
http_method="GET",
disposition="inline",
filename=None,
):
"""Generate a presigned URL to share an S3 object"""
if expiration is None:
expiration = self.signed_url_expiration
content_disposition = self._get_content_disposition(disposition, filename)
"""Generate a presigned URL to share an S3 object"""
try:
response = self.s3_client.generate_presigned_url(
"get_object",
@@ -164,26 +158,3 @@ class S3Storage(S3Boto3Storage):
return None
return response
def upload_file(
self,
file_obj,
object_name: str,
content_type: str = None,
extra_args: dict = {},
) -> bool:
"""Upload a file directly to S3"""
try:
if content_type:
extra_args["ContentType"] = content_type
self.s3_client.upload_fileobj(
file_obj,
self.aws_storage_bucket_name,
object_name,
ExtraArgs=extra_args,
)
return True
except ClientError as e:
log_exception(e)
return False
+5 -6
View File
@@ -11,12 +11,11 @@ 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
# Module imports
from .base import BaseAPIView
from plane.db.models import DeployBoard, FileAsset
from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
class EntityAssetEndpoint(BaseAPIView):
@@ -168,7 +167,7 @@ class EntityAssetEndpoint(BaseAPIView):
class AssetRestoreEndpoint(BaseAPIView):
"""Endpoint to restore a deleted assets."""
def post(self, request, anchor, pk):
def post(self, request, anchor, asset_id):
# Get the deploy board
deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").first()
# Check if the project is published
@@ -176,7 +175,7 @@ class AssetRestoreEndpoint(BaseAPIView):
return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND)
# Get the asset
asset = FileAsset.all_objects.get(id=pk, workspace=deploy_board.workspace)
asset = FileAsset.all_objects.get(id=asset_id, workspace=deploy_board.workspace)
asset.is_deleted = False
asset.deleted_at = None
asset.save(update_fields=["is_deleted", "deleted_at"])
@@ -1,7 +1,8 @@
import pytest
from rest_framework import status
from django.db import IntegrityError
from django.utils import timezone
from datetime import timedelta
from datetime import datetime, timedelta
from uuid import uuid4
from plane.db.models import Cycle, Project, ProjectMember
@@ -57,6 +58,8 @@ def create_cycle(db, project, create_user):
)
@pytest.mark.contract
class TestCycleListCreateAPIEndpoint:
"""Test Cycle List and Create API Endpoint"""
@@ -82,6 +85,7 @@ class TestCycleListCreateAPIEndpoint:
assert created_cycle.project == project
assert created_cycle.owned_by_id is not None
@pytest.mark.django_db
def test_create_cycle_invalid_data(self, api_key_client, workspace, project):
"""Test cycle creation with invalid data"""
@@ -193,7 +197,7 @@ class TestCycleListCreateAPIEndpoint:
# Create cycles in different states
now = timezone.now()
# Current cycle (started but not ended)
Cycle.objects.create(
name="Current Cycle",
@@ -203,7 +207,7 @@ class TestCycleListCreateAPIEndpoint:
end_date=now + timedelta(days=6),
owned_by=create_user,
)
# Upcoming cycle
Cycle.objects.create(
name="Upcoming Cycle",
@@ -213,7 +217,7 @@ class TestCycleListCreateAPIEndpoint:
end_date=now + timedelta(days=8),
owned_by=create_user,
)
# Completed cycle
Cycle.objects.create(
name="Completed Cycle",
@@ -223,7 +227,7 @@ class TestCycleListCreateAPIEndpoint:
end_date=now - timedelta(days=3),
owned_by=create_user,
)
# Draft cycle
Cycle.objects.create(
name="Draft Cycle",
@@ -316,9 +320,7 @@ class TestCycleDetailAPIEndpoint:
assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK]
@pytest.mark.django_db
def test_update_cycle_with_external_id_conflict(
self, api_key_client, workspace, project, create_cycle, create_user
):
def test_update_cycle_with_external_id_conflict(self, api_key_client, workspace, project, create_cycle, create_user ):
"""Test cycle update with conflicting external ID"""
url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id)
@@ -361,7 +363,7 @@ class TestCycleDetailAPIEndpoint:
response = api_key_client.get(url)
assert response.status_code == status.HTTP_200_OK
# Check that metrics are included in response
cycle_data = response.data
assert "total_issues" in cycle_data
@@ -370,11 +372,11 @@ class TestCycleDetailAPIEndpoint:
assert "started_issues" in cycle_data
assert "unstarted_issues" in cycle_data
assert "backlog_issues" in cycle_data
# All should be 0 for a new cycle
assert cycle_data["total_issues"] == 0
assert cycle_data["completed_issues"] == 0
assert cycle_data["cancelled_issues"] == 0
assert cycle_data["started_issues"] == 0
assert cycle_data["unstarted_issues"] == 0
assert cycle_data["backlog_issues"] == 0
assert cycle_data["backlog_issues"] == 0
@@ -1,202 +0,0 @@
import os
from unittest.mock import Mock, patch
import pytest
from plane.settings.storage import S3Storage
@pytest.mark.unit
class TestS3StorageSignedURLExpiration:
"""Test the configurable signed URL expiration in S3Storage"""
@patch.dict(os.environ, {}, clear=True)
@patch("plane.settings.storage.boto3")
def test_default_expiration_without_env_variable(self, mock_boto3):
"""Test that default expiration is 3600 seconds when env variable is not set"""
# Mock the boto3 client
mock_boto3.client.return_value = Mock()
# Create S3Storage instance without SIGNED_URL_EXPIRATION env variable
storage = S3Storage()
# Assert default expiration is 3600
assert storage.signed_url_expiration == 3600
@patch.dict(os.environ, {"SIGNED_URL_EXPIRATION": "30"}, clear=True)
@patch("plane.settings.storage.boto3")
def test_custom_expiration_with_env_variable(self, mock_boto3):
"""Test that expiration is read from SIGNED_URL_EXPIRATION env variable"""
# Mock the boto3 client
mock_boto3.client.return_value = Mock()
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
storage = S3Storage()
# Assert expiration is 30
assert storage.signed_url_expiration == 30
@patch.dict(os.environ, {"SIGNED_URL_EXPIRATION": "300"}, clear=True)
@patch("plane.settings.storage.boto3")
def test_custom_expiration_multiple_values(self, mock_boto3):
"""Test that expiration works with different custom values"""
# Mock the boto3 client
mock_boto3.client.return_value = Mock()
# Create S3Storage instance with SIGNED_URL_EXPIRATION=300
storage = S3Storage()
# Assert expiration is 300
assert storage.signed_url_expiration == 300
@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",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_post_uses_default_expiration(self, mock_boto3):
"""Test that generate_presigned_post uses the configured default expiration"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_post.return_value = {
"url": "https://test-url.com",
"fields": {},
}
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance
storage = S3Storage()
# Call generate_presigned_post without explicit expiration
storage.generate_presigned_post("test-object", "image/png", 1024)
# Assert that the boto3 method was called with the default expiration (3600)
mock_s3_client.generate_presigned_post.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_post.call_args[1]
assert call_kwargs["ExpiresIn"] == 3600
@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",
"SIGNED_URL_EXPIRATION": "60",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_post_uses_custom_expiration(self, mock_boto3):
"""Test that generate_presigned_post uses custom expiration from env variable"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_post.return_value = {
"url": "https://test-url.com",
"fields": {},
}
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance with SIGNED_URL_EXPIRATION=60
storage = S3Storage()
# Call generate_presigned_post without explicit expiration
storage.generate_presigned_post("test-object", "image/png", 1024)
# Assert that the boto3 method was called with custom expiration (60)
mock_s3_client.generate_presigned_post.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_post.call_args[1]
assert call_kwargs["ExpiresIn"] == 60
@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",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_url_uses_default_expiration(self, mock_boto3):
"""Test that generate_presigned_url uses the configured default expiration"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance
storage = S3Storage()
# Call generate_presigned_url without explicit expiration
storage.generate_presigned_url("test-object")
# Assert that the boto3 method was called with the default expiration (3600)
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
assert call_kwargs["ExpiresIn"] == 3600
@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",
"SIGNED_URL_EXPIRATION": "30",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_generate_presigned_url_uses_custom_expiration(self, mock_boto3):
"""Test that generate_presigned_url uses custom expiration from env variable"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
storage = S3Storage()
# Call generate_presigned_url without explicit expiration
storage.generate_presigned_url("test-object")
# Assert that the boto3 method was called with custom expiration (30)
mock_s3_client.generate_presigned_url.assert_called_once()
call_kwargs = mock_s3_client.generate_presigned_url.call_args[1]
assert call_kwargs["ExpiresIn"] == 30
@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",
"SIGNED_URL_EXPIRATION": "30",
},
clear=True,
)
@patch("plane.settings.storage.boto3")
def test_explicit_expiration_overrides_default(self, mock_boto3):
"""Test that explicit expiration parameter overrides the default"""
# Mock the boto3 client and its response
mock_s3_client = Mock()
mock_s3_client.generate_presigned_url.return_value = "https://test-url.com"
mock_boto3.client.return_value = mock_s3_client
# Create S3Storage instance with SIGNED_URL_EXPIRATION=30
storage = S3Storage()
# Call generate_presigned_url with explicit expiration=120
storage.generate_presigned_url("test-object", expiration=120)
# Assert that the boto3 method was called with explicit expiration (120)
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
-2
View File
@@ -140,7 +140,6 @@ from .examples import (
WORKSPACE_MEMBER_EXAMPLE,
PROJECT_MEMBER_EXAMPLE,
CYCLE_ISSUE_EXAMPLE,
STICKY_EXAMPLE,
)
# Helper decorators
@@ -293,7 +292,6 @@ __all__ = [
"WORKSPACE_MEMBER_EXAMPLE",
"PROJECT_MEMBER_EXAMPLE",
"CYCLE_ISSUE_EXAMPLE",
"STICKY_EXAMPLE",
# Decorators
"workspace_docs",
"project_docs",
@@ -262,18 +262,3 @@ def state_docs(**kwargs):
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
def sticky_docs(**kwargs):
"""Decorator for sticky management endpoints"""
defaults = {
"tags": ["Stickies"],
"summary": "Endpoints for sticky create/update/delete and fetch sticky details",
"parameters": [WORKSPACE_SLUG_PARAMETER],
"responses": {
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: NOT_FOUND_RESPONSE,
},
}
return extend_schema(**_merge_schema_options(defaults, kwargs))
-17
View File
@@ -672,15 +672,6 @@ CYCLE_ISSUE_EXAMPLE = OpenApiExample(
},
)
STICKY_EXAMPLE = OpenApiExample(
name="Sticky",
value={
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Sticky 1",
"description_html": "<p>Sticky 1 description</p>",
"created_at": "2024-01-01T10:30:00Z",
},
)
# Sample data for different entity types
SAMPLE_ISSUE = {
@@ -790,13 +781,6 @@ SAMPLE_CYCLE_ISSUE = {
"created_at": "2024-01-01T10:30:00Z",
}
SAMPLE_STICKY = {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Sticky 1",
"description_html": "<p>Sticky 1 description</p>",
"created_at": "2024-01-01T10:30:00Z",
}
# Mapping of schema types to sample data
SCHEMA_EXAMPLES = {
"Issue": SAMPLE_ISSUE,
@@ -811,7 +795,6 @@ SCHEMA_EXAMPLES = {
"Activity": SAMPLE_ACTIVITY,
"Intake": SAMPLE_INTAKE,
"CycleIssue": SAMPLE_CYCLE_ISSUE,
"Sticky": SAMPLE_STICKY,
}
+4 -4
View File
@@ -1,13 +1,13 @@
# base requirements
# django
Django==4.2.27
Django==4.2.26
# rest framework
djangorestframework==3.15.2
# postgres
psycopg==3.3.0
psycopg-binary==3.3.0
psycopg-c==3.3.0
psycopg==3.2.9
psycopg-binary==3.2.9
psycopg-c==3.2.9
dj-database-url==2.1.0
# mongo
pymongo==4.6.3
+4
View File
@@ -0,0 +1,4 @@
.turbo/*
out/*
dist/*
public/*
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/server.js"],
};
+5 -9
View File
@@ -1,10 +1,6 @@
.next/
.react-router/
.turbo/
.vite/
build/
dist/
node_modules/
.next
.turbo
out/
pnpm-lock.yaml
storybook-static/
dist/
build/
node_modules/
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5",
"plugins": ["@prettier/plugin-oxc"]
}
+3 -5
View File
@@ -15,7 +15,7 @@ RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
ARG TURBO_VERSION=2.6.3
ARG TURBO_VERSION=2.5.6
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
COPY . .
RUN turbo prune --scope=live --docker
@@ -34,13 +34,11 @@ COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN corepack enable pnpm
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
# Copy full directory structure before fetch to ensure all package.json files are available
# Build the project and its dependencies
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
# Fetch dependencies to cache store, then install offline with dev deps
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm fetch --store-dir=/pnpm/store
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store CI=true pnpm install --offline --frozen-lockfile --store-dir=/pnpm/store
ENV TURBO_TELEMETRY_DISABLED=1
+10 -7
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "1.2.1",
"version": "1.1.0",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./dist/start.mjs",
@@ -15,16 +15,16 @@
"build": "tsc --noEmit && tsdown",
"dev": "tsdown --watch --onSuccess \"node --env-file=.env .\"",
"start": "node --env-file=.env .",
"check:lint": "eslint . --max-warnings=160",
"check:lint": "eslint . --max-warnings 10",
"check:types": "tsc --noEmit",
"check:format": "prettier --check .",
"fix:lint": "eslint . --fix --max-warnings=160",
"fix:format": "prettier --write .",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"author": "Plane Software Inc.",
"dependencies": {
"@dotenvx/dotenvx": "catalog:",
"@dotenvx/dotenvx": "^1.49.0",
"@hocuspocus/extension-database": "2.15.2",
"@hocuspocus/extension-logger": "2.15.2",
"@hocuspocus/extension-redis": "2.15.2",
@@ -41,7 +41,8 @@
"axios": "catalog:",
"compression": "1.8.1",
"cors": "^2.8.5",
"express": "catalog:",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "5.7.0",
@@ -53,7 +54,9 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@prettier/plugin-oxc": "0.0.4",
"@types/compression": "1.8.1",
"@types/cors": "^2.8.17",
"@types/express": "4.17.23",
+1 -2
View File
@@ -1,6 +1,5 @@
import { CollaborationController } from "./collaboration.controller";
import { DocumentController } from "./document.controller";
import { HealthController } from "./health.controller";
import { PdfExportController } from "./pdf-export.controller";
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController];
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController];
@@ -1,135 +0,0 @@
import type { Request, Response } from "express";
import { Effect, Schema, Cause } from "effect";
import { Controller, Post } from "@plane/decorators";
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
import {
PdfExportRequestBody,
PdfValidationError,
PdfAuthenticationError,
PdfContentFetchError,
PdfGenerationError,
PdfTimeoutError,
} from "@/schema/pdf-export";
import { PdfExportService, exportToPdf } from "@/services/pdf-export";
import type { PdfExportInput } from "@/services/pdf-export";
type HttpErrorResponse = { status: number; error: string };
@Controller("/pdf-export")
export class PdfExportController {
/**
* Parses and validates the request, returning a typed input object
*/
private parseRequest(
req: Request,
requestId: string
): Effect.Effect<PdfExportInput, PdfValidationError | PdfAuthenticationError> {
return Effect.gen(function* () {
const cookie = req.headers.cookie || "";
if (!cookie) {
return yield* Effect.fail(
new PdfAuthenticationError({
message: "Authentication required",
})
);
}
const body = yield* Schema.decodeUnknown(PdfExportRequestBody)(req.body).pipe(
Effect.mapError(
(cause) =>
new PdfValidationError({
message: "Invalid request body",
cause,
})
)
);
// Get baseUrl from request body or fall back to origin header
const baseUrl = body.baseUrl || req.headers.origin || "";
return {
pageId: body.pageId,
workspaceSlug: body.workspaceSlug,
projectId: body.projectId,
title: body.title,
author: body.author,
subject: body.subject,
pageSize: body.pageSize,
pageOrientation: body.pageOrientation,
fileName: body.fileName,
noAssets: body.noAssets,
baseUrl,
apiBaseUrl: body.apiBaseUrl,
cookie,
requestId,
};
});
}
@Post("/")
async exportToPdf(req: Request, res: Response) {
const requestId = crypto.randomUUID();
const effect = Effect.gen(this, function* () {
// Parse request
const input = yield* this.parseRequest(req, requestId);
// Delegate to service (fat model)
return yield* exportToPdf(input);
}).pipe(
// Log errors before catching them - serialize error properly
Effect.tapError((error) => {
const errorInfo =
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: error;
return Effect.logError("PDF_EXPORT: Export failed", { requestId, error: errorInfo });
}),
// Map tagged errors to HTTP responses using catchTags
Effect.catchTags({
PdfValidationError: (e: PdfValidationError): Effect.Effect<HttpErrorResponse> =>
Effect.succeed({ status: 400, error: e.message }),
PdfAuthenticationError: (e: PdfAuthenticationError): Effect.Effect<HttpErrorResponse> =>
Effect.succeed({ status: 401, error: e.message }),
PdfContentFetchError: (e: PdfContentFetchError): Effect.Effect<HttpErrorResponse> =>
Effect.succeed({ status: e.message.includes("not found") ? 404 : 502, error: e.message }),
PdfTimeoutError: (e: PdfTimeoutError): Effect.Effect<HttpErrorResponse> =>
Effect.succeed({ status: 504, error: e.message }),
PdfGenerationError: (e: PdfGenerationError): Effect.Effect<HttpErrorResponse> =>
Effect.succeed({ status: 500, error: e.message }),
}),
// Handle unexpected defects
Effect.catchAllDefect((defect) => {
const appError = new AppError(Cause.pretty(Cause.die(defect)), {
context: { requestId, operation: "exportToPdf" },
});
logger.error("PDF_EXPORT: Unexpected failure", appError);
return Effect.succeed({ status: 500, error: "Failed to generate PDF" });
})
);
const result = await Effect.runPromise(Effect.provide(effect, PdfExportService.Default));
// Check if result is an error response
if ("error" in result && "status" in result) {
return res.status(result.status).json({ message: result.error });
}
// Success - send PDF
const { pdfBuffer, outputFileName } = result;
// Sanitize filename for Content-Disposition header to prevent header injection
const sanitizedFileName = outputFileName
.replace(/["\\\r\n]/g, "") // Remove quotes, backslashes, and CRLF
.replace(/[^\x20-\x7E]/g, "_"); // Replace non-ASCII with underscore
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
`attachment; filename="${sanitizedFileName}"; filename*=UTF-8''${encodeURIComponent(outputFileName)}`
);
res.setHeader("Content-Length", pdfBuffer.length);
return res.send(pdfBuffer);
}
}
+4 -25
View File
@@ -20,32 +20,13 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP
try {
const service = getPageService(context.documentType, context);
// fetch details
const response = (await service.fetchDescriptionBinary(pageId)) as Buffer;
const response = await service.fetchDescriptionBinary(pageId);
const binaryData = new Uint8Array(response);
// if binary data is empty, convert HTML to binary data
if (binaryData.byteLength === 0) {
const pageDetails = await service.fetchDetails(pageId);
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(
pageDetails.description_html ?? "<p></p>",
pageDetails.name
);
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>");
if (convertedBinaryData) {
// save the converted binary data back to the database
try {
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
convertedBinaryData,
true
);
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
await service.updateDescriptionBinary(pageId, payload);
} catch (e) {
const error = new AppError(e);
logger.error("Failed to save binary after first convertion from html:", error);
}
return convertedBinaryData;
}
}
@@ -71,10 +52,8 @@ const storeDocument = async ({
try {
const service = getPageService(context.documentType, context);
// convert binary data to all formats
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
pageBinaryData,
true
);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(pageBinaryData);
// create payload
const payload = {
description_binary: contentBinaryEncoded,
@@ -12,7 +12,7 @@ export class ForceCloseHandler implements Extension {
priority = 999;
async onConfigure({ instance }: onConfigurePayload) {
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis);
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
if (!redisExt) {
logger.warn("[FORCE_CLOSE_HANDLER] Redis extension not found");
@@ -149,7 +149,7 @@ export const forceCloseDocumentAcrossServers = async (
logger.info(`[FORCE_CLOSE] Closed ${closedCount}/${connectionsBefore} local connections`);
// STEP 4: BROADCAST TO OTHER SERVERS
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis);
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined;
if (redisExt) {
const commandData: ForceCloseCommandData = {
+1 -9
View File
@@ -1,13 +1,5 @@
import { Database } from "./database";
import { ForceCloseHandler } from "./force-close-handler";
import { Logger } from "./logger";
import { Redis } from "./redis";
import { TitleSyncExtension } from "./title-sync";
export const getExtensions = () => [
new Logger(),
new Database(),
new Redis(),
new TitleSyncExtension(),
new ForceCloseHandler(), // Must be after Redis to receive broadcasts
];
export const getExtensions = () => [new Logger(), new Database(), new Redis()];
-175
View File
@@ -1,175 +0,0 @@
// hocuspocus
import type { Extension, Hocuspocus, Document } from "@hocuspocus/server";
import { TiptapTransformer } from "@hocuspocus/transformer";
import type { AnyExtension, JSONContent } from "@tiptap/core";
import type * as Y from "yjs";
// editor extensions
import {
TITLE_EDITOR_EXTENSIONS,
createRealtimeEvent,
extractTextFromHTML,
generateTitleProsemirrorJson,
} from "@plane/editor";
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
// helpers
import { getPageService } from "@/services/page/handler";
import type { HocusPocusServerContext, OnLoadDocumentPayloadWithContext } from "@/types";
import { broadcastMessageToPage } from "@/utils/broadcast-message";
import { TitleUpdateManager } from "./title-update/title-update-manager";
/**
* Hocuspocus extension for synchronizing document titles
*/
export class TitleSyncExtension implements Extension {
// Maps document names to their observers and update managers
private titleObservers: Map<string, (events: Y.YEvent<any>[]) => void> = new Map();
private titleUpdateManagers: Map<string, TitleUpdateManager> = new Map();
// Store minimal data needed for each document's title observer (prevents closure memory leaks)
private titleObserverData: Map<
string,
{
parentId?: string | null;
userId: string;
workspaceSlug: string | null;
instance: Hocuspocus;
}
> = new Map();
/**
* Handle document loading - migrate old titles if needed
*/
async onLoadDocument({ context, document, documentName }: OnLoadDocumentPayloadWithContext) {
try {
// initially for on demand migration of old titles to a new title field
// in the yjs binary
if (document.isEmpty("title")) {
const service = getPageService(context.documentType, context);
const pageDetails = await service.fetchDetails(documentName);
const title = pageDetails.name;
if (title == null) return;
const titleJson = (generateTitleProsemirrorJson as (text: string) => JSONContent)(title);
const titleField = TiptapTransformer.toYdoc(titleJson, "title", TITLE_EDITOR_EXTENSIONS as AnyExtension[]);
document.merge(titleField);
}
} catch (error) {
const appError = new AppError(error, {
context: { operation: "onLoadDocument", documentName },
});
logger.error("Error loading document title", appError);
}
}
/**
* Set up title synchronization for a document after it's loaded
*/
async afterLoadDocument({
document,
documentName,
context,
instance,
}: {
document: Document;
documentName: string;
context: HocusPocusServerContext;
instance: Hocuspocus;
}) {
// Create a title update manager for this document
const updateManager = new TitleUpdateManager(documentName, context);
// Store the manager
this.titleUpdateManagers.set(documentName, updateManager);
// Store minimal data needed for the observer (prevents closure memory leak)
this.titleObserverData.set(documentName, {
userId: context.userId,
workspaceSlug: context.workspaceSlug,
instance: instance,
});
// Create observer using bound method to avoid closure capturing heavy objects
const titleObserver = this.handleTitleChange.bind(this, documentName);
// Observe the title field
document.getXmlFragment("title").observeDeep(titleObserver);
this.titleObservers.set(documentName, titleObserver);
}
/**
* Handle title changes for a document
* This is a separate method to avoid closure memory leaks
*/
private handleTitleChange(documentName: string, events: Y.YEvent<any>[]) {
let title = "";
events.forEach((event) => {
title = extractTextFromHTML(event.currentTarget.toJSON() as string);
});
// Get the manager for this document
const manager = this.titleUpdateManagers.get(documentName);
// Get the stored data for this document
const data = this.titleObserverData.get(documentName);
// Broadcast to parent page if it exists
if (data?.parentId && data.workspaceSlug && data.instance) {
const event = createRealtimeEvent({
user_id: data.userId,
workspace_slug: data.workspaceSlug,
action: "property_updated",
page_id: documentName,
data: { name: title },
descendants_ids: [],
});
// Use the instance from stored data (guaranteed to be set)
broadcastMessageToPage(data.instance, data.parentId, event);
}
// Schedule the title update
if (manager) {
manager.scheduleUpdate(title);
}
}
/**
* Force save title before unloading the document
*/
async beforeUnloadDocument({ documentName }: { documentName: string }) {
const updateManager = this.titleUpdateManagers.get(documentName);
if (updateManager) {
// Force immediate save and wait for it to complete
await updateManager.forceSave();
// Clean up the manager
this.titleUpdateManagers.delete(documentName);
}
}
/**
* Remove observers after document unload
*/
async afterUnloadDocument({ documentName, document }: { documentName: string; document?: Document }) {
// Clean up observer when document is unloaded
const observer = this.titleObservers.get(documentName);
if (observer) {
// unregister observer from Y.js document to prevent memory leak
if (document) {
try {
document.getXmlFragment("title").unobserveDeep(observer);
} catch (error) {
logger.error("Failed to unobserve title field", new AppError(error, { context: { documentName } }));
}
}
this.titleObservers.delete(documentName);
}
// Clean up the observer data map to prevent memory leak
this.titleObserverData.delete(documentName);
// Ensure manager is cleaned up if beforeUnloadDocument somehow didn't run
if (this.titleUpdateManagers.has(documentName)) {
const manager = this.titleUpdateManagers.get(documentName)!;
manager.cancel();
this.titleUpdateManagers.delete(documentName);
}
}
}
@@ -1,277 +0,0 @@
import { logger } from "@plane/logger";
/**
* DebounceState - Tracks the state of a debounced function
*/
export interface DebounceState {
lastArgs: any[] | null;
timerId: ReturnType<typeof setTimeout> | null;
lastCallTime: number | undefined;
lastExecutionTime: number;
inProgress: boolean;
abortController: AbortController | null;
}
/**
* Creates a new DebounceState object
*/
export const createDebounceState = (): DebounceState => ({
lastArgs: null,
timerId: null,
lastCallTime: undefined,
lastExecutionTime: 0,
inProgress: false,
abortController: null,
});
/**
* DebounceOptions - Configuration options for debounce
*/
export interface DebounceOptions {
/** The wait time in milliseconds */
wait: number;
/** Optional logging prefix for debug messages */
logPrefix?: string;
}
/**
* Enhanced debounce manager with abort support
* Manages the state and timing of debounced function calls
*/
export class DebounceManager {
private state: DebounceState;
private wait: number;
private logPrefix: string;
/**
* Creates a new DebounceManager
* @param options Debounce configuration options
*/
constructor(options: DebounceOptions) {
this.state = createDebounceState();
this.wait = options.wait;
this.logPrefix = options.logPrefix || "";
}
/**
* Schedule a debounced function call
* @param func The function to call
* @param args The arguments to pass to the function
*/
schedule(func: (...args: any[]) => Promise<void>, ...args: any[]): void {
// Always update the last arguments
this.state.lastArgs = args;
const time = Date.now();
this.state.lastCallTime = time;
// If an operation is in progress, just store the new args and start the timer
if (this.state.inProgress) {
// Always restart the timer for the new call, even if an operation is in progress
if (this.state.timerId) {
clearTimeout(this.state.timerId);
}
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// If already scheduled, update the args and restart the timer
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
return;
}
// Start the timer for the trailing edge execution
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
/**
* Called when the timer expires
*/
private timerExpired(func: (...args: any[]) => Promise<void>): void {
const time = Date.now();
// Check if this timer expiration represents the end of the debounce period
if (this.shouldInvoke(time)) {
// Execute the function
this.executeFunction(func, time);
return;
}
// Otherwise restart the timer
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.remainingWait(time));
}
/**
* Execute the debounced function
*/
private executeFunction(func: (...args: any[]) => Promise<void>, time: number): void {
this.state.timerId = null;
this.state.lastExecutionTime = time;
// Execute the function asynchronously
this.performFunction(func).catch((error) => {
logger.error(`${this.logPrefix}: Error in execution:`, error);
});
}
/**
* Perform the actual function call, handling any in-progress operations
*/
private async performFunction(func: (...args: any[]) => Promise<void>): Promise<void> {
const args = this.state.lastArgs;
if (!args) return;
// Store the args we're about to use
const currentArgs = [...args];
// If another operation is in progress, abort it
await this.abortOngoingOperation();
// Mark that we're starting a new operation
this.state.inProgress = true;
this.state.abortController = new AbortController();
try {
// Add the abort signal to the arguments if the function can use it
const execArgs = [...currentArgs];
execArgs.push(this.state.abortController.signal);
await func(...execArgs);
// Only clear lastArgs if they haven't been changed during this operation
if (this.state.lastArgs && this.arraysEqual(this.state.lastArgs, currentArgs)) {
this.state.lastArgs = null;
// Clear any timer as we've successfully processed the latest args
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
} else if (this.state.lastArgs) {
// If lastArgs have changed during this operation, the timer should already be running
// but let's make sure it is
if (!this.state.timerId) {
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// Nothing to do here, the new operation will be triggered by the timer expiration
} else {
logger.error(`${this.logPrefix}: Error during operation:`, error);
// On error (not abort), make sure we have a timer running to retry
if (!this.state.timerId && this.state.lastArgs) {
this.state.timerId = setTimeout(() => {
this.timerExpired(func);
}, this.wait);
}
}
} finally {
this.state.inProgress = false;
this.state.abortController = null;
}
}
/**
* Abort any ongoing operation
*/
private async abortOngoingOperation(): Promise<void> {
if (this.state.inProgress && this.state.abortController) {
this.state.abortController.abort();
// Small delay to ensure the abort has had time to propagate
await new Promise((resolve) => setTimeout(resolve, 20));
// Double-check that state has been reset, force it if not
if (this.state.inProgress || this.state.abortController) {
this.state.inProgress = false;
this.state.abortController = null;
}
}
}
/**
* Determine if we should invoke the function now
*/
private shouldInvoke(time: number): boolean {
// Either this is the first call, or we've waited long enough since the last call
return this.state.lastCallTime === undefined || time - this.state.lastCallTime >= this.wait;
}
/**
* Calculate how much longer we should wait
*/
private remainingWait(time: number): number {
const timeSinceLastCall = time - (this.state.lastCallTime || 0);
return Math.max(0, this.wait - timeSinceLastCall);
}
/**
* Force immediate execution
*/
async flush(func: (...args: any[]) => Promise<void>): Promise<void> {
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Perform the function immediately
if (this.state.lastArgs) {
await this.performFunction(func);
}
}
/**
* Cancel any pending operations without executing
*/
cancel(): void {
// Clear any pending timeout
if (this.state.timerId) {
clearTimeout(this.state.timerId);
this.state.timerId = null;
}
// Reset timing state
this.state.lastCallTime = undefined;
// Abort any in-progress operation
if (this.state.inProgress && this.state.abortController) {
this.state.abortController.abort();
this.state.inProgress = false;
this.state.abortController = null;
}
// Clear args
this.state.lastArgs = null;
}
/**
* Compare two arrays for equality
*/
private arraysEqual(a: any[], b: any[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
}
@@ -1,90 +0,0 @@
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
import { getPageService } from "@/services/page/handler";
import type { HocusPocusServerContext } from "@/types";
import { DebounceManager } from "./debounce";
/**
* Manages title update operations for a single document
* Handles debouncing, aborting, and force saving title updates
*/
export class TitleUpdateManager {
private documentName: string;
private context: HocusPocusServerContext;
private debounceManager: DebounceManager;
private lastTitle: string | null = null;
/**
* Create a new TitleUpdateManager instance
*/
constructor(documentName: string, context: HocusPocusServerContext, wait: number = 5000) {
this.documentName = documentName;
this.context = context;
// Set up debounce manager with logging
this.debounceManager = new DebounceManager({
wait,
logPrefix: `TitleManager[${documentName.substring(0, 8)}]`,
});
}
/**
* Schedule a debounced title update
*/
scheduleUpdate(title: string): void {
// Store the latest title
this.lastTitle = title;
// Schedule the update with the debounce manager
this.debounceManager.schedule(this.updateTitle.bind(this), title);
}
/**
* Update the title - will be called by the debounce manager
*/
private async updateTitle(title: string, signal?: AbortSignal): Promise<void> {
const service = getPageService(this.context.documentType, this.context);
if (!service.updatePageProperties) {
logger.warn(`No updateTitle method found for document ${this.documentName}`);
return;
}
try {
await service.updatePageProperties(this.documentName, {
data: { name: title },
abortSignal: signal,
});
// Clear last title only if it matches what we just updated
if (this.lastTitle === title) {
this.lastTitle = null;
}
} catch (error) {
const appError = new AppError(error, {
context: { operation: "updateTitle", documentName: this.documentName },
});
logger.error("Error updating title", appError);
}
}
/**
* Force save the current title immediately
*/
async forceSave(): Promise<void> {
// Ensure we have the current title
if (!this.lastTitle) {
return;
}
// Use the debounce manager to flush the operation
await this.debounceManager.flush(this.updateTitle.bind(this));
}
/**
* Cancel any pending updates
*/
cancel(): void {
this.debounceManager.cancel();
this.lastTitle = null;
}
}
-225
View File
@@ -1,225 +0,0 @@
/**
* PDF Export Color Constants
*
* These colors are mapped from the editor CSS variables and tailwind-config tokens
* to ensure PDF exports match the editor's appearance.
*
* Source mappings:
* - Editor colors: packages/editor/src/styles/variables.css
* - Tailwind tokens: packages/tailwind-config/variables.css
*/
// Editor text colors (from variables.css :root)
export const EDITOR_TEXT_COLORS = {
gray: "#5c5e63",
peach: "#ff5b59",
pink: "#f65385",
orange: "#fd9038",
green: "#0fc27b",
"light-blue": "#17bee9",
"dark-blue": "#266df0",
purple: "#9162f9",
} as const;
// Editor background colors - Light theme (from variables.css [data-theme*="light"])
export const EDITOR_BACKGROUND_COLORS_LIGHT = {
gray: "#d6d6d8",
peach: "#ffd5d7",
pink: "#fdd4e3",
orange: "#ffe3cd",
green: "#c3f0de",
"light-blue": "#c5eff9",
"dark-blue": "#c9dafb",
purple: "#e3d8fd",
} as const;
// Editor background colors - Dark theme (from variables.css [data-theme*="dark"])
export const EDITOR_BACKGROUND_COLORS_DARK = {
gray: "#404144",
peach: "#593032",
pink: "#562e3d",
orange: "#583e2a",
green: "#1d4a3b",
"light-blue": "#1f495c",
"dark-blue": "#223558",
purple: "#3d325a",
} as const;
// Use light theme colors by default for PDF exports
export const EDITOR_BACKGROUND_COLORS = EDITOR_BACKGROUND_COLORS_LIGHT;
// Color key type
export type EditorColorKey = keyof typeof EDITOR_TEXT_COLORS;
/**
* Maps a color key to its text color hex value
*/
export const getTextColorHex = (colorKey: string): string | null => {
if (colorKey in EDITOR_TEXT_COLORS) {
return EDITOR_TEXT_COLORS[colorKey as EditorColorKey];
}
return null;
};
/**
* Maps a color key to its background color hex value
*/
export const getBackgroundColorHex = (colorKey: string): string | null => {
if (colorKey in EDITOR_BACKGROUND_COLORS) {
return EDITOR_BACKGROUND_COLORS[colorKey as EditorColorKey];
}
return null;
};
/**
* Checks if a value is a CSS variable reference (e.g., "var(--editor-colors-gray-text)")
*/
export const isCssVariable = (value: string): boolean => {
return value.startsWith("var(");
};
/**
* Extracts the color key from a CSS variable reference
* e.g., "var(--editor-colors-gray-text)" -> "gray"
* e.g., "var(--editor-colors-light-blue-background)" -> "light-blue"
*/
export const extractColorKeyFromCssVariable = (cssVar: string): string | null => {
// Match patterns like: var(--editor-colors-{color}-text) or var(--editor-colors-{color}-background)
const match = cssVar.match(/var\(--editor-colors-([\w-]+)-(text|background)\)/);
if (match) {
return match[1];
}
return null;
};
/**
* Resolves a color value to a hex color for PDF rendering
* Handles both direct hex values and CSS variable references
*/
export const resolveColorForPdf = (value: string | null | undefined, type: "text" | "background"): string | null => {
if (!value) return null;
// If it's already a hex color, return it
if (value.startsWith("#")) {
return value;
}
// If it's a CSS variable, extract the key and get the hex value
if (isCssVariable(value)) {
const colorKey = extractColorKeyFromCssVariable(value);
if (colorKey) {
return type === "text" ? getTextColorHex(colorKey) : getBackgroundColorHex(colorKey);
}
}
// If it's just a color key (e.g., "gray", "peach"), get the hex value
if (type === "text") {
return getTextColorHex(value);
}
return getBackgroundColorHex(value);
};
// Semantic colors from tailwind-config (light theme)
// These are derived from the CSS variables in packages/tailwind-config/variables.css
// Neutral colors (light theme)
export const NEUTRAL_COLORS = {
white: "#ffffff",
100: "#fafafa", // oklch(0.9848 0.0003 230.66) ≈ #fafafa
200: "#f5f5f5", // oklch(0.9696 0.0007 230.67) ≈ #f5f5f5
300: "#f0f0f0", // oklch(0.9543 0.001 230.67) ≈ #f0f0f0
400: "#ebebeb", // oklch(0.9389 0.0014 230.68) ≈ #ebebeb
500: "#e5e5e5", // oklch(0.9235 0.001733 230.6853) ≈ #e5e5e5
600: "#d9d9d9", // oklch(0.8925 0.0024 230.7) ≈ #d9d9d9
700: "#cccccc", // oklch(0.8612 0.0032 230.71) ≈ #cccccc
800: "#8c8c8c", // oklch(0.6668 0.0079 230.82) ≈ #8c8c8c
900: "#7a7a7a", // oklch(0.6161 0.009153 230.867) ≈ #7a7a7a
1000: "#636363", // oklch(0.5288 0.0083 230.88) ≈ #636363
1100: "#4d4d4d", // oklch(0.4377 0.0066 230.87) ≈ #4d4d4d
1200: "#1f1f1f", // oklch(0.2378 0.0029 230.83) ≈ #1f1f1f
black: "#0f0f0f", // oklch(0.1472 0.0034 230.83) ≈ #0f0f0f
} as const;
// Brand colors (light theme accent)
export const BRAND_COLORS = {
default: "#3f76ff", // oklch(0.4799 0.1158 242.91) - primary accent blue
100: "#f5f8ff",
200: "#e8f0ff",
300: "#d1e1ff",
400: "#b3d0ff",
500: "#8ab8ff",
600: "#5c9aff",
700: "#3f76ff",
900: "#2952b3",
1000: "#1e3d80",
1100: "#142b5c",
1200: "#0d1f40",
} as const;
// Semantic text colors
export const TEXT_COLORS = {
primary: NEUTRAL_COLORS[1200], // --txt-primary
secondary: NEUTRAL_COLORS[1100], // --txt-secondary
tertiary: NEUTRAL_COLORS[1000], // --txt-tertiary
placeholder: NEUTRAL_COLORS[900], // --txt-placeholder
disabled: NEUTRAL_COLORS[800], // --txt-disabled
accentPrimary: BRAND_COLORS.default, // --txt-accent-primary
linkPrimary: BRAND_COLORS.default, // --txt-link-primary
} as const;
// Semantic background colors
export const BACKGROUND_COLORS = {
canvas: NEUTRAL_COLORS[300], // --bg-canvas
surface1: NEUTRAL_COLORS.white, // --bg-surface-1
surface2: NEUTRAL_COLORS[100], // --bg-surface-2
layer1: NEUTRAL_COLORS[200], // --bg-layer-1
layer2: NEUTRAL_COLORS.white, // --bg-layer-2
layer3: NEUTRAL_COLORS[300], // --bg-layer-3
accentSubtle: "#f5f8ff", // --bg-accent-subtle (brand-100)
} as const;
// Semantic border colors
export const BORDER_COLORS = {
subtle: NEUTRAL_COLORS[400], // --border-subtle
subtle1: NEUTRAL_COLORS[500], // --border-subtle-1
strong: NEUTRAL_COLORS[600], // --border-strong
strong1: NEUTRAL_COLORS[700], // --border-strong-1
accentStrong: BRAND_COLORS.default, // --border-accent-strong
} as const;
// Code/inline code colors
export const CODE_COLORS = {
background: NEUTRAL_COLORS[200], // Similar to bg-layer-1
text: "#dc2626", // Red for inline code text (matches editor)
blockText: NEUTRAL_COLORS[1200], // Regular text for code blocks
} as const;
// Link colors
export const LINK_COLORS = {
primary: BRAND_COLORS.default,
hover: BRAND_COLORS[900],
} as const;
// Mention colors (from pi-chat-editor mention styles: bg-accent-primary/20 text-accent-primary)
export const MENTION_COLORS = {
background: "#e0e9ff", // accent-primary with ~20% opacity on white
text: BRAND_COLORS.default,
} as const;
// Success/Green colors
export const SUCCESS_COLORS = {
primary: "#10b981",
subtle: "#d1fae5",
} as const;
// Warning/Amber colors
export const WARNING_COLORS = {
primary: "#f59e0b",
subtle: "#fef3c7",
} as const;
// Danger/Red colors
export const DANGER_COLORS = {
primary: "#ef4444",
subtle: "#fee2e2",
} as const;
-226
View File
@@ -1,226 +0,0 @@
import { Circle, Path, Rect, Svg } from "@react-pdf/renderer";
type IconProps = {
size?: number;
color?: string;
};
// Lightbulb icon for callouts (default)
export const LightbulbIcon = ({ size = 16, color = "#ffffff" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M9 21h6M12 3a6 6 0 0 0-6 6c0 2.22 1.21 4.16 3 5.19V17a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-2.81c1.79-1.03 3-2.97 3-5.19a6 6 0 0 0-6-6z"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
// Document/file icon for page embeds
export const DocumentIcon = ({ size = 12, color = "#1e40af" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path d="M14 2v6h6" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
<Path d="M16 13H8M16 17H8M10 9H8" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
</Svg>
);
// Link icon for page links and external links
export const LinkIcon = ({ size = 12, color = "#2563eb" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
// Paperclip icon for attachments (default)
export const PaperclipIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
// Image icon for image attachments
export const ImageIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Rect x={3} y={3} width={18} height={18} rx={2} ry={2} fill="none" stroke={color} strokeWidth={2} />
<Circle cx={8.5} cy={8.5} r={1.5} fill={color} />
<Path d="M21 15l-5-5L5 21" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
</Svg>
);
// Video icon for video attachments
export const VideoIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Rect x={2} y={4} width={15} height={16} rx={2} ry={2} fill="none" stroke={color} strokeWidth={2} />
<Path d="M17 10l5-3v10l-5-3z" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
</Svg>
);
// Music/audio icon
export const MusicIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path d="M9 18V5l12-2v13" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
<Circle cx={6} cy={18} r={3} fill="none" stroke={color} strokeWidth={2} />
<Circle cx={18} cy={16} r={3} fill="none" stroke={color} strokeWidth={2} />
</Svg>
);
// File-text icon for PDFs and documents
export const FileTextIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
/>
<Path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
</Svg>
);
// Table/spreadsheet icon
export const TableIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Rect x={3} y={3} width={18} height={18} rx={2} fill="none" stroke={color} strokeWidth={2} />
<Path d="M3 9h18M3 15h18M9 3v18M15 3v18" fill="none" stroke={color} strokeWidth={2} />
</Svg>
);
// Presentation icon
export const PresentationIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Rect x={2} y={3} width={20} height={14} rx={2} fill="none" stroke={color} strokeWidth={2} />
<Path d="M8 21l4-4 4 4M12 17v-4" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
</Svg>
);
// Archive/zip icon
export const ArchiveIcon = ({ size = 16, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M21 8v13a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
/>
<Path
d="M23 3H1v5h22V3zM10 12h4"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
// Globe icon for external embeds (rich cards)
export const GlobeIcon = ({ size = 12, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Circle cx={12} cy={12} r={10} fill="none" stroke={color} strokeWidth={2} />
<Path
d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
fill="none"
stroke={color}
strokeWidth={2}
/>
</Svg>
);
// Clipboard icon for whiteboards
export const ClipboardIcon = ({ size = 12, color = "#6b7280" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
/>
<Rect x={8} y={2} width={8} height={4} rx={1} fill="none" stroke={color} strokeWidth={2} />
</Svg>
);
// Ruler/diagram icon for diagrams
export const DiagramIcon = ({ size = 12, color = "#6b7280" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M14 3v4a1 1 0 0 0 1 1h4"
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"
fill="none"
stroke={color}
strokeWidth={2}
/>
<Path d="M9 9h1M9 13h6M9 17h6" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" />
</Svg>
);
// Work item / task icon
export const TaskIcon = ({ size = 14, color = "#374151" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Rect x={3} y={3} width={18} height={18} rx={2} fill="none" stroke={color} strokeWidth={2} />
<Path d="M9 12l2 2 4-4" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
);
// Checkmark icon for checked task items
export const CheckIcon = ({ size = 10, color = "#ffffff" }: IconProps) => (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path d="M20 6L9 17l-5-5" fill="none" stroke={color} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" />
</Svg>
);
// Helper to get file icon component based on file type
export const getFileIcon = (fileType: string, size = 16, color = "#374151") => {
if (fileType.startsWith("image/")) return <ImageIcon size={size} color={color} />;
if (fileType.startsWith("video/")) return <VideoIcon size={size} color={color} />;
if (fileType.startsWith("audio/")) return <MusicIcon size={size} color={color} />;
if (fileType.includes("pdf")) return <FileTextIcon size={size} color="#dc2626" />;
if (fileType.includes("spreadsheet") || fileType.includes("excel")) return <TableIcon size={size} color="#16a34a" />;
if (fileType.includes("document") || fileType.includes("word")) return <FileTextIcon size={size} color="#2563eb" />;
if (fileType.includes("presentation") || fileType.includes("powerpoint"))
return <PresentationIcon size={size} color="#ea580c" />;
if (fileType.includes("zip") || fileType.includes("archive")) return <ArchiveIcon size={size} color={color} />;
return <PaperclipIcon size={size} color={color} />;
};
-18
View File
@@ -1,18 +0,0 @@
export { createPdfDocument, renderPlaneDocToPdfBlob, renderPlaneDocToPdfBuffer } from "./plane-pdf-exporter";
export { createKeyGenerator, nodeRenderers, renderNode } from "./node-renderers";
export { markRenderers, applyMarks } from "./mark-renderers";
export { pdfStyles } from "./styles";
export type {
KeyGenerator,
MarkRendererRegistry,
NodeRendererRegistry,
PDFExportMetadata,
PDFExportOptions,
PDFMarkRenderer,
PDFNodeRenderer,
PDFRenderContext,
PDFUserMention,
TipTapDocument,
TipTapMark,
TipTapNode,
} from "./types";
-138
View File
@@ -1,138 +0,0 @@
import type { Style } from "@react-pdf/types";
import {
BACKGROUND_COLORS,
CODE_COLORS,
EDITOR_BACKGROUND_COLORS,
EDITOR_TEXT_COLORS,
LINK_COLORS,
resolveColorForPdf,
} from "./colors";
import type { MarkRendererRegistry, TipTapMark } from "./types";
export const markRenderers: MarkRendererRegistry = {
bold: (_mark: TipTapMark, style: Style): Style => ({
...style,
fontWeight: "bold",
}),
italic: (_mark: TipTapMark, style: Style): Style => ({
...style,
fontStyle: "italic",
}),
underline: (_mark: TipTapMark, style: Style): Style => ({
...style,
textDecoration: "underline",
}),
strike: (_mark: TipTapMark, style: Style): Style => ({
...style,
textDecoration: "line-through",
}),
code: (_mark: TipTapMark, style: Style): Style => ({
...style,
fontFamily: "Courier",
fontSize: 10,
backgroundColor: BACKGROUND_COLORS.layer1,
color: CODE_COLORS.text,
}),
link: (_mark: TipTapMark, style: Style): Style => ({
...style,
color: LINK_COLORS.primary,
textDecoration: "underline",
}),
textStyle: (mark: TipTapMark, style: Style): Style => {
const attrs = mark.attrs || {};
const newStyle: Style = { ...style };
if (attrs.color && typeof attrs.color === "string") {
newStyle.color = attrs.color;
}
if (attrs.backgroundColor && typeof attrs.backgroundColor === "string") {
newStyle.backgroundColor = attrs.backgroundColor;
}
return newStyle;
},
highlight: (mark: TipTapMark, style: Style): Style => {
const attrs = mark.attrs || {};
return {
...style,
backgroundColor: (attrs.color as string) || EDITOR_BACKGROUND_COLORS.purple,
};
},
subscript: (_mark: TipTapMark, style: Style): Style => ({
...style,
fontSize: 8,
}),
superscript: (_mark: TipTapMark, style: Style): Style => ({
...style,
fontSize: 8,
}),
/**
* Custom color mark handler
* Handles the customColor extension which stores colors as data-text-color and data-background-color attributes
* The colors can be either:
* 1. Color keys like "gray", "peach", "pink", etc. (from COLORS_LIST)
* 2. Direct hex values for custom colors
* 3. CSS variable references like "var(--editor-colors-gray-text)"
*/
customColor: (mark: TipTapMark, style: Style): Style => {
const attrs = mark.attrs || {};
const newStyle: Style = { ...style };
// Handle text color (stored in 'color' attribute)
const textColor = attrs.color as string | undefined;
if (textColor) {
const resolvedColor = resolveColorForPdf(textColor, "text");
if (resolvedColor) {
newStyle.color = resolvedColor;
} else if (textColor.startsWith("#") || textColor.startsWith("rgb")) {
// Direct color value
newStyle.color = textColor;
} else if (textColor in EDITOR_TEXT_COLORS) {
// Color key lookup
newStyle.color = EDITOR_TEXT_COLORS[textColor as keyof typeof EDITOR_TEXT_COLORS];
}
}
// Handle background color (stored in 'backgroundColor' attribute)
const backgroundColor = attrs.backgroundColor as string | undefined;
if (backgroundColor) {
const resolvedColor = resolveColorForPdf(backgroundColor, "background");
if (resolvedColor) {
newStyle.backgroundColor = resolvedColor;
} else if (backgroundColor.startsWith("#") || backgroundColor.startsWith("rgb")) {
// Direct color value
newStyle.backgroundColor = backgroundColor;
} else if (backgroundColor in EDITOR_BACKGROUND_COLORS) {
// Color key lookup
newStyle.backgroundColor = EDITOR_BACKGROUND_COLORS[backgroundColor as keyof typeof EDITOR_BACKGROUND_COLORS];
}
}
return newStyle;
},
};
export const applyMarks = (marks: TipTapMark[] | undefined, baseStyle: Style = {}): Style => {
if (!marks || marks.length === 0) {
return baseStyle;
}
return marks.reduce((style, mark) => {
const renderer = markRenderers[mark.type];
if (renderer) {
return renderer(mark, style);
}
return style;
}, baseStyle);
};
-438
View File
@@ -1,438 +0,0 @@
import { Image, Link, Text, View } from "@react-pdf/renderer";
import type { Style } from "@react-pdf/types";
import type { ReactElement } from "react";
import { CORE_EXTENSIONS } from "@plane/editor";
import { BACKGROUND_COLORS, EDITOR_BACKGROUND_COLORS, resolveColorForPdf, TEXT_COLORS } from "./colors";
import { CheckIcon, ClipboardIcon, DocumentIcon, GlobeIcon, LightbulbIcon, LinkIcon } from "./icons";
import { applyMarks } from "./mark-renderers";
import { pdfStyles } from "./styles";
import type { KeyGenerator, NodeRendererRegistry, PDFExportMetadata, PDFRenderContext, TipTapNode } from "./types";
const getCalloutIcon = (node: TipTapNode, color: string): ReactElement => {
const logoInUse = node.attrs?.["data-logo-in-use"] as string | undefined;
const iconName = node.attrs?.["data-icon-name"] as string | undefined;
const iconColor = (node.attrs?.["data-icon-color"] as string) || color;
if (logoInUse === "emoji") {
// react-pdf doesn't support emoji rendering (PDF fonts lack emoji glyphs)
// Fall back to a generic icon instead
return <LightbulbIcon size={16} color={iconColor} />;
}
if (iconName) {
switch (iconName) {
case "FileText":
case "File":
return <DocumentIcon size={16} color={iconColor} />;
case "Link":
return <LinkIcon size={16} color={iconColor} />;
case "Globe":
return <GlobeIcon size={16} color={iconColor} />;
case "Clipboard":
return <ClipboardIcon size={16} color={iconColor} />;
case "CheckSquare":
case "Check":
return <CheckIcon size={16} color={iconColor} />;
case "Lightbulb":
default:
return <LightbulbIcon size={16} color={iconColor} />;
}
}
return <LightbulbIcon size={16} color={color} />;
};
export const createKeyGenerator = (): KeyGenerator => {
let counter = 0;
return () => `node-${counter++}`;
};
const renderTextWithMarks = (node: TipTapNode, getKey: KeyGenerator): ReactElement => {
const style = applyMarks(node.marks, {});
const hasLink = node.marks?.find((m) => m.type === "link");
if (hasLink) {
const href = (hasLink.attrs?.href as string) || "#";
return (
<Link key={getKey()} src={href} style={{ ...pdfStyles.link, ...style }}>
{node.text || ""}
</Link>
);
}
return (
<Text key={getKey()} style={style}>
{node.text || ""}
</Text>
);
};
const getTextAlignStyle = (textAlign: string | null | undefined): Style => {
if (!textAlign) return {};
return {
textAlign: textAlign as "left" | "right" | "center" | "justify",
};
};
const getFlexAlignStyle = (textAlign: string | null | undefined): Style => {
if (!textAlign) return {};
if (textAlign === "right") return { alignItems: "flex-end" };
if (textAlign === "center") return { alignItems: "center" };
return {};
};
export const nodeRenderers: NodeRendererRegistry = {
doc: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
<View key={ctx.getKey()}>{children}</View>
),
text: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement =>
renderTextWithMarks(node, ctx.getKey),
paragraph: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const textAlign = node.attrs?.textAlign as string | null;
const background = node.attrs?.backgroundColor as string | undefined;
const alignStyle = getTextAlignStyle(textAlign);
const flexStyle = getFlexAlignStyle(textAlign);
const resolvedBgColor =
background && background !== "default" ? resolveColorForPdf(background, "background") : null;
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
return (
<View key={ctx.getKey()} style={[pdfStyles.paragraphWrapper, flexStyle, bgStyle]}>
<Text style={[pdfStyles.paragraph, alignStyle, bgStyle]}>{children}</Text>
</View>
);
},
heading: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const level = (node.attrs?.level as number) || 1;
const styleKey = `heading${level}` as keyof typeof pdfStyles;
const style = pdfStyles[styleKey] || pdfStyles.heading1;
const textAlign = node.attrs?.textAlign as string | null;
const alignStyle = getTextAlignStyle(textAlign);
const flexStyle = getFlexAlignStyle(textAlign);
return (
<View key={ctx.getKey()} style={flexStyle}>
<Text style={[style, alignStyle]}>{children}</Text>
</View>
);
},
blockquote: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
<View key={ctx.getKey()} style={pdfStyles.blockquote} wrap={false}>
{children}
</View>
),
codeBlock: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const codeContent = node.content?.map((c) => c.text || "").join("") || "";
return (
<View key={ctx.getKey()} style={pdfStyles.codeBlock} wrap={false}>
<Text>{codeContent}</Text>
</View>
);
},
bulletList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const nestingLevel = (node.attrs?._nestingLevel as number) || 0;
const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {};
return (
<View key={ctx.getKey()} style={[pdfStyles.bulletList, indentStyle]}>
{children}
</View>
);
},
orderedList: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const nestingLevel = (node.attrs?._nestingLevel as number) || 0;
const indentStyle = nestingLevel > 0 ? { marginLeft: 18 } : {};
return (
<View key={ctx.getKey()} style={[pdfStyles.orderedList, indentStyle]}>
{children}
</View>
);
},
listItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const isOrdered = node.attrs?._parentType === "orderedList";
const index = (node.attrs?._listItemIndex as number) || 0;
const bullet = isOrdered ? `${index}.` : "•";
const textAlign = node.attrs?._textAlign as string | null;
const flexStyle = getFlexAlignStyle(textAlign);
return (
<View key={ctx.getKey()} style={[pdfStyles.listItem, flexStyle]} wrap={false}>
<View style={pdfStyles.listItemBullet}>
<Text>{bullet}</Text>
</View>
<View style={pdfStyles.listItemContent}>{children}</View>
</View>
);
},
taskList: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
<View key={ctx.getKey()} style={pdfStyles.taskList}>
{children}
</View>
),
taskItem: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const checked = node.attrs?.checked === true;
return (
<View key={ctx.getKey()} style={pdfStyles.taskItem} wrap={false}>
<View style={checked ? [pdfStyles.taskCheckbox, pdfStyles.taskCheckboxChecked] : pdfStyles.taskCheckbox}>
{checked && <CheckIcon size={8} color="#ffffff" />}
</View>
<View style={pdfStyles.listItemContent}>{children}</View>
</View>
);
},
table: (_node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
<View key={ctx.getKey()} style={pdfStyles.table}>
{children}
</View>
),
tableRow: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const isHeader = node.attrs?._isHeader === true;
return (
<View key={ctx.getKey()} style={isHeader ? pdfStyles.tableHeaderRow : pdfStyles.tableRow} wrap={false}>
{children}
</View>
);
},
tableHeader: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const colwidth = node.attrs?.colwidth as number[] | undefined;
const background = node.attrs?.background as string | undefined;
const width = colwidth?.[0];
const widthStyle = width ? { width, flex: undefined } : {};
const resolvedBgColor = background ? resolveColorForPdf(background, "background") : null;
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
return (
<View key={ctx.getKey()} style={[pdfStyles.tableHeaderCell, widthStyle, bgStyle]}>
{children}
</View>
);
},
tableCell: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const colwidth = node.attrs?.colwidth as number[] | undefined;
const background = node.attrs?.background as string | undefined;
const width = colwidth?.[0];
const widthStyle = width ? { width, flex: undefined } : {};
const resolvedBgColor = background ? resolveColorForPdf(background, "background") : null;
const bgStyle = resolvedBgColor ? { backgroundColor: resolvedBgColor } : {};
return (
<View key={ctx.getKey()} style={[pdfStyles.tableCell, widthStyle, bgStyle]}>
{children}
</View>
);
},
horizontalRule: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
<View key={ctx.getKey()} style={pdfStyles.horizontalRule} />
),
hardBreak: (_node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => (
<Text key={ctx.getKey()}>{"\n"}</Text>
),
image: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
if (ctx.metadata?.noAssets) {
return <View key={ctx.getKey()} />;
}
const src = (node.attrs?.src as string) || "";
const width = node.attrs?.width as number | undefined;
const alignment = (node.attrs?.alignment as string) || "left";
if (!src) {
return <View key={ctx.getKey()} />;
}
const alignmentStyle =
alignment === "center"
? { alignItems: "center" as const }
: alignment === "right"
? { alignItems: "flex-end" as const }
: { alignItems: "flex-start" as const };
return (
<View key={ctx.getKey()} style={[{ width: "100%" }, alignmentStyle]}>
<Image
src={src}
style={[pdfStyles.image, width ? { width, maxHeight: 500 } : { maxWidth: 400, maxHeight: 500 }]}
/>
</View>
);
},
imageComponent: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
if (ctx.metadata?.noAssets) {
return <View key={ctx.getKey()} />;
}
const assetId = (node.attrs?.src as string) || "";
const rawWidth = node.attrs?.width;
const width = typeof rawWidth === "string" ? parseInt(rawWidth, 10) : (rawWidth as number | undefined);
const alignment = (node.attrs?.alignment as string) || "left";
if (!assetId) {
return <View key={ctx.getKey()} />;
}
let resolvedSrc = assetId;
if (ctx.metadata?.resolvedImageUrls && ctx.metadata.resolvedImageUrls[assetId]) {
resolvedSrc = ctx.metadata.resolvedImageUrls[assetId];
}
const alignmentStyle =
alignment === "center"
? { alignItems: "center" as const }
: alignment === "right"
? { alignItems: "flex-end" as const }
: { alignItems: "flex-start" as const };
if (!resolvedSrc.startsWith("http") && !resolvedSrc.startsWith("data:")) {
return (
<View key={ctx.getKey()} style={[pdfStyles.imagePlaceholder, alignmentStyle]}>
<Text style={pdfStyles.imagePlaceholderText}>[Image: {assetId.slice(0, 8)}...]</Text>
</View>
);
}
const imageStyle = width && !isNaN(width) ? { width, maxHeight: 500 } : { maxWidth: 400, maxHeight: 500 };
return (
<View key={ctx.getKey()} style={[{ width: "100%" }, alignmentStyle]}>
<Image src={resolvedSrc} style={[pdfStyles.image, imageStyle]} />
</View>
);
},
calloutComponent: (node: TipTapNode, children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const backgroundKey = (node.attrs?.["data-background"] as string) || "gray";
const backgroundColor =
EDITOR_BACKGROUND_COLORS[backgroundKey as keyof typeof EDITOR_BACKGROUND_COLORS] || BACKGROUND_COLORS.layer3;
return (
<View key={ctx.getKey()} style={[pdfStyles.callout, { backgroundColor }]}>
<View style={pdfStyles.calloutIconContainer}>{getCalloutIcon(node, TEXT_COLORS.primary)}</View>
<View style={[pdfStyles.calloutContent, { color: TEXT_COLORS.primary }]}>{children}</View>
</View>
);
},
mention: (node: TipTapNode, _children: ReactElement[], ctx: PDFRenderContext): ReactElement => {
const id = (node.attrs?.id as string) || "";
const entityIdentifier = (node.attrs?.entity_identifier as string) || "";
const entityName = (node.attrs?.entity_name as string) || "";
let displayText = entityName || id || entityIdentifier;
if (ctx.metadata && (entityName === "user_mention" || entityName === "user")) {
const userMention = ctx.metadata.userMentions?.find((u) => u.id === entityIdentifier || u.id === id);
if (userMention) {
displayText = userMention.display_name;
}
}
return (
<Text key={ctx.getKey()} style={pdfStyles.mention}>
@{displayText}
</Text>
);
},
};
type InternalRenderContext = {
parentType?: string;
nestingLevel: number;
listItemIndex: number;
textAlign?: string | null;
pdfContext: PDFRenderContext;
};
const renderNodeWithContext = (node: TipTapNode, context: InternalRenderContext): ReactElement => {
const { parentType, nestingLevel, listItemIndex, textAlign, pdfContext } = context;
const isListContainer = node.type === CORE_EXTENSIONS.BULLET_LIST || node.type === CORE_EXTENSIONS.ORDERED_LIST;
let childTextAlign = textAlign;
if (node.type === CORE_EXTENSIONS.PARAGRAPH && node.attrs?.textAlign) {
childTextAlign = node.attrs.textAlign as string;
}
const nodeWithContext = {
...node,
attrs: {
...node.attrs,
_parentType: parentType,
_nestingLevel: nestingLevel,
_listItemIndex: listItemIndex,
_textAlign: childTextAlign,
_isHeader: node.content?.some((child) => child.type === CORE_EXTENSIONS.TABLE_HEADER),
},
};
let childNestingLevel = nestingLevel;
if (isListContainer && parentType === CORE_EXTENSIONS.LIST_ITEM) {
childNestingLevel = nestingLevel + 1;
}
let currentListItemIndex = 0;
const children: ReactElement[] =
node.content?.map((child) => {
const childContext: InternalRenderContext = {
parentType: node.type,
nestingLevel: childNestingLevel,
listItemIndex: 0,
textAlign: childTextAlign,
pdfContext,
};
if (isListContainer && child.type === CORE_EXTENSIONS.LIST_ITEM) {
currentListItemIndex++;
childContext.listItemIndex = currentListItemIndex;
}
return renderNodeWithContext(child, childContext);
}) || [];
const renderer = nodeRenderers[node.type];
if (renderer) {
return renderer(nodeWithContext, children, pdfContext);
}
if (children.length > 0) {
return <View key={pdfContext.getKey()}>{children}</View>;
}
return <View key={pdfContext.getKey()} />;
};
export const renderNode = (
node: TipTapNode,
parentType?: string,
_index?: number,
metadata?: PDFExportMetadata,
getKey?: KeyGenerator
): ReactElement => {
const keyGen = getKey ?? createKeyGenerator();
return renderNodeWithContext(node, {
parentType,
nestingLevel: 0,
listItemIndex: 0,
pdfContext: { getKey: keyGen, metadata },
});
};
@@ -1,82 +0,0 @@
import { createRequire } from "module";
import path from "path";
import { Document, Font, Page, pdf, Text } from "@react-pdf/renderer";
import { createKeyGenerator, renderNode } from "./node-renderers";
import { pdfStyles } from "./styles";
import type { PDFExportOptions, TipTapDocument } from "./types";
// Use createRequire for ESM compatibility to resolve font file paths
const require = createRequire(import.meta.url);
// Resolve local font file paths from @fontsource/inter package
const interFontDir = path.dirname(require.resolve("@fontsource/inter/package.json"));
Font.register({
family: "Inter",
fonts: [
{
src: path.join(interFontDir, "files/inter-latin-400-normal.woff"),
fontWeight: 400,
},
{
src: path.join(interFontDir, "files/inter-latin-400-italic.woff"),
fontWeight: 400,
fontStyle: "italic",
},
{
src: path.join(interFontDir, "files/inter-latin-600-normal.woff"),
fontWeight: 600,
},
{
src: path.join(interFontDir, "files/inter-latin-600-italic.woff"),
fontWeight: 600,
fontStyle: "italic",
},
{
src: path.join(interFontDir, "files/inter-latin-700-normal.woff"),
fontWeight: 700,
},
{
src: path.join(interFontDir, "files/inter-latin-700-italic.woff"),
fontWeight: 700,
fontStyle: "italic",
},
],
});
export const createPdfDocument = (doc: TipTapDocument, options: PDFExportOptions = {}) => {
const { title, author, subject, pageSize = "A4", pageOrientation = "portrait", metadata, noAssets } = options;
// Merge noAssets into metadata for use in node renderers
const mergedMetadata = { ...metadata, noAssets };
const content = doc.content || [];
const getKey = createKeyGenerator();
const renderedContent = content.map((node, index) => renderNode(node, "doc", index, mergedMetadata, getKey));
return (
<Document title={title} author={author} subject={subject}>
<Page size={pageSize} orientation={pageOrientation} style={pdfStyles.page}>
{title && <Text style={pdfStyles.title}>{title}</Text>}
{renderedContent}
</Page>
</Document>
);
};
export const renderPlaneDocToPdfBuffer = async (
doc: TipTapDocument,
options: PDFExportOptions = {}
): Promise<Buffer> => {
const pdfDocument = createPdfDocument(doc, options);
const pdfInstance = pdf(pdfDocument);
const blob = await pdfInstance.toBlob();
const arrayBuffer = await blob.arrayBuffer();
return Buffer.from(arrayBuffer);
};
export const renderPlaneDocToPdfBlob = async (doc: TipTapDocument, options: PDFExportOptions = {}): Promise<Blob> => {
const pdfDocument = createPdfDocument(doc, options);
const pdfInstance = pdf(pdfDocument);
return await pdfInstance.toBlob();
};
-245
View File
@@ -1,245 +0,0 @@
import { StyleSheet } from "@react-pdf/renderer";
import {
BACKGROUND_COLORS,
BORDER_COLORS,
BRAND_COLORS,
CODE_COLORS,
LINK_COLORS,
MENTION_COLORS,
NEUTRAL_COLORS,
TEXT_COLORS,
} from "./colors";
export const pdfStyles = StyleSheet.create({
page: {
padding: 40,
fontFamily: "Inter",
fontSize: 11,
lineHeight: 1.6,
color: TEXT_COLORS.primary,
},
title: {
fontSize: 24,
fontWeight: 600,
marginBottom: 20,
color: TEXT_COLORS.primary,
},
heading1: {
fontSize: 20,
fontWeight: 600,
marginTop: 16,
marginBottom: 8,
color: TEXT_COLORS.primary,
},
heading2: {
fontSize: 16,
fontWeight: 600,
marginTop: 14,
marginBottom: 6,
color: TEXT_COLORS.primary,
},
heading3: {
fontSize: 14,
fontWeight: 600,
marginTop: 12,
marginBottom: 4,
color: TEXT_COLORS.primary,
},
heading4: {
fontSize: 12,
fontWeight: 600,
marginTop: 10,
marginBottom: 4,
color: TEXT_COLORS.secondary,
},
heading5: {
fontSize: 11,
fontWeight: 600,
marginTop: 8,
marginBottom: 4,
color: TEXT_COLORS.secondary,
},
heading6: {
fontSize: 10,
fontWeight: 600,
marginTop: 6,
marginBottom: 4,
color: TEXT_COLORS.tertiary,
},
paragraph: {
marginBottom: 0,
},
paragraphWrapper: {
marginBottom: 8,
},
blockquote: {
borderLeftWidth: 3,
borderLeftColor: BORDER_COLORS.strong, // Matches .ProseMirror blockquote border-strong
paddingLeft: 12,
marginLeft: 0,
marginVertical: 8,
fontStyle: "normal", // Matches editor: font-style: normal
fontWeight: 400, // Matches editor: font-weight: 400
color: TEXT_COLORS.primary,
breakInside: "avoid",
},
codeBlock: {
backgroundColor: BACKGROUND_COLORS.layer1, // bg-layer-1 equivalent
padding: 12,
borderRadius: 4,
fontFamily: "Courier",
fontSize: 10,
marginVertical: 8,
color: TEXT_COLORS.primary,
breakInside: "avoid",
},
codeInline: {
backgroundColor: BACKGROUND_COLORS.layer1,
padding: 2,
paddingHorizontal: 4,
borderRadius: 2,
fontFamily: "Courier",
fontSize: 10,
color: CODE_COLORS.text, // Red for inline code
},
bulletList: {
marginVertical: 8,
paddingLeft: 0,
},
orderedList: {
marginVertical: 8,
paddingLeft: 0,
},
listItem: {
display: "flex",
flexDirection: "row",
gap: 6,
marginBottom: 4,
paddingRight: 10,
breakInside: "avoid",
},
listItemBullet: {},
listItemContent: {
flex: 1,
},
taskList: {
marginVertical: 8,
},
taskItem: {
display: "flex",
flexDirection: "row",
gap: 6,
marginBottom: 4,
alignItems: "flex-start",
paddingRight: 10,
breakInside: "avoid",
},
taskCheckbox: {
width: 12,
height: 12,
borderWidth: 1,
borderColor: BORDER_COLORS.strong, // Matches editor: border-strong
borderRadius: 2,
marginTop: 2,
alignItems: "center",
justifyContent: "center",
},
taskCheckboxChecked: {
backgroundColor: BRAND_COLORS.default, // --background-color-accent-primary
borderColor: BRAND_COLORS.default, // --border-color-accent-strong
},
table: {
marginVertical: 8,
borderWidth: 1,
borderColor: BORDER_COLORS.subtle1, // border-subtle-1
},
tableRow: {
flexDirection: "row",
borderBottomWidth: 1,
borderBottomColor: BORDER_COLORS.subtle1,
breakInside: "avoid",
},
tableHeaderRow: {
backgroundColor: BACKGROUND_COLORS.surface2, // Slightly different from white
flexDirection: "row",
borderBottomWidth: 1,
borderBottomColor: BORDER_COLORS.subtle1,
},
tableCell: {
padding: 8,
borderRightWidth: 1,
borderRightColor: BORDER_COLORS.subtle1,
flex: 1,
},
tableHeaderCell: {
padding: 8,
borderRightWidth: 1,
borderRightColor: BORDER_COLORS.subtle1,
flex: 1,
fontWeight: "bold",
},
horizontalRule: {
borderBottomWidth: 1,
borderBottomColor: BORDER_COLORS.subtle1, // Matches div[data-type="horizontalRule"] border-subtle-1
marginVertical: 16,
},
image: {
maxWidth: "100%",
marginVertical: 8,
},
imagePlaceholder: {
backgroundColor: BACKGROUND_COLORS.layer1,
padding: 16,
borderRadius: 4,
marginVertical: 8,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: BORDER_COLORS.subtle,
borderStyle: "dashed",
},
imagePlaceholderText: {
color: TEXT_COLORS.tertiary,
fontSize: 10,
},
callout: {
backgroundColor: BACKGROUND_COLORS.layer3, // bg-layer-3 (default callout background)
padding: 12,
borderRadius: 6,
marginVertical: 8,
flexDirection: "row",
alignItems: "flex-start",
breakInside: "avoid",
},
calloutIconContainer: {
marginRight: 10,
marginTop: 2,
},
calloutContent: {
flex: 1,
color: TEXT_COLORS.primary, // text-primary
},
mention: {
backgroundColor: MENTION_COLORS.background, // bg-accent-primary/20 equivalent
color: MENTION_COLORS.text, // text-accent-primary
padding: 2,
paddingHorizontal: 4,
borderRadius: 2,
},
link: {
color: LINK_COLORS.primary, // --txt-link-primary
textDecoration: "underline",
},
bold: {
fontWeight: "bold",
},
italic: {
fontStyle: "italic",
},
underline: {
textDecoration: "underline",
},
strike: {
textDecoration: "line-through",
},
});
-67
View File
@@ -1,67 +0,0 @@
import type { Style } from "@react-pdf/types";
export type TipTapMark = {
type: string;
attrs?: Record<string, unknown>;
};
export type TipTapNode = {
type: string;
attrs?: Record<string, unknown>;
content?: TipTapNode[];
text?: string;
marks?: TipTapMark[];
};
export type TipTapDocument = {
type: "doc";
content?: TipTapNode[];
};
export type KeyGenerator = () => string;
export type PDFRenderContext = {
getKey: KeyGenerator;
metadata?: PDFExportMetadata;
};
export type PDFNodeRenderer = (
node: TipTapNode,
children: React.ReactElement[],
context: PDFRenderContext
) => React.ReactElement;
export type PDFMarkRenderer = (mark: TipTapMark, currentStyle: Style) => Style;
export type NodeRendererRegistry = Record<string, PDFNodeRenderer>;
export type MarkRendererRegistry = Record<string, PDFMarkRenderer>;
export type PDFExportOptions = {
title?: string;
author?: string;
subject?: string;
pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
pageOrientation?: "portrait" | "landscape";
metadata?: PDFExportMetadata;
/** When true, images and other assets are excluded from the PDF */
noAssets?: boolean;
};
/**
* Metadata for resolving entity references in PDF export
*/
export type PDFExportMetadata = {
/** User mentions (user_mention in mention node) */
userMentions?: PDFUserMention[];
/** Resolved image URLs: Map of asset ID to presigned URL */
resolvedImageUrls?: Record<string, string>;
/** When true, images and other assets are excluded from the PDF */
noAssets?: boolean;
};
export type PDFUserMention = {
id: string;
display_name: string;
avatar_url?: string;
};
-65
View File
@@ -1,65 +0,0 @@
import { Schema } from "effect";
export const PdfExportRequestBody = Schema.Struct({
pageId: Schema.NonEmptyTrimmedString,
workspaceSlug: Schema.NonEmptyTrimmedString,
projectId: Schema.optional(Schema.NonEmptyTrimmedString),
title: Schema.optional(Schema.String),
author: Schema.optional(Schema.String),
subject: Schema.optional(Schema.String),
pageSize: Schema.optional(Schema.Literal("A4", "A3", "A2", "LETTER", "LEGAL", "TABLOID")),
pageOrientation: Schema.optional(Schema.Literal("portrait", "landscape")),
fileName: Schema.optional(Schema.String),
noAssets: Schema.optional(Schema.Boolean),
baseUrl: Schema.optional(Schema.String),
// API base URL for asset resolution (e.g., "https://plane.example.com/api" or "https://api.plane.example.com")
// Used to generate correct presigned URLs for images in self-hosted environments
apiBaseUrl: Schema.optional(Schema.String),
});
export type TPdfExportRequestBody = Schema.Schema.Type<typeof PdfExportRequestBody>;
export class PdfValidationError extends Schema.TaggedError<PdfValidationError>()("PdfValidationError", {
message: Schema.NonEmptyTrimmedString,
cause: Schema.optional(Schema.Unknown),
}) {}
export class PdfAuthenticationError extends Schema.TaggedError<PdfAuthenticationError>()("PdfAuthenticationError", {
message: Schema.NonEmptyTrimmedString,
}) {}
export class PdfContentFetchError extends Schema.TaggedError<PdfContentFetchError>()("PdfContentFetchError", {
message: Schema.NonEmptyTrimmedString,
cause: Schema.optional(Schema.Unknown),
}) {}
export class PdfMetadataFetchError extends Schema.TaggedError<PdfMetadataFetchError>()("PdfMetadataFetchError", {
message: Schema.NonEmptyTrimmedString,
source: Schema.Literal("user-mentions"),
cause: Schema.optional(Schema.Unknown),
}) {}
export class PdfImageProcessingError extends Schema.TaggedError<PdfImageProcessingError>()("PdfImageProcessingError", {
message: Schema.NonEmptyTrimmedString,
assetId: Schema.NonEmptyTrimmedString,
cause: Schema.optional(Schema.Unknown),
}) {}
export class PdfGenerationError extends Schema.TaggedError<PdfGenerationError>()("PdfGenerationError", {
message: Schema.NonEmptyTrimmedString,
cause: Schema.optional(Schema.Unknown),
}) {}
export class PdfTimeoutError extends Schema.TaggedError<PdfTimeoutError>()("PdfTimeoutError", {
message: Schema.NonEmptyTrimmedString,
operation: Schema.NonEmptyTrimmedString,
}) {}
export type PdfExportError =
| PdfValidationError
| PdfAuthenticationError
| PdfContentFetchError
| PdfMetadataFetchError
| PdfImageProcessingError
| PdfGenerationError
| PdfTimeoutError;
-153
View File
@@ -1,32 +1,9 @@
import type { AxiosError } from "axios";
import { logger } from "@plane/logger";
import type { TPage } from "@plane/types";
// services
import { AppError } from "@/lib/errors";
import { APIService } from "../api.service";
/**
* Type guard to check if an error is an Axios error with a response
*/
function isAxiosErrorWithResponse(
error: unknown
): error is AxiosError & { response: NonNullable<AxiosError["response"]> } {
return (
typeof error === "object" &&
error !== null &&
"isAxiosError" in error &&
(error as AxiosError).isAxiosError === true &&
"response" in error &&
(error as AxiosError).response !== undefined
);
}
export type TUserMention = {
id: string;
display_name: string;
avatar_url?: string;
};
export type TPageDescriptionPayload = {
description_binary: string;
description_html: string;
@@ -139,134 +116,4 @@ export abstract class PageCoreService extends APIService {
throw appError;
});
}
/**
* Fetches user mentions for a page
* @param pageId - The page ID
* @returns Array of user mentions
*/
async fetchUserMentions(pageId: string): Promise<TUserMention[]> {
try {
const response = await this.get(`${this.basePath}/pages/${pageId}/user-mentions/`, {
headers: this.getHeader(),
});
return (response?.data as TUserMention[]) || [];
} catch (error) {
logger.warn("Failed to fetch user mentions", {
pageId,
error: error instanceof Error ? error.message : String(error),
});
return [];
}
}
/**
* Resolves an image asset ID to its actual URL (presigned URL)
* @param workspaceSlug - The workspace slug
* @param assetId - The asset UUID
* @param projectId - Optional project ID for project-specific assets
* @param apiBaseUrl - Optional API base URL for generating correct presigned URLs
* @returns The resolved URL or null if resolution fails
*/
async resolveImageAssetUrl(
workspaceSlug: string,
assetId: string,
projectId?: string | null,
apiBaseUrl?: string
): Promise<string | null> {
const assetPath = projectId
? `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/?disposition=inline`
: `/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/?disposition=inline`;
try {
logger.debug("Resolving image asset URL", {
assetId,
workspaceSlug,
projectId: projectId ?? "(workspace-level)",
assetPath,
apiBaseUrl: apiBaseUrl ?? "(default)",
});
// If apiBaseUrl is provided and non-empty, use fetch directly to that URL
// This ensures the API sees the public host and generates correct presigned URLs
if (apiBaseUrl && apiBaseUrl.trim() !== "") {
const fullUrl = `${apiBaseUrl.replace(/\/$/, "")}${assetPath}`;
const response = await fetch(fullUrl, {
method: "GET",
headers: this.getHeader(),
redirect: "manual",
});
if (response.status === 302 || response.status === 301) {
const resolvedUrl = response.headers.get("location");
logger.debug("Image asset URL resolved (via apiBaseUrl)", {
assetId,
resolvedUrl: resolvedUrl ? `${resolvedUrl.substring(0, 80)}...` : null,
});
return resolvedUrl;
}
logger.warn("Unexpected response status when resolving asset URL", {
assetId,
status: response.status,
fullUrl,
});
return null;
}
// Fallback to axios-based request using internal API_BASE_URL
const path = projectId
? `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/?disposition=inline`
: `/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/?disposition=inline`;
const response = await this.get(path, {
headers: this.getHeader(),
maxRedirects: 0,
validateStatus: (status: number) => status >= 200 && status < 400,
});
// If we get a 302, the Location header contains the presigned URL
if (response.status === 302 || response.status === 301) {
const resolvedUrl = response.headers?.location || null;
logger.debug("Image asset URL resolved", {
assetId,
resolvedUrl: resolvedUrl ? `${resolvedUrl.substring(0, 80)}...` : null,
});
return resolvedUrl;
}
logger.warn("Unexpected response status when resolving asset URL", {
assetId,
status: response.status,
apiPath: path,
});
return null;
} catch (error) {
// Axios throws on 3xx when maxRedirects is 0, so we need to handle the redirect from the error
if (isAxiosErrorWithResponse(error)) {
const { status, headers } = error.response;
if (status === 302 || status === 301) {
const resolvedUrl = (headers?.location as string) || null;
logger.debug("Image asset URL resolved (from redirect error)", {
assetId,
resolvedUrl: resolvedUrl ? `${resolvedUrl.substring(0, 80)}...` : null,
});
return resolvedUrl;
}
logger.error("Failed to resolve image asset URL", {
assetId,
workspaceSlug,
projectId: projectId ?? "(workspace-level)",
assetPath,
status,
error: error.message,
});
return null;
}
logger.error("Failed to resolve image asset URL", {
assetId,
workspaceSlug,
projectId: projectId ?? "(workspace-level)",
assetPath,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
}
@@ -1,50 +0,0 @@
import { Effect, Duration, Schedule, pipe } from "effect";
import { PdfTimeoutError } from "@/schema/pdf-export";
/**
* Wraps an effect with timeout and exponential backoff retry logic.
* Preserves the environment type R for proper dependency injection.
*/
export const withTimeoutAndRetry =
(operation: string, { timeoutMs = 5000, maxRetries = 2 }: { timeoutMs?: number; maxRetries?: number } = {}) =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | PdfTimeoutError, R> =>
effect.pipe(
Effect.timeoutFail({
duration: Duration.millis(timeoutMs),
onTimeout: () =>
new PdfTimeoutError({
message: `Operation "${operation}" timed out after ${timeoutMs}ms`,
operation,
}),
}),
Effect.retry(
pipe(
Schedule.exponential(Duration.millis(200)),
Schedule.compose(Schedule.recurs(maxRetries)),
Schedule.tapInput((error: E | PdfTimeoutError) =>
Effect.logWarning("PDF_EXPORT: Retrying operation", { operation, error })
)
)
)
);
/**
* Recovers from any error with a default fallback value.
* Logs the error before recovering.
*/
export const recoverWithDefault =
<A>(fallback: A) =>
<E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, never, R> =>
effect.pipe(
Effect.tapError((error) => Effect.logWarning("PDF_EXPORT: Operation failed, using fallback", { error })),
Effect.catchAll(() => Effect.succeed(fallback))
);
/**
* Wraps a promise-returning function with proper Effect error handling
*/
export const tryAsync = <A, E>(fn: () => Promise<A>, onError: (cause: unknown) => E): Effect.Effect<A, E> =>
Effect.tryPromise({
try: fn,
catch: onError,
});
@@ -1,3 +0,0 @@
export { PdfExportService, exportToPdf } from "./pdf-export.service";
export * from "./effect-utils";
export * from "./types";
@@ -1,383 +0,0 @@
import { Effect } from "effect";
import sharp from "sharp";
import { getAllDocumentFormatsFromDocumentEditorBinaryData } from "@plane/editor/lib";
import type { PDFExportMetadata, TipTapDocument } from "@/lib/pdf";
import { renderPlaneDocToPdfBuffer } from "@/lib/pdf";
import { getPageService } from "@/services/page/handler";
import type { TDocumentTypes } from "@/types";
import {
PdfContentFetchError,
PdfGenerationError,
PdfImageProcessingError,
PdfTimeoutError,
} from "@/schema/pdf-export";
import { withTimeoutAndRetry, recoverWithDefault, tryAsync } from "./effect-utils";
import type { PdfExportInput, PdfExportResult, PageContent, MetadataResult } from "./types";
const IMAGE_CONCURRENCY = 4;
const IMAGE_TIMEOUT_MS = 8000;
const CONTENT_FETCH_TIMEOUT_MS = 7000;
const PDF_RENDER_TIMEOUT_MS = 15000;
const IMAGE_MAX_DIMENSION = 1200;
type TipTapNode = {
type: string;
attrs?: Record<string, unknown>;
content?: TipTapNode[];
};
/**
* PDF Export Service
*/
export class PdfExportService extends Effect.Service<PdfExportService>()("PdfExportService", {
sync: () => ({
/**
* Determines document type
*/
getDocumentType: (_input: PdfExportInput): TDocumentTypes => {
return "project_page";
},
/**
* Extracts image asset IDs from document content
*/
extractImageAssetIds: (doc: TipTapNode): string[] => {
const assetIds: string[] = [];
const traverse = (node: TipTapNode) => {
if ((node.type === "imageComponent" || node.type === "image") && node.attrs?.src) {
const src = node.attrs.src as string;
if (src && !src.startsWith("http") && !src.startsWith("data:")) {
assetIds.push(src);
}
}
if (node.content) {
for (const child of node.content) {
traverse(child);
}
}
};
traverse(doc);
return [...new Set(assetIds)];
},
/**
* Fetches page content (description binary) and parses it
*/
fetchPageContent: (
pageService: ReturnType<typeof getPageService>,
pageId: string,
requestId: string
): Effect.Effect<PageContent, PdfContentFetchError | PdfTimeoutError> =>
Effect.gen(function* () {
yield* Effect.logDebug("PDF_EXPORT: Fetching page content", { requestId, pageId });
const descriptionBinary = yield* tryAsync(
() => pageService.fetchDescriptionBinary(pageId),
(cause) =>
new PdfContentFetchError({
message: "Failed to fetch page content",
cause,
})
).pipe(
withTimeoutAndRetry("fetch page content", {
timeoutMs: CONTENT_FETCH_TIMEOUT_MS,
maxRetries: 3,
})
);
if (!descriptionBinary) {
return yield* Effect.fail(
new PdfContentFetchError({
message: "Page content not found",
})
);
}
const binaryData = new Uint8Array(descriptionBinary);
const { contentJSON, titleHTML } = getAllDocumentFormatsFromDocumentEditorBinaryData(binaryData, true);
return {
contentJSON: contentJSON as TipTapDocument,
titleHTML: titleHTML || null,
descriptionBinary,
};
}),
/**
* Fetches user mentions for the page
*/
fetchUserMentions: (
pageService: ReturnType<typeof getPageService>,
pageId: string,
requestId: string
): Effect.Effect<MetadataResult> =>
Effect.gen(function* () {
yield* Effect.logDebug("PDF_EXPORT: Fetching user mentions", { requestId });
const userMentionsRaw = yield* tryAsync(
async () => {
if (pageService.fetchUserMentions) {
return await pageService.fetchUserMentions(pageId);
}
return [];
},
() => []
).pipe(recoverWithDefault([] as Array<{ id: string; display_name: string; avatar_url?: string }>));
return {
userMentions: userMentionsRaw.map((u) => ({
id: u.id,
display_name: u.display_name,
avatar_url: u.avatar_url,
})),
};
}),
/**
* Resolves and processes images for PDF embedding
*/
processImages: (
pageService: ReturnType<typeof getPageService>,
workspaceSlug: string,
projectId: string | undefined,
assetIds: string[],
requestId: string,
apiBaseUrl?: string,
baseUrl?: string
): Effect.Effect<Record<string, string>> =>
Effect.gen(function* () {
if (assetIds.length === 0) {
return {};
}
yield* Effect.logDebug("PDF_EXPORT: Processing images", {
requestId,
count: assetIds.length,
});
// Resolve URLs first - pass apiBaseUrl (or baseUrl as fallback) for correct presigned URL generation
const effectiveApiBaseUrl = apiBaseUrl && apiBaseUrl.trim() !== "" ? apiBaseUrl : baseUrl;
const resolvedUrlMap = yield* tryAsync(
async () => {
const urlMap = new Map<string, string>();
for (const assetId of assetIds) {
const url = await pageService.resolveImageAssetUrl?.(
workspaceSlug,
assetId,
projectId,
effectiveApiBaseUrl
);
if (url) urlMap.set(assetId, url);
}
return urlMap;
},
() => new Map<string, string>()
).pipe(recoverWithDefault(new Map<string, string>()));
if (resolvedUrlMap.size === 0) {
return {};
}
// Process each image
const processSingleImage = ([assetId, url]: [string, string]) =>
Effect.gen(function* () {
const response = yield* tryAsync(
() => fetch(url),
(cause) =>
new PdfImageProcessingError({
message: "Failed to fetch image",
assetId,
cause,
})
);
if (!response.ok) {
return yield* Effect.fail(
new PdfImageProcessingError({
message: `Image fetch returned ${response.status}`,
assetId,
})
);
}
const arrayBuffer = yield* tryAsync(
() => response.arrayBuffer(),
(cause) =>
new PdfImageProcessingError({
message: "Failed to read image body",
assetId,
cause,
})
);
const processedBuffer = yield* tryAsync(
() =>
sharp(Buffer.from(arrayBuffer))
.rotate()
.flatten({ background: { r: 255, g: 255, b: 255 } })
.resize(IMAGE_MAX_DIMENSION, IMAGE_MAX_DIMENSION, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 85 })
.toBuffer(),
(cause) =>
new PdfImageProcessingError({
message: "Failed to process image",
assetId,
cause,
})
);
const base64 = processedBuffer.toString("base64");
return [assetId, `data:image/jpeg;base64,${base64}`] as const;
}).pipe(
withTimeoutAndRetry(`process image ${assetId}`, {
timeoutMs: IMAGE_TIMEOUT_MS,
maxRetries: 1,
}),
Effect.tapError((error) =>
Effect.logWarning("PDF_EXPORT: Image processing failed", {
requestId,
assetId,
error,
})
),
Effect.catchAll(() => Effect.succeed(null as readonly [string, string] | null))
);
const entries = Array.from(resolvedUrlMap.entries());
const pairs = yield* Effect.forEach(entries, processSingleImage, {
concurrency: IMAGE_CONCURRENCY,
});
const filtered = pairs.filter((p): p is readonly [string, string] => p !== null);
return Object.fromEntries(filtered);
}),
/**
* Renders document to PDF buffer
*/
renderPdf: (
contentJSON: TipTapDocument,
metadata: PDFExportMetadata,
options: {
title?: string;
author?: string;
subject?: string;
pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
pageOrientation?: "portrait" | "landscape";
noAssets?: boolean;
},
requestId: string
): Effect.Effect<Buffer, PdfGenerationError | PdfTimeoutError> =>
Effect.gen(function* () {
yield* Effect.logDebug("PDF_EXPORT: Rendering PDF", { requestId });
const pdfBuffer = yield* tryAsync(
() =>
renderPlaneDocToPdfBuffer(contentJSON, {
title: options.title,
author: options.author,
subject: options.subject,
pageSize: options.pageSize,
pageOrientation: options.pageOrientation,
metadata,
noAssets: options.noAssets,
}),
(cause) =>
new PdfGenerationError({
message: "Failed to render PDF",
cause,
})
).pipe(withTimeoutAndRetry("render PDF", { timeoutMs: PDF_RENDER_TIMEOUT_MS, maxRetries: 0 }));
yield* Effect.logInfo("PDF_EXPORT: PDF rendered successfully", {
requestId,
size: pdfBuffer.length,
});
return pdfBuffer;
}),
}),
}) {}
/**
* Main export pipeline - orchestrates the entire PDF export process
* Separate function to avoid circular dependency in service definition
*/
export const exportToPdf = (
input: PdfExportInput
): Effect.Effect<PdfExportResult, PdfContentFetchError | PdfGenerationError | PdfTimeoutError, PdfExportService> =>
Effect.gen(function* () {
const service = yield* PdfExportService;
const { requestId, pageId, workspaceSlug, projectId, noAssets } = input;
yield* Effect.logInfo("PDF_EXPORT: Starting export", { requestId, pageId, workspaceSlug });
// Create page service
const documentType = service.getDocumentType(input);
const pageService = getPageService(documentType, {
workspaceSlug,
projectId: projectId || null,
cookie: input.cookie,
documentType,
userId: "",
});
// Fetch content
const content = yield* service.fetchPageContent(pageService, pageId, requestId);
// Extract image asset IDs
const imageAssetIds = service.extractImageAssetIds(content.contentJSON as TipTapNode);
// Fetch user mentions
let metadata = yield* service.fetchUserMentions(pageService, pageId, requestId);
// Process images if needed
if (!noAssets && imageAssetIds.length > 0) {
const resolvedImages = yield* service.processImages(
pageService,
workspaceSlug,
projectId,
imageAssetIds,
requestId,
input.apiBaseUrl,
input.baseUrl
);
metadata = { ...metadata, resolvedImageUrls: resolvedImages };
}
yield* Effect.logDebug("PDF_EXPORT: Metadata prepared", {
requestId,
userMentions: metadata.userMentions?.length ?? 0,
resolvedImages: Object.keys(metadata.resolvedImageUrls ?? {}).length,
});
// Render PDF
const documentTitle = input.title || content.titleHTML || undefined;
const pdfBuffer = yield* service.renderPdf(
content.contentJSON,
metadata,
{
title: documentTitle,
author: input.author,
subject: input.subject,
pageSize: input.pageSize,
pageOrientation: input.pageOrientation,
noAssets,
},
requestId
);
yield* Effect.logInfo("PDF_EXPORT: Export complete", {
requestId,
pageId,
size: pdfBuffer.length,
});
return {
pdfBuffer,
outputFileName: input.fileName || `page-${pageId}.pdf`,
pageId,
};
});
@@ -1,39 +0,0 @@
import type { TipTapDocument, PDFUserMention } from "@/lib/pdf";
export interface PdfExportInput {
readonly pageId: string;
readonly workspaceSlug: string;
readonly projectId?: string;
readonly title?: string;
readonly author?: string;
readonly subject?: string;
readonly pageSize?: "A4" | "A3" | "A2" | "LETTER" | "LEGAL" | "TABLOID";
readonly pageOrientation?: "portrait" | "landscape";
readonly fileName?: string;
readonly noAssets?: boolean;
readonly baseUrl?: string;
/** API base URL for asset resolution (e.g., "https://plane.example.com/api") */
readonly apiBaseUrl?: string;
readonly cookie: string;
readonly requestId: string;
}
export interface PdfExportResult {
readonly pdfBuffer: Buffer;
readonly outputFileName: string;
readonly pageId: string;
}
export interface PageContent {
readonly contentJSON: TipTapDocument;
readonly titleHTML: string | null;
readonly descriptionBinary: Buffer;
}
/**
* Metadata - includes user mentions
*/
export interface MetadataResult {
readonly userMentions: PDFUserMention[];
readonly resolvedImageUrls?: Record<string, string>;
}
+1
View File
@@ -1,3 +1,4 @@
// eslint-disable-next-line import/order
import { setupSentry } from "./instrument";
setupSentry();
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Hocuspocus } from "@hocuspocus/server";
import { createRealtimeEvent } from "@plane/editor";
import { logger } from "@plane/logger";
import type { HocusPocusServerContext } from "@/types";
import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
import { broadcastMessageToPage } from "./broadcast-message";
// Helper to broadcast error to frontend
@@ -10,7 +10,7 @@ export const broadcastError = async (
pageId: string,
errorMessage: string,
errorType: "fetch" | "store",
context: HocusPocusServerContext,
context: FetchPayloadWithContext["context"] | StorePayloadWithContext["context"],
errorCode?: "content_too_large" | "page_locked" | "page_archived",
shouldDisconnect?: boolean
) => {
+21
View File
@@ -0,0 +1,21 @@
export const generateTitleProsemirrorJson = (text: string) => {
return {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
...(text
? {
content: [
{
type: "text",
text,
},
],
}
: {}),
},
],
};
};
+1
View File
@@ -0,0 +1 @@
export * from "./document";

Some files were not shown because too many files have changed in this diff Show More