diff --git a/package.json b/package.json index 05b77509f..d7eaa7e8f 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "clsx": "^2.1.1", "drizzle-kit": "^0.31.10", "drizzle-orm": "^0.45.2", + "fflate": "^0.8.3", "hast-util-to-string": "^3.0.1", "katex": "^0.17.0", "linkedom": "0.18.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5eb56efb5..03e892521 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -236,6 +236,9 @@ importers: drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@cloudflare/workers-types@5.20260801.1)(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0)(sql.js@1.14.1) + fflate: + specifier: ^0.8.3 + version: 0.8.3 hast-util-to-string: specifier: ^3.0.1 version: 3.0.1 @@ -6225,6 +6228,9 @@ packages: fflate@0.4.9: resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -14685,6 +14691,8 @@ snapshots: fflate@0.4.9: {} + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 diff --git a/src/features/workspaces/ai/ai-thread.ts b/src/features/workspaces/ai/ai-thread.ts index e4da44214..9300ea65d 100644 --- a/src/features/workspaces/ai/ai-thread.ts +++ b/src/features/workspaces/ai/ai-thread.ts @@ -131,10 +131,6 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) { ); } - getSystemPrompt(): string { - return getAIThreadSoulPrompt(); - } - // On-demand instruction bundles (progressive disclosure). The model sees // only each skill's name/description until a task matches, then calls // activate_skill to load the full guide. Bundled from ./skills via the diff --git a/src/features/workspaces/components/WorkspaceContextBar.tsx b/src/features/workspaces/components/WorkspaceContextBar.tsx index 168092d69..9f75cf64f 100644 --- a/src/features/workspaces/components/WorkspaceContextBar.tsx +++ b/src/features/workspaces/components/WorkspaceContextBar.tsx @@ -1,5 +1,6 @@ -import { ChevronDown, Clock3, Settings, Share2 } from "lucide-react"; +import { ChevronDown, Clock3, Download, Settings, Share2 } from "lucide-react"; import { type ComponentType, type ReactElement, useState } from "react"; +import { toast } from "sonner"; import { Breadcrumb, @@ -94,6 +95,14 @@ export default function WorkspaceContextBar({ const workspaceItems = Array.from(itemsById.values()); const searchHotkey = formatAppHotkey(getAppHotkey("workspace.search.open").hotkey); const openWorkspaceSearch = () => setSearchOpen(true); + const exportWorkspace = () => { + toast.message("Preparing workspace export…"); + const link = document.createElement("a"); + link.href = `/api/v1/workspaces/${encodeURIComponent(workspace.id)}/export`; + link.target = "_blank"; + link.rel = "noopener"; + link.click(); + }; useAppHotkey("workspace.search.open", () => { openWorkspaceSearch(); @@ -117,6 +126,7 @@ export default function WorkspaceContextBar({ ) : ( setSettingsOpen(true)} onOpenShare={() => setShareOpen(true)} trigger={ @@ -221,11 +231,13 @@ export default function WorkspaceContextBar({ function WorkspaceRootActionsMenu({ capabilities, + onExport, onOpenSettings, onOpenShare, trigger, }: { capabilities: ReturnType["capabilities"]; + onExport: () => void; onOpenSettings: () => void; onOpenShare: () => void; trigger: ReactElement; @@ -237,6 +249,7 @@ function WorkspaceRootActionsMenu({ {renderWorkspaceMenuActions( getWorkspaceRootMenuActions({ canOpenSettings: capabilities.canMutateContent, + onExport, onOpenSettings, onOpenShare, }), @@ -249,6 +262,7 @@ function WorkspaceRootActionsMenu({ function getWorkspaceRootMenuActions(input: { canOpenSettings: boolean; + onExport: () => void; onOpenSettings: () => void; onOpenShare: () => void; }): WorkspaceMenuAction[] { @@ -268,6 +282,13 @@ function getWorkspaceRootMenuActions(input: { trailing: "Soon", disabled: true, }, + { + kind: "item", + id: "export", + label: "Export", + leading: , + onSelect: input.onExport, + }, { kind: "item", id: "settings", diff --git a/src/features/workspaces/contracts.ts b/src/features/workspaces/contracts.ts index 3f82a6408..e36f52ea1 100644 --- a/src/features/workspaces/contracts.ts +++ b/src/features/workspaces/contracts.ts @@ -8,6 +8,8 @@ import { export { workspaceItemTypeSchema }; export type { WorkspaceItemType }; +export const WORKSPACE_ITEM_NAME_MAX_LENGTH = 160; + export type JsonValue = | string | number @@ -226,7 +228,7 @@ export const createWorkspaceItemInputSchema = z workspaceId: z.string().min(1), parentId: z.string().min(1).nullable().optional(), type: workspaceItemTypeSchema, - name: z.string().trim().min(1).max(160).optional(), + name: z.string().trim().min(1).max(WORKSPACE_ITEM_NAME_MAX_LENGTH).optional(), color: workspaceColorSchema.optional(), initialContent: z.string().optional(), clientMutationId: z.uuid().optional(), @@ -244,7 +246,7 @@ export const createWorkspaceItemInputSchema = z export const renameWorkspaceItemInputSchema = z.object({ workspaceId: z.string().min(1), itemId: z.string().min(1), - name: z.string().trim().min(1).max(160), + name: z.string().trim().min(1).max(WORKSPACE_ITEM_NAME_MAX_LENGTH), clientMutationId: z.uuid().optional(), }); diff --git a/src/features/workspaces/defaults.test.ts b/src/features/workspaces/defaults.test.ts new file mode 100644 index 000000000..82603e00c --- /dev/null +++ b/src/features/workspaces/defaults.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { + getAvailableWorkspaceItemName, + normalizeWorkspaceItemName, +} from "#/features/workspaces/defaults"; + +describe("workspace item names", () => { + it("normalizes names for portable filesystem paths", () => { + expect(normalizeWorkspaceItemName(" Notes: draft?.md. ")).toBe("Notes- draft-.md"); + expect(normalizeWorkspaceItemName("..")).toBe("Untitled"); + expect(normalizeWorkspaceItemName("CON.txt")).toBe("_CON.txt"); + }); + + it("allocates sibling names case-insensitively", () => { + expect( + getAvailableWorkspaceItemName({ + type: "folder", + existingNames: ["Notes"], + requestedName: "notes", + }), + ).toBe("notes 2"); + }); +}); diff --git a/src/features/workspaces/defaults.ts b/src/features/workspaces/defaults.ts index 526e8d607..af25a5fb7 100644 --- a/src/features/workspaces/defaults.ts +++ b/src/features/workspaces/defaults.ts @@ -1,7 +1,8 @@ -import type { - WorkspaceColor, - WorkspaceIcon, - WorkspaceItemType, +import { + WORKSPACE_ITEM_NAME_MAX_LENGTH, + type WorkspaceColor, + type WorkspaceIcon, + type WorkspaceItemType, } from "#/features/workspaces/contracts"; import { getWorkspaceItemRegistryEntry } from "#/features/workspaces/workspace-item-registry"; @@ -27,9 +28,9 @@ export function getAvailableWorkspaceItemName(input: { ? normalizeWorkspaceItemName(input.requestedName, "") : ""; const baseName = requestedName || getDefaultWorkspaceItemName(input.type); - const existingNames = new Set(input.existingNames); + const existingNames = new Set(Array.from(input.existingNames, getWorkspaceItemNameKey)); - if (requestedName && !existingNames.has(baseName)) { + if (requestedName && !existingNames.has(getWorkspaceItemNameKey(baseName))) { return baseName; } @@ -37,7 +38,7 @@ export function getAvailableWorkspaceItemName(input: { for (let suffix = 1; suffix < 1000; suffix += 1) { const candidate = `${baseName} ${suffix}`; - if (!existingNames.has(candidate)) { + if (!existingNames.has(getWorkspaceItemNameKey(candidate))) { return candidate; } } @@ -48,7 +49,7 @@ export function getAvailableWorkspaceItemName(input: { for (let suffix = 2; suffix < 1000; suffix += 1) { const candidate = `${baseName} ${suffix}`; - if (!existingNames.has(candidate)) { + if (!existingNames.has(getWorkspaceItemNameKey(candidate))) { return candidate; } } @@ -59,13 +60,25 @@ export function getAvailableWorkspaceItemName(input: { export function normalizeWorkspaceItemName(name: string | null | undefined, fallback = "Untitled") { const normalized = stripControlCharacters(name ?? "") - .replace(/[\\/]+/g, "-") + .normalize("NFC") + .replace(/[<>:"/\\|?*]+/g, "-") .replace(/\s+/g, " ") .trim() - .slice(0, 160) - .trim() ?? ""; + .slice(0, WORKSPACE_ITEM_NAME_MAX_LENGTH) + .trim() + .replace(/[. ]+$/g, "") ?? ""; + + if (!normalized) { + return fallback; + } + + return /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(normalized) + ? `_${normalized}`.slice(0, WORKSPACE_ITEM_NAME_MAX_LENGTH) + : normalized; +} - return normalized || fallback; +export function getWorkspaceItemNameKey(name: string) { + return name.normalize("NFC").toLowerCase(); } function stripControlCharacters(value: string) { diff --git a/src/features/workspaces/export/workspace-export-archive.test.ts b/src/features/workspaces/export/workspace-export-archive.test.ts new file mode 100644 index 000000000..851b81a2f --- /dev/null +++ b/src/features/workspaces/export/workspace-export-archive.test.ts @@ -0,0 +1,108 @@ +import { strFromU8, unzipSync } from "fflate"; +import { describe, expect, it, vi } from "vitest"; + +import type { WorkspaceItemSummary } from "#/features/workspaces/contracts"; +import { createWorkspaceExportStream } from "#/features/workspaces/export/workspace-export-archive"; + +const baseItem = { + workspaceId: "workspace-1", + meta: "", + color: null, + metadataJson: {}, + sortOrder: 0, + createdAt: "2026-08-05T00:00:00.000Z", + updatedAt: "2026-08-05T00:00:00.000Z", + deletedAt: null, +} as const; + +describe("workspace export archive", () => { + it("preserves folders, converts documents to Markdown, and streams original files", async () => { + const items: WorkspaceItemSummary[] = [ + { + ...baseItem, + id: "folder", + parentId: null, + type: "folder", + title: "Research", + name: "Research", + }, + { + ...baseItem, + id: "document", + parentId: "folder", + type: "document", + title: "Notes", + name: "Notes", + }, + { + ...baseItem, + id: "file", + parentId: "folder", + type: "file", + title: "source.pdf", + name: "source.pdf", + }, + { ...baseItem, id: "empty", parentId: null, type: "folder", title: "Empty", name: "Empty" }, + ]; + const archive = await new Response( + createWorkspaceExportStream(items, { + readDocument: vi.fn().mockReturnValue({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "Hello" }] }], + }), + readFile: vi.fn().mockResolvedValue(new Blob(["PDF bytes"]).stream()), + }), + ).arrayBuffer(); + const files = unzipSync(new Uint8Array(archive)); + + expect(Object.keys(files).sort()).toEqual([ + "Empty/", + "Research/", + "Research/Notes.md", + "Research/source.pdf", + ]); + expect(strFromU8(files["Research/Notes.md"]!)).toBe("Hello\n"); + expect(strFromU8(files["Research/source.pdf"]!)).toBe("PDF bytes"); + }); + + it("keeps every item when Markdown extensions collide", async () => { + const items: WorkspaceItemSummary[] = [ + { + ...baseItem, + id: "document-1", + parentId: null, + type: "document", + title: "Notes", + name: "Notes", + }, + { + ...baseItem, + id: "document-2", + parentId: null, + type: "document", + title: "Notes.md", + name: "Notes.md", + }, + { + ...baseItem, + id: "file", + parentId: null, + type: "file", + title: "notes.md", + name: "notes.md", + }, + ]; + const archive = await new Response( + createWorkspaceExportStream(items, { + readDocument: vi.fn().mockReturnValue({ type: "doc" }), + readFile: vi.fn().mockResolvedValue(new Blob(["file"]).stream()), + }), + ).arrayBuffer(); + + expect(Object.keys(unzipSync(new Uint8Array(archive))).sort()).toEqual([ + "Notes (2).md", + "Notes (3).md", + "notes.md", + ]); + }); +}); diff --git a/src/features/workspaces/export/workspace-export-archive.ts b/src/features/workspaces/export/workspace-export-archive.ts new file mode 100644 index 000000000..a0113815f --- /dev/null +++ b/src/features/workspaces/export/workspace-export-archive.ts @@ -0,0 +1,167 @@ +import { Zip, ZipPassThrough } from "fflate"; + +import type { WorkspaceItemSummary } from "#/features/workspaces/contracts"; +import { serializeTiptapDocumentToMarkdown } from "#/features/workspaces/documents/document-markdown"; +import type { TiptapDocumentJson } from "#/features/workspaces/documents/tiptap-document"; +import { buildWorkspaceKernelItemPathIndex } from "#/features/workspaces/kernel/workspace-kernel-paths"; + +const emptyBytes = new Uint8Array(); +const textEncoder = new TextEncoder(); + +interface WorkspaceExportReaders { + readDocument: (item: WorkspaceItemSummary) => TiptapDocumentJson; + readFile: (item: WorkspaceItemSummary) => Promise>; +} + +export function createWorkspaceExportStream( + items: readonly WorkspaceItemSummary[], + readers: WorkspaceExportReaders, +) { + const { readable, writable } = new TransformStream(); + void writeWorkspaceExport(writable, items, readers); + return readable; +} + +async function writeWorkspaceExport( + writable: WritableStream, + items: readonly WorkspaceItemSummary[], + readers: WorkspaceExportReaders, +) { + const writer = writable.getWriter(); + let output = Promise.resolve(); + const zip = new Zip((error, data, final) => { + if (error) { + output = output.then(() => writer.abort(error)); + return; + } + + output = output.then(() => writer.write(data)); + if (final) { + output = output.then(() => writer.close()); + } + }); + + try { + const paths = buildWorkspaceKernelItemPathIndex([...items]); + const archivePaths = buildArchivePathIndex(items, paths); + for (const item of items) { + const path = archivePaths.get(item.id); + if (!path) { + continue; + } + + if (item.type === "folder") { + await addZipBytes(zip, `${path}/`, emptyBytes, () => output); + continue; + } + if (item.type === "document") { + const markdown = serializeTiptapDocumentToMarkdown(readers.readDocument(item)); + await addZipBytes(zip, path, textEncoder.encode(`${markdown}\n`), () => output); + continue; + } + if (item.type === "file") { + await addZipStream(zip, path, await readers.readFile(item), () => output); + } + } + + zip.end(); + await output; + } catch (error) { + zip.terminate(); + await writer.abort(error).catch(() => undefined); + } +} + +function buildArchivePathIndex( + items: readonly WorkspaceItemSummary[], + workspacePaths: ReadonlyMap, +) { + const archivePaths = new Map(); + const reservedPaths = new Set(); + + for (const item of items) { + if (item.type !== "folder" && item.type !== "file") { + continue; + } + const path = workspacePaths.get(item.id)?.slice(1); + if (path) { + archivePaths.set(item.id, path); + reservedPaths.add(path.toLowerCase()); + } + } + + for (const item of items) { + if (item.type !== "document") { + continue; + } + const workspacePath = workspacePaths.get(item.id)?.slice(1); + if (!workspacePath) { + continue; + } + + const markdownPath = workspacePath.toLowerCase().endsWith(".md") + ? workspacePath + : `${workspacePath}.md`; + const path = reserveArchivePath(markdownPath, reservedPaths); + archivePaths.set(item.id, path); + reservedPaths.add(path.toLowerCase()); + } + + return archivePaths; +} + +function reserveArchivePath(path: string, reservedPaths: ReadonlySet) { + if (!reservedPaths.has(path.toLowerCase())) { + return path; + } + + const base = path.slice(0, -3); + for (let suffix = 2; ; suffix += 1) { + const candidate = `${base} (${suffix}).md`; + if (!reservedPaths.has(candidate.toLowerCase())) { + return candidate; + } + } +} + +async function addZipBytes( + zip: Zip, + path: string, + bytes: Uint8Array, + getOutput: () => Promise, +) { + const file = new ZipPassThrough(path); + zip.add(file); + file.push(bytes, true); + await getOutput(); +} + +async function addZipStream( + zip: Zip, + path: string, + stream: ReadableStream, + getOutput: () => Promise, +) { + const file = new ZipPassThrough(path); + zip.add(file); + const reader = stream.getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + file.push(emptyBytes, true); + await getOutput(); + return; + } + + file.push(value); + await getOutput(); + } + } catch (error) { + await reader.cancel(error).catch(() => undefined); + throw error; + } finally { + reader.releaseLock(); + } +} diff --git a/src/features/workspaces/export/workspace-export.ts b/src/features/workspaces/export/workspace-export.ts new file mode 100644 index 000000000..4d8163196 --- /dev/null +++ b/src/features/workspaces/export/workspace-export.ts @@ -0,0 +1,63 @@ +import { env } from "cloudflare:workers"; + +import { normalizeWorkspaceItemName } from "#/features/workspaces/defaults"; +import { + parseTiptapDocumentJson, + type TiptapDocumentJson, +} from "#/features/workspaces/documents/tiptap-document"; +import { createWorkspaceExportStream } from "#/features/workspaces/export/workspace-export-archive"; +import { getWorkspaceKernel } from "#/features/workspaces/kernel/workspace-kernel-access"; +import { WorkspaceForbiddenError } from "#/features/workspaces/server/permissions"; +import { getWorkspacePageForUser } from "#/features/workspaces/server/queries"; + +export async function createWorkspaceExport(input: { workspaceId: string; userId: string }) { + const page = await getWorkspacePageForUser(input.workspaceId, input.userId); + if (!page) { + throw new WorkspaceForbiddenError(); + } + + const kernel = await getWorkspaceKernel(input.workspaceId); + const documents = new Map(); + const fileObjectKeys = new Map(); + + // Resolve fallible metadata before the response starts streaming. Once ZIP + // bytes are sent, an error can only abort the download and leave a partial archive. + for (const item of page.items) { + if (item.type === "document") { + const { content } = await kernel.readDocumentCheckpoint({ itemId: item.id }); + documents.set(item.id, parseTiptapDocumentJson(content)); + continue; + } + if (item.type === "file") { + const source = await kernel.getFileSource({ itemId: item.id }); + if (!(await env.WORKSPACE_KERNEL_FILES.head(source.objectKey))) { + throw new Error(`Workspace file source is missing for ${item.name}.`); + } + fileObjectKeys.set(item.id, source.objectKey); + } + } + + return { + fileName: `${normalizeWorkspaceItemName(page.workspace.name, "Workspace")}-${new Date().toISOString().slice(0, 10)}.zip`, + stream: createWorkspaceExportStream(page.items, { + readDocument: (item) => { + const document = documents.get(item.id); + if (!document) { + throw new Error(`Workspace document was not prepared for ${item.name}.`); + } + return document; + }, + readFile: async (item) => { + const objectKey = fileObjectKeys.get(item.id); + if (!objectKey) { + throw new Error(`Workspace file was not prepared for ${item.name}.`); + } + const object = await env.WORKSPACE_KERNEL_FILES.get(objectKey); + if (!object) { + throw new Error(`Workspace file source is missing for ${item.name}.`); + } + return object.body; + }, + }), + }; +} diff --git a/src/features/workspaces/kernel/workspace-kernel-store.ts b/src/features/workspaces/kernel/workspace-kernel-store.ts index 018db6023..90356671b 100644 --- a/src/features/workspaces/kernel/workspace-kernel-store.ts +++ b/src/features/workspaces/kernel/workspace-kernel-store.ts @@ -5,6 +5,7 @@ import type { } from "#/features/workspaces/contracts"; import { getAvailableWorkspaceItemName, + getWorkspaceItemNameKey, normalizeWorkspaceItemName, } from "#/features/workspaces/defaults"; import { @@ -200,11 +201,10 @@ export class WorkspaceKernelStore { ...this.getActiveSiblingNames(input.parentId, input.excludeItemId), ...(input.reservedNames ?? []), ]; - const requestedName = input.requestedName - ? normalizeWorkspaceItemName(input.requestedName, "") - : ""; - if (input.onNameConflict === "error") { + const requestedName = input.requestedName + ? normalizeWorkspaceItemName(input.requestedName, "") + : ""; if (!requestedName) { return { conflict: { @@ -216,7 +216,12 @@ export class WorkspaceKernelStore { }; } - if (existingNames.includes(requestedName)) { + if ( + existingNames.some( + (existingName) => + getWorkspaceItemNameKey(existingName) === getWorkspaceItemNameKey(requestedName), + ) + ) { return { conflict: { code: "name_conflict", diff --git a/src/features/workspaces/operations/create-items.ts b/src/features/workspaces/operations/create-items.ts index 671b3204d..08e53b467 100644 --- a/src/features/workspaces/operations/create-items.ts +++ b/src/features/workspaces/operations/create-items.ts @@ -54,7 +54,7 @@ export interface CreateWorkspaceItemsOperationResult { type CreateWorkspaceItemPathResolution = | { - code: "cannot_create_root" | "path_not_absolute" | "path_not_canonical"; + code: "cannot_create_root" | "path_not_absolute"; path: string; status: "failed"; } @@ -247,14 +247,6 @@ function resolveCreateWorkspaceItemPath(path: string): CreateWorkspaceItemPathRe const name = getWorkspacePathName(normalizedPath); const canonicalPath = joinWorkspaceItemPath(parentPath, name); - if (canonicalPath !== normalizedPath) { - return { - code: "path_not_canonical", - path: normalizedPath, - status: "failed", - }; - } - return { name, parentPath, diff --git a/src/features/workspaces/operations/workspace-operation-failure-codes.ts b/src/features/workspaces/operations/workspace-operation-failure-codes.ts index 6294c9d7b..c5c54e0c1 100644 --- a/src/features/workspaces/operations/workspace-operation-failure-codes.ts +++ b/src/features/workspaces/operations/workspace-operation-failure-codes.ts @@ -11,7 +11,6 @@ export const createWorkspaceItemsFailureCodes = [ "invalid_initial_content", "path_already_exists", "path_not_absolute", - "path_not_canonical", "path_not_folder", "path_not_found", ...workspaceRelationFailureCodes, diff --git a/src/features/workspaces/operations/workspace-tool-schemas.ts b/src/features/workspaces/operations/workspace-tool-schemas.ts index 4d9247c92..4262cb413 100644 --- a/src/features/workspaces/operations/workspace-tool-schemas.ts +++ b/src/features/workspaces/operations/workspace-tool-schemas.ts @@ -13,6 +13,7 @@ import { renameWorkspaceItemFailureCodes, } from "#/features/workspaces/operations/workspace-operation-failure-codes"; import { + WORKSPACE_ITEM_NAME_MAX_LENGTH, workspaceItemTypeSchema, workspaceRelationKindSchema, } from "#/features/workspaces/contracts"; @@ -151,7 +152,12 @@ export const workspaceLinkItemsInputSchema = z.object({ }); export const workspaceRenameItemInputSchema = z.object({ - name: z.string().trim().min(1).max(160).describe("New user-visible item name."), + name: z + .string() + .trim() + .min(1) + .max(WORKSPACE_ITEM_NAME_MAX_LENGTH) + .describe("New user-visible item name."), path: z.string().min(1).describe("Absolute path of one actual ThinkEx workspace item to rename."), }); diff --git a/src/features/workspaces/server/queries.ts b/src/features/workspaces/server/queries.ts index d0a8d4360..d8ba45849 100644 --- a/src/features/workspaces/server/queries.ts +++ b/src/features/workspaces/server/queries.ts @@ -51,41 +51,58 @@ export async function getWorkspacePageForCurrentUser( const [userId, dbContext] = await Promise.all([getCurrentUserId(), createDbContext()]); try { - const [workspaceRow] = await dbContext.db - .select({ - lastOpenedAt: workspaceMembers.lastOpenedAt, - membershipRole: workspaceMembers.role, - workspace: workspaces, - }) - .from(workspaceMembers) - .innerJoin(workspaces, eq(workspaceMembers.workspaceId, workspaces.id)) - .where( - and( - eq(workspaceMembers.workspaceId, workspaceId), - eq(workspaceMembers.userId, userId), - isNull(workspaces.archivedAt), - ), - ) - .limit(1); - - if (!workspaceRow) { - return null; - } + return await getWorkspacePage(dbContext.db, workspaceId, userId); + } finally { + await dbContext.dispose(); + } +} - const workspace = mapWorkspaceDetailRow( - { - ...workspaceRow.workspace, - lastOpenedAt: workspaceRow.lastOpenedAt, - }, - workspaceRow.membershipRole, - ); +export async function getWorkspacePageForUser( + workspaceId: string, + userId: string, +): Promise { + const dbContext = await createDbContext(); - return await getWorkspaceKernelPage({ - workspaceId, - userId, - workspace, - }); + try { + return await getWorkspacePage(dbContext.db, workspaceId, userId); } finally { await dbContext.dispose(); } } + +async function getWorkspacePage(db: Db, workspaceId: string, userId: string) { + const [workspaceRow] = await db + .select({ + lastOpenedAt: workspaceMembers.lastOpenedAt, + membershipRole: workspaceMembers.role, + workspace: workspaces, + }) + .from(workspaceMembers) + .innerJoin(workspaces, eq(workspaceMembers.workspaceId, workspaces.id)) + .where( + and( + eq(workspaceMembers.workspaceId, workspaceId), + eq(workspaceMembers.userId, userId), + isNull(workspaces.archivedAt), + ), + ) + .limit(1); + + if (!workspaceRow) { + return null; + } + + const workspace = mapWorkspaceDetailRow( + { + ...workspaceRow.workspace, + lastOpenedAt: workspaceRow.lastOpenedAt, + }, + workspaceRow.membershipRole, + ); + + return await getWorkspaceKernelPage({ + workspaceId, + userId, + workspace, + }); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 7ff8bc377..fc83da84a 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -30,6 +30,7 @@ import { Route as ProtectedWorkspacesWorkspaceIdRouteImport } from './routes/_pr import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiPosthogSurveyFeedbackRouteImport } from './routes/api/posthog/survey-feedback' import { Route as ApiV1WorkspacesRouteImport } from './routes/api/v1/workspaces' +import { Route as ApiV1WorkspacesWorkspaceIdExportRouteImport } from './routes/api/v1/workspaces.$workspaceId.export' import { Route as ApiV1WorkspacesWorkspaceIdFileUploadRouteImport } from './routes/api/v1/workspaces.$workspaceId.file-upload' import { Route as ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRouteImport } from './routes/api/v1/workspaces.$workspaceId.ai-threads.$threadId.attachments' import { Route as ApiV1WorkspacesWorkspaceIdFilesItemIdContentRouteImport } from './routes/api/v1/workspaces.$workspaceId.files.$itemId.content' @@ -142,6 +143,12 @@ const ApiV1WorkspacesRoute = ApiV1WorkspacesRouteImport.update({ path: '/api/v1/workspaces', getParentRoute: () => rootRouteImport, } as any) +const ApiV1WorkspacesWorkspaceIdExportRoute = + ApiV1WorkspacesWorkspaceIdExportRouteImport.update({ + id: '/$workspaceId/export', + path: '/$workspaceId/export', + getParentRoute: () => ApiV1WorkspacesRoute, + } as any) const ApiV1WorkspacesWorkspaceIdFileUploadRoute = ApiV1WorkspacesWorkspaceIdFileUploadRouteImport.update({ id: '/$workspaceId/file-upload', @@ -197,6 +204,7 @@ export interface FileRoutesByFullPath { '/api/auth/$': typeof ApiAuthSplatRoute '/api/posthog/survey-feedback': typeof ApiPosthogSurveyFeedbackRoute '/api/v1/workspaces': typeof ApiV1WorkspacesRouteWithChildren + '/api/v1/workspaces/$workspaceId/export': typeof ApiV1WorkspacesWorkspaceIdExportRoute '/api/v1/workspaces/$workspaceId/file-upload': typeof ApiV1WorkspacesWorkspaceIdFileUploadRoute '/api/v1/workspaces/$workspaceId/ai-threads/$threadId/attachments': typeof ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRouteWithChildren '/api/v1/workspaces/$workspaceId/files/$itemId/content': typeof ApiV1WorkspacesWorkspaceIdFilesItemIdContentRoute @@ -223,6 +231,7 @@ export interface FileRoutesByTo { '/api/auth/$': typeof ApiAuthSplatRoute '/api/posthog/survey-feedback': typeof ApiPosthogSurveyFeedbackRoute '/api/v1/workspaces': typeof ApiV1WorkspacesRouteWithChildren + '/api/v1/workspaces/$workspaceId/export': typeof ApiV1WorkspacesWorkspaceIdExportRoute '/api/v1/workspaces/$workspaceId/file-upload': typeof ApiV1WorkspacesWorkspaceIdFileUploadRoute '/api/v1/workspaces/$workspaceId/ai-threads/$threadId/attachments': typeof ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRouteWithChildren '/api/v1/workspaces/$workspaceId/files/$itemId/content': typeof ApiV1WorkspacesWorkspaceIdFilesItemIdContentRoute @@ -252,6 +261,7 @@ export interface FileRoutesById { '/api/auth/$': typeof ApiAuthSplatRoute '/api/posthog/survey-feedback': typeof ApiPosthogSurveyFeedbackRoute '/api/v1/workspaces': typeof ApiV1WorkspacesRouteWithChildren + '/api/v1/workspaces/$workspaceId/export': typeof ApiV1WorkspacesWorkspaceIdExportRoute '/api/v1/workspaces/$workspaceId/file-upload': typeof ApiV1WorkspacesWorkspaceIdFileUploadRoute '/api/v1/workspaces/$workspaceId/ai-threads/$threadId/attachments': typeof ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRouteWithChildren '/api/v1/workspaces/$workspaceId/files/$itemId/content': typeof ApiV1WorkspacesWorkspaceIdFilesItemIdContentRoute @@ -281,6 +291,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/posthog/survey-feedback' | '/api/v1/workspaces' + | '/api/v1/workspaces/$workspaceId/export' | '/api/v1/workspaces/$workspaceId/file-upload' | '/api/v1/workspaces/$workspaceId/ai-threads/$threadId/attachments' | '/api/v1/workspaces/$workspaceId/files/$itemId/content' @@ -307,6 +318,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/posthog/survey-feedback' | '/api/v1/workspaces' + | '/api/v1/workspaces/$workspaceId/export' | '/api/v1/workspaces/$workspaceId/file-upload' | '/api/v1/workspaces/$workspaceId/ai-threads/$threadId/attachments' | '/api/v1/workspaces/$workspaceId/files/$itemId/content' @@ -335,6 +347,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/posthog/survey-feedback' | '/api/v1/workspaces' + | '/api/v1/workspaces/$workspaceId/export' | '/api/v1/workspaces/$workspaceId/file-upload' | '/api/v1/workspaces/$workspaceId/ai-threads/$threadId/attachments' | '/api/v1/workspaces/$workspaceId/files/$itemId/content' @@ -509,6 +522,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiV1WorkspacesRouteImport parentRoute: typeof rootRouteImport } + '/api/v1/workspaces/$workspaceId/export': { + id: '/api/v1/workspaces/$workspaceId/export' + path: '/$workspaceId/export' + fullPath: '/api/v1/workspaces/$workspaceId/export' + preLoaderRoute: typeof ApiV1WorkspacesWorkspaceIdExportRouteImport + parentRoute: typeof ApiV1WorkspacesRoute + } '/api/v1/workspaces/$workspaceId/file-upload': { id: '/api/v1/workspaces/$workspaceId/file-upload' path: '/$workspaceId/file-upload' @@ -593,6 +613,7 @@ const ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRouteWithChildren = ) interface ApiV1WorkspacesRouteChildren { + ApiV1WorkspacesWorkspaceIdExportRoute: typeof ApiV1WorkspacesWorkspaceIdExportRoute ApiV1WorkspacesWorkspaceIdFileUploadRoute: typeof ApiV1WorkspacesWorkspaceIdFileUploadRoute ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRoute: typeof ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRouteWithChildren ApiV1WorkspacesWorkspaceIdFilesItemIdContentRoute: typeof ApiV1WorkspacesWorkspaceIdFilesItemIdContentRoute @@ -600,6 +621,7 @@ interface ApiV1WorkspacesRouteChildren { } const ApiV1WorkspacesRouteChildren: ApiV1WorkspacesRouteChildren = { + ApiV1WorkspacesWorkspaceIdExportRoute: ApiV1WorkspacesWorkspaceIdExportRoute, ApiV1WorkspacesWorkspaceIdFileUploadRoute: ApiV1WorkspacesWorkspaceIdFileUploadRoute, ApiV1WorkspacesWorkspaceIdAiThreadsThreadIdAttachmentsRoute: diff --git a/src/routes/api/v1/workspaces.$workspaceId.export.ts b/src/routes/api/v1/workspaces.$workspaceId.export.ts new file mode 100644 index 000000000..cca1ec8fb --- /dev/null +++ b/src/routes/api/v1/workspaces.$workspaceId.export.ts @@ -0,0 +1,56 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { createWorkspaceExport } from "#/features/workspaces/export/workspace-export"; +import { WorkspaceForbiddenError } from "#/features/workspaces/server/permissions"; +import { apiError, getRequestId } from "#/lib/api/http"; +import { getSessionFromRequest } from "#/lib/auth-queries.server"; + +async function handleWorkspaceExport(request: Request, workspaceId: string) { + const requestId = getRequestId(request); + const session = await getSessionFromRequest(request); + if (!session) { + return apiError(requestId, 401, "UNAUTHORIZED", "You must be signed in to export a workspace."); + } + + try { + const archive = await createWorkspaceExport({ + workspaceId, + userId: session.user.id, + }); + return new Response(archive.stream, { + headers: { + "cache-control": "private, no-store", + "content-disposition": getAttachmentHeader(archive.fileName), + "content-type": "application/zip", + "x-request-id": requestId, + }, + }); + } catch (error) { + if (error instanceof WorkspaceForbiddenError) { + return apiError( + requestId, + 403, + "FORBIDDEN", + "You do not have permission to export this workspace.", + ); + } + + console.error("Workspace export failed.", { error, requestId, workspaceId }); + return apiError(requestId, 500, "EXPORT_FAILED", "Unable to export this workspace."); + } +} + +export const Route = createFileRoute("/api/v1/workspaces/$workspaceId/export")({ + server: { + handlers: { + GET: ({ params, request }) => handleWorkspaceExport(request, params.workspaceId), + }, + }, +}); + +function getAttachmentHeader(fileName: string) { + const fallback = fileName.replace(/[^\x20-\x7e]|["\\]/g, "_"); + return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(fileName)}`; +} + +export { handleWorkspaceExport };