mirror of
https://github.com/docmost/docmost.git
synced 2025-11-16 07:51:09 +10:00
bug fixes and UI
This commit is contained in:
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
20
apps/client/src/components/icons/confluence-icon.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfluenceIcon({ size }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
style={{ width: rem(size), height: rem(size) }}
|
||||||
|
>
|
||||||
|
<path d="M.87 18.257c-.248.382-.53.875-.763 1.245a.764.764 0 0 0 .255 1.04l4.965 3.054a.764.764 0 0 0 1.058-.26c.199-.332.454-.763.733-1.221 1.967-3.247 3.945-2.853 7.508-1.146l4.957 2.337a.764.764 0 0 0 1.028-.382l2.364-5.346a.764.764 0 0 0-.382-1 599.851 599.851 0 0 1-4.965-2.361C10.911 10.97 5.224 11.185.87 18.257zM23.131 5.743c.249-.405.531-.875.764-1.25a.764.764 0 0 0-.256-1.034L18.675.404a.764.764 0 0 0-1.058.26c-.195.335-.451.763-.734 1.225-1.966 3.246-3.945 2.85-7.508 1.146L4.437.694a.764.764 0 0 0-1.027.382L1.046 6.422a.764.764 0 0 0 .382 1c1.039.49 3.105 1.467 4.965 2.361 6.698 3.246 12.392 3.029 16.738-4.04z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import api from "@/lib/api-client";
|
||||||
|
import { IFileTask } from "@/features/file-task/types/file-task.types.ts";
|
||||||
|
|
||||||
|
export async function getFileTaskById(fileTaskId: string): Promise<IFileTask> {
|
||||||
|
const req = await api.post<IFileTask>("/file-tasks/info", {
|
||||||
|
fileTaskId: fileTaskId,
|
||||||
|
});
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFileTasks(): Promise<IFileTask[]> {
|
||||||
|
const req = await api.post<IFileTask[]>("/file-tasks");
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
17
apps/client/src/features/file-task/types/file-task.types.ts
Normal file
17
apps/client/src/features/file-task/types/file-task.types.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export interface IFileTask {
|
||||||
|
id: string;
|
||||||
|
type: "import" | "export";
|
||||||
|
source: string;
|
||||||
|
status: string;
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
fileSize: number;
|
||||||
|
fileExt: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
creatorId: string;
|
||||||
|
spaceId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
@ -1,18 +1,36 @@
|
|||||||
import { Modal, Button, SimpleGrid, FileButton } from "@mantine/core";
|
|
||||||
import {
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
SimpleGrid,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconBrandNotion,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
IconFileCode,
|
IconFileCode,
|
||||||
|
IconFileTypeZip,
|
||||||
IconMarkdown,
|
IconMarkdown,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { importPage } from "@/features/page/services/page-service.ts";
|
import {
|
||||||
|
importPage,
|
||||||
|
importZip,
|
||||||
|
} from "@/features/page/services/page-service.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { buildTree } from "@/features/page/tree/utils";
|
import { buildTree } from "@/features/page/tree/utils";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ConfluenceIcon } from "@/components/icons/confluence-icon.tsx";
|
||||||
|
import { getFileImportSizeLimit, isCloud } from "@/lib/config.ts";
|
||||||
|
import { formatBytes } from "@/lib";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { getFileTaskById } from "@/features/file-task/services/file-task-service.ts";
|
||||||
|
|
||||||
interface PageImportModalProps {
|
interface PageImportModalProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -59,6 +77,113 @@ interface ImportFormatSelection {
|
|||||||
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||||
|
const [workspace] = useAtom(workspaceAtom);
|
||||||
|
const [fileTaskId, setFileTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
|
||||||
|
|
||||||
|
const handleZipUpload = async (selectedFile: File, source: string) => {
|
||||||
|
if (!selectedFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const importTask = await importZip(selectedFile, spaceId, source);
|
||||||
|
notifications.show({
|
||||||
|
id: "import",
|
||||||
|
title: t("Importing pages"),
|
||||||
|
message: t(
|
||||||
|
"Page import is in progress. Refresh this tab after a while.",
|
||||||
|
),
|
||||||
|
loading: true,
|
||||||
|
withCloseButton: false,
|
||||||
|
autoClose: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
setFileTaskId(importTask.id);
|
||||||
|
console.log("taskId set", importTask.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Failed to import page", err);
|
||||||
|
notifications.update({
|
||||||
|
id: "import",
|
||||||
|
color: "red",
|
||||||
|
title: t("Failed to import pages"),
|
||||||
|
message: t("Unable to import pages. Please try again."),
|
||||||
|
icon: <IconX size={18} />,
|
||||||
|
loading: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
autoClose: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fileTaskId) return;
|
||||||
|
|
||||||
|
const intervalId = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const fileTask = await getFileTaskById(fileTaskId);
|
||||||
|
const status = fileTask.status;
|
||||||
|
|
||||||
|
if (status === "success") {
|
||||||
|
notifications.update({
|
||||||
|
id: "import",
|
||||||
|
color: "teal",
|
||||||
|
title: t("Import complete"),
|
||||||
|
message: t("Your pages were successfully imported."),
|
||||||
|
icon: <IconCheck size={18} />,
|
||||||
|
loading: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
autoClose: 5000,
|
||||||
|
});
|
||||||
|
clearInterval(intervalId);
|
||||||
|
setFileTaskId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "failed") {
|
||||||
|
notifications.update({
|
||||||
|
id: "import",
|
||||||
|
color: "red",
|
||||||
|
title: t("Page import failed"),
|
||||||
|
message: t(
|
||||||
|
"Something went wrong while importing pages: {{reason}}.",
|
||||||
|
{
|
||||||
|
reason: fileTask.errorMessage,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
icon: <IconX size={18} />,
|
||||||
|
loading: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
autoClose: 5000,
|
||||||
|
});
|
||||||
|
clearInterval(intervalId);
|
||||||
|
setFileTaskId(null);
|
||||||
|
console.error(fileTask.errorMessage);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notifications.update({
|
||||||
|
id: "import",
|
||||||
|
color: "red",
|
||||||
|
title: t("Import failed"),
|
||||||
|
message: t(
|
||||||
|
"Something went wrong while importing pages: {{reason}}.",
|
||||||
|
{
|
||||||
|
reason: err.response?.data.message,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
icon: <IconX size={18} />,
|
||||||
|
loading: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
autoClose: 5000,
|
||||||
|
});
|
||||||
|
clearInterval(intervalId);
|
||||||
|
setFileTaskId(null);
|
||||||
|
console.error("Failed to fetch import status", err);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}, [fileTaskId]);
|
||||||
|
|
||||||
const handleFileUpload = async (selectedFiles: File[]) => {
|
const handleFileUpload = async (selectedFiles: File[]) => {
|
||||||
if (!selectedFiles) {
|
if (!selectedFiles) {
|
||||||
@ -120,6 +245,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SimpleGrid cols={2}>
|
<SimpleGrid cols={2}>
|
||||||
@ -148,7 +274,76 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</FileButton>
|
</FileButton>
|
||||||
|
|
||||||
|
<FileButton
|
||||||
|
onChange={(file) => handleZipUpload(file, "notion")}
|
||||||
|
accept="application/zip"
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
justify="start"
|
||||||
|
variant="default"
|
||||||
|
leftSection={<IconBrandNotion size={18} />}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
Notion
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
<FileButton
|
||||||
|
onChange={(file) => handleZipUpload(file, "confluence")}
|
||||||
|
accept="application/zip"
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Tooltip
|
||||||
|
label="Available in enterprise edition"
|
||||||
|
disabled={canUseConfluence}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={!canUseConfluence}
|
||||||
|
justify="start"
|
||||||
|
variant="default"
|
||||||
|
leftSection={<ConfluenceIcon size={18} />}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
Confluence
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Group justify="center" gap="xl" mih={150}>
|
||||||
|
<div>
|
||||||
|
<Text ta="center" size="lg" inline>
|
||||||
|
Import zip file
|
||||||
|
</Text>
|
||||||
|
<Text ta="center" size="sm" c="dimmed" inline py="sm">
|
||||||
|
{t(
|
||||||
|
`Upload zip file containing Markdown and HTML files. Max: {{sizeLimit}}`,
|
||||||
|
{
|
||||||
|
sizeLimit: formatBytes(getFileImportSizeLimit()),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<FileButton
|
||||||
|
onChange={(file) => handleZipUpload(file, "generic")}
|
||||||
|
accept="application/zip"
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Group justify="center">
|
||||||
|
<Button
|
||||||
|
justify="center"
|
||||||
|
leftSection={<IconFileTypeZip size={18} />}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{t("Upload file")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,10 @@ import {
|
|||||||
IPage,
|
IPage,
|
||||||
IPageInput,
|
IPageInput,
|
||||||
SidebarPagesParams,
|
SidebarPagesParams,
|
||||||
} from "@/features/page/types/page.types";
|
} from '@/features/page/types/page.types';
|
||||||
import { IAttachment, IPagination } from "@/lib/types.ts";
|
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||||
import { saveAs } from "file-saver";
|
import { saveAs } from "file-saver";
|
||||||
|
import { IFileTask } from '@/features/file-task/types/file-task.types.ts';
|
||||||
|
|
||||||
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||||
const req = await api.post<IPage>("/pages/create", data);
|
const req = await api.post<IPage>("/pages/create", data);
|
||||||
@ -92,6 +93,25 @@ export async function importPage(file: File, spaceId: string) {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function importZip(
|
||||||
|
file: File,
|
||||||
|
spaceId: string,
|
||||||
|
source?: string,
|
||||||
|
): Promise<IFileTask> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("spaceId", spaceId);
|
||||||
|
formData.append("source", source);
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const req = await api.post<any>("/pages/import-zip", formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadFile(
|
export async function uploadFile(
|
||||||
file: File,
|
file: File,
|
||||||
pageId: string,
|
pageId: string,
|
||||||
|
|||||||
@ -70,6 +70,11 @@ export function getFileUploadSizeLimit() {
|
|||||||
return bytes(limit);
|
return bytes(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFileImportSizeLimit() {
|
||||||
|
const limit = getConfigValue("FILE_IMPORT_SIZE_LIMIT", "200mb");
|
||||||
|
return bytes(limit);
|
||||||
|
}
|
||||||
|
|
||||||
export function getDrawioUrl() {
|
export function getDrawioUrl() {
|
||||||
return getConfigValue("DRAWIO_URL", "https://embed.diagrams.net");
|
return getConfigValue("DRAWIO_URL", "https://embed.diagrams.net");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
const {
|
const {
|
||||||
APP_URL,
|
APP_URL,
|
||||||
FILE_UPLOAD_SIZE_LIMIT,
|
FILE_UPLOAD_SIZE_LIMIT,
|
||||||
|
FILE_IMPORT_SIZE_LIMIT,
|
||||||
DRAWIO_URL,
|
DRAWIO_URL,
|
||||||
CLOUD,
|
CLOUD,
|
||||||
SUBDOMAIN_HOST,
|
SUBDOMAIN_HOST,
|
||||||
@ -20,6 +21,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
"process.env": {
|
"process.env": {
|
||||||
APP_URL,
|
APP_URL,
|
||||||
FILE_UPLOAD_SIZE_LIMIT,
|
FILE_UPLOAD_SIZE_LIMIT,
|
||||||
|
FILE_IMPORT_SIZE_LIMIT,
|
||||||
DRAWIO_URL,
|
DRAWIO_URL,
|
||||||
CLOUD,
|
CLOUD,
|
||||||
SUBDOMAIN_HOST,
|
SUBDOMAIN_HOST,
|
||||||
|
|||||||
@ -6,22 +6,17 @@ export async function up(db: Kysely<any>): Promise<void> {
|
|||||||
.addColumn('id', 'uuid', (col) =>
|
.addColumn('id', 'uuid', (col) =>
|
||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||||
)
|
)
|
||||||
//type: import or export
|
// type (import, export)
|
||||||
.addColumn('type', 'varchar', (col) => col)
|
.addColumn('type', 'varchar', (col) => col)
|
||||||
// source - generic, notion, confluence
|
// source (generic, notion, confluence)
|
||||||
// type or provider?
|
|
||||||
.addColumn('source', 'varchar', (col) => col)
|
.addColumn('source', 'varchar', (col) => col)
|
||||||
// status (enum: PENDING|PROCESSING|SUCCESS|FAILED),
|
// status (pending|processing|success|failed),
|
||||||
.addColumn('status', 'varchar', (col) => col)
|
.addColumn('status', 'varchar', (col) => col)
|
||||||
// file name
|
|
||||||
// file path
|
|
||||||
// file size
|
|
||||||
|
|
||||||
.addColumn('file_name', 'varchar', (col) => col.notNull())
|
.addColumn('file_name', 'varchar', (col) => col.notNull())
|
||||||
.addColumn('file_path', 'varchar', (col) => col.notNull())
|
.addColumn('file_path', 'varchar', (col) => col.notNull())
|
||||||
.addColumn('file_size', 'int8', (col) => col)
|
.addColumn('file_size', 'int8', (col) => col)
|
||||||
.addColumn('file_ext', 'varchar', (col) => col)
|
.addColumn('file_ext', 'varchar', (col) => col)
|
||||||
|
.addColumn('error_message', 'varchar', (col) => col)
|
||||||
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
|
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
|
||||||
.addColumn('space_id', 'uuid', (col) =>
|
.addColumn('space_id', 'uuid', (col) =>
|
||||||
col.references('spaces.id').onDelete('cascade'),
|
col.references('spaces.id').onDelete('cascade'),
|
||||||
@ -35,7 +30,6 @@ export async function up(db: Kysely<any>): Promise<void> {
|
|||||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
.addColumn('updated_at', 'timestamptz', (col) =>
|
||||||
col.notNull().defaultTo(sql`now()`),
|
col.notNull().defaultTo(sql`now()`),
|
||||||
)
|
)
|
||||||
.addColumn('completed_at', 'timestamptz', (col) => col)
|
|
||||||
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
.addColumn('deleted_at', 'timestamptz', (col) => col)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|||||||
2
apps/server/src/database/types/db.d.ts
vendored
2
apps/server/src/database/types/db.d.ts
vendored
@ -123,10 +123,10 @@ export interface Comments {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FileTasks {
|
export interface FileTasks {
|
||||||
completedAt: Timestamp | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
creatorId: string | null;
|
creatorId: string | null;
|
||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
|
errorMessage: string | null;
|
||||||
fileExt: string | null;
|
fileExt: string | null;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
|||||||
@ -67,6 +67,10 @@ export class EnvironmentService {
|
|||||||
return this.configService.get<string>('FILE_UPLOAD_SIZE_LIMIT', '50mb');
|
return this.configService.get<string>('FILE_UPLOAD_SIZE_LIMIT', '50mb');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getFileImportSizeLimit(): string {
|
||||||
|
return this.configService.get<string>('FILE_IMPORT_SIZE_LIMIT', '200mb');
|
||||||
|
}
|
||||||
|
|
||||||
getAwsS3AccessKeyId(): string {
|
getAwsS3AccessKeyId(): string {
|
||||||
return this.configService.get<string>('AWS_S3_ACCESS_KEY_ID');
|
return this.configService.get<string>('AWS_S3_ACCESS_KEY_ID');
|
||||||
}
|
}
|
||||||
|
|||||||
7
apps/server/src/integrations/import/dto/file-task-dto.ts
Normal file
7
apps/server/src/integrations/import/dto/file-task-dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class FileTaskIdDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsUUID()
|
||||||
|
fileTaskId: string;
|
||||||
|
}
|
||||||
79
apps/server/src/integrations/import/file-task.controller.ts
Normal file
79
apps/server/src/integrations/import/file-task.controller.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
||||||
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
import {
|
||||||
|
SpaceCaslAction,
|
||||||
|
SpaceCaslSubject,
|
||||||
|
} from '../../core/casl/interfaces/space-ability.type';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
|
import { FileTaskIdDto } from './dto/file-task-dto';
|
||||||
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
||||||
|
|
||||||
|
@Controller('file-tasks')
|
||||||
|
export class FileTaskController {
|
||||||
|
constructor(
|
||||||
|
private readonly spaceMemberRepo: SpaceMemberRepo,
|
||||||
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post()
|
||||||
|
async getFileTasks(@AuthUser() user: User) {
|
||||||
|
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(user.id);
|
||||||
|
|
||||||
|
if (!userSpaceIds || userSpaceIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileTasks = await this.db
|
||||||
|
.selectFrom('fileTasks')
|
||||||
|
.selectAll()
|
||||||
|
.where('spaceId', 'in', userSpaceIds)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (!fileTasks) {
|
||||||
|
throw new NotFoundException('File task not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileTasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('info')
|
||||||
|
async getFileTask(@Body() dto: FileTaskIdDto, @AuthUser() user: User) {
|
||||||
|
const fileTask = await this.db
|
||||||
|
.selectFrom('fileTasks')
|
||||||
|
.selectAll()
|
||||||
|
.where('id', '=', dto.fileTaskId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!fileTask || !fileTask.spaceId) {
|
||||||
|
throw new NotFoundException('File task not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ability = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
fileTask.spaceId,
|
||||||
|
);
|
||||||
|
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,6 +23,7 @@ import * as bytes from 'bytes';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { ImportService } from './services/import.service';
|
import { ImportService } from './services/import.service';
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class ImportController {
|
export class ImportController {
|
||||||
@ -31,6 +32,7 @@ export class ImportController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly importService: ImportService,
|
private readonly importService: ImportService,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@UseInterceptors(FileInterceptor)
|
@UseInterceptors(FileInterceptor)
|
||||||
@ -44,18 +46,18 @@ export class ImportController {
|
|||||||
) {
|
) {
|
||||||
const validFileExtensions = ['.md', '.html'];
|
const validFileExtensions = ['.md', '.html'];
|
||||||
|
|
||||||
const maxFileSize = bytes('100mb');
|
const maxFileSize = bytes('10mb');
|
||||||
|
|
||||||
let file = null;
|
let file = null;
|
||||||
try {
|
try {
|
||||||
file = await req.file({
|
file = await req.file({
|
||||||
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
|
limits: { fileSize: maxFileSize, fields: 4, files: 1 },
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(err.message);
|
this.logger.error(err.message);
|
||||||
if (err?.statusCode === 413) {
|
if (err?.statusCode === 413) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`File too large. Exceeds the 100mb import limit`,
|
`File too large. Exceeds the 10mb import limit`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -73,7 +75,7 @@ export class ImportController {
|
|||||||
const spaceId = file.fields?.spaceId?.value;
|
const spaceId = file.fields?.spaceId?.value;
|
||||||
|
|
||||||
if (!spaceId) {
|
if (!spaceId) {
|
||||||
throw new BadRequestException('spaceId or format not found');
|
throw new BadRequestException('spaceId is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
||||||
@ -87,7 +89,6 @@ export class ImportController {
|
|||||||
@UseInterceptors(FileInterceptor)
|
@UseInterceptors(FileInterceptor)
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
// temporary naming
|
|
||||||
@Post('pages/import-zip')
|
@Post('pages/import-zip')
|
||||||
async importZip(
|
async importZip(
|
||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
@ -96,7 +97,7 @@ export class ImportController {
|
|||||||
) {
|
) {
|
||||||
const validFileExtensions = ['.zip'];
|
const validFileExtensions = ['.zip'];
|
||||||
|
|
||||||
const maxFileSize = bytes('100mb');
|
const maxFileSize = bytes(this.environmentService.getFileImportSizeLimit());
|
||||||
|
|
||||||
let file = null;
|
let file = null;
|
||||||
try {
|
try {
|
||||||
@ -107,7 +108,7 @@ export class ImportController {
|
|||||||
this.logger.error(err.message);
|
this.logger.error(err.message);
|
||||||
if (err?.statusCode === 413) {
|
if (err?.statusCode === 413) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`File too large. Exceeds the 100mb import limit`,
|
`File too large. Exceeds the ${this.environmentService.getFileImportSizeLimit()} import limit`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -119,14 +120,21 @@ export class ImportController {
|
|||||||
if (
|
if (
|
||||||
!validFileExtensions.includes(path.extname(file.filename).toLowerCase())
|
!validFileExtensions.includes(path.extname(file.filename).toLowerCase())
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException('Invalid import file type.');
|
throw new BadRequestException('Invalid import file extension.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const spaceId = file.fields?.spaceId?.value;
|
const spaceId = file.fields?.spaceId?.value;
|
||||||
const source = file.fields?.source?.value;
|
const source = file.fields?.source?.value;
|
||||||
|
|
||||||
|
const validZipSources = ['generic', 'notion', 'confluence'];
|
||||||
|
if (!validZipSources.includes(source)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Invalid import source. Import source must either be generic, notion or confluence.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!spaceId) {
|
if (!spaceId) {
|
||||||
throw new BadRequestException('spaceId or format not found');
|
throw new BadRequestException('spaceId is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
const ability = await this.spaceAbility.createForUser(user, spaceId);
|
||||||
@ -134,6 +142,12 @@ export class ImportController {
|
|||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.importService.importZip(file, source, user.id, spaceId, workspace.id);
|
return this.importService.importZip(
|
||||||
|
file,
|
||||||
|
source,
|
||||||
|
user.id,
|
||||||
|
spaceId,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { StorageModule } from '../storage/storage.module';
|
|||||||
import { FileTaskService } from './services/file-task.service';
|
import { FileTaskService } from './services/file-task.service';
|
||||||
import { FileTaskProcessor } from './processors/file-task.processor';
|
import { FileTaskProcessor } from './processors/file-task.processor';
|
||||||
import { ImportAttachmentService } from './services/import-attachment.service';
|
import { ImportAttachmentService } from './services/import-attachment.service';
|
||||||
|
import { FileTaskController } from './file-task.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [
|
||||||
@ -14,7 +15,7 @@ import { ImportAttachmentService } from './services/import-attachment.service';
|
|||||||
ImportAttachmentService,
|
ImportAttachmentService,
|
||||||
],
|
],
|
||||||
exports: [ImportService, ImportAttachmentService],
|
exports: [ImportService, ImportAttachmentService],
|
||||||
controllers: [ImportController],
|
controllers: [ImportController, FileTaskController],
|
||||||
imports: [StorageModule],
|
imports: [StorageModule],
|
||||||
})
|
})
|
||||||
export class ImportModule {}
|
export class ImportModule {}
|
||||||
|
|||||||
@ -3,11 +3,17 @@ import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
|
|||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||||
import { FileTaskService } from '../services/file-task.service';
|
import { FileTaskService } from '../services/file-task.service';
|
||||||
|
import { FileTaskStatus } from '../utils/file.utils';
|
||||||
|
import { StorageService } from '../../storage/storage.service';
|
||||||
|
|
||||||
@Processor(QueueName.FILE_TASK_QUEUE)
|
@Processor(QueueName.FILE_TASK_QUEUE)
|
||||||
export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
||||||
private readonly logger = new Logger(FileTaskProcessor.name);
|
private readonly logger = new Logger(FileTaskProcessor.name);
|
||||||
constructor(private readonly fileTaskService: FileTaskService) {
|
|
||||||
|
constructor(
|
||||||
|
private readonly fileTaskService: FileTaskService,
|
||||||
|
private readonly storageService: StorageService,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -18,10 +24,11 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
await this.fileTaskService.processZIpImport(job.data.fileTaskId);
|
||||||
break;
|
break;
|
||||||
case QueueJob.EXPORT_TASK:
|
case QueueJob.EXPORT_TASK:
|
||||||
console.log('export task', job.data.fileTaskId);
|
// TODO: export task
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
this.logger.error('File task failed', err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -32,15 +39,45 @@ export class FileTaskProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OnWorkerEvent('failed')
|
@OnWorkerEvent('failed')
|
||||||
onError(job: Job) {
|
async onFailed(job: Job) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
|
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const MAX_JOB_ATTEMPTS = 3;
|
||||||
|
const fileTaskId = job.data.fileTaskId;
|
||||||
|
|
||||||
|
if (job.attemptsMade >= MAX_JOB_ATTEMPTS) {
|
||||||
|
this.logger.error(`Max import attempts reached for Task ${fileTaskId}.`);
|
||||||
|
await this.fileTaskService.updateTaskStatus(
|
||||||
|
fileTaskId,
|
||||||
|
FileTaskStatus.Failed,
|
||||||
|
job.failedReason,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileTask = await this.fileTaskService.getFileTask(fileTaskId);
|
||||||
|
if (fileTask) {
|
||||||
|
await this.storageService.delete(fileTask.filePath);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnWorkerEvent('stalled')
|
||||||
|
async onStalled(job: Job) {
|
||||||
|
this.logger.error(
|
||||||
|
`Stalled processing ${job.name} job. Reason: ${job.failedReason}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnWorkerEvent('completed')
|
@OnWorkerEvent('completed')
|
||||||
onCompleted(job: Job) {
|
onCompleted(job: Job) {
|
||||||
this.logger.debug(`Completed ${job.name} job`);
|
this.logger.log(
|
||||||
|
`Completed ${job.name} job for File task ID ${job.data.fileTaskId}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { InjectKysely } from 'nestjs-kysely';
|
|||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import {
|
import {
|
||||||
extractZip,
|
extractZip,
|
||||||
FileImportType,
|
FileImportSource,
|
||||||
FileTaskStatus,
|
FileTaskStatus,
|
||||||
} from '../utils/file.utils';
|
} from '../utils/file.utils';
|
||||||
import { StorageService } from '../../storage/storage.service';
|
import { StorageService } from '../../storage/storage.service';
|
||||||
@ -40,7 +40,6 @@ export class FileTaskService {
|
|||||||
private readonly backlinkRepo: BacklinkRepo,
|
private readonly backlinkRepo: BacklinkRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private readonly importAttachmentService: ImportAttachmentService,
|
private readonly importAttachmentService: ImportAttachmentService,
|
||||||
// private readonly confluenceTaskService: ConfluenceImportService,
|
|
||||||
private moduleRef: ModuleRef,
|
private moduleRef: ModuleRef,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -72,15 +71,23 @@ export class FileTaskService {
|
|||||||
unsafeCleanup: true,
|
unsafeCleanup: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileStream = await this.storageService.readStream(fileTask.filePath);
|
try {
|
||||||
await pipeline(fileStream, createWriteStream(tmpZipPath));
|
const fileStream = await this.storageService.readStream(
|
||||||
|
fileTask.filePath,
|
||||||
|
);
|
||||||
|
await pipeline(fileStream, createWriteStream(tmpZipPath));
|
||||||
|
await extractZip(tmpZipPath, tmpExtractDir);
|
||||||
|
} catch (err) {
|
||||||
|
await cleanupTmpFile();
|
||||||
|
await cleanupTmpDir();
|
||||||
|
|
||||||
await extractZip(tmpZipPath, tmpExtractDir);
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
fileTask.source === FileImportType.Generic ||
|
fileTask.source === FileImportSource.Generic ||
|
||||||
fileTask.source === FileImportType.Notion
|
fileTask.source === FileImportSource.Notion
|
||||||
) {
|
) {
|
||||||
await this.processGenericImport({
|
await this.processGenericImport({
|
||||||
extractDir: tmpExtractDir,
|
extractDir: tmpExtractDir,
|
||||||
@ -88,7 +95,7 @@ export class FileTaskService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileTask.source === FileImportType.Confluence) {
|
if (fileTask.source === FileImportSource.Confluence) {
|
||||||
let ConfluenceModule: any;
|
let ConfluenceModule: any;
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
@ -109,13 +116,21 @@ export class FileTaskService {
|
|||||||
fileTask,
|
fileTask,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await this.updateTaskStatus(fileTaskId, FileTaskStatus.Success);
|
try {
|
||||||
} catch (error) {
|
await this.updateTaskStatus(fileTaskId, FileTaskStatus.Success, null);
|
||||||
await this.updateTaskStatus(fileTaskId, FileTaskStatus.Failed);
|
// delete stored file on success
|
||||||
this.logger.error(error);
|
await this.storageService.delete(fileTask.filePath);
|
||||||
} finally {
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to delete import file from storage. Task ID: ${fileTaskId}`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
await cleanupTmpFile();
|
await cleanupTmpFile();
|
||||||
await cleanupTmpDir();
|
await cleanupTmpDir();
|
||||||
|
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,11 +294,27 @@ export class FileTaskService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTaskStatus(fileTaskId: string, status: FileTaskStatus) {
|
async getFileTask(fileTaskId: string) {
|
||||||
await this.db
|
return this.db
|
||||||
.updateTable('fileTasks')
|
.selectFrom('fileTasks')
|
||||||
.set({ status: status })
|
.selectAll()
|
||||||
.where('id', '=', fileTaskId)
|
.where('id', '=', fileTaskId)
|
||||||
.execute();
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTaskStatus(
|
||||||
|
fileTaskId: string,
|
||||||
|
status: FileTaskStatus,
|
||||||
|
errorMessage?: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.db
|
||||||
|
.updateTable('fileTasks')
|
||||||
|
.set({ status: status, errorMessage, updatedAt: new Date() })
|
||||||
|
.where('id', '=', fileTaskId)
|
||||||
|
.execute();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
} from '../../../collaboration/collaboration.util';
|
} from '../../../collaboration/collaboration.util';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { generateSlugId } from '../../../common/helpers';
|
import { generateSlugId, sanitizeFileName } from '../../../common/helpers';
|
||||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
@ -20,15 +20,11 @@ import {
|
|||||||
FileTaskType,
|
FileTaskType,
|
||||||
getFileTaskFolderPath,
|
getFileTaskFolderPath,
|
||||||
} from '../utils/file.utils';
|
} from '../utils/file.utils';
|
||||||
import { v7, v7 as uuid7 } from 'uuid';
|
import { v7 as uuid7 } from 'uuid';
|
||||||
import { StorageService } from '../../storage/storage.service';
|
import { StorageService } from '../../storage/storage.service';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { QueueJob, QueueName } from '../../queue/constants';
|
import { QueueJob, QueueName } from '../../queue/constants';
|
||||||
import { Node as PMNode } from '@tiptap/pm/model';
|
|
||||||
import { EditorState, Transaction } from '@tiptap/pm/state';
|
|
||||||
import { getSchema } from '@tiptap/core';
|
|
||||||
import { FileTask } from '@docmost/db/types/entity.types';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportService {
|
export class ImportService {
|
||||||
@ -204,13 +200,15 @@ export class ImportService {
|
|||||||
const file = await filePromise;
|
const file = await filePromise;
|
||||||
const fileBuffer = await file.toBuffer();
|
const fileBuffer = await file.toBuffer();
|
||||||
const fileExtension = path.extname(file.filename).toLowerCase();
|
const fileExtension = path.extname(file.filename).toLowerCase();
|
||||||
const fileName = sanitize(
|
const fileName = sanitizeFileName(
|
||||||
path.basename(file.filename, fileExtension).slice(0, 255),
|
path.basename(file.filename, fileExtension),
|
||||||
);
|
);
|
||||||
const fileSize = fileBuffer.length;
|
const fileSize = fileBuffer.length;
|
||||||
|
|
||||||
|
const fileNameWithExt = fileName + fileExtension;
|
||||||
|
|
||||||
const fileTaskId = uuid7();
|
const fileTaskId = uuid7();
|
||||||
const filePath = `${getFileTaskFolderPath(FileTaskType.Import, workspaceId)}/${fileTaskId}/${fileName}`;
|
const filePath = `${getFileTaskFolderPath(FileTaskType.Import, workspaceId)}/${fileTaskId}/${fileNameWithExt}`;
|
||||||
|
|
||||||
// upload file
|
// upload file
|
||||||
await this.storageService.upload(filePath, fileBuffer);
|
await this.storageService.upload(filePath, fileBuffer);
|
||||||
@ -222,7 +220,7 @@ export class ImportService {
|
|||||||
type: FileTaskType.Import,
|
type: FileTaskType.Import,
|
||||||
source: source,
|
source: source,
|
||||||
status: FileTaskStatus.Processing,
|
status: FileTaskStatus.Processing,
|
||||||
fileName: fileName,
|
fileName: fileNameWithExt,
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
fileSize: fileSize,
|
fileSize: fileSize,
|
||||||
fileExt: 'zip',
|
fileExt: 'zip',
|
||||||
@ -231,7 +229,7 @@ export class ImportService {
|
|||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
})
|
})
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.execute();
|
.executeTakeFirst();
|
||||||
|
|
||||||
await this.fileTaskQueue.add(QueueJob.IMPORT_TASK, {
|
await this.fileTaskQueue.add(QueueJob.IMPORT_TASK, {
|
||||||
fileTaskId: fileTaskId,
|
fileTaskId: fileTaskId,
|
||||||
@ -239,89 +237,4 @@ export class ImportService {
|
|||||||
|
|
||||||
return fileTask;
|
return fileTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
async markdownOrHtmlToProsemirror(
|
|
||||||
fileContent: string,
|
|
||||||
fileExtension: string,
|
|
||||||
): Promise<any> {
|
|
||||||
let prosemirrorState = '';
|
|
||||||
if (fileExtension === '.md') {
|
|
||||||
prosemirrorState = await this.processMarkdown(fileContent);
|
|
||||||
} else if (fileExtension.endsWith('.html')) {
|
|
||||||
prosemirrorState = await this.processHTML(fileContent);
|
|
||||||
}
|
|
||||||
return prosemirrorState;
|
|
||||||
}
|
|
||||||
|
|
||||||
async convertInternalLinksToMentionsPM(
|
|
||||||
doc: PMNode,
|
|
||||||
currentFilePath: string,
|
|
||||||
filePathToPageMetaMap: Map<
|
|
||||||
string,
|
|
||||||
{ id: string; title: string; slugId: string }
|
|
||||||
>,
|
|
||||||
): Promise<PMNode> {
|
|
||||||
const schema = getSchema(tiptapExtensions);
|
|
||||||
const state = EditorState.create({ doc, schema });
|
|
||||||
let tr: Transaction = state.tr;
|
|
||||||
|
|
||||||
const normalizePath = (p: string) => p.replace(/\\/g, '/');
|
|
||||||
|
|
||||||
// Collect replacements from the original doc.
|
|
||||||
const replacements: Array<{
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
mentionNode: PMNode;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
doc.descendants((node, pos) => {
|
|
||||||
if (!node.isText || !node.marks?.length) return;
|
|
||||||
|
|
||||||
// Look for the link mark
|
|
||||||
const linkMark = node.marks.find(
|
|
||||||
(mark) => mark.type.name === 'link' && mark.attrs?.href,
|
|
||||||
);
|
|
||||||
if (!linkMark) return;
|
|
||||||
|
|
||||||
// Compute the range for the entire text node.
|
|
||||||
const from = pos;
|
|
||||||
const to = pos + node.nodeSize;
|
|
||||||
|
|
||||||
// Resolve the path and get page meta.
|
|
||||||
const resolvedPath = normalizePath(
|
|
||||||
path.join(path.dirname(currentFilePath), linkMark.attrs.href),
|
|
||||||
);
|
|
||||||
const pageMeta = filePathToPageMetaMap.get(resolvedPath);
|
|
||||||
if (!pageMeta) return;
|
|
||||||
|
|
||||||
// Create the mention node with all required attributes.
|
|
||||||
const mentionNode = schema.nodes.mention.create({
|
|
||||||
id: v7(),
|
|
||||||
entityType: 'page',
|
|
||||||
entityId: pageMeta.id,
|
|
||||||
label: node.text || pageMeta.title,
|
|
||||||
slugId: pageMeta.slugId,
|
|
||||||
creatorId: 'not available', // This is required per your schema.
|
|
||||||
});
|
|
||||||
|
|
||||||
replacements.push({ from, to, mentionNode });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Apply replacements in reverse order.
|
|
||||||
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
||||||
const { from, to, mentionNode } = replacements[i];
|
|
||||||
try {
|
|
||||||
tr = tr.replaceWith(from, to, mentionNode);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('❌ Failed to insert mention:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tr.docChanged) {
|
|
||||||
console.log('doc changed');
|
|
||||||
console.log(JSON.stringify(state.apply(tr).doc.toJSON()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the updated document if any change was made.
|
|
||||||
return tr.docChanged ? state.apply(tr).doc : doc;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export enum FileTaskType {
|
|||||||
Export = 'export',
|
Export = 'export',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum FileImportType {
|
export enum FileImportSource {
|
||||||
Generic = 'generic',
|
Generic = 'generic',
|
||||||
Notion = 'notion',
|
Notion = 'notion',
|
||||||
Confluence = 'confluence',
|
Confluence = 'confluence',
|
||||||
|
|||||||
@ -51,6 +51,10 @@ import { BacklinksProcessor } from './processors/backlinks.processor';
|
|||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: QueueName.FILE_TASK_QUEUE,
|
name: QueueName.FILE_TASK_QUEUE,
|
||||||
|
defaultJobOptions: {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
exports: [BullModule],
|
exports: [BullModule],
|
||||||
|
|||||||
Reference in New Issue
Block a user