From 7f8b367076747bddb304c3b8e4dc9a5ee5fc4cb1 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 21 Sep 2026 17:34:16 +0000 Subject: [PATCH 01/18] feat(workspace): document portability and private reuse --- .env.example | 1 + .../features/workspace/WorkspaceEditor.tsx | 41 ++- .../workspace/WorkspacePortabilityPanel.tsx | 219 ++++++++++++++ src/lib/workspace/document-conversion.ts | 246 +++++++++++++-- src/lib/workspace/google-drive.ts | 112 +++++++ src/lib/workspace/portability-client.ts | 120 ++++++++ src/locales/en.json | 27 ++ src/locales/es.json | 27 ++ src/locales/hu.json | 27 ++ src/pages/LibraryPage.tsx | 21 ++ src/pages/WorkspacePage.tsx | 19 ++ .../workspace/document-conversion.test.ts | 16 + supabase/README.md | 5 +- supabase/config.toml | 6 + .../functions/_shared/google-connections.ts | 134 +++++++++ .../functions/google-connections/index.ts | 154 ++++++++++ .../functions/workspace-google-docs/index.ts | 193 ++++++++++++ .../20260921190000_workspace_portability.sql | 281 ++++++++++++++++++ 18 files changed, 1616 insertions(+), 33 deletions(-) create mode 100644 src/components/features/workspace/WorkspacePortabilityPanel.tsx create mode 100644 src/lib/workspace/google-drive.ts create mode 100644 src/lib/workspace/portability-client.ts create mode 100644 supabase/functions/_shared/google-connections.ts create mode 100644 supabase/functions/google-connections/index.ts create mode 100644 supabase/functions/workspace-google-docs/index.ts create mode 100644 supabase/migrations/20260921190000_workspace_portability.sql diff --git a/.env.example b/.env.example index a3f0218a8..11ddf9ec9 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ VITE_SENTRY_DSN= VITE_POSTHOG_KEY= VITE_POSTHOG_HOST=https://eu.i.posthog.com +VITE_GOOGLE_PICKER_API_KEY= diff --git a/src/components/features/workspace/WorkspaceEditor.tsx b/src/components/features/workspace/WorkspaceEditor.tsx index cea48f2e7..59593afd4 100644 --- a/src/components/features/workspace/WorkspaceEditor.tsx +++ b/src/components/features/workspace/WorkspaceEditor.tsx @@ -21,6 +21,8 @@ import { createProposal, createQuoteAnchor, type ProposalHunkInput } from "@/lib import { createWorkspaceComment, saveWorkspaceProposal } from "@/lib/workspace/client"; import { useOnlineStatus } from "@/hooks/use-online-status"; import type { WorkspaceSource } from "@/lib/workspace/practice-client"; +import type { JSONContent } from "@tiptap/core"; +import { savePrivateWritingItem } from "@/lib/workspace/portability-client"; const acknowledgementSchema = z.object({ epoch: z.number().int().nonnegative(), @@ -34,6 +36,7 @@ export type SaveStatus = "Offline" | "Saved" | "Saving"; export function WorkspaceEditor({ documentId, citationToInsert, + contentToInsert, aiSuggestion, editable, epoch, @@ -44,12 +47,15 @@ export function WorkspaceEditor({ workspaceId, onDurableSequence, onCitationInserted, + onContentInserted, onAiSuggestionConsumed, onSaveStatus, onTextChange, + onDocumentChange, }: { documentId: string; citationToInsert?: WorkspaceSource | null; + contentToInsert?: { content: JSONContent; id: string; mode: "insert" | "replace" } | null; aiSuggestion?: { id: string; text: string } | null; editable: boolean; epoch: number; @@ -60,9 +66,11 @@ export function WorkspaceEditor({ workspaceId: string; onDurableSequence?: (sequence: number) => void; onCitationInserted?: () => void; + onContentInserted?: () => void; onAiSuggestionConsumed?: () => void; onSaveStatus?: (status: SaveStatus) => void; onTextChange?: (text: string) => void; + onDocumentChange?: (content: JSONContent) => void; }) { const { t } = useTranslation(); const online = useOnlineStatus(); @@ -243,7 +251,10 @@ export function WorkspaceEditor({ provider, user: { color: `hsl(${Math.abs(userId.charCodeAt(0) * 47) % 360} 70% 45%)`, name: userName }, }); - reportText = () => onTextChange?.(activeEditor?.getText() ?? ""); + reportText = () => { + onTextChange?.(activeEditor?.getText() ?? ""); + if (activeEditor) onDocumentChange?.(activeEditor.getJSON()); + }; activeEditor.on("update", reportText); reportText(); document.on("update", (_update, origin) => { @@ -268,7 +279,7 @@ export function WorkspaceEditor({ provider?.destroy(); document.destroy(); }; - }, [documentId, editable, epoch, identity, onDurableSequence, onTextChange, online, queryClient, t, token, userId, userName, workspaceId]); + }, [documentId, editable, epoch, identity, onDocumentChange, onDurableSequence, onTextChange, online, queryClient, t, token, userId, userName, workspaceId]); useEffect(() => { if (!editor || !citationToInsert) return; @@ -286,6 +297,30 @@ export function WorkspaceEditor({ onCitationInserted?.(); }, [citationToInsert, editor, onCitationInserted]); + useEffect(() => { + if (!editor || !contentToInsert || status !== "Saved") return; + if (contentToInsert.mode === "replace") editor.commands.setContent(contentToInsert.content); + else editor.chain().focus().insertContent(contentToInsert.content.content ?? []).run(); + onContentInserted?.(); + }, [contentToInsert, editor, onContentInserted, status]); + + const saveWriting = async (kind: "snippet" | "template") => { + if (!editor) return; + const content = kind === "snippet" + ? { type: "doc", content: editor.state.selection.content().content.toJSON() } + : editor.getJSON(); + if (kind === "snippet" && !content.content.length) return; + const title = window.prompt(t(kind === "snippet" ? "workspaces.snippetTitlePrompt" : "workspaces.templateTitlePrompt")); + if (!title?.trim()) return; + await savePrivateWritingItem({ + content, + kind, + provenance: { documentEpoch: epoch, documentId, workspaceId }, + title: title.trim(), + }); + await queryClient.invalidateQueries({ queryKey: ["private-writing-items"] }); + }; + useEffect(() => { if (!editor || !aiSuggestion || status !== "Saved") return; onAiSuggestionConsumed?.(); @@ -326,6 +361,8 @@ export function WorkspaceEditor({ setLink(event.target.value)} /> + + diff --git a/src/components/features/workspace/WorkspacePortabilityPanel.tsx b/src/components/features/workspace/WorkspacePortabilityPanel.tsx new file mode 100644 index 000000000..c6ae96394 --- /dev/null +++ b/src/components/features/workspace/WorkspacePortabilityPanel.tsx @@ -0,0 +1,219 @@ +import { useState } from "react"; +import type { JSONContent } from "@tiptap/core"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { + previewWorkspaceImport, + fromWorkspaceMarkdown, + toWorkspaceMarkdown, + toWorkspaceDocx, + toWorkspacePacketDocx, + toWorkspacePacketPdf, + toWorkspacePdf, +} from "@/lib/workspace/document-conversion"; +import { listWorkspacePractice } from "@/lib/workspace/practice-client"; +import { + deletePrivateWritingItem, + getWorkspaceExportManifest, + listPrivateWritingItems, + saveWorkspaceDocumentProvenance, +} from "@/lib/workspace/portability-client"; +import { workspaceSchema } from "@/lib/workspace/schema"; +import { + connectGoogleAccount, + disconnectGoogleAccount, + exportGoogleDocument, + importGoogleDocument, + listGoogleConnections, + pickGoogleDocument, +} from "@/lib/workspace/google-drive"; + +function download(bytes: Uint8Array, name: string, type: string) { + const blob = new Blob([new Uint8Array(bytes)], { type }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = name; + link.click(); + URL.revokeObjectURL(url); +} + +export function WorkspacePortabilityPanel({ + content, + documentId, + documentTitle, + onInsertContent, + userId, + workspaceId, +}: { + content: JSONContent; + documentId: string; + documentTitle: string; + onInsertContent: (content: JSONContent, mode: "insert" | "replace") => void; + userId: string; + workspaceId: string; +}) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [preview, setPreview] = useState<{ + content: JSONContent; + markdown: string; + name: string; + provenance: Record; + sourceKind: "markdown" | "docx" | "google_docs"; + warnings: string[]; + } | null>(null); + const [selectedArtifactIds, setSelectedArtifactIds] = useState([]); + const [includeReview, setIncludeReview] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [connectionId, setConnectionId] = useState(""); + const [googleExportRequestId, setGoogleExportRequestId] = useState(() => crypto.randomUUID()); + const library = useQuery({ queryKey: ["private-writing-items", userId], queryFn: listPrivateWritingItems }); + const practice = useQuery({ queryKey: ["workspace-practice", userId, workspaceId], queryFn: () => listWorkspacePractice(workspaceId) }); + const connections = useQuery({ queryKey: ["google-connections", userId], queryFn: listGoogleConnections }); + const selectedConnectionId = connectionId || connections.data?.[0]?.id || ""; + const run = async (operation: () => Promise) => { + if (busy) return; + setBusy(true); + setError(null); + try { + await operation(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : t("workspaces.portabilityFailed", "The document operation failed.")); + } finally { + setBusy(false); + } + }; + const insertItem = (value: unknown, mode: "insert" | "replace") => { + try { + onInsertContent(workspaceSchema.nodeFromJSON(value).toJSON(), mode); + } catch { + setError(t("workspaces.invalidLibraryItem", "This saved item is not a valid workspace document.")); + } + }; + const packet = async (format: "docx" | "pdf") => { + const manifest = await getWorkspaceExportManifest(workspaceId, selectedArtifactIds, includeReview); + const bytes = format === "docx" ? await toWorkspacePacketDocx(manifest) : await toWorkspacePacketPdf(manifest); + download(bytes, `workspace-packet.${format}`, format === "docx" ? "application/vnd.openxmlformats-officedocument.wordprocessingml.document" : "application/pdf"); + }; + + return ( +
+ {t("workspaces.portability", "Import, reuse & export")} + +
+

{t("workspaces.privateWriting", "Private snippets and templates")}

+ {library.data?.map((item) => ( +
+ {item.kind} · {item.title} + + {item.kind === "template" && } + +
+ ))} +
+ +
+ + { + const file = event.target.files?.[0]; + if (!file) return; + void run(async () => { + const imported = await previewWorkspaceImport(file); + setPreview({ + ...imported, + name: file.name, + provenance: { fileName: file.name, fileSize: file.size, importedAt: new Date().toISOString() }, + sourceKind: file.name.toLowerCase().endsWith(".docx") ? "docx" : "markdown", + }); + }); + }} /> + {preview && ( +
+

{preview.name}

+ {preview.warnings.map((warning) =>

{warning}

)} +