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/.superset/workspace-trial-nginx.conf b/.superset/workspace-trial-nginx.conf new file mode 100644 index 000000000..60a79cded --- /dev/null +++ b/.superset/workspace-trial-nginx.conf @@ -0,0 +1,50 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 127.0.0.1:18480; + server_name codex-remote.tailab0ab3.ts.net; + + if ($http_host !~ ^(codex-remote\.tailab0ab3\.ts\.net(:18443)?|127\.0\.0\.1:18480)$) { return 444; } + + root /usr/share/nginx/html; + index index.html; + + add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' https://*.jam.dev; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com https://*.jam.dev; connect-src 'self' wss://codex-remote.tailab0ab3.ts.net:18443 https://*.jam.dev https://oauth2.googleapis.com https://www.googleapis.com;" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + add_header X-Robots-Tag "noindex, nofollow" always; + + location ^~ /supabase/ { + proxy_pass http://127.0.0.1:54321/; + proxy_set_header Host 127.0.0.1:54321; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + } + + location = /collaboration { + proxy_pass http://127.0.0.1:12345; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host 127.0.0.1:12345; + proxy_read_timeout 1h; + } + + location ^~ /access/ { + proxy_pass http://127.0.0.1:12345; + proxy_set_header Host 127.0.0.1:12345; + } + + location ^~ /review/ { + proxy_pass http://127.0.0.1:12345; + proxy_set_header Host 127.0.0.1:12345; + } + + location ~ /\. { return 404; } + location ~* \.(?:env|log|lock|md|ya?ml)$ { return 404; } + location / { try_files $uri $uri/ /index.html; } +} diff --git a/.superset/workspace-trial.sh b/.superset/workspace-trial.sh new file mode 100755 index 000000000..a735456a1 --- /dev/null +++ b/.superset/workspace-trial.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$DIR/lib/lock.sh" + +DB_LOCK="$HOME/.superset/debatelab-db.lock" +TRIAL_ORIGIN="https://codex-remote.tailab0ab3.ts.net:18443" +APP_PORT=18480 +COLLABORATION_PORT=12345 +SERVE_PORT=18443 +PROXY_NAME="debatelab-workspace-trial-proxy-$$" + +runtime_dir="" +stack_started=false +serve_started=false +proxy_started=false +owned_pids=() + +stop_owned_processes() { + local index + if [ "$proxy_started" = true ]; then + docker rm --force "$PROXY_NAME" >/dev/null 2>&1 || true + fi + for ((index=${#owned_pids[@]}-1; index>=0; index--)); do + kill -- "-${owned_pids[$index]}" 2>/dev/null || true + done + for ((index=${#owned_pids[@]}-1; index>=0; index--)); do + wait "${owned_pids[$index]}" 2>/dev/null || true + done +} + +cleanup() { + local status=$? + trap - EXIT INT TERM + if [ "$serve_started" = true ]; then + tailscale serve --https="$SERVE_PORT" off >/dev/null 2>&1 || status=1 + fi + stop_owned_processes + if [ "$stack_started" = true ] && ! supabase stop; then + status=1 + fi + if [ -n "$runtime_dir" ] && [ -d "$runtime_dir" ]; then + rm -r -- "$runtime_dir" + fi + lock_release "$DB_LOCK" + exit "$status" +} + +for port in "$APP_PORT" "$COLLABORATION_PORT"; do + if ss -ltn "sport = :$port" | tail -n +2 | grep -q .; then + echo "Workspace trial port $port is already in use" >&2 + exit 1 + fi +done +if tailscale serve status --json | node -e 'const s=JSON.parse(require("fs").readFileSync(0,"utf8")); process.exit(s.TCP?.[process.argv[1]] ? 0 : 1)' "$SERVE_PORT"; then + echo "Tailscale Serve port $SERVE_PORT is already configured" >&2 + exit 1 +fi +if supabase status -o json >/dev/null 2>&1; then + echo "A local Supabase stack is already running" >&2 + exit 1 +fi + +lock_acquire "$DB_LOCK" 600 +trap cleanup EXIT INT TERM +runtime_dir="$(mktemp -d)" + +supabase start >"$runtime_dir/supabase.log" 2>&1 +stack_started=true +status_json="$(supabase status -o json 2>/dev/null)" +status_value() { + node -e 'const value=JSON.parse(process.argv[1])[process.argv[2]]; if (!value) process.exit(1); process.stdout.write(String(value))' "$status_json" "$1" +} +api_url="$(status_value API_URL)" +anon_key="$(status_value ANON_KEY)" +service_role_key="$(status_value SERVICE_ROLE_KEY)" +database_url="$(status_value DB_URL)" +project_id="$(basename "$(pwd)")" +mailer_host="$(docker ps --filter "name=supabase_inbucket_$project_id" --format '{{.Names}}')" +if [ -z "$mailer_host" ]; then + echo "Local Mailpit container is unavailable" >&2 + exit 1 +fi + +LOCAL_SERVICE_ROLE_KEY="$service_role_key" \ +LOCAL_MAILER_HOST="$mailer_host" \ + node scripts/workspace-test-env.mjs .superset/workspace-test.env "$runtime_dir/function.env" "$TRIAL_ORIGIN" + +setsid supabase functions serve workspace-invites --no-verify-jwt \ + --env-file "$runtime_dir/function.env" >"$runtime_dir/functions.log" 2>&1 & +owned_pids+=("$!") + +COLLABORATION_DATABASE_URL="$database_url" \ +COLLABORATION_PORT="$COLLABORATION_PORT" \ +COLLABORATION_ALLOWED_ORIGINS="$TRIAL_ORIGIN" \ +SUPABASE_URL="$api_url" \ +SUPABASE_ANON_KEY="$anon_key" \ + setsid npm run start:collaboration >"$runtime_dir/collaboration.log" 2>&1 & +owned_pids+=("$!") + +VITE_LOCAL_PREVIEW=true \ +VITE_LOCAL_SUPABASE_URL="$TRIAL_ORIGIN/supabase" \ +VITE_LOCAL_SUPABASE_ANON_KEY="$anon_key" \ +VITE_COLLABORATION_URL="wss://codex-remote.tailab0ab3.ts.net:$SERVE_PORT/collaboration" \ + npm run build + +setsid docker run --rm --name "$PROXY_NAME" --network host \ + --volume "$PWD/dist:/usr/share/nginx/html:ro" \ + --volume "$DIR/workspace-trial-nginx.conf:/etc/nginx/conf.d/default.conf:ro" \ + nginx:alpine >"$runtime_dir/proxy.log" 2>&1 & +owned_pids+=("$!") +proxy_started=true + +for _ in $(seq 1 200); do + if curl --fail --silent --output /dev/null --header "Host: codex-remote.tailab0ab3.ts.net:$SERVE_PORT" "http://127.0.0.1:$APP_PORT"; then break; fi + sleep 0.1 +done +curl --fail --silent --output /dev/null --header "Host: codex-remote.tailab0ab3.ts.net:$SERVE_PORT" "http://127.0.0.1:$APP_PORT" + +tailscale serve --bg --https="$SERVE_PORT" "http://127.0.0.1:$APP_PORT" +serve_started=true +tailscale_ip="$(tailscale ip -4)" +curl --fail --silent --output /dev/null \ + --resolve "codex-remote.tailab0ab3.ts.net:$SERVE_PORT:$tailscale_ip" \ + "$TRIAL_ORIGIN" + +echo "Workspace trial ready: $TRIAL_ORIGIN" +echo "Commit: $(git rev-parse HEAD)" +echo "Stop with: systemctl --user stop debatelab-workspace-trial.service" + +while kill -0 "${owned_pids[0]}" 2>/dev/null && kill -0 "${owned_pids[1]}" 2>/dev/null; do + sleep 5 +done +echo "A workspace trial process exited unexpectedly" >&2 +exit 1 diff --git a/e2e/workspace-collaboration.spec.ts b/e2e/workspace-collaboration.spec.ts index c9b23392d..b1b4f7f68 100644 --- a/e2e/workspace-collaboration.spec.ts +++ b/e2e/workspace-collaboration.spec.ts @@ -10,6 +10,7 @@ const anonKey = process.env.LOCAL_SUPABASE_ANON_KEY; const serviceKey = process.env.LOCAL_SUPABASE_SERVICE_ROLE_KEY; const mailerUrl = process.env.WORKSPACE_TEST_MAILER_URL; const collaborationPort = Number(process.env.COLLABORATION_PORT ?? 12345); +const reuseCollaboration = process.env.WORKSPACE_TEST_REUSE_COLLABORATION === "true"; test.skip(!supabaseUrl || !anonKey || !serviceKey || !mailerUrl, "Local workspace services are required"); @@ -42,6 +43,10 @@ async function stop(child: ChildProcess | undefined) { } async function startCollaboration() { + if (reuseCollaboration) { + await waitForPort(collaborationPort); + return undefined; + } const child = spawn("npm", ["run", "start:collaboration"], { cwd: process.cwd(), detached: true, @@ -488,3 +493,83 @@ test("workspace evidence, team AI review, and practice survive a teammate reload await Promise.allSettled(ids.map((id) => admin.auth.admin.deleteUser(id))); } }); + +test("private writing and independent imports export useful workspace files", async ({ browser }, testInfo) => { + test.skip(testInfo.project.name === "mobile-chromium", "The complete mobile collaboration journey runs above"); + test.setTimeout(120_000); + const admin = createClient(supabaseUrl!, serviceKey!, { auth: { persistSession: false } }); + const password = `Local-${crypto.randomUUID()}!`; + const ids: string[] = []; + const contexts: BrowserContext[] = []; + let collaboration: ChildProcess | undefined; + + try { + const email = `portability-${Date.now()}-${crypto.randomUUID()}@workspace.test`; + const created = await admin.auth.admin.createUser({ email, email_confirm: true, password }); + if (created.error || !created.data.user) throw created.error ?? new Error("User creation failed"); + ids.push(created.data.user.id); + const client = createClient(supabaseUrl!, anonKey!, { auth: { persistSession: false } }); + const signedIn = await client.auth.signInWithPassword({ email, password }); + if (signedIn.error || !signedIn.data.session) throw signedIn.error ?? new Error("Sign in failed"); + + collaboration = await startCollaboration(); + const owner = await authenticatedPage(browser, signedIn.data.session); + contexts.push(owner.context); + await owner.page.goto("/app/workspaces"); + await owner.page.getByRole("textbox", { name: "Motion" }).fill("Schools should teach media literacy"); + await owner.page.getByRole("button", { name: "Create workspace" }).click(); + await expect(owner.page).toHaveURL(/\/app\/workspaces\/[0-9a-f-]+$/, { timeout: 20_000 }); + await owner.page.getByRole("button", { name: "R1" }).click(); + await owner.page.locator(".ProseMirror").fill("Reusable R1 paragraph"); + await expect(owner.page.getByText("Saved", { exact: true })).toBeVisible(); + + await owner.page.locator(".ProseMirror").click(); + await owner.page.locator(".ProseMirror").press("ControlOrMeta+A"); + owner.page.once("dialog", (dialog) => dialog.accept("R1 snippet")); + await owner.page.getByRole("button", { name: "Save snippet" }).click(); + owner.page.once("dialog", (dialog) => dialog.accept("R1 template")); + await owner.page.getByRole("button", { name: "Save template" }).click(); + await expect.poll(async () => { + const result = await admin.from("private_writing_items").select("id").eq("user_id", ids[0]); + if (result.error) throw result.error; + return result.data.length; + }).toBe(2); + + await owner.page.goto("/app/workspaces"); + await owner.page.getByRole("textbox", { name: "Motion" }).fill("Cities should expand transit"); + await owner.page.getByRole("button", { name: "Create workspace" }).click(); + await expect(owner.page).toHaveURL(/\/app\/workspaces\/[0-9a-f-]+$/, { timeout: 20_000 }); + await owner.page.getByRole("button", { name: "R1" }).click(); + await openPanel(owner.page, "Import, reuse & export"); + const snippet = owner.page.getByRole("group", { name: "R1 snippet" }); + await snippet.getByRole("button", { name: "Insert copy" }).click(); + await expect(owner.page.locator(".ProseMirror")).toContainText("Reusable R1 paragraph"); + const changed = await admin.from("private_writing_items").update({ + content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Changed original" }] }] }, + }).eq("user_id", ids[0]).eq("title", "R1 snippet"); + if (changed.error) throw changed.error; + await expect(owner.page.locator(".ProseMirror")).not.toContainText("Changed original"); + + await owner.page.locator("#workspace-document-import").setInputFiles({ + buffer: Buffer.from("# Importált beszéd\n\n1. Árvíztűrő tükörfúrógép\n2. [Forrás](https://example.com)"), + mimeType: "text/markdown", + name: "speech.md", + }); + await expect(owner.page.getByLabel("Import preview")).toContainText("Árvíztűrő"); + await owner.page.getByRole("button", { name: "Replace with independent copy" }).click(); + await expect(owner.page.locator(".ProseMirror")).toContainText("Árvíztűrő tükörfúrógép"); + await expect(owner.page.getByText("Saved", { exact: true })).toBeVisible(); + + const portability = owner.page.locator("details").filter({ has: owner.page.locator("summary").filter({ hasText: "Import, reuse & export" }) }); + for (const name of ["DOCX", "PDF", "Packet DOCX", "Packet PDF"]) { + const download = owner.page.waitForEvent("download"); + await portability.getByRole("button", { name, exact: true }).click(); + await expect(await download).toBeTruthy(); + } + await expect(portability.getByRole("button", { name: "Connect another Google account" })).toBeVisible(); + } finally { + await stop(collaboration); + await Promise.allSettled(contexts.map((context) => context.close())); + await Promise.allSettled(ids.map((id) => admin.auth.admin.deleteUser(id))); + } +}); diff --git a/package.json b/package.json index bdda8f8ed..e9ee7f152 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "test:collaboration": "vitest run --config services/collaboration/vitest.config.ts", "start:collaboration": "tsx services/collaboration/src/index.ts", "test:watch": "vitest", - "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENROUTER_API_KEY,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/", + "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENROUTER_API_KEY,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY,GOOGLE_CONNECTION_ENCRYPTION_KEY,GOOGLE_OAUTH_CLIENT_ID,GOOGLE_OAUTH_CLIENT_SECRET --allow-net=127.0.0.1 supabase/functions/", "test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh", "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", "test:e2e:webkit": "playwright test --project=webkit", diff --git a/playwright.workspace.config.ts b/playwright.workspace.config.ts index 3f1c69452..df8e88940 100644 --- a/playwright.workspace.config.ts +++ b/playwright.workspace.config.ts @@ -1,6 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:18081"; +const hostResolverRules = process.env.PLAYWRIGHT_HOST_RESOLVER_RULES; export default defineConfig({ testDir: "./e2e", @@ -11,6 +12,7 @@ export default defineConfig({ use: { actionTimeout: 10_000, baseURL, + launchOptions: hostResolverRules ? { args: [`--host-resolver-rules=${hostResolverRules}`] } : undefined, locale: "en-US", screenshot: "only-on-failure", trace: "retain-on-failure", diff --git a/scripts/workspace-test-env.mjs b/scripts/workspace-test-env.mjs index 48b67bb6f..6b5dc99b5 100644 --- a/scripts/workspace-test-env.mjs +++ b/scripts/workspace-test-env.mjs @@ -1,8 +1,10 @@ import { readFile, writeFile } from "node:fs/promises"; -const [source, target] = process.argv.slice(2); +const [source, target, appOrigin] = process.argv.slice(2); const serviceRoleKey = process.env.LOCAL_SERVICE_ROLE_KEY; const mailerHost = process.env.LOCAL_MAILER_HOST; if (!source || !target || !serviceRoleKey || !mailerHost) throw new Error("Local Edge environment is incomplete"); -const fixture = (await readFile(source, "utf8")).replace("workspace-mailer.invalid", mailerHost); +const fixture = (await readFile(source, "utf8")) + .replace("workspace-mailer.invalid", mailerHost) + .replaceAll("http://127.0.0.1:18081", appOrigin || "http://127.0.0.1:18081"); await writeFile(target, `${fixture.trim()}\nWORKSPACE_INVITE_SERVICE_ROLE_KEY=${serviceRoleKey}\n`, { mode: 0o600 }); diff --git a/src/components/features/workspace/WorkspaceEditor.tsx b/src/components/features/workspace/WorkspaceEditor.tsx index cea48f2e7..05702bc42 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,34 @@ 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; + try { + await savePrivateWritingItem({ + content, + kind, + provenance: { documentEpoch: epoch, documentId, workspaceId }, + title: title.trim(), + }); + await queryClient.invalidateQueries({ queryKey: ["private-writing-items"] }); + } catch { + setReviewError(t("workspaces.privateWritingFailed", "Private writing could not be saved.")); + } + }; + useEffect(() => { if (!editor || !aiSuggestion || status !== "Saved") return; onAiSuggestionConsumed?.(); @@ -326,6 +365,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..ff5e47040 --- /dev/null +++ b/src/components/features/workspace/WorkspacePortabilityPanel.tsx @@ -0,0 +1,214 @@ +import { useState } from "react"; +import type { JSONContent } from "@tiptap/core"; +import { saveAs } from "file-saver"; +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) { + saveAs(new Blob([new Uint8Array(bytes)], { type }), name); +} + +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}

)} +