diff --git a/frontend/packages/erd-core/src/components/ERDRenderer/ERDContent/useInitialAutoLayout.ts b/frontend/packages/erd-core/src/components/ERDRenderer/ERDContent/useInitialAutoLayout.ts index add72d5f..b167c8b4 100644 --- a/frontend/packages/erd-core/src/components/ERDRenderer/ERDContent/useInitialAutoLayout.ts +++ b/frontend/packages/erd-core/src/components/ERDRenderer/ERDContent/useInitialAutoLayout.ts @@ -1,6 +1,6 @@ import type { QueryParam } from '@/schemas/queryParam' import { addHiddenNodeIds, updateActiveTableName } from '@/stores' -import { decompressFromUTF16 } from '@/utils' +import { decompressFromEncodedURIComponent } from '@/utils' import { useNodesInitialized } from '@xyflow/react' import { useEffect } from 'react' import { useERDContentContext } from './ERDContentContext' @@ -19,7 +19,7 @@ const getHiddenNodeIdsFromUrl = async (): Promise => { const hiddenQueryParam: QueryParam = 'hidden' const compressed = urlParams.get(hiddenQueryParam) const hiddenNodeIds = compressed - ? await decompressFromUTF16(compressed).catch(() => undefined) + ? await decompressFromEncodedURIComponent(compressed).catch(() => undefined) : undefined return hiddenNodeIds ? hiddenNodeIds.split(',') : [] diff --git a/frontend/packages/erd-core/src/stores/userEditing/store.ts b/frontend/packages/erd-core/src/stores/userEditing/store.ts index 89a839ef..edabad13 100644 --- a/frontend/packages/erd-core/src/stores/userEditing/store.ts +++ b/frontend/packages/erd-core/src/stores/userEditing/store.ts @@ -1,6 +1,6 @@ import type { QueryParam } from '@/schemas/queryParam' import type { ShowMode } from '@/schemas/showMode' -import { compressToUTF16 } from '@/utils' +import { compressToEncodedURIComponent } from '@/utils' import { proxy, subscribe } from 'valtio' import { proxySet } from 'valtio/utils' @@ -41,7 +41,7 @@ subscribe(userEditingStore.hiddenNodeIds, async () => { url.searchParams.delete(activeQueryParam) if (hiddenNodeIds) { - const compressed = await compressToUTF16(hiddenNodeIds) + const compressed = await compressToEncodedURIComponent(hiddenNodeIds) url.searchParams.set(activeQueryParam, compressed) } diff --git a/frontend/packages/erd-core/src/utils/compressionString.test.ts b/frontend/packages/erd-core/src/utils/compressionString.test.ts index aec8dea2..5fd69d6b 100644 Binary files a/frontend/packages/erd-core/src/utils/compressionString.test.ts and b/frontend/packages/erd-core/src/utils/compressionString.test.ts differ diff --git a/frontend/packages/erd-core/src/utils/compressionString.ts b/frontend/packages/erd-core/src/utils/compressionString.ts index b7d2f04a..80445d6f 100644 --- a/frontend/packages/erd-core/src/utils/compressionString.ts +++ b/frontend/packages/erd-core/src/utils/compressionString.ts @@ -9,66 +9,106 @@ function createReadableStreamFromBytes( }) } -export async function compressToUTF16(input: string): Promise { - const encoder = new TextEncoder() - const inputBytes = encoder.encode(input) - - // Deflate compression - const compressionStream = new CompressionStream('deflate') - const compressedStream = - createReadableStreamFromBytes(inputBytes).pipeThrough(compressionStream) - const compressedBuffer = await new Response(compressedStream).arrayBuffer() - const compressedBytes = new Uint8Array(compressedBuffer) - - // Store the length of the compressed bytes in the first 4 bytes - const length = compressedBytes.length - const totalLength = 4 + length // Store length in the first 4 bytes - // Align to 2-byte (UTF-16 code unit) boundary, add 1 byte padding if necessary - const paddedLength = totalLength % 2 === 0 ? totalLength : totalLength + 1 - - const resultBytes = new Uint8Array(paddedLength) - const dataView = new DataView(resultBytes.buffer) +/** + * Perform Deflate compression and return a Uint8Array + */ +async function deflateCompress(inputBytes: Uint8Array): Promise { + const compression = new CompressionStream('deflate') + const stream = + createReadableStreamFromBytes(inputBytes).pipeThrough(compression) + const compressedBuffer = await new Response(stream).arrayBuffer() + return new Uint8Array(compressedBuffer) +} - // Store length information in the first 4 bytes (Little Endian) - dataView.setUint32(0, length, true) - // Store the compressed data after the first 4 bytes - resultBytes.set(compressedBytes, 4) +/** + * Perform Deflate decompression and return a Uint8Array + */ +async function deflateDecompress(inputBytes: Uint8Array): Promise { + const decompression = new DecompressionStream('deflate') + const stream = + createReadableStreamFromBytes(inputBytes).pipeThrough(decompression) + const decompressedBuffer = await new Response(stream).arrayBuffer() + return new Uint8Array(decompressedBuffer) +} - // Convert to Uint16Array to interpret as UTF-16 code units - const uint16Array = new Uint16Array(resultBytes.buffer) +/** + * Encode byte array to Base64 string + */ +function bytesToBase64(bytes: Uint8Array): string { + let binaryString = '' + for (const b of Array.from(bytes)) { + binaryString += String.fromCharCode(b) + } + return btoa(binaryString) +} - // Convert each Uint16 element to a character - return String.fromCharCode(...Array.from(uint16Array)) +/** + * Decode Base64 string to byte array + */ +function base64ToBytes(base64: string): Uint8Array { + const binaryString = atob(base64) + const length = binaryString.length + const bytes = new Uint8Array(length) + for (let i = 0; i < length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + return bytes } -export async function decompressFromUTF16(input: string): Promise { - const decoder = new TextDecoder() +/** + * Convert Base64 string to URL-safe string (`+` => `-`, `/` => `_`, remove `=`) + */ +function base64ToUrlSafe(base64: string): string { + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} - // Reconstruct Uint16Array from UTF-16 code units - const codeUnits = new Uint16Array(input.length) - for (let i = 0; i < input.length; i++) { - codeUnits[i] = input.charCodeAt(i) +/** + * Convert URL-safe string back to Base64 + */ +function urlSafeToBase64(urlSafe: string): string { + let base64 = urlSafe.replace(/-/g, '+').replace(/_/g, '/') + while (base64.length % 4) { + base64 += '=' } + return base64 +} - // Reference Uint8Array from Uint16Array - const bytes = new Uint8Array(codeUnits.buffer) - const dataView = new DataView(bytes.buffer) - - // Get the actual length of the compressed data from the first 4 bytes - const length = dataView.getUint32(0, true) +/** + * Compress string using Deflate and return as Base64 + */ +async function compressToBase64(input: string): Promise { + const textEncoder = new TextEncoder() + const inputBytes = textEncoder.encode(input) + const compressedBytes = await deflateCompress(inputBytes) + return bytesToBase64(compressedBytes) +} - // Extract the compressed data part - const compressedBytes = bytes.subarray(4, 4 + length) +/** + * Decompress Base64 string and return the original string + */ +async function decompressFromBase64(input: string): Promise { + const textDecoder = new TextDecoder() + const compressedBytes = base64ToBytes(input) + const decompressedBytes = await deflateDecompress(compressedBytes) + return textDecoder.decode(decompressedBytes) +} - // Decompress - const decompressionStream = new DecompressionStream('deflate') - const decompressedStream = - createReadableStreamFromBytes(compressedBytes).pipeThrough( - decompressionStream, - ) - const decompressedBuffer = await new Response( - decompressedStream, - ).arrayBuffer() +/** + * Compress string using Deflate and return as URL-safe encoded string (based on Base64) + */ +export async function compressToEncodedURIComponent( + input: string, +): Promise { + const base64 = await compressToBase64(input) + return base64ToUrlSafe(base64) +} - return decoder.decode(decompressedBuffer) +/** + * Restore original string from URL-safe encoded string + */ +export async function decompressFromEncodedURIComponent( + input: string, +): Promise { + const base64 = urlSafeToBase64(input) + return decompressFromBase64(base64) }