Compare commits

...

68 Commits

Author SHA1 Message Date
Palanikannan M 67d2a3277e fix: add better algorithm 2026-02-09 14:59:27 +05:30
sriram veeraghanta 81cea3256a release: v1.2.1 #8322 2025-12-12 16:36:50 +05:30
sriramveeraghanta 07f269e7f3 chore: version bump 2025-12-12 15:09:53 +05:30
sriramveeraghanta ce69644d53 chore(deps): upgrade next themes package 2025-12-12 13:43:47 +05:30
sriram veeraghanta bc37edbe9a release: v1.2.0 #8283 2025-12-11 20:18:14 +05:30
Akshat Jain 7451d5e125 [INFRA-288] fix: add missing reverse proxy rule for bucket in Caddyfile.ce (#8319)
* fix: add missing reverse proxy rule for bucket in Caddyfile.ce

* fix: correct typo in reverse proxy address in Caddyfile.aio.ce
2025-12-11 19:21:10 +05:30
Akshat Jain 58e48a995b [INFRA-287] Fix: AIO Docker Image build failure (#8311)
* refactor: update build scripts and supervisor configuration

* Changed Caddyfile source in build.sh to use Caddyfile.aio.ce
* Updated Dockerfile to copy web assets from the correct directory
* Modified supervisor.conf to remove web and admin program configurations, and updated space program command to use react-router-serve

* chore: remove obsolete Caddyfile.aio.ce configuration

* chore: clean up Dockerfile by removing redundant cache removal command

* fix: update live program command to use .mjs extension in supervisor configuration

* fix: update live program command in supervisor configuration to remove .mjs extension

* fix: update reverse proxy ports in Caddyfile.aio.ce configuration

* fix: correct reverse proxy addresses in Caddyfile.aio.ce configuration

* refactor: remove outdated reverse proxy address updates from build script
2025-12-11 18:06:27 +05:30
b-saikrishnakanth 7b41ffa08e [WEB-5624] chore: added webhook translations #8312 2025-12-11 17:12:19 +05:30
M. Palanikannan e0c97c5471 refactor: sanitize HTML function (#8307)
* refactor: replace isomorphic-dompurify with sanitize-html

* dompurify fixes

* more fixes with fallback and title

* build

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2025-12-11 13:30:31 +05:30
M. Palanikannan 76ebf395e6 fix: isomorphic dompurify #8301 2025-12-10 19:51:11 +05:30
M. Palanikannan 67dfe91890 fix missing package (#8300) 2025-12-10 19:29:36 +05:30
sriramveeraghanta b53016b449 chore: realtime updates fix 2025-12-10 19:13:36 +05:30
sriram veeraghanta be722f708d chore: moving star us button to the top navigation (#8289) 2025-12-10 15:12:49 +05:30
b-saikrishnakanth 43b3a7730e [WEB-5608] chore: Hide "Pro" Features in Community Edition (#8288)
* chore: Hide "Pro" Features in Community Edition

* refactor: remove time tracking feature and simplify project features list
2025-12-10 15:12:49 +05:30
Anmol Singh Bhatia df1a512a80 [WEB-5609] fix: extended sidebar item pin/unpin #8287 2025-12-10 13:48:58 +05:30
sriram veeraghanta a55253d242 chore: update package version 2025-12-09 23:35:52 +05:30
Anmol Singh Bhatia af939fca41 [WEB-5604] Label layout title column and content wrapper styling #8273 2025-12-09 18:37:46 +05:30
sriram veeraghanta a3e651854a fix: updated npmrc with hoist patterns (#8271) 2025-12-09 16:32:49 +05:30
Nikhil 2240ac0e74 [WEB-5583]feat: add avatar download and upload functionality in authentication adapter (#8247)
* feat: add avatar download and upload functionality in authentication adapter

- Implemented `download_and_upload_avatar` method to fetch and store user avatars from OAuth providers.
- Enhanced user data saving process to include avatar handling.
- Updated `S3Storage` class with a new `upload_file` method for direct file uploads to S3.

* feat: enhance avatar download functionality with size limit checks

- Added checks for content length before downloading avatar images to ensure they do not exceed the maximum allowed size.
- Implemented chunked downloading of avatar images to handle large files efficiently.
- Updated the upload process to return None if the upload fails, improving error handling.

* feat: improve avatar filename generation with content type handling

- Refactored avatar download logic to determine file extension based on the content type from the response headers.
- Removed redundant code for extension mapping, ensuring a cleaner implementation.
- Enhanced error handling by returning None for unsupported content types.

* fix: remove authorization header for avatar download

- Updated the avatar download logic to remove the Authorization header when token data is not present, ensuring compatibility with scenarios where authentication is not required.

* feat: add method for avatar download headers

- Introduced `get_avatar_download_headers` method to centralize header management for avatar downloads.
- Updated `download_and_upload_avatar` method to utilize the new header method, improving code clarity and maintainability.
2025-12-09 15:48:27 +05:30
Aaron 11e7bd115b docs: eslint (#8269) 2025-12-09 13:26:22 +05:30
Aaron 0ab94ed6d6 chore: run fixes (#8257)
* chore: run fixes

* fix: type, just use hocuspocusservercontext

* fix: codemod

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
2025-12-08 22:26:50 +05:30
Vipin Chaudhary a9e9cb2983 fix: activity tracking description (#8268)
* feat: add no_activity flag to control issue activity tracking during partial updates

* refactor: rename no_activity flag to skip_activity for clarity in issue activity tracking

* enhance description input handling with migration update support

* feat: implement skip_activity flag to conditionally log issue updates during partial updates

* refactor: skip-activity

* feat: add migration description update check to conditionally log issue updates

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-12-08 22:18:14 +05:30
Anmol Singh Bhatia f0bc2bd3bd [WEB-5600] chore: project identifier char limit updated and table layout enhancements (#8263) 2025-12-08 20:00:54 +05:30
Prateek Shourya 7659997b53 [WEB-5594] feat: enhance authentication method handling in member columns and introduce new login labels (#8260) 2025-12-08 18:25:48 +05:30
Prateek Shourya 316856a555 [WEB-5599] refactor: enhance Kanban swimlane components with improved props and structure (#8262) 2025-12-08 18:17:29 +05:30
Anmol Singh Bhatia f41e121e58 [WEB-5317] chore: enable multi-select for use case in onboarding flow (#8049)
* chore: update use_case type from string to array

* chore: convert use_case field to JSONField with array support

* feat: implement multi-select UI for use case in onboarding

* chore: code refactor

* chore: revert backend changes

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-12-08 15:48:15 +05:30
sriram veeraghanta 85daa1572c fix: upgrade turbo version (#8259) 2025-12-08 15:46:23 +05:30
Aaryan Khandelwal b359849606 [WIKI-840] chore: accept spaces while searching for mentions #8258 2025-12-08 15:16:22 +05:30
b-saikrishnakanth 5ea74e1112 [WEB-5591] chore: update discount info component to use local SVG assets #8250 2025-12-08 13:47:26 +05:30
Anmol Singh Bhatia d9ea119588 [WEB-5589] chore: intake icon updated #8249 2025-12-08 13:46:23 +05:30
Nikhil d34c7f9a0e [WEB-5254] chore: add bot user to create workspace seed data (#8225)
* chore: add bot user to workspace seed task

* refactor: use BotTypeEnum for bot type in workspace seed task

* refactor: update bot user display name and last name in workspace seed task

* fix: return empty dictionary for missing cycle and module seeds in workspace seed task
2025-12-05 18:03:58 +05:30
Nikhil f81e120328 chore(deps): upgrade psycopg packages to version 3.3.0 (#8222)
* chore(deps): upgrade psycopg packages to version 3.3.0

* chore: update Python version to 3.12.x in CI workflow

* refactor: clean up imports and improve code formatting across multiple files
2025-12-05 16:06:19 +05:30
Aaron 85d90030cf fix: eslint (#8185)
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2025-12-05 16:03:51 +05:30
M. Palanikannan 82c970ac4b [WIKI-804] fix: refactor image uploader (#8210)
* fix: refactor uploader

* fix: props

* fix: sites fix
2025-12-05 13:20:15 +05:30
Anmol Singh Bhatia 392c8cf2e1 [WEB-5582] chore: top nav and user menu improvement (#8245)
* chore: user menu code refactor

* chore: CustomizeNavigationDialog enhancements
2025-12-04 21:25:09 +05:30
sriram veeraghanta c3906048ea fix(CI): upgrade prettier oxc plugin version (#8242)
* fix: upgrade prettier oxc plugin version

* fix: type errors
2025-12-04 18:27:50 +05:30
Anmol Singh Bhatia 1090b3e938 [WEB-5573] refactor: app rail enhancements (#8239)
* chore: app rail context added

* chore: dock/undock app rail implementation

* chore: refactor

* chore: code refactor

* chore: code refactor
2025-12-04 18:14:59 +05:30
sriramveeraghanta fe867135c4 Merge branch 'preview' of github.com:makeplane/plane into canary 2025-12-04 16:35:46 +05:30
sriramveeraghanta 65166d19be fix: docker ngnix version 2025-12-04 16:35:19 +05:30
sriramveeraghanta 3bc85c8430 fix: docker pnpm installation setup 2025-12-04 16:30:42 +05:30
sriramveeraghanta cf84ade52c fix: turbo version upgrade in docker files 2025-12-04 16:11:59 +05:30
Anmol Singh Bhatia 9699a01fb8 [WEB-5572] fix storybook 500 error #8231 2025-12-04 15:37:14 +05:30
Prateek Shourya bc96d34cb7 [WEB-5576] fix: remove optionsClassName from email and workspace forms (#8240) 2025-12-04 15:36:39 +05:30
Vipin Chaudhary 739b1bfb75 fix: undo unique id (#8238) 2025-12-04 14:45:43 +05:30
Nikhil 22da26742c chore(deps): update Django version to 4.2.27 in base requirements (#8237) 2025-12-04 14:07:52 +05:30
Prateek Shourya 63f3a57022 chore: add permanent redirects for /spaces and /god-mode in Caddyfile (#8236) 2025-12-04 14:03:07 +05:30
Jayash Tripathy 36d42856e9 [WEB-5493] feat: implement static cover image handling and selection (#8184)
* feat: implement cover image handling and static image selection

- Added functionality to handle cover image uploads and selections in project and profile forms.
- Introduced a new helper for managing cover images, including static images and uploaded assets.
- Updated components to utilize the new cover image helper for displaying and processing cover images.
- Added a set of static cover images for selection in the image picker.
- Enhanced error handling for image uploads and processing.

This update improves the user experience by providing a more robust cover image management system.

* refactor: rename STATIC_COVER_IMAGES_ARRAY to STATIC_COVER_IMAGES for consistency

- Updated the cover image helper to export STATIC_COVER_IMAGES instead of STATIC_COVER_IMAGES_ARRAY.
- Adjusted the ImagePickerPopover component to utilize the renamed export for rendering static cover images.

* feat: enhance project creation and image handling

- Introduced default project form values with a random emoji for logo props.
- Updated cover image handling in various components, ensuring consistent usage of the new cover image helper.
- Refactored image picker to improve search functionality and loading states.
- Removed unused constants and streamlined cover image type checks for better clarity and performance.

This update enhances the user experience in project creation and image selection processes.

* refactor: simplify cover image type definition and clean up code

- Removed duplicate type from TCoverImageType, streamlining the definition.
- Cleaned up whitespace in the cover image helper for improved readability.

This update enhances code clarity and maintains consistency in cover image handling.

* refactor: update cover image type definitions and simplify logic

- Changed ICoverImageResult and ICoverImagePayload interfaces to type aliases for better clarity.
- Simplified the logic in getCoverImageDisplayURL function to enhance readability and maintainability.

This update improves the structure and clarity of the cover image helper code.

* refactor: remove unused project cover image endpoint and update cover image handling

- Removed the ProjectPublicCoverImagesEndpoint and its associated URL from the project.
- Updated the cover image handling in the cover-image helper to utilize imported assets instead of static paths.
- Cleaned up the ProjectFavoritesViewSet and FileService by removing the now obsolete getProjectCoverImages method.

This update streamlines the cover image management and eliminates unnecessary code, enhancing overall maintainability.

* refactor: update cover image imports to new asset structure

- Replaced static path imports for cover images with updated paths to the new asset structure.
- This change improves organization and maintainability of cover image assets in the project.

This update aligns with recent refactoring efforts to streamline cover image handling.

* feat: add additional cover images to the helper

- Imported new cover images (24 to 29) into the cover-image helper.
- This update expands the available cover image options for use in the project, enhancing visual variety.

* refactor: remove ProjectPublicCoverImagesEndpoint from project URLs and views

* refactor: update cover image imports to include URL query parameter

- Modified cover image imports in the cover-image helper to append a URL query parameter for better asset handling.
- This change enhances the way cover images are processed and utilized within the project.

* refactor: extract default project form values into a utility function

- Created a new utility function `getProjectFormValues` to encapsulate the default project form values.
- Updated the `CreateProjectForm` component to use this utility function for setting default form values, improving code organization and maintainability.

* feat: integrate project update functionality in CreateProjectForm

- Added `updateProject` method to the `CreateProjectForm` component for updating project cover images after creation.
- Enhanced cover image handling by ensuring the correct URL is set for both uploaded and existing cover images.
This update improves the project creation workflow and ensures cover images are accurately updated.

* fix: update documentation for cover image handling

- Corrected the comment regarding local static images to reflect that they are served from the assets folder instead of the public folder.
- This change ensures accurate documentation for the `getCoverImageType` and `getCoverImageDisplayURL` functions, improving clarity for future developers.

* feat: implement random cover image selection for project forms

- Replaced the default cover image URL with a new utility function `getRandomCoverImage` that selects a random cover image from the available options.
- Updated the `getProjectFormValues` function to utilize this new method, enhancing the project creation experience with varied cover images.

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2025-12-03 16:20:38 +05:30
Anmol Singh Bhatia 105ac5ece5 [WEB-5569] chore: top nav search enhancements (#8226)
* chore: top nav power k search menu enhancements

* chore: expandable search panel refactor
2025-12-03 16:09:12 +05:30
Sangeetha b8a41ad5a0 [WEB-5560] fix: restrict guest users to view all details of a workspace members (#8215)
* fix: separate retrieve method in WorkspaceMemberViewSet

* fix: non project members accessing member detail:

* chore: error handle

* fix: role based response

* fix: use Enum
2025-12-03 16:06:46 +05:30
Prateek Shourya 7c74d0a403 [WEB-5290] feat: selfhosted check (#8227)
* feat: add in common py

* fix: update marketing consent screen based on is self managed flag

* improvement: enhance ImagePickerPopover with dynamic tab options based on Unsplash configuration

* refactor: product updates modal to include changelog

* [WEB-5290] feat: implement fallback for product updates changelog with loading state and error handling

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-12-03 15:53:42 +05:30
Gustavo de Oliveira 5f7ffcb37a - Add SIGNED_URL_EXPIRATION environment variable (#8136)
- Update S3Storage to use configurable expiration time
  - Default remains 3600 seconds (1 hour) for backward compatibility
  - Add comprehensive unit tests with mocked S3 client
  - Update .env.example with documentation and examples
2025-12-03 10:52:19 +05:30
Samuel Torres 584a1aa725 fix: PATCH endpoint project estimate validation (#7872) 2025-12-03 01:44:31 +05:30
Saurabh Kumar 1e1520b85f [SILO-699] chore: add check for feature enabled for module and cycle create (#8146)
* add check for feature enabled for module and cycle create

* add more checks
2025-12-03 01:26:52 +05:30
b-saikrishnakanth 0bc45e3047 [WEB-5503] fix: enhance error handling for label creation and update operations (#8175)
* fix: enhance error handling for label creation and update operations

* fix: improve error handling for label creation and update operations

* refactor: change error codes from enum to object for label operations

* refactor: update error code references in label handling to use consistent naming

* fix: improve error handling for label creation and update
2025-12-03 00:05:44 +05:30
Prateek Shourya cacd1b489e [WEB-5559] improvement: chat support functionality and remove Intercom provider (#8217)
* [WEB-5559] improve: chat support functionality and remove Intercom provider

- Added ChatSupportModal component for chat support integration.
- Replaced IntercomProvider with ChatSupportModal in AppProvider.
- Introduced useChatSupport hook to manage chat support state and actions.
- Updated help commands to utilize chat support instead of Intercom.
- Removed obsolete Intercom-related components and hooks.

* refactor: lazy load ChatSupportModal in AppProvider
2025-12-03 00:03:56 +05:30
Anmol Singh Bhatia e650b19933 [WEB-5556] chore: tab navigation and sidebar improvements (#8218) 2025-12-02 18:05:01 +05:30
sriram veeraghanta ec478a8979 chore: standard replacement from dotenv to dotenvx (#8216) 2025-12-02 15:32:00 +05:30
sriramveeraghanta e07d91e857 fix: update express version in overrides 2025-12-02 15:09:25 +05:30
sriramveeraghanta 9ccb3e321a chore(deps): upgrade express version 2025-12-02 15:01:12 +05:30
sriramveeraghanta f2c046dff9 fix: update mdast-util-to-hast package in overrides 2025-12-02 14:58:06 +05:30
Anmol Singh Bhatia 2a378b3bc1 [WEB-5556] chore: tab navigation project header enhancement (#8212) 2025-12-02 14:35:19 +05:30
Sangeetha 8b0a797906 fix: import error (#8213) 2025-12-02 13:55:54 +05:30
Saurabh Kumar cea6f7530b [SILO-671] feat: add sticky external apis (#8139)
* add sticky external apis

* add created_at sort by to list

* remove select related method from query set
2025-12-01 18:57:54 +05:30
Nikhil a7e2e596bf [WEB-5518]: update magic code token to send code as 6 digit numbers (#8188)
* refactor: simplify token generation in MagicCodeProvider by using a numeric approach

* fix: update placeholder text for unique code input across multiple languages

* refactor: replace token generation with a numeric approach for user email updates

* fix: update placeholder text for unique code input in multiple languages to a numeric format

* refactor: replace random token generation with secrets for enhanced security in user email updates and magic code provider
2025-12-01 18:56:14 +05:30
Anmol Singh Bhatia 980428b204 [WEB-5536] feat: prevent search panels from reopening on programmatic focus restoration (#8207) 2025-12-01 18:37:06 +05:30
Anmol Singh Bhatia f428c3bdaf [WEB-5542] chore: scrollIntoView implementation for sidebar active project (#8208) 2025-12-01 17:43:05 +05:30
Prateek Shourya a05cd88a53 [WEB-5491] refactor: onboarding tour components (#8167) 2025-12-01 17:14:28 +05:30
Sangeetha 60220801ac [WEB-4338] fix: incorrect error code in project retrieve API (#7234)
* fix: project error message and status code

* fix: incorrect member role check

* fix: project error message and status code

* fix: improve project permission checks and error handling in ProjectViewSet

* feat: enhance project settings layout with better loading strategy and fix all flicker

* fix: prevent rendering during project loading in ProjectAuthWrapper

* refactor: adjust layout structure in ProjectDetailSettingsLayout and enhance access restriction logic in ProjectAccessRestriction

* refactor: replace ProjectAccessRestriction component with updated version and enhance error handling

- Deleted the old ProjectAccessRestriction component.
- Introduced a new ProjectAccessRestriction component with improved error handling and user prompts for joining projects.
- Updated translations for new error states in multiple languages.

* fix: enhance error handling in IssueDetailsPage and remove JoinProject component

---------

Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2025-12-01 17:14:01 +05:30
617 changed files with 15205 additions and 7917 deletions
@@ -31,7 +31,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
python-version: "3.12.x"
- name: Install Pylint
run: python -m pip install ruff
- name: Install API Dependencies
@@ -43,6 +43,9 @@ 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
@@ -51,6 +54,3 @@ 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,3 +111,4 @@ build/
.react-router/
AGENTS.md
temp/
scripts/
+1
View File
@@ -0,0 +1 @@
pnpm lint-staged
+45 -25
View File
@@ -1,34 +1,54 @@
# Enforce pnpm workspace behavior and allow Turbo's lifecycle hooks if scripts are disabled
# This repo uses pnpm with workspaces.
# ------------------------------
# Core Workspace Behavior
# ------------------------------
# Prefer linking local workspace packages when available
prefer-workspace-packages=true
link-workspace-packages=true
shared-workspace-lockfile=true
# Always prefer using local workspace packages when available
prefer-workspace-packages = true
# Make peer installs smoother across the monorepo
auto-install-peers=true
strict-peer-dependencies=false
# Symlink workspace packages instead of duplicating them
link-workspace-packages = 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)
# Use a single lockfile across the whole monorepo
shared-workspace-lockfile = true
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=prettier
public-hoist-pattern[]=typescript
# Ensure packages added from workspace save using workspace: protocol
save-workspace-protocol = true
# Reproducible installs across CI and dev
prefer-frozen-lockfile=true
# Prefer resolving to highest versions in monorepo to reduce duplication
resolution-mode=highest
# ------------------------------
# Dependency Resolution
# ------------------------------
# Speed up native module builds by caching side effects
side-effects-cache=true
# Choose the highest compatible version across the workspace
# → reduces fragmentation & node_modules bloat
resolution-mode = highest
# Speed up local dev by reusing local store when possible
prefer-offline=true
# Automatically install peer dependencies instead of forcing every package to declare them
auto-install-peers = true
# Ensure workspace protocol is used when adding internal deps
save-workspace-protocol=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
+10
View File
@@ -0,0 +1,10 @@
.next/
.react-router/
.turbo/
.vite/
build/
dist/
node_modules/
out/
pnpm-lock.yaml
storybook-static/
+11 -2
View File
@@ -1,6 +1,15 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"overrides": [
{
"files": ["packages/codemods/**/*"],
"options": {
"printWidth": 80
}
}
],
"plugins": ["@prettier/plugin-oxc"],
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5",
"plugins": ["@prettier/plugin-oxc"]
"trailingComma": "es5"
}
+1
View File
@@ -0,0 +1 @@
eslint.config.mjs @lifeiscontent
+10 -8
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 use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
- 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`.
## Ways to contribute
@@ -187,18 +187,19 @@ 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**
@@ -210,6 +211,7 @@ Adding a new language involves several steps to ensure it integrates seamlessly
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
@@ -1,14 +0,0 @@
.next/*
.react-router/*
.vite/*
out/*
public/*
dist/*
node_modules/*
.turbo/*
.env*
.env
.env.local
.env.development
.env.production
.env.test
-18
View File
@@ -1,18 +0,0 @@
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,
},
],
},
};
+9 -7
View File
@@ -1,8 +1,10 @@
.next
.react-router
.vite
.vercel
.tubro
out/
dist/
.next/
.react-router/
.turbo/
.vite/
build/
dist/
node_modules/
out/
pnpm-lock.yaml
storybook-static/
-6
View File
@@ -1,6 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5",
"plugins": ["@prettier/plugin-oxc"]
}
+6 -4
View File
@@ -13,7 +13,7 @@ RUN corepack enable pnpm
FROM base AS builder
RUN pnpm add -g turbo@2.5.8
RUN pnpm add -g turbo@2.6.3
COPY . .
@@ -62,10 +62,12 @@ COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
# 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 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
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
@@ -73,7 +75,7 @@ RUN pnpm turbo run build --filter=admin
# =========================================================================== #
FROM nginx:1.27-alpine AS production
FROM nginx:1.29-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,7 +163,6 @@ 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,7 +177,6 @@ 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}>
+3 -6
View File
@@ -26,7 +26,7 @@ export enum EErrorAlertType {
}
const errorCodeMessages: {
[key in EAdminAuthErrorCodes]: { title: string; message: (email?: string | undefined) => React.ReactNode };
[key in EAdminAuthErrorCodes]: { title: string; message: (email?: string) => React.ReactNode };
} = {
// admin
[EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST]: {
@@ -79,14 +79,11 @@ 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 | undefined
): TAdminAuthErrorInfo | undefined => {
export const authErrorHandler = (errorCode: EAdminAuthErrorCodes, email?: string): TAdminAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
-1
View File
@@ -1,4 +1,3 @@
/* eslint-disable import/order */
import * as Sentry from "@sentry/react-router";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
@@ -11,6 +11,7 @@ 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,6 +24,7 @@ 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
+7 -9
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "1.1.0",
"version": "1.2.1",
"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 19",
"check:lint": "eslint . --max-warnings=485",
"check:types": "react-router typegen && tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\""
"check:format": "prettier --check .",
"fix:lint": "eslint . --fix --max-warnings=485",
"fix:format": "prettier --write ."
},
"dependencies": {
"@bprogress/core": "catalog:",
@@ -37,7 +37,7 @@
"lucide-react": "catalog:",
"mobx": "catalog:",
"mobx-react": "catalog:",
"next-themes": "^0.2.1",
"next-themes": "0.4.6",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "7.51.5",
@@ -48,16 +48,14 @@
"uuid": "catalog:"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@dotenvx/dotenvx": "catalog:",
"@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,2 +1 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
module.exports = require("@plane/tailwind-config/postcss.config.js");
+21
View File
@@ -296,6 +296,10 @@ body {
}
/* scrollbar style */
::-webkit-scrollbar {
display: none;
}
@-moz-document url-prefix() {
* {
scrollbar-width: none;
@@ -356,6 +360,23 @@ 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,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const sharedConfig = require("@plane/tailwind-config/tailwind.config.js");
module.exports = {
+13 -8
View File
@@ -1,16 +1,21 @@
{
"extends": "@plane/typescript-config/react-router.json",
"compilerOptions": {
"baseUrl": ".",
"noImplicitOverride": false,
"exactOptionalPropertyTypes": false,
"noUnusedParameters": false,
"noUnusedLocals": false,
"rootDirs": [".", "./.react-router/types"],
"types": ["node", "vite/client"],
"types": ["vite/client"],
"paths": {
"@/app/*": ["app/*"],
"@/*": ["core/*"],
"@/plane-admin/*": ["ce/*"],
"@/styles/*": ["styles/*"]
},
"strictNullChecks": true
"package.json": ["./package.json"],
"ce/*": ["./ce/*"],
"@/app/*": ["./app/*"],
"@/*": ["./core/*"],
"@/plane-admin/*": ["./ce/*"],
"@/ce/*": ["./ce/*"],
"@/styles/*": ["./styles/*"]
}
},
"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,6 +32,9 @@ 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
@@ -0,0 +1,10 @@
.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.1.0",
"version": "1.2.1",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+2 -1
View File
@@ -54,4 +54,5 @@ from .asset import (
FileAssetSerializer,
)
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
from .member import ProjectMemberSerializer
from .sticky import StickySerializer
+13 -8
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
from plane.db.models import Cycle, CycleIssue, User, Project
from plane.utils.timezone_converter import convert_to_utc
@@ -55,6 +55,18 @@ 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
@@ -63,13 +75,6 @@ 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,6 +10,7 @@ from plane.db.models import (
ModuleMember,
ModuleIssue,
ProjectMember,
Project,
)
@@ -53,6 +54,14 @@ 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")).exists()
and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate").id).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
@@ -0,0 +1,30 @@
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,6 +9,7 @@ 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,
@@ -22,4 +23,5 @@ urlpatterns = [
*user_patterns,
*work_item_patterns,
*invite_patterns,
*sticky_patterns,
]
+5 -1
View File
@@ -1,6 +1,10 @@
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
@@ -0,0 +1,12 @@
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)),
]
+3 -1
View File
@@ -54,4 +54,6 @@ from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpo
from .user import UserEndpoint
from .invite import WorkspaceInvitationsViewset
from .invite import WorkspaceInvitationsViewset
from .sticky import StickyViewSet
-2
View File
@@ -13,8 +13,6 @@ 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,9 +65,7 @@ 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
@@ -0,0 +1,109 @@
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,7 +11,6 @@ from plane.app.views import (
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
UserProjectInvitationsViewset,
ProjectPublicCoverImagesEndpoint,
UserProjectRolesEndpoint,
ProjectArchiveUnarchiveEndpoint,
ProjectMemberPreferenceEndpoint,
@@ -106,11 +105,6 @@ 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,7 +3,6 @@ from .project.base import (
ProjectIdentifierEndpoint,
ProjectUserViewsEndpoint,
ProjectFavoritesViewSet,
ProjectPublicCoverImagesEndpoint,
DeployBoardViewSet,
ProjectArchiveUnarchiveEndpoint,
)
+26 -19
View File
@@ -322,6 +322,9 @@ 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,
@@ -418,26 +421,30 @@ 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 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 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 intake_serializer:
intake_serializer.save()
+34 -26
View File
@@ -611,6 +611,10 @@ 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(
@@ -659,32 +663,36 @@ class IssueViewSet(BaseViewSet):
serializer = IssueCreateSerializer(issue, data=request.data, partial=True, context={"project_id": project_id})
if serializer.is_valid():
serializer.save()
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,
)
# 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,
)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+29 -65
View File
@@ -1,43 +1,44 @@
# Python imports
import boto3
from django.conf import settings
from django.utils import timezone
import json
import boto3
# Django imports
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
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
# 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.views.base import BaseViewSet, BaseAPIView
from plane.app.permissions import ROLE, ProjectMemberPermission, allow_permission
from plane.app.serializers import (
ProjectSerializer,
ProjectListSerializer,
DeployBoardSerializer,
ProjectListSerializer,
ProjectSerializer,
)
from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE
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.db.models import (
UserFavorite,
Intake,
DeployBoard,
Intake,
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
@@ -210,19 +211,25 @@ 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(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
.filter(archived_at__isnull=True)
.filter(pk=pk)
).first()
project = self.get_queryset().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,
@@ -519,49 +526,6 @@ 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,6 +164,40 @@ 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)
+26 -63
View File
@@ -129,9 +129,7 @@ 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):
@@ -155,9 +153,7 @@ 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):
@@ -177,9 +173,7 @@ 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())),
)
)
@@ -196,20 +190,16 @@ 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):
@@ -233,9 +223,7 @@ 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):
@@ -294,9 +282,7 @@ 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())
@@ -306,9 +292,7 @@ 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)
@@ -320,7 +304,6 @@ 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 = {}
@@ -367,14 +350,10 @@ 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])
@@ -389,15 +368,12 @@ 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)
@@ -456,20 +432,16 @@ 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(
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
@@ -587,9 +559,7 @@ 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)
@@ -603,15 +573,12 @@ 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)
@@ -668,20 +635,16 @@ 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(
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
+2 -10
View File
@@ -2,8 +2,6 @@
import uuid
import json
import logging
import random
import string
import secrets
# Django imports
@@ -151,13 +149,7 @@ 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 = (
"".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))
)
token = str(secrets.randbelow(900000) + 100000)
# Store in cache with 10 minute expiration
cache_data = json.dumps({"token": token})
cache.set(cache_key, cache_data, timeout=600)
@@ -218,7 +210,7 @@ class UserEndpoint(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
except Exception:
return Response(
{"error": "Failed to verify code. Please try again."},
status=status.HTTP_400_BAD_REQUEST,
@@ -50,6 +50,25 @@ 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(
+106 -6
View File
@@ -1,22 +1,26 @@
# 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
from plane.db.models import Profile, User, WorkspaceMemberInvite, FileAsset
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:
@@ -86,9 +90,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():
@@ -101,6 +105,93 @@ 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
@@ -151,14 +242,23 @@ 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,8 +1,7 @@
# Python imports
import json
import os
import random
import string
import secrets
# Module imports
@@ -50,13 +49,7 @@ class MagicCodeProvider(CredentialAdapter):
def initiate(self):
## Generate a random token
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))
)
token = str(secrets.randbelow(900000) + 100000)
ri = redis_instance()
-1
View File
@@ -2,7 +2,6 @@
import io
import zipfile
from typing import List
from collections import defaultdict
import boto3
from botocore.client import Config
from uuid import UUID
+120 -92
View File
@@ -10,6 +10,7 @@ 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
@@ -34,6 +35,8 @@ from plane.db.models import (
CycleIssue,
ModuleIssue,
IssueView,
User,
BotTypeEnum,
)
logger = logging.getLogger("plane.worker")
@@ -61,7 +64,7 @@ def read_seed_file(filename):
return None
def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
def create_project_and_member(workspace: Workspace, bot_user: User) -> 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
@@ -69,7 +72,7 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
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
"""
@@ -96,7 +99,7 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
workspace=workspace,
name=workspace.name, # Use workspace name
identifier=project_identifier,
created_by_id=workspace.created_by_id,
created_by_id=bot_user.id,
# Enable all views in seed data
cycle_view=True,
module_view=True,
@@ -104,60 +107,56 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
)
# 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=workspace.created_by_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=bot_user.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=workspace.created_by_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=bot_user.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")
@@ -165,13 +164,15 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
return projects_map
def create_project_states(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]:
def create_project_states(
workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User
) -> 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
"""
@@ -190,7 +191,7 @@ def create_project_states(workspace: Workspace, project_map: Dict[int, uuid.UUID
**state_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
created_by_id=bot_user.id,
)
state_map[state_id] = state.id
@@ -198,13 +199,15 @@ def create_project_states(workspace: Workspace, project_map: Dict[int, uuid.UUID
return state_map
def create_project_labels(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]:
def create_project_labels(
workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User
) -> 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
"""
@@ -221,7 +224,7 @@ def create_project_labels(workspace: Workspace, project_map: Dict[int, uuid.UUID
**label_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
created_by_id=bot_user.id,
)
label_map[label_id] = label.id
@@ -236,6 +239,7 @@ 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.
@@ -273,13 +277,13 @@ def create_project_issues(
state_id=states_map[state_id],
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
created_by_id=bot_user.id,
)
IssueSequence.objects.create(
issue=issue,
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=workspace.created_by_id,
created_by_id=bot_user.id,
)
IssueActivity.objects.create(
@@ -288,7 +292,7 @@ def create_project_issues(
workspace_id=workspace.id,
comment="created the issue",
verb="created",
actor_id=workspace.created_by_id,
actor_id=bot_user.id,
epoch=time.time(),
)
@@ -299,7 +303,7 @@ def create_project_issues(
label_id=labels_map[label_id],
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=workspace.created_by_id,
created_by_id=bot_user.id,
)
# Create cycle issues
@@ -309,7 +313,7 @@ def create_project_issues(
cycle_id=cycles_map[cycle_id],
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=workspace.created_by_id,
created_by_id=bot_user.id,
)
# Create module issues
@@ -320,19 +324,20 @@ def create_project_issues(
module_id=module_map[module_id],
project_id=project_map[project_id],
workspace_id=workspace.id,
created_by_id=workspace.created_by_id,
created_by_id=bot_user.id,
)
logger.info(f"Task: workspace_seed_task -> Issue {issue_id} created")
return
def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> 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")
@@ -351,9 +356,9 @@ def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Non
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=workspace.created_by_id,
updated_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
created_by_id=bot_user.id,
updated_by_id=bot_user.id,
owned_by_id=bot_user.id,
)
logger.info(f"Task: workspace_seed_task -> Page {page_id} created")
@@ -362,19 +367,27 @@ def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Non
workspace_id=workspace.id,
project_id=project_map[page_seed.get("project_id")],
page_id=page.id,
created_by_id=workspace.created_by_id,
updated_by_id=workspace.created_by_id,
created_by_id=bot_user.id,
updated_by_id=bot_user.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]) -> Dict[int, uuid.UUID]:
# Create cycles
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
"""
cycle_seeds = read_seed_file("cycles.json")
if not cycle_seeds:
return
return {}
cycle_map: Dict[int, uuid.UUID] = {}
@@ -403,8 +416,8 @@ def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Di
end_date=end_date,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
created_by_id=bot_user.id,
owned_by_id=bot_user.id,
)
cycle_map[cycle_id] = cycle.id
@@ -412,16 +425,17 @@ def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Di
return cycle_map
def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None:
def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> 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] = {}
@@ -438,19 +452,20 @@ def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> N
target_date=end_date,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
created_by_id=bot_user.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]) -> None:
def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID], bot_user: User) -> 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")
@@ -463,8 +478,8 @@ def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Non
**view_seed,
project_id=project_map[project_id],
workspace=workspace,
created_by_id=workspace.created_by_id,
owned_by_id=workspace.created_by_id,
created_by_id=bot_user.id,
owned_by_id=bot_user.id,
)
@@ -486,29 +501,42 @@ 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)
project_map = create_project_and_member(workspace, bot_user)
# Create project states
state_map = create_project_states(workspace, project_map)
state_map = create_project_states(workspace, project_map, bot_user)
# Create project labels
label_map = create_project_labels(workspace, project_map)
label_map = create_project_labels(workspace, project_map, bot_user)
# Create project cycles
cycle_map = create_cycles(workspace, project_map)
cycle_map = create_cycles(workspace, project_map, bot_user)
# Create project modules
module_map = create_modules(workspace, project_map)
module_map = create_modules(workspace, project_map, bot_user)
# create project issues
create_project_issues(workspace, project_map, state_map, label_map, cycle_map, module_map)
create_project_issues(workspace, project_map, state_map, label_map, cycle_map, module_map, bot_user)
# create project views
create_views(workspace, project_map)
create_views(workspace, project_map, bot_user)
# create project pages
create_pages(workspace, project_map)
create_pages(workspace, project_map, bot_user)
logger.info(f"Task: workspace_seed_task -> Workspace {workspace_id} seeded successfully")
return
+2 -1
View File
@@ -52,12 +52,13 @@ 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
from .user import Account, Profile, User, BotTypeEnum
from .view import IssueView
from .webhook import Webhook, WebhookLog
from .workspace import (
+4
View File
@@ -35,6 +35,10 @@ 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,6 +175,7 @@ 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
+4 -3
View File
@@ -25,6 +25,9 @@ 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(",")
@@ -69,9 +72,7 @@ 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",
+32 -3
View File
@@ -29,6 +29,8 @@ 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
@@ -56,8 +58,10 @@ class S3Storage(S3Boto3Storage):
config=boto3.session.Config(signature_version="s3v4"),
)
def generate_presigned_post(self, object_name, file_type, file_size, expiration=3600):
def generate_presigned_post(self, object_name, file_type, file_size, expiration=None):
"""Generate a presigned URL to upload an S3 object"""
if expiration is None:
expiration = self.signed_url_expiration
fields = {"Content-Type": file_type}
conditions = [
@@ -104,13 +108,15 @@ class S3Storage(S3Boto3Storage):
def generate_presigned_url(
self,
object_name,
expiration=3600,
expiration=None,
http_method="GET",
disposition="inline",
filename=None,
):
content_disposition = self._get_content_disposition(disposition, filename)
"""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)
try:
response = self.s3_client.generate_presigned_url(
"get_object",
@@ -158,3 +164,26 @@ 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
+6 -5
View File
@@ -11,11 +11,12 @@ from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
# Module imports
from .base import BaseAPIView
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.db.models import DeployBoard, FileAsset
from plane.settings.storage import S3Storage
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
# Module imports
from .base import BaseAPIView
class EntityAssetEndpoint(BaseAPIView):
@@ -167,7 +168,7 @@ class EntityAssetEndpoint(BaseAPIView):
class AssetRestoreEndpoint(BaseAPIView):
"""Endpoint to restore a deleted assets."""
def post(self, request, anchor, asset_id):
def post(self, request, anchor, pk):
# Get the deploy board
deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").first()
# Check if the project is published
@@ -175,7 +176,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=asset_id, workspace=deploy_board.workspace)
asset = FileAsset.all_objects.get(id=pk, workspace=deploy_board.workspace)
asset.is_deleted = False
asset.deleted_at = None
asset.save(update_fields=["is_deleted", "deleted_at"])
@@ -1,8 +1,7 @@
import pytest
from rest_framework import status
from django.db import IntegrityError
from django.utils import timezone
from datetime import datetime, timedelta
from datetime import timedelta
from uuid import uuid4
from plane.db.models import Cycle, Project, ProjectMember
@@ -58,8 +57,6 @@ def create_cycle(db, project, create_user):
)
@pytest.mark.contract
class TestCycleListCreateAPIEndpoint:
"""Test Cycle List and Create API Endpoint"""
@@ -85,7 +82,6 @@ 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"""
@@ -197,7 +193,7 @@ class TestCycleListCreateAPIEndpoint:
# Create cycles in different states
now = timezone.now()
# Current cycle (started but not ended)
Cycle.objects.create(
name="Current Cycle",
@@ -207,7 +203,7 @@ class TestCycleListCreateAPIEndpoint:
end_date=now + timedelta(days=6),
owned_by=create_user,
)
# Upcoming cycle
Cycle.objects.create(
name="Upcoming Cycle",
@@ -217,7 +213,7 @@ class TestCycleListCreateAPIEndpoint:
end_date=now + timedelta(days=8),
owned_by=create_user,
)
# Completed cycle
Cycle.objects.create(
name="Completed Cycle",
@@ -227,7 +223,7 @@ class TestCycleListCreateAPIEndpoint:
end_date=now - timedelta(days=3),
owned_by=create_user,
)
# Draft cycle
Cycle.objects.create(
name="Draft Cycle",
@@ -320,7 +316,9 @@ 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)
@@ -363,7 +361,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
@@ -372,11 +370,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
@@ -0,0 +1,202 @@
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,6 +140,7 @@ from .examples import (
WORKSPACE_MEMBER_EXAMPLE,
PROJECT_MEMBER_EXAMPLE,
CYCLE_ISSUE_EXAMPLE,
STICKY_EXAMPLE,
)
# Helper decorators
@@ -292,6 +293,7 @@ __all__ = [
"WORKSPACE_MEMBER_EXAMPLE",
"PROJECT_MEMBER_EXAMPLE",
"CYCLE_ISSUE_EXAMPLE",
"STICKY_EXAMPLE",
# Decorators
"workspace_docs",
"project_docs",
@@ -262,3 +262,18 @@ 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,6 +672,15 @@ 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 = {
@@ -781,6 +790,13 @@ 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,
@@ -795,6 +811,7 @@ 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.26
Django==4.2.27
# rest framework
djangorestframework==3.15.2
# postgres
psycopg==3.2.9
psycopg-binary==3.2.9
psycopg-c==3.2.9
psycopg==3.3.0
psycopg-binary==3.3.0
psycopg-c==3.3.0
dj-database-url==2.1.0
# mongo
pymongo==4.6.3
-4
View File
@@ -1,4 +0,0 @@
.turbo/*
out/*
dist/*
public/*
-4
View File
@@ -1,4 +0,0 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/server.js"],
};
+9 -5
View File
@@ -1,6 +1,10 @@
.next
.turbo
out/
dist/
.next/
.react-router/
.turbo/
.vite/
build/
node_modules/
dist/
node_modules/
out/
pnpm-lock.yaml
storybook-static/
-6
View File
@@ -1,6 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5",
"plugins": ["@prettier/plugin-oxc"]
}
+5 -3
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.5.6
ARG TURBO_VERSION=2.6.3
RUN corepack enable pnpm && pnpm add -g turbo@${TURBO_VERSION}
COPY . .
RUN turbo prune --scope=live --docker
@@ -34,11 +34,13 @@ 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
# Build the project and its dependencies
# 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
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
+7 -10
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "1.1.0",
"version": "1.2.1",
"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 10",
"check:lint": "eslint . --max-warnings=160",
"check:types": "tsc --noEmit",
"check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"",
"fix:lint": "eslint . --fix",
"fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"",
"check:format": "prettier --check .",
"fix:lint": "eslint . --fix --max-warnings=160",
"fix:format": "prettier --write .",
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"author": "Plane Software Inc.",
"dependencies": {
"@dotenvx/dotenvx": "^1.49.0",
"@dotenvx/dotenvx": "catalog:",
"@hocuspocus/extension-database": "2.15.2",
"@hocuspocus/extension-logger": "2.15.2",
"@hocuspocus/extension-redis": "2.15.2",
@@ -41,8 +41,7 @@
"axios": "catalog:",
"compression": "1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"express": "catalog:",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "5.7.0",
@@ -54,9 +53,7 @@
"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",
+2 -1
View File
@@ -1,5 +1,6 @@
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];
export const CONTROLLERS = [CollaborationController, DocumentController, HealthController, PdfExportController];
@@ -0,0 +1,135 @@
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);
}
}
+25 -4
View File
@@ -20,13 +20,32 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP
try {
const service = getPageService(context.documentType, context);
// fetch details
const response = await service.fetchDescriptionBinary(pageId);
const response = (await service.fetchDescriptionBinary(pageId)) as Buffer;
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>");
const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(
pageDetails.description_html ?? "<p></p>",
pageDetails.name
);
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;
}
}
@@ -52,8 +71,10 @@ const storeDocument = async ({
try {
const service = getPageService(context.documentType, context);
// convert binary data to all formats
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(pageBinaryData);
const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromDocumentEditorBinaryData(
pageBinaryData,
true
);
// 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) as Redis | undefined;
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis);
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) as Redis | undefined;
const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis);
if (redisExt) {
const commandData: ForceCloseCommandData = {
+9 -1
View File
@@ -1,5 +1,13 @@
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()];
export const getExtensions = () => [
new Logger(),
new Database(),
new Redis(),
new TitleSyncExtension(),
new ForceCloseHandler(), // Must be after Redis to receive broadcasts
];
+175
View File
@@ -0,0 +1,175 @@
// 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);
}
}
}
@@ -0,0 +1,277 @@
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;
}
}
@@ -0,0 +1,90 @@
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
@@ -0,0 +1,225 @@
/**
* 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
@@ -0,0 +1,226 @@
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
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,138 @@
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
@@ -0,0 +1,438 @@
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 },
});
};
@@ -0,0 +1,82 @@
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
@@ -0,0 +1,245 @@
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
@@ -0,0 +1,67 @@
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
@@ -0,0 +1,65 @@
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,9 +1,32 @@
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;
@@ -116,4 +139,134 @@ 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;
}
}
}
@@ -0,0 +1,50 @@
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,
});
@@ -0,0 +1,3 @@
export { PdfExportService, exportToPdf } from "./pdf-export.service";
export * from "./effect-utils";
export * from "./types";
@@ -0,0 +1,383 @@
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,
};
});
@@ -0,0 +1,39 @@
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,4 +1,3 @@
// 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 { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
import type { HocusPocusServerContext } 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: FetchPayloadWithContext["context"] | StorePayloadWithContext["context"],
context: HocusPocusServerContext,
errorCode?: "content_too_large" | "page_locked" | "page_archived",
shouldDisconnect?: boolean
) => {
-21
View File
@@ -1,21 +0,0 @@
export const generateTitleProsemirrorJson = (text: string) => {
return {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
...(text
? {
content: [
{
type: "text",
text,
},
],
}
: {}),
},
],
};
};
-1
View File
@@ -1 +0,0 @@
export * from "./document";

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