Compare commits

...

2 Commits

Author SHA1 Message Date
Philipinho ce27e0197e feat: DOCX import 2026-02-06 10:14:27 -08:00
Philip Okugbe 4878850b25 fix: attachment bugs in safari(#1908)
* use widely available arrayBuffer
* fix stream fails in safari
* fix hasFocus bug
* fix safari upload bug
* feat: add HTTP range request support for file serving
2026-02-05 07:47:03 -08:00
12 changed files with 220 additions and 51 deletions
@@ -170,6 +170,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = "image/*"; input.accept = "image/*";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { 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.remove();
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -202,6 +203,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = "video/*"; input.accept = "video/*";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { 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.remove();
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -234,6 +236,8 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.type = "file"; input.type = "file";
input.accept = ""; input.accept = "";
input.multiple = true; input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
for (const file of input.files) { 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.remove();
input.value = "";
}; };
input.click(); input.click();
}, },
@@ -157,7 +157,9 @@ export function TitleEditor({
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
titleEditor?.commands.focus("end"); // guard against Cannot access view['hasFocus'] error
if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end");
}, 500); }, 500);
}, [titleEditor]); }, [titleEditor]);
@@ -11,6 +11,7 @@ import {
IconBrandNotion, IconBrandNotion,
IconCheck, IconCheck,
IconFileCode, IconFileCode,
IconFileTypeDocx,
IconFileTypeZip, IconFileTypeZip,
IconMarkdown, IconMarkdown,
IconX, IconX,
@@ -86,11 +87,13 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
const markdownFileRef = useRef<() => void>(null); const markdownFileRef = useRef<() => void>(null);
const htmlFileRef = useRef<() => void>(null); const htmlFileRef = useRef<() => void>(null);
const docxFileRef = useRef<() => void>(null);
const notionFileRef = useRef<() => void>(null); const notionFileRef = useRef<() => void>(null);
const confluenceFileRef = useRef<() => void>(null); const confluenceFileRef = useRef<() => void>(null);
const zipFileRef = useRef<() => void>(null); const zipFileRef = useRef<() => void>(null);
const canUseConfluence = isCloud() || workspace?.hasLicenseKey; const canUseConfluence = isCloud() || workspace?.hasLicenseKey;
const canUseDocx = isCloud() || workspace?.hasLicenseKey;
const handleZipUpload = async (selectedFile: File, source: string) => { const handleZipUpload = async (selectedFile: File, source: string) => {
if (!selectedFile) { if (!selectedFile) {
@@ -265,6 +268,7 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
// Reset file inputs after successful upload // Reset file inputs after successful upload
if (markdownFileRef.current) markdownFileRef.current(); if (markdownFileRef.current) markdownFileRef.current();
if (htmlFileRef.current) htmlFileRef.current(); if (htmlFileRef.current) htmlFileRef.current();
if (docxFileRef.current) docxFileRef.current();
const pageCountText = const pageCountText =
pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`; pageCount === 1 ? `1 ${t("page")}` : `${pageCount} ${t("pages")}`;
@@ -321,6 +325,30 @@ function ImportFormatSelection({ spaceId, onClose }: ImportFormatSelection) {
)} )}
</FileButton> </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 <FileButton
onChange={(file) => handleZipUpload(file, "notion")} onChange={(file) => handleZipUpload(file, "notion")}
accept="application/zip" accept="application/zip"
@@ -17,13 +17,13 @@ import {
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { AttachmentService } from './services/attachment.service'; import { AttachmentService } from './services/attachment.service';
import { FastifyReply } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import { FileInterceptor } from '../../common/interceptors/file.interceptor'; import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes'; import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; 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 { StorageService } from '../../integrations/storage/storage.service';
import { import {
getAttachmentFolderPath, getAttachmentFolderPath,
@@ -151,6 +151,7 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName') @Get('/files/:fileId/:fileName')
async getFile( async getFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@@ -181,22 +182,7 @@ export class AttachmentController {
} }
try { try {
const fileStream = await this.storageService.readStream( return await this.sendFileResponse(req, res, attachment, 'private');
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);
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
throw new NotFoundException('File not found'); throw new NotFoundException('File not found');
@@ -205,6 +191,7 @@ export class AttachmentController {
@Get('/files/public/:fileId/:fileName') @Get('/files/public/:fileId/:fileName')
async getPublicFile( async getPublicFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string, @Param('fileId') fileId: string,
@@ -243,22 +230,7 @@ export class AttachmentController {
} }
try { try {
const fileStream = await this.storageService.readStream( return await this.sendFileResponse(req, res, attachment, 'public');
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);
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
throw new NotFoundException('File not found'); throw new NotFoundException('File not found');
@@ -433,4 +405,69 @@ export class AttachmentController {
return; 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);
}
} }
@@ -44,7 +44,7 @@ export class ImportController {
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
const validFileExtensions = ['.md', '.html']; const validFileExtensions = ['.md', '.html', '.docx'];
const maxFileSize = bytes('10mb'); const maxFileSize = bytes('10mb');
@@ -29,6 +29,7 @@ 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 { ModuleRef } from '@nestjs/core';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
@@ -40,6 +41,7 @@ export class ImportService {
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.FILE_TASK_QUEUE) @InjectQueue(QueueName.FILE_TASK_QUEUE)
private readonly fileTaskQueue: Queue, private readonly fileTaskQueue: Queue,
private moduleRef: ModuleRef,
) {} ) {}
async importPage( async importPage(
@@ -59,11 +61,22 @@ export class ImportService {
let prosemirrorState = null; let prosemirrorState = null;
let createdPage = null; let createdPage = null;
// For DOCX, we need the page ID upfront so images can reference it
const pageId = fileExtension === '.docx' ? uuid7() : undefined;
try { try {
if (fileExtension.endsWith('.md')) { if (fileExtension.endsWith('.md')) {
prosemirrorState = await this.processMarkdown(fileContent); prosemirrorState = await this.processMarkdown(fileContent);
} else if (fileExtension.endsWith('.html')) { } else if (fileExtension.endsWith('.html')) {
prosemirrorState = await this.processHTML(fileContent); prosemirrorState = await this.processHTML(fileContent);
} else if (fileExtension.endsWith('.docx')) {
prosemirrorState = await this.processDocx(
fileBuffer,
workspaceId,
spaceId,
pageId,
userId,
);
} }
} catch (err) { } catch (err) {
const message = 'Error processing file content'; const message = 'Error processing file content';
@@ -87,6 +100,7 @@ export class ImportService {
const pagePosition = await this.getNewPagePosition(spaceId); const pagePosition = await this.getNewPagePosition(spaceId);
createdPage = await this.pageRepo.insertPage({ createdPage = await this.pageRepo.insertPage({
...(pageId ? { id: pageId } : {}),
slugId: generateSlugId(), slugId: generateSlugId(),
title: pageTitle, title: pageTitle,
content: prosemirrorJson, 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> { async createYdoc(prosemirrorJson: any): Promise<Buffer | null> {
if (prosemirrorJson) { if (prosemirrorJson) {
// this.logger.debug(`Converting prosemirror json state to ydoc`); // 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> { async exists(filePath: string): Promise<boolean> {
try { try {
return await fs.pathExists(this._fullPath(filePath)); 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> { async exists(filePath: string): Promise<boolean> {
try { try {
const command = new HeadObjectCommand({ const command = new HeadObjectCommand({
@@ -11,6 +11,11 @@ export interface StorageDriver {
readStream(filePath: string): Promise<Readable>; readStream(filePath: string): Promise<Readable>;
readRangeStream(
filePath: string,
range: { start: number; end: number },
): Promise<Readable>;
exists(filePath: string): Promise<boolean>; exists(filePath: string): Promise<boolean>;
getUrl(filePath: string): string; getUrl(filePath: string): string;
@@ -33,6 +33,13 @@ export class StorageService {
return this.storageDriver.readStream(filePath); 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> { async exists(filePath: string): Promise<boolean> {
return this.storageDriver.exists(filePath); return this.storageDriver.exists(filePath);
} }
@@ -1,9 +1,9 @@
import { imageDimensionsFromStream } from "image-dimensions"; import { imageDimensionsFromData } from 'image-dimensions';
import { MediaUploadOptions, UploadFn } from "../media-utils"; import { MediaUploadOptions, UploadFn } from '../media-utils';
import { IAttachment } from "../types"; import { IAttachment } from '../types';
import { generateNodeId } from "../utils"; import { generateNodeId } from '../utils';
import { Node } from "@tiptap/pm/model"; import { Node } from '@tiptap/pm/model';
import { Command } from "@tiptap/core"; import { Command } from '@tiptap/core';
const findImageNodeByPlaceholderId = ( const findImageNodeByPlaceholderId = (
doc: Node, doc: Node,
@@ -14,7 +14,7 @@ const findImageNodeByPlaceholderId = (
doc.descendants((node, pos) => { doc.descendants((node, pos) => {
if (result) return false; if (result) return false;
if ( if (
node.type.name === "image" && node.type.name === 'image' &&
node.attrs.placeholder?.id === placeholderId node.attrs.placeholder?.id === placeholderId
) { ) {
result = { node, pos }; result = { node, pos };
@@ -34,7 +34,11 @@ const handleImageUpload =
if (!validated) return; if (!validated) return;
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
const imageDimensions = await imageDimensionsFromStream(file.stream());
const imageDimensions = imageDimensionsFromData(
new Uint8Array(await file.arrayBuffer()),
);
const placeholderId = generateNodeId(); const placeholderId = generateNodeId();
const aspectRatio = imageDimensions const aspectRatio = imageDimensions
? imageDimensions.width / imageDimensions.height ? imageDimensions.width / imageDimensions.height