]> gerrit.midnightthoughts Code Review - neoboard-miro-converter.git/commitdiff
Handle errors, offset paths correctly and allow urls to be exported 65/165/1
authorMTRNord <mtrnord1@gmail.com>
Wed, 15 Jan 2025 15:00:40 +0000 (16:00 +0100)
committerMTRNord <mtrnord1@gmail.com>
Wed, 15 Jan 2025 15:00:40 +0000 (16:00 +0100)
Change-Id: I516a4bc41796fa6eef96c364185e37db9a36a7da

src/app/importActions.ts
src/app/page.tsx
src/app/selectionFormComponent.tsx

index 9d14df7ec172df20ad902c52ccd39c4c7069b275..695e45abf70933d80f497e7b10d6c070f94f30d7 100644 (file)
@@ -10,9 +10,11 @@ import { createCanvas } from 'canvas';
 export type FormState = {
     neoboards: {
         id: string;
+        name?: string;
         neoboard: Whiteboard;
     }[];
     message: string;
+    url?: string;
 }
 
 const mapColorNameToHex = (color: string | undefined): string => {
@@ -26,6 +28,26 @@ const mapColorNameToHex = (color: string | undefined): string => {
     }
 }
 
+/** 
+ * Gets the ID from the URL
+ * 
+ * A miro looks like this: https://miro.com/app/board/<id>/
+ * 
+ * @param url The URL to parse
+ * @returns The ID from the URL
+ * @throws If the URL is not valid
+ */
+function parseUrl(url: string): string {
+    const url_parsed = new URL(url);
+    const path = url_parsed.pathname.split('/');
+    // Note that we might have something behind the slash of the board ID or no slash at all at the end
+    if (path.length < 3) {
+        throw new Error('Invalid URL');
+    }
+
+    return path[3];
+}
+
 export async function importBoard(prevState: FormState, formData: FormData): Promise<FormState> {
     const boardIdsRaw = formData.getAll('board');
     if (!boardIdsRaw) {
@@ -33,6 +55,14 @@ export async function importBoard(prevState: FormState, formData: FormData): Pro
     }
     const boardIds: string[] = Array.isArray(boardIdsRaw) ? boardIdsRaw as string[] : [boardIdsRaw] as string[];
 
+    if (formData.has("url")) {
+        const url = formData.get("url") as string;
+        if (url && url.length > 0) {
+            const boardId = parseUrl(url);
+            boardIds.push(boardId);
+        }
+    }
+
     // Get board from Miro API
     const { miro, userId } = initMiroAPI();
 
@@ -49,6 +79,7 @@ export async function importBoard(prevState: FormState, formData: FormData): Pro
 
     const resultingBoards: {
         id: string;
+        name: string;
         neoboard: Whiteboard;
     }[] = [];
     for (const boardId of boardIds) {
@@ -150,10 +181,45 @@ export async function importBoard(prevState: FormState, formData: FormData): Pro
                 const startItem = frameData.children.find(c => c.id === connector.startItem?.id);
                 const endItem = frameData.children.find(c => c.id === connector.endItem?.id);
                 if (startItem && endItem) {
-                    const startItemX = startItem.position?.x ?? 0;
-                    const startItemY = startItem.position?.y ?? 0;
-                    const endItemX = endItem.position?.x ?? 0;
-                    const endItemY = endItem.position?.y ?? 0;
+                    let startItemX = startItem.position?.x ?? 0;
+                    let startItemY = startItem.position?.y ?? 0;
+                    let endItemX = endItem.position?.x ?? 0;
+                    let endItemY = endItem.position?.y ?? 0;
+
+                    // Adjust based on the connector item's position which contains a relative offset where x=0% and y=0% equals the top left corner of the item
+                    const startRelativeOffset = connector.startItem?.position;
+                    const endRelativeOffset = connector.endItem?.position;
+
+                    // Note that the startItemX and startItemY are the center of the item and not the top left corner
+                    // Same goes for endItemX and endItemY
+
+                    if (startRelativeOffset && startRelativeOffset.x && startRelativeOffset.y) {
+                        // We need to remove the % sign from the string and parse it as a number
+                        const offsetXNumber = parseFloat(startRelativeOffset.x.replace('%', ''));
+                        const offsetYNumber = parseFloat(startRelativeOffset.y.replace('%', ''));
+
+                        // Note that startItemX and startItemY are the center of the item and not the top left corner so offset x of 50% is no change to the x position and not half of the width
+
+                        // 1. Move the x corrdinate to the top left corner by subtracting half of the width
+                        // 2. Add the offset in percentage
+
+                        startItemX = (startItemX - (startItem.geometry?.width ?? 1) / 2) + (offsetXNumber / 100) * (startItem.geometry?.width ?? 1);
+                        startItemY = (startItemY - (startItem.geometry?.height ?? 1) / 2) + (offsetYNumber / 100) * (startItem.geometry?.height ?? 1);
+                    }
+
+                    if (endRelativeOffset && endRelativeOffset.x && endRelativeOffset.y) {
+                        // We need to remove the % sign from the string and parse it as a number
+                        const offsetXNumber = parseFloat(endRelativeOffset.x.replace('%', ''));
+                        const offsetYNumber = parseFloat(endRelativeOffset.y.replace('%', ''));
+
+                        // Note that endItemX and endItemY are the center of the item and not the top left corner so offset x of 50% is no change to the x position and not half of the width
+
+                        // 1. Move the x corrdinate to the top left corner by subtracting half of the width
+                        // 2. Add the offset in percentage
+
+                        endItemX = (endItemX - (endItem.geometry?.width ?? 1) / 2) + (offsetXNumber / 100) * (endItem.geometry?.width ?? 1);
+                        endItemY = (endItemY - (endItem.geometry?.height ?? 1) / 2) + (offsetYNumber / 100) * (endItem.geometry?.height ?? 1);
+                    }
 
                     const points = [
                         {
@@ -189,7 +255,7 @@ export async function importBoard(prevState: FormState, formData: FormData): Pro
         // Transform the board into neoboard coordinates
         const fittedNeoboard = fitItemsBestIntoFrame(neoboard);
 
-        resultingBoards.push({ id: boardId, neoboard: fittedNeoboard });
+        resultingBoards.push({ id: boardId, neoboard: fittedNeoboard, name: board.name });
     }
 
     return {
@@ -528,7 +594,8 @@ function convertStickyNote(item: StickyNoteItem): Shape {
             y: item.position?.y ?? 0,
         },
         width: item.geometry?.width ?? 1,
-        height: item.geometry?.height ?? 1,
+        // We remove the shadow size from the height as miro adds the shadow to the height
+        height: (item.geometry?.height ?? 1) - 43,
         fillColor: fillColor ?? "#ffffff",
         strokeColor: 'transparent',
         strokeWidth: 0,
index 216719436a1d295d6858b551a6c4716895bf3145..1f9cd56971011326b977728e1635a86360b855f2 100644 (file)
@@ -18,16 +18,32 @@ const getBoards = async () => {
     };
   }
 
-  const api = miro.as(userIdAwaited);
+  try {
+    const api = miro.as(userIdAwaited);
 
-  const boards: Board[] = [];
-  for await (const board of api.getAllBoards()) {
-    boards.push(board);
-  }
+    const boards: Board[] = [];
+    for await (const board of api.getAllBoards()) {
+      boards.push(board);
+    }
 
-  return {
-    boards,
-  };
+    return {
+      boards,
+    };
+  } catch (error) {
+    console.error(error);
+    // @ts-expect-error - error is usually a response object
+    if (error.hasOwnProperty('statusCode')) {
+      // @ts-expect-error - error is usually a response object
+      if (error.statusCode === 401) {
+        return {
+          authUrl: miro.getAuthUrl(),
+        };
+      }
+    }
+    return {
+      error: 'Failed to get boards',
+    };
+  }
 };
 
 export default async function Page() {
index 2607b4de005fca40c7af387a3a8ce22f57537941..c9c63c09c2d880568fe50244ec4bd6c42d77950c 100644 (file)
@@ -19,6 +19,7 @@ export default function SelectionFormComponent({ boards }: SelectionFormProps) {
             return {
                 neoboards: [],
                 message: '',
+                url: undefined,
             } as FormState;
         }
 
@@ -40,8 +41,8 @@ export default function SelectionFormComponent({ boards }: SelectionFormProps) {
                 {state.message && <p>{state.message}</p>}
                 <p>Download the Neoboards below</p>
                 {neoboardBlobs.map((blob, index) => (
-                    <a key={index} href={URL.createObjectURL(blob)} download={`${uniqueBoards.find(x => state.neoboards[index].id === x.id)?.name}.nwb`} className="button button-primary">
-                        Download Neoboard &ldquo;{uniqueBoards.find(x => state.neoboards[index].id === x.id)?.name}&rdquo;
+                    <a key={index} href={URL.createObjectURL(blob)} download={`${state.neoboards[index].name}.nwb`} className="button button-primary">
+                        Download Neoboard &ldquo;{state.neoboards[index].name}&rdquo;
                     </a>
                 ))}
                 {/* Start over button which resets state */}
@@ -70,6 +71,12 @@ export default function SelectionFormComponent({ boards }: SelectionFormProps) {
                     </label>
                 ))}
 
+                {/* Allow entering an url for manual selection */}
+                <div className="form-group-small">
+                    <label htmlFor="url">Or enter a board url</label>
+                    <input type="text" name="url" id="url" className="input" />
+                </div>
+
                 <button type="submit" className="button button-primary">
                     Import
                 </button>