Compare commits

...

3 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
Philip Okugbe 5c3942c159 fix safari print (#1907) 2026-02-04 08:26:03 -08:00
13 changed files with 221 additions and 52 deletions
@@ -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);
}
}
@@ -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