Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion containers/gotenberg/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
FROM gotenberg/gotenberg:8.34.0-libreoffice
FROM gotenberg/gotenberg:8.34.0

EXPOSE 3000
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"partyserver": "^0.5.8",
"posthog-js": "^1.396.6",
"posthog-node": "^5.39.4",
"prosemirror-transform": "1.12.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-resizable-panels": "^4.12.1",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 66 additions & 1 deletion src/features/workspaces/components/WorkspaceTopBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { MessageSquare, Share2 } from "lucide-react";
import { Download, MessageSquare, Share2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { toast } from "sonner";

import UserProfileDropdown from "#/components/UserProfileDropdown";
import { Kbd } from "#/components/ui/kbd";
Expand All @@ -20,6 +21,7 @@ import {
useWorkspaceAiChatSurfaceMode,
useWorkspaceUiStore,
} from "#/features/workspaces/state/workspace-ui-store";
import { getErrorMessage } from "#/lib/error-message";
import { formatAppHotkey, getAppHotkey } from "#/lib/hotkeys-core";

type PresenceStatus = "connecting" | "connected" | "disconnected";
Expand Down Expand Up @@ -61,7 +63,41 @@ export default function WorkspaceTopBar({
const chatSurfaceMode = useWorkspaceAiChatSurfaceMode(workspace.id);
const setChatSurfaceMode = useWorkspaceUiStore((state) => state.setChatSurfaceMode);
const [shareOpen, setShareOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const aiChatHotkey = formatAppHotkey(getAppHotkey("workspace.aiChat.toggle").hotkey);
const handleExport = async () => {
if (exporting) {
return;
}

setExporting(true);

try {
const response = await fetch(`/api/v1/workspaces/${encodeURIComponent(workspace.id)}/export`);

if (!response.ok) {
throw new Error(
(await getExportErrorMessage(response)) ?? "Unable to export this workspace right now.",
);
}

const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement("a");

link.href = objectUrl;
link.download = getDownloadFileName(response.headers) ?? `${workspace.name}.zip`;
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(objectUrl);
} catch (error) {
toast.error(getErrorMessage(error, "Unable to export this workspace right now."));
} finally {
setExporting(false);
}
};

return (
<>
Expand All @@ -75,6 +111,16 @@ export default function WorkspaceTopBar({
>
<Share2 />
</WorkspaceToolbarIconButton>
<WorkspaceToolbarTextButton
aria-busy={exporting}
disabled={exporting}
onClick={() => void handleExport()}
variant="outline"
className="border-border bg-background shadow-xs hover:bg-muted"
>
<Download />
<span>Export</span>
</WorkspaceToolbarTextButton>
<UserProfileDropdown />
{chatSurfaceMode === "hidden" ? (
<Tooltip>
Expand Down Expand Up @@ -126,3 +172,22 @@ export default function WorkspaceTopBar({
</>
);
}

function getDownloadFileName(headers: Headers) {
const contentDisposition = headers.get("content-disposition");
const match =
contentDisposition?.match(/filename\*=UTF-8''([^;]+)/i) ??
contentDisposition?.match(/filename="([^"]+)"/i) ??
contentDisposition?.match(/filename=([^;]+)/i);

return match?.[1] ? decodeURIComponent(match[1].trim()) : null;
}

async function getExportErrorMessage(response: Response) {
try {
const body = (await response.json()) as { message?: unknown };
return typeof body.message === "string" ? body.message : null;
} catch {
return null;
}
}
232 changes: 232 additions & 0 deletions src/features/workspaces/export/workspace-export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { describe, expect, it } from "vitest";

import type {
WorkspaceItemSummary,
WorkspacePage,
WorkspaceSummary,
} from "#/features/workspaces/contracts";
import {
buildWorkspaceExportEntries,
buildWorkspaceExportPathIndex,
extractTextFromHtml,
reconstructWorkspaceState,
sanitizeExportName,
} from "./workspace-export";
import { createZipArchive } from "./workspace-zip";

describe("workspace export helpers", () => {
it("replays events after a snapshot in chronological order", () => {
const page = createPage({
items: [
createItem({
id: "doc",
name: "Draft",
sortOrder: 1000,
type: "document",
}),
createItem({
id: "folder",
name: "Folder",
sortOrder: 2000,
type: "folder",
}),
],
revision: 1,
});

const moved = createItem({
id: "doc",
name: "Final",
parentId: "folder",
sortOrder: 1000,
type: "document",
});
const result = reconstructWorkspaceState({
snapshot: page,
eventsAfterSnapshot: [
{
actorUserId: "user",
clientMutationId: null,
createdAt: "2026-07-28T00:00:02.000Z",
id: "event-2",
payload: { items: [moved] },
revision: 3,
type: "workspace.items.moved",
workspaceId: "workspace",
},
{
actorUserId: "user",
clientMutationId: null,
createdAt: "2026-07-28T00:00:01.000Z",
id: "event-1",
payload: {
item: createItem({
id: "doc",
name: "Final",
sortOrder: 1000,
type: "document",
}),
},
revision: 2,
type: "workspace.item.renamed",
workspaceId: "workspace",
},
],
});

expect(result.revision).toBe(3);
expect(result.items.find((item) => item.id === "doc")).toMatchObject({
name: "Final",
parentId: "folder",
});
});

it("creates nested, collision-safe export paths", () => {
const paths = buildWorkspaceExportPathIndex("My Workspace", [
createItem({ id: "folder", name: "Biology", sortOrder: 1000, type: "folder" }),
createItem({
id: "doc-a",
name: "Cell Notes",
parentId: "folder",
sortOrder: 1000,
type: "document",
}),
createItem({
id: "doc-b",
name: "Cell Notes",
parentId: "folder",
sortOrder: 2000,
type: "document",
}),
createItem({
id: "unsafe",
name: "../bad:name*",
parentId: "folder",
sortOrder: 3000,
type: "file",
}),
]);

expect(paths.get("doc-a")).toBe("My Workspace/Biology/Cell Notes");
expect(paths.get("doc-b")).toBe("My Workspace/Biology/Cell Notes (1)");
expect(paths.get("unsafe")).toBe("My Workspace/Biology/_bad_name_");
});

it("sanitizes empty and unsafe names", () => {
expect(sanitizeExportName("")).toBe("Untitled");
expect(sanitizeExportName("..")).toBe("Untitled");
expect(sanitizeExportName('Chapter: 1 / "Draft"')).toBe("Chapter_ 1 _ _Draft_");
});

it("represents empty folders in the zip", () => {
const zip = createZipArchive([{ path: "Workspace/" }, { path: "Workspace/Empty/" }]);
const text = new TextDecoder().decode(zip);

expect(text).toContain("Workspace/Empty/");
expect(zip[0]).toBe(0x50);
expect(zip[1]).toBe(0x4b);
});

it("adds a notice entry when a file source is missing", async () => {
const page = createPage({
items: [
createItem({
id: "missing-file",
name: "Lecture.pdf",
sortOrder: 1000,
type: "file",
}),
],
revision: 1,
});
const entries = await buildWorkspaceExportEntries(
createEnv(),
{
getFileSource: async () => {
throw new Error("Workspace file source object is missing.");
},
} as never,
page,
);
const notice = entries.find((entry) => entry.path.endsWith(".missing.txt"));

expect(notice).toMatchObject({ path: "My Workspace/Lecture.pdf.missing.txt" });
expect(new TextDecoder().decode(notice?.data as Uint8Array)).toContain(
'The file "Lecture.pdf" could not be included in this export.',
);
});

it("removes generated CSS when extracting fallback PDF text", () => {
const text = extractTextFromHtml(`<!doctype html>
<html>
<head>
<title>New document 1</title>
<style>
@page { size: Letter; margin: 0.75in; }
body { color: #111827; }
</style>
</head>
<body>
<main><p>test</p></main>
</body>
</html>`);

expect(text).toBe("test");
});
});

function createPage(input: { items: WorkspaceItemSummary[]; revision: number }): WorkspacePage {
return {
itemFacts: [],
items: input.items,
revision: input.revision,
workspace: createWorkspace(),
};
}

function createWorkspace(): WorkspaceSummary {
return {
archivedAt: null,
color: null,
createdAt: "2026-07-28T00:00:00.000Z",
description: null,
icon: null,
id: "workspace",
lastOpenedAt: null,
membershipRole: "owner",
name: "My Workspace",
updatedAt: "2026-07-28T00:00:00.000Z",
};
}

function createEnv() {
return {
WORKSPACE_KERNEL_FILES: {
get: async () => null,
},
} as never;
}

function createItem(input: {
id: string;
name: string;
parentId?: string | null;
sortOrder: number;
type: WorkspaceItemSummary["type"];
}): WorkspaceItemSummary {
return {
color: null,
createdAt: "2026-07-28T00:00:00.000Z",
deletedAt: null,
id: input.id,
meta: input.type,
metadataJson: {},
name: input.name,
parentId: input.parentId ?? null,
sortOrder: input.sortOrder,
title: input.name,
type: input.type,
updatedAt: "2026-07-28T00:00:00.000Z",
workspaceId: "workspace",
};
}
Loading