mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce27e0197e | |||
| 4878850b25 | |||
| 5c3942c159 |
@@ -170,6 +170,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.multiple = true;
|
||||
input.style.display = "none";
|
||||
document.body.appendChild(input);
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
for (const file of input.files) {
|
||||
@@ -179,8 +181,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
input.remove();
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
@@ -202,6 +203,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
input.type = "file";
|
||||
input.accept = "video/*";
|
||||
input.multiple = true;
|
||||
input.style.display = "none";
|
||||
document.body.appendChild(input);
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
for (const file of input.files) {
|
||||
@@ -211,8 +214,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
input.remove();
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
@@ -234,6 +236,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
input.type = "file";
|
||||
input.accept = "";
|
||||
input.multiple = true;
|
||||
input.style.display = "none";
|
||||
document.body.appendChild(input);
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
for (const file of input.files) {
|
||||
@@ -243,8 +247,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the input value to allow uploading the same file again if needed
|
||||
input.value = "";
|
||||
input.remove();
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
|
||||
.mantine-AppShell-main {
|
||||
padding-top: 0 !important;
|
||||
padding: 0 !important;
|
||||
min-height: auto !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,9 @@ export function TitleEditor({
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
titleEditor?.commands.focus("end");
|
||||
// guard against Cannot access view['hasFocus'] error
|
||||
if (!titleEditor?.isInitialized) return;
|
||||
titleEditor?.commands?.focus("end");
|
||||
}, 500);
|
||||
}, [titleEditor]);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconBrandNotion,
|
||||
IconCheck,
|
||||
IconFileCode,
|
||||
IconFileTypeDocx,
|
||||
IconFileTypeZip,
|
||||
IconMarkdown,
|
||||
IconX,
|
||||
@@ -86,11 +87,13 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
|
||||
const markdownFileRef = useRef<() => void>(null);
|
||||
const htmlFileRef = useRef<() => void>(null);
|
||||
const docxFileRef = useRef<() => void>(null);
|
||||
const notionFileRef = useRef<() => void>(null);
|
||||
const confluenceFileRef = useRef<() => void>(null);
|
||||
const zipFileRef = useRef<() => void>(null);
|
||||
|
||||
const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
|
||||
const canUseDocx = isCloud() || workspace?.hasLicenseKey;
|
||||
|
||||
const handleZipUpload = async (selectedFile: File, source: string) => {
|
||||
if (!selectedFile) {
|
||||
@@ -265,6 +268,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
// Reset file inputs after successful upload
|
||||
if (markdownFileRef.current) markdownFileRef.current();
|
||||
if (htmlFileRef.current) htmlFileRef.current();
|
||||
if (docxFileRef.current) docxFileRef.current();
|
||||
|
||||
const pageCountText =
|
||||
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
|
||||
@@ -321,6 +325,30 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<FileButton
|
||||
onChange={handleFileUpload}
|
||||
accept=".docx"
|
||||
multiple
|
||||
resetRef={docxFileRef}
|
||||
>
|
||||
{(props) => (
|
||||
<Tooltip
|
||||
label={t("Available in enterprise edition")}
|
||||
disabled={canUseDocx}
|
||||
>
|
||||
<Button
|
||||
disabled={!canUseDocx}
|
||||
justify="start"
|
||||
variant="default"
|
||||
leftSection={<IconFileTypeDocx size={18} />}
|
||||
{...props}
|
||||
>
|
||||
Word (DOCX)
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<FileButton
|
||||
onChange={(file) => handleZipUpload(file, "notion")}
|
||||
accept="application/zip"
|
||||
|
||||
@@ -17,13 +17,13 @@ import {
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { AttachmentService } from './services/attachment.service';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
||||
import * as bytes from 'bytes';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { StorageService } from '../../integrations/storage/storage.service';
|
||||
import {
|
||||
getAttachmentFolderPath,
|
||||
@@ -151,6 +151,7 @@ export class AttachmentController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('/files/:fileId/:fileName')
|
||||
async getFile(
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() res: FastifyReply,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@@ -181,22 +182,7 @@ export class AttachmentController {
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStream = await this.storageService.readStream(
|
||||
attachment.filePath,
|
||||
);
|
||||
res.headers({
|
||||
'Content-Type': attachment.mimeType,
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
});
|
||||
|
||||
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
||||
res.header(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.send(fileStream);
|
||||
return await this.sendFileResponse(req, res, attachment, 'private');
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
throw new NotFoundException('File not found');
|
||||
@@ -205,6 +191,7 @@ export class AttachmentController {
|
||||
|
||||
@Get('/files/public/:fileId/:fileName')
|
||||
async getPublicFile(
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() res: FastifyReply,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Param('fileId') fileId: string,
|
||||
@@ -243,22 +230,7 @@ export class AttachmentController {
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStream = await this.storageService.readStream(
|
||||
attachment.filePath,
|
||||
);
|
||||
res.headers({
|
||||
'Content-Type': attachment.mimeType,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
});
|
||||
|
||||
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
||||
res.header(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.send(fileStream);
|
||||
return await this.sendFileResponse(req, res, attachment, 'public');
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
throw new NotFoundException('File not found');
|
||||
@@ -433,4 +405,69 @@ export class AttachmentController {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendFileResponse(
|
||||
req: FastifyRequest,
|
||||
res: FastifyReply,
|
||||
attachment: Attachment,
|
||||
cacheScope: 'private' | 'public',
|
||||
) {
|
||||
const fileSize = Number(attachment.fileSize);
|
||||
const rangeHeader = req.headers.range;
|
||||
|
||||
res.header('Accept-Ranges', 'bytes');
|
||||
|
||||
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
||||
res.header(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (rangeHeader && fileSize) {
|
||||
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
|
||||
if (match) {
|
||||
const start = parseInt(match[1], 10);
|
||||
const end = match[2]
|
||||
? Math.min(parseInt(match[2], 10), fileSize - 1)
|
||||
: fileSize - 1;
|
||||
|
||||
if (start >= fileSize || start > end) {
|
||||
res.status(416);
|
||||
res.header('Content-Range', `bytes */${fileSize}`);
|
||||
return res.send();
|
||||
}
|
||||
|
||||
const fileStream = await this.storageService.readRangeStream(
|
||||
attachment.filePath,
|
||||
{ start, end },
|
||||
);
|
||||
|
||||
res.status(206);
|
||||
res.headers({
|
||||
'Content-Type': attachment.mimeType,
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Content-Length': end - start + 1,
|
||||
'Cache-Control': `${cacheScope}, max-age=3600`,
|
||||
});
|
||||
|
||||
return res.send(fileStream);
|
||||
}
|
||||
}
|
||||
|
||||
const fileStream = await this.storageService.readStream(
|
||||
attachment.filePath,
|
||||
);
|
||||
|
||||
res.headers({
|
||||
'Content-Type': attachment.mimeType,
|
||||
'Cache-Control': `${cacheScope}, max-age=3600`,
|
||||
});
|
||||
|
||||
if (fileSize) {
|
||||
res.header('Content-Length', fileSize);
|
||||
}
|
||||
|
||||
return res.send(fileStream);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 6d3eb76d4e...d93f53e3c7
@@ -44,7 +44,7 @@ export class ImportController {
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
const validFileExtensions = ['.md', '.html'];
|
||||
const validFileExtensions = ['.md', '.html', '.docx'];
|
||||
|
||||
const maxFileSize = bytes('10mb');
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { StorageService } from '../../storage/storage.service';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../../queue/constants';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
@Injectable()
|
||||
export class ImportService {
|
||||
@@ -40,6 +41,7 @@ export class ImportService {
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.FILE_TASK_QUEUE)
|
||||
private readonly fileTaskQueue: Queue,
|
||||
private moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
async importPage(
|
||||
@@ -59,11 +61,22 @@ export class ImportService {
|
||||
let prosemirrorState = null;
|
||||
let createdPage = null;
|
||||
|
||||
// For DOCX, we need the page ID upfront so images can reference it
|
||||
const pageId = fileExtension === '.docx' ? uuid7() : undefined;
|
||||
|
||||
try {
|
||||
if (fileExtension.endsWith('.md')) {
|
||||
prosemirrorState = await this.processMarkdown(fileContent);
|
||||
} else if (fileExtension.endsWith('.html')) {
|
||||
prosemirrorState = await this.processHTML(fileContent);
|
||||
} else if (fileExtension.endsWith('.docx')) {
|
||||
prosemirrorState = await this.processDocx(
|
||||
fileBuffer,
|
||||
workspaceId,
|
||||
spaceId,
|
||||
pageId,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = 'Error processing file content';
|
||||
@@ -87,6 +100,7 @@ export class ImportService {
|
||||
const pagePosition = await this.getNewPagePosition(spaceId);
|
||||
|
||||
createdPage = await this.pageRepo.insertPage({
|
||||
...(pageId ? { id: pageId } : {}),
|
||||
slugId: generateSlugId(),
|
||||
title: pageTitle,
|
||||
content: prosemirrorJson,
|
||||
@@ -129,6 +143,42 @@ export class ImportService {
|
||||
}
|
||||
}
|
||||
|
||||
async processDocx(
|
||||
fileBuffer: Buffer,
|
||||
workspaceId: string,
|
||||
spaceId: string,
|
||||
pageId: string,
|
||||
userId: string,
|
||||
): Promise<any> {
|
||||
let DocxImportModule: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
DocxImportModule = require('./../../../ee/docx-import/docx-import.service');
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'DOCX import requested but EE module not bundled in this build',
|
||||
);
|
||||
throw new BadRequestException(
|
||||
'This feature requires a valid enterprise license.',
|
||||
);
|
||||
}
|
||||
|
||||
const docxImportService = this.moduleRef.get(
|
||||
DocxImportModule.DocxImportService,
|
||||
{ strict: false },
|
||||
);
|
||||
|
||||
const html = await docxImportService.convertDocxToHtml(
|
||||
fileBuffer,
|
||||
workspaceId,
|
||||
spaceId,
|
||||
pageId,
|
||||
userId,
|
||||
);
|
||||
|
||||
return this.processHTML(html);
|
||||
}
|
||||
|
||||
async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
|
||||
if (prosemirrorJson) {
|
||||
// this.logger.debug(`Converting prosemirror json state to ydoc`);
|
||||
|
||||
@@ -73,6 +73,20 @@ export class LocalDriver implements StorageDriver {
|
||||
}
|
||||
}
|
||||
|
||||
async readRangeStream(
|
||||
filePath: string,
|
||||
range: { start: number; end: number },
|
||||
): Promise<Readable> {
|
||||
try {
|
||||
return createReadStream(this._fullPath(filePath), {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read file: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return await fs.pathExists(this._fullPath(filePath));
|
||||
|
||||
@@ -130,6 +130,25 @@ export class S3Driver implements StorageDriver {
|
||||
}
|
||||
}
|
||||
|
||||
async readRangeStream(
|
||||
filePath: string,
|
||||
range: { start: number; end: number },
|
||||
): Promise<Readable> {
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.config.bucket,
|
||||
Key: filePath,
|
||||
Range: `bytes=${range.start}-${range.end}`,
|
||||
});
|
||||
|
||||
const response = await this.s3Client.send(command);
|
||||
|
||||
return response.Body as Readable;
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read file from S3: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const command = new HeadObjectCommand({
|
||||
|
||||
@@ -11,6 +11,11 @@ export interface StorageDriver {
|
||||
|
||||
readStream(filePath: string): Promise<Readable>;
|
||||
|
||||
readRangeStream(
|
||||
filePath: string,
|
||||
range: { start: number; end: number },
|
||||
): Promise<Readable>;
|
||||
|
||||
exists(filePath: string): Promise<boolean>;
|
||||
|
||||
getUrl(filePath: string): string;
|
||||
|
||||
@@ -33,6 +33,13 @@ export class StorageService {
|
||||
return this.storageDriver.readStream(filePath);
|
||||
}
|
||||
|
||||
async readRangeStream(
|
||||
filePath: string,
|
||||
range: { start: number; end: number },
|
||||
): Promise<Readable> {
|
||||
return this.storageDriver.readRangeStream(filePath, range);
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
return this.storageDriver.exists(filePath);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { imageDimensionsFromStream } from "image-dimensions";
|
||||
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
||||
import { IAttachment } from "../types";
|
||||
import { generateNodeId } from "../utils";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { Command } from "@tiptap/core";
|
||||
import { imageDimensionsFromData } from 'image-dimensions';
|
||||
import { MediaUploadOptions, UploadFn } from '../media-utils';
|
||||
import { IAttachment } from '../types';
|
||||
import { generateNodeId } from '../utils';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
import { Command } from '@tiptap/core';
|
||||
|
||||
const findImageNodeByPlaceholderId = (
|
||||
doc: Node,
|
||||
@@ -14,7 +14,7 @@ const findImageNodeByPlaceholderId = (
|
||||
doc.descendants((node, pos) => {
|
||||
if (result) return false;
|
||||
if (
|
||||
node.type.name === "image" &&
|
||||
node.type.name === 'image' &&
|
||||
node.attrs.placeholder?.id === placeholderId
|
||||
) {
|
||||
result = { node, pos };
|
||||
@@ -34,7 +34,11 @@ const handleImageUpload =
|
||||
if (!validated) return;
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const imageDimensions = await imageDimensionsFromStream(file.stream());
|
||||
|
||||
const imageDimensions = imageDimensionsFromData(
|
||||
new Uint8Array(await file.arrayBuffer()),
|
||||
);
|
||||
|
||||
const placeholderId = generateNodeId();
|
||||
const aspectRatio = imageDimensions
|
||||
? imageDimensions.width / imageDimensions.height
|
||||
|
||||
Reference in New Issue
Block a user