From 5fe28ba7f89095d0ceacbaa9997b93a09e46e8cc Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 4 Sep 2025 16:26:35 +0200 Subject: [PATCH] update R2 storage to JSON format --- src/ui/CustomMainMenu.tsx | 136 +++++++++++++++++++++++++++++++++- worker/TldrawDurableObject.ts | 6 +- worker/worker.ts | 50 ++++++++++++- 3 files changed, 187 insertions(+), 5 deletions(-) diff --git a/src/ui/CustomMainMenu.tsx b/src/ui/CustomMainMenu.tsx index 1f2ce89..a3fdc3a 100644 --- a/src/ui/CustomMainMenu.tsx +++ b/src/ui/CustomMainMenu.tsx @@ -23,8 +23,140 @@ export function CustomMainMenu() { if (typeof event.target?.result !== 'string') { return } - const jsonData = JSON.parse(event.target.result) as TLContent - editor.putContentOntoCurrentPage(jsonData, { select: true }) + try { + const jsonData = JSON.parse(event.target.result) + console.log('Parsed JSON data:', jsonData) + + // Handle different JSON formats + let contentToImport: TLContent + + // Check if it's a worker export format (has documents array) + if (jsonData.documents && Array.isArray(jsonData.documents)) { + console.log('Detected worker export format with', jsonData.documents.length, 'documents') + + // Convert worker export format to TLContent format + const shapes = jsonData.documents + .filter((doc: any) => doc.state?.typeName === 'shape') + .map((doc: any) => doc.state) + + const bindings = jsonData.documents + .filter((doc: any) => doc.state?.typeName === 'binding') + .map((doc: any) => doc.state) + + const assets = jsonData.documents + .filter((doc: any) => doc.state?.typeName === 'asset') + .map((doc: any) => doc.state) + + console.log('Extracted:', { shapes: shapes.length, bindings: bindings.length, assets: assets.length }) + + contentToImport = { + rootShapeIds: shapes.map((shape: any) => shape.id).filter(Boolean), + schema: jsonData.schema || { schemaVersion: 1, storeVersion: 4, recordVersions: {} }, + shapes: shapes, + bindings: bindings, + assets: assets, + } + } else if (jsonData.shapes && Array.isArray(jsonData.shapes)) { + console.log('Detected standard TLContent format with', jsonData.shapes.length, 'shapes') + // Already in TLContent format, but ensure all required properties exist + contentToImport = { + rootShapeIds: jsonData.rootShapeIds || jsonData.shapes.map((shape: any) => shape.id).filter(Boolean), + schema: jsonData.schema || { schemaVersion: 1, storeVersion: 4, recordVersions: {} }, + shapes: jsonData.shapes, + bindings: jsonData.bindings || [], + assets: jsonData.assets || [], + } + } else { + console.log('Detected unknown format, attempting fallback') + // Try to extract shapes from any other format + contentToImport = { + rootShapeIds: jsonData.rootShapeIds || [], + schema: jsonData.schema || { schemaVersion: 1, storeVersion: 4, recordVersions: {} }, + shapes: jsonData.shapes || [], + bindings: jsonData.bindings || [], + assets: jsonData.assets || [], + } + } + + // Validate all required properties + console.log('Final contentToImport:', contentToImport) + + if (!contentToImport.shapes || !Array.isArray(contentToImport.shapes)) { + console.error('Invalid JSON format: missing or invalid shapes array') + alert('Invalid JSON format. Please ensure the file contains valid TLDraw content.') + return + } + + if (!contentToImport.rootShapeIds || !Array.isArray(contentToImport.rootShapeIds)) { + console.error('Invalid JSON format: missing or invalid rootShapeIds array') + alert('Invalid JSON format. Please ensure the file contains valid TLDraw content.') + return + } + + if (!contentToImport.schema) { + console.error('Invalid JSON format: missing schema') + alert('Invalid JSON format. Please ensure the file contains valid TLDraw content.') + return + } + + if (!contentToImport.bindings || !Array.isArray(contentToImport.bindings)) { + contentToImport.bindings = [] + } + + if (!contentToImport.assets || !Array.isArray(contentToImport.assets)) { + contentToImport.assets = [] + } + + console.log('About to call putContentOntoCurrentPage with:', contentToImport) + + try { + editor.putContentOntoCurrentPage(contentToImport, { select: true }) + } catch (putContentError) { + console.error('putContentOntoCurrentPage failed, trying alternative approach:', putContentError) + + // Fallback: Create shapes individually + if (contentToImport.shapes && contentToImport.shapes.length > 0) { + console.log('Attempting to create shapes individually...') + + // Clear current page first + const currentShapes = editor.getCurrentPageShapes() + if (currentShapes.length > 0) { + editor.deleteShapes(currentShapes.map(shape => shape.id)) + } + + // Create shapes one by one + contentToImport.shapes.forEach((shape: any) => { + try { + if (shape && shape.id && shape.type) { + editor.createShape(shape) + } + } catch (shapeError) { + console.error('Failed to create shape:', shape, shapeError) + } + }) + + // Create bindings if any + if (contentToImport.bindings && contentToImport.bindings.length > 0) { + contentToImport.bindings.forEach((binding: any) => { + try { + if (binding && binding.id) { + editor.createBinding(binding) + } + } catch (bindingError) { + console.error('Failed to create binding:', binding, bindingError) + } + }) + } + + console.log('Individual shape creation completed') + } else { + alert('No valid shapes found in the JSON file.') + } + } + } catch (error) { + console.error('Error parsing JSON:', error) + alert('Error parsing JSON file. Please ensure the file is valid JSON.') + } }; if (file) { reader.readAsText(file); diff --git a/worker/TldrawDurableObject.ts b/worker/TldrawDurableObject.ts index 30317f5..cae4b15 100644 --- a/worker/TldrawDurableObject.ts +++ b/worker/TldrawDurableObject.ts @@ -270,7 +270,11 @@ export class TldrawDurableObject { // convert the room to JSON and upload it to R2 const snapshot = JSON.stringify(room.getCurrentSnapshot()) - await this.r2.put(`rooms/${this.roomId}`, snapshot) + await this.r2.put(`rooms/${this.roomId}`, snapshot, { + httpMetadata: { + contentType: 'application/json' + } + }) console.log(`Board persisted to R2: ${this.roomId}`) }, 30_000) diff --git a/worker/worker.ts b/worker/worker.ts index dc7d833..ca3aa44 100644 --- a/worker/worker.ts +++ b/worker/worker.ts @@ -595,8 +595,12 @@ async function backupAllBoards(env: Environment) { // Create backup key with date only const backupKey = `${date}/${room.key}` - // Store in backup bucket as JSON - await env.BOARD_BACKUPS_BUCKET.put(backupKey, jsonData) + // Store in backup bucket as JSON with proper content-type + await env.BOARD_BACKUPS_BUCKET.put(backupKey, jsonData, { + httpMetadata: { + contentType: 'application/json' + } + }) // Backed up successfully } catch (error) { @@ -624,6 +628,48 @@ async function backupAllBoards(env: Environment) { } router + .get("/export/:roomId", async (request, env) => { + try { + const roomId = request.params.roomId + if (!roomId) { + return new Response(JSON.stringify({ error: 'Room ID is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }) + } + + // Get the room data from R2 + const roomData = await env.TLDRAW_BUCKET.get(`rooms/${roomId}`) + if (!roomData) { + return new Response(JSON.stringify({ error: 'Room not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }) + } + + // Get the JSON data + const jsonData = await roomData.text() + + // Return as downloadable JSON file + return new Response(jsonData, { + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="${roomId}-board.json"`, + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': '*', + } + }) + } catch (error) { + console.error('Export failed:', error) + return new Response(JSON.stringify({ + error: 'Export failed', + message: (error as Error).message + }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }) + } + }) .get("/backup", async (_, env) => { const result = await backupAllBoards(env) return new Response(JSON.stringify(result), {