base
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { AxiosInstance } from "axios";
|
||||
import axios from "axios";
|
||||
import { env } from "@/env";
|
||||
import { AppError } from "@/lib/errors";
|
||||
|
||||
export abstract class APIService {
|
||||
protected baseURL: string;
|
||||
private axiosInstance: AxiosInstance;
|
||||
private header: Record<string, string> = {};
|
||||
|
||||
constructor(baseURL?: string) {
|
||||
this.baseURL = baseURL || env.API_BASE_URL;
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
withCredentials: true,
|
||||
timeout: 20000,
|
||||
});
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors() {
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
return Promise.reject(new AppError(error));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
setHeader(key: string, value: string) {
|
||||
this.header[key] = value;
|
||||
}
|
||||
|
||||
getHeader() {
|
||||
return this.header;
|
||||
}
|
||||
|
||||
get(url: string, params = {}, config = {}) {
|
||||
return this.axiosInstance.get(url, {
|
||||
...params,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
post(url: string, data = {}, config = {}) {
|
||||
return this.axiosInstance.post(url, data, config);
|
||||
}
|
||||
|
||||
put(url: string, data = {}, config = {}) {
|
||||
return this.axiosInstance.put(url, data, config);
|
||||
}
|
||||
|
||||
patch(url: string, data = {}, config = {}) {
|
||||
return this.axiosInstance.patch(url, data, config);
|
||||
}
|
||||
|
||||
delete(url: string, data?: Record<string, unknown> | null | string, config = {}) {
|
||||
return this.axiosInstance.delete(url, { data, ...config });
|
||||
}
|
||||
|
||||
request(config = {}) {
|
||||
return this.axiosInstance(config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { logger } from "@plane/logger";
|
||||
import type { TDocumentPayload, TPage } from "@plane/types";
|
||||
// services
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { APIService } from "../api.service";
|
||||
|
||||
export type TUserMention = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
|
||||
export abstract class PageCoreService extends APIService {
|
||||
protected abstract basePath: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchDetails(pageId: string): Promise<TPage> {
|
||||
try {
|
||||
const response = await this.get(`${this.basePath}/pages/${pageId}/`, {
|
||||
headers: this.getHeader(),
|
||||
});
|
||||
return response?.data as TPage;
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "fetchDetails", pageId },
|
||||
});
|
||||
logger.error("Failed to fetch page details", appError);
|
||||
throw appError;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchDescriptionBinary(pageId: string): Promise<Buffer> {
|
||||
try {
|
||||
const response = await this.get(`${this.basePath}/pages/${pageId}/description/`, {
|
||||
headers: {
|
||||
...this.getHeader(),
|
||||
"Content-Type": "application/octet-stream",
|
||||
},
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
const data = response?.data;
|
||||
if (!Buffer.isBuffer(data)) {
|
||||
throw new Error("Expected response to be a Buffer");
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "fetchDescriptionBinary", pageId },
|
||||
});
|
||||
logger.error("Failed to fetch page description binary", appError);
|
||||
throw appError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the title of a page
|
||||
*/
|
||||
async updatePageProperties(
|
||||
pageId: string,
|
||||
params: { data: Partial<TPage>; abortSignal?: AbortSignal }
|
||||
): Promise<TPage> {
|
||||
const { data, abortSignal } = params;
|
||||
|
||||
// Early abort check
|
||||
if (abortSignal?.aborted) {
|
||||
throw new AppError(new DOMException("Aborted", "AbortError"));
|
||||
}
|
||||
|
||||
// Create an abort listener that will reject the pending promise
|
||||
let abortListener: (() => void) | undefined;
|
||||
const abortPromise = new Promise((_, reject) => {
|
||||
if (abortSignal) {
|
||||
abortListener = () => {
|
||||
reject(new AppError(new DOMException("Aborted", "AbortError")));
|
||||
};
|
||||
abortSignal.addEventListener("abort", abortListener);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
this.patch(`${this.basePath}/pages/${pageId}/`, data, {
|
||||
headers: this.getHeader(),
|
||||
signal: abortSignal,
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "updatePageProperties", pageId },
|
||||
});
|
||||
|
||||
if (appError.code === "ABORT_ERROR") {
|
||||
throw appError;
|
||||
}
|
||||
|
||||
logger.error("Failed to update page properties", appError);
|
||||
throw appError;
|
||||
}),
|
||||
abortPromise,
|
||||
]);
|
||||
} finally {
|
||||
// Clean up abort listener
|
||||
if (abortSignal && abortListener) {
|
||||
abortSignal.removeEventListener("abort", abortListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateDescriptionBinary(pageId: string, data: TDocumentPayload): Promise<any> {
|
||||
try {
|
||||
const response = await this.patch(`${this.basePath}/pages/${pageId}/description/`, data, {
|
||||
headers: this.getHeader(),
|
||||
});
|
||||
return response?.data as unknown;
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "updateDescriptionBinary", pageId },
|
||||
});
|
||||
logger.error("Failed to update page description binary", appError);
|
||||
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}/mentions/`, {
|
||||
headers: this.getHeader(),
|
||||
params: {
|
||||
mention_type: "user_mention",
|
||||
},
|
||||
});
|
||||
return (response?.data as TUserMention[]) ?? [];
|
||||
} catch (error) {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "fetchUserMentions", pageId },
|
||||
});
|
||||
logger.error("Failed to fetch user mentions", appError);
|
||||
throw appError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an image asset ID to its actual URL by following the 302 redirect
|
||||
* @param workspaceSlug - The workspace slug
|
||||
* @param assetId - The asset UUID
|
||||
* @param projectId - Optional project ID for project-specific assets
|
||||
* @returns The resolved image URL (presigned S3 URL)
|
||||
*/
|
||||
async resolveImageAssetUrl(
|
||||
workspaceSlug: string,
|
||||
assetId: string,
|
||||
projectId?: string | null
|
||||
): Promise<string | null> {
|
||||
const path = projectId
|
||||
? `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${assetId}/?disposition=inline`
|
||||
: `/api/assets/v2/workspaces/${workspaceSlug}/${assetId}/?disposition=inline`;
|
||||
|
||||
try {
|
||||
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) {
|
||||
return response.headers?.location || null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Axios throws on 3xx when maxRedirects is 0, so we need to handle the redirect from the error
|
||||
if ((error as any).response?.status === 302 || (error as any).response?.status === 301) {
|
||||
return (error as any).response.headers?.location || null;
|
||||
}
|
||||
logger.error("Failed to resolve image asset URL", {
|
||||
assetId,
|
||||
workspaceSlug,
|
||||
error: (error as any).message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves multiple image asset IDs to their actual URLs
|
||||
* @param workspaceSlug - The workspace slug
|
||||
* @param assetIds - Array of asset UUIDs
|
||||
* @param projectId - Optional project ID for project-specific assets
|
||||
* @returns Map of assetId to resolved URL
|
||||
*/
|
||||
async resolveImageAssetUrls(
|
||||
workspaceSlug: string,
|
||||
assetIds: string[],
|
||||
projectId?: string | null
|
||||
): Promise<Map<string, string>> {
|
||||
const urlMap = new Map<string, string>();
|
||||
|
||||
// Resolve all asset URLs in parallel
|
||||
const results = await Promise.allSettled(
|
||||
assetIds.map(async (assetId) => {
|
||||
const url = await this.resolveImageAssetUrl(workspaceSlug, assetId, projectId);
|
||||
return { assetId, url };
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled" && result.value.url) {
|
||||
urlMap.set(result.value.assetId, result.value.url);
|
||||
}
|
||||
}
|
||||
|
||||
return urlMap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { PageCoreService } from "./core.service";
|
||||
|
||||
/**
|
||||
* This is the extended service for the page service.
|
||||
* It extends the core service and adds additional functionality.
|
||||
* Implementation for this is found in the enterprise repository.
|
||||
*/
|
||||
export abstract class PageService extends PageCoreService {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { AppError } from "@/lib/errors";
|
||||
import type { HocusPocusServerContext, TDocumentTypes } from "@/types";
|
||||
// services
|
||||
import { ProjectPageService } from "./project-page.service";
|
||||
|
||||
export const getPageService = (documentType: TDocumentTypes, context: HocusPocusServerContext) => {
|
||||
if (documentType === "project_page") {
|
||||
return new ProjectPageService({
|
||||
workspaceSlug: context.workspaceSlug,
|
||||
projectId: context.projectId,
|
||||
cookie: context.cookie,
|
||||
});
|
||||
}
|
||||
|
||||
throw new AppError(`Invalid document type ${documentType} provided.`);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { PageService } from "./extended.service";
|
||||
|
||||
interface ProjectPageServiceParams {
|
||||
workspaceSlug: string | null;
|
||||
projectId: string | null;
|
||||
cookie: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class ProjectPageService extends PageService {
|
||||
protected basePath: string;
|
||||
|
||||
constructor(params: ProjectPageServiceParams) {
|
||||
super();
|
||||
const { workspaceSlug, projectId } = params;
|
||||
if (!workspaceSlug || !projectId) throw new AppError("Missing required fields.");
|
||||
// validate cookie
|
||||
if (!params.cookie) throw new AppError("Cookie is required.");
|
||||
// set cookie
|
||||
this.setHeader("Cookie", params.cookie);
|
||||
// set base path
|
||||
this.basePath = `/api/workspaces/${workspaceSlug}/projects/${projectId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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,9 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export { PdfExportService, exportToPdf } from "./pdf-export.service";
|
||||
export * from "./effect-utils";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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
|
||||
): 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
|
||||
const resolvedUrlMap = yield* tryAsync(
|
||||
async () => {
|
||||
const urlMap = new Map<string, string>();
|
||||
for (const assetId of assetIds) {
|
||||
const url = await pageService.resolveImageAssetUrl?.(workspaceSlug, assetId, projectId);
|
||||
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
|
||||
);
|
||||
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,42 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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 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>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// types
|
||||
import { logger } from "@plane/logger";
|
||||
import type { IUser } from "@plane/types";
|
||||
// services
|
||||
import { AppError } from "@/lib/errors";
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export class UserService extends APIService {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
currentUserConfig() {
|
||||
return {
|
||||
url: `${this.baseURL}/api/users/me/`,
|
||||
};
|
||||
}
|
||||
|
||||
async currentUser(cookie: string): Promise<IUser> {
|
||||
return this.get("/api/users/me/", {
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
const appError = new AppError(error, {
|
||||
context: { operation: "currentUser" },
|
||||
});
|
||||
logger.error("Failed to fetch current user", appError);
|
||||
throw appError;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user