Skip to content

Commit

Permalink
Merge pull request #329 from liam-hq/compress-query-parameter
Browse files Browse the repository at this point in the history
feat: get hidden nodes via query parameter now compresses
  • Loading branch information
hoshinotsuyoshi authored Dec 19, 2024
2 parents d993117 + 0838e34 commit 70aa9c0
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 18 deletions.
6 changes: 6 additions & 0 deletions frontend/.changeset/funny-lemons-decide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@liam-hq/erd-core": patch
"@liam-hq/cli": patch
---

feat: get hidden nodes via query parameter now compresses
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { QueryParam } from '@/schemas/queryParam'
import { addHiddenNodeIds, updateActiveTableName } from '@/stores'
import { decompressFromEncodedURIComponent } from '@/utils'
import { useNodesInitialized } from '@xyflow/react'
import { useEffect } from 'react'
import { useERDContentContext } from './ERDContentContext'
Expand All @@ -13,10 +14,13 @@ const getActiveTableNameFromUrl = (): string | undefined => {
return tableName || undefined
}

const getHiddenNodeIdsFromUrl = (): string[] => {
const getHiddenNodeIdsFromUrl = async (): Promise<string[]> => {
const urlParams = new URLSearchParams(window.location.search)
const hiddenQueryParam: QueryParam = 'hidden'
const hiddenNodeIds = urlParams.get(hiddenQueryParam)
const compressed = urlParams.get(hiddenQueryParam)
const hiddenNodeIds = compressed
? await decompressFromEncodedURIComponent(compressed).catch(() => undefined)
: undefined

return hiddenNodeIds ? hiddenNodeIds.split(',') : []
}
Expand All @@ -29,21 +33,25 @@ export const useInitialAutoLayout = () => {
const { handleLayout } = useAutoLayout()

useEffect(() => {
if (initializeComplete) {
return
const initialize = async () => {
if (initializeComplete) {
return
}

const tableNameFromUrl = getActiveTableNameFromUrl()
updateActiveTableName(tableNameFromUrl)
const hiddenNodeIds = await getHiddenNodeIdsFromUrl()
addHiddenNodeIds(hiddenNodeIds)

const fitViewOptions = tableNameFromUrl
? { maxZoom: 1, duration: 300, nodes: [{ id: tableNameFromUrl }] }
: undefined

if (nodesInitialized) {
handleLayout(fitViewOptions, hiddenNodeIds)
}
}

const tableNameFromUrl = getActiveTableNameFromUrl()
updateActiveTableName(tableNameFromUrl)
const hiddenNodeIds = getHiddenNodeIdsFromUrl()
addHiddenNodeIds(hiddenNodeIds)

const fitViewOptions = tableNameFromUrl
? { maxZoom: 1, duration: 300, nodes: [{ id: tableNameFromUrl }] }
: undefined

if (nodesInitialized) {
handleLayout(fitViewOptions, hiddenNodeIds)
}
initialize()
}, [nodesInitialized, initializeComplete, handleLayout])
}
6 changes: 4 additions & 2 deletions frontend/packages/erd-core/src/stores/userEditing/store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { QueryParam } from '@/schemas/queryParam'
import type { ShowMode } from '@/schemas/showMode'
import { compressToEncodedURIComponent } from '@/utils'
import { proxy, subscribe } from 'valtio'
import { proxySet } from 'valtio/utils'

Expand Down Expand Up @@ -33,14 +34,15 @@ subscribe(userEditingStore.active, () => {
window.history.pushState({}, '', url)
})

subscribe(userEditingStore.hiddenNodeIds, () => {
subscribe(userEditingStore.hiddenNodeIds, async () => {
const url = new URL(window.location.href)
const activeQueryParam: QueryParam = 'hidden'
const hiddenNodeIds = Array.from(userEditingStore.hiddenNodeIds).join(',')

url.searchParams.delete(activeQueryParam)
if (hiddenNodeIds) {
url.searchParams.set(activeQueryParam, hiddenNodeIds)
const compressed = await compressToEncodedURIComponent(hiddenNodeIds)
url.searchParams.set(activeQueryParam, compressed)
}

window.history.pushState({}, '', url)
Expand Down
58 changes: 58 additions & 0 deletions frontend/packages/erd-core/src/utils/compressionString.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import {
compressToEncodedURIComponent,
decompressFromEncodedURIComponent,
} from './compressionString'

describe('compressionString', () => {
it('should compress and decompress a string correctly', async () => {
const input = 'Hello, world!'
const compressed = await compressToEncodedURIComponent(input)
const decompressed = await decompressFromEncodedURIComponent(compressed)

expect(compressed).toMatchInlineSnapshot(`"eJzzSM3JyddRKM8vyklRBAAgXgSK"`)
expect(decompressed).toBe(input)
})

it('should handle empty string', async () => {
const input = ''
const compressed = await compressToEncodedURIComponent(input)
const decompressed = await decompressFromEncodedURIComponent(compressed)

expect(compressed).toMatchInlineSnapshot(`"eJwDAAAAAAE"`)
expect(decompressed).toBe(input)
})

it('should handle long string', async () => {
const input = 'a'.repeat(1000)
const compressed = await compressToEncodedURIComponent(input)
const decompressed = await decompressFromEncodedURIComponent(compressed)

expect(compressed).toMatchInlineSnapshot(`"eJxLTBwFo2AUDHcAAPnYevg"`)
expect(decompressed).toBe(input)
})

it('should handle special characters', async () => {
const input = 'こんにちは、世界!'
const compressed = await compressToEncodedURIComponent(input)
const decompressed = await decompressFromEncodedURIComponent(compressed)

expect(compressed).toMatchInlineSnapshot(
`"eJwBGwDk_-OBk-OCk-OBq-OBoeOBr-OAgeS4lueVjO-8gQC2EmE"`,
)
expect(decompressed).toBe(input)
})

it('should handle binary data', async () => {
const input = String.fromCharCode(
...Array.from({ length: 256 }, (_, i) => i),
)
const compressed = await compressToEncodedURIComponent(input)
const decompressed = await decompressFromEncodedURIComponent(compressed)

expect(compressed).toMatchInlineSnapshot(
`"eJwFwQNXHQAABtBse9mu1bKWbbuWFpbN9arXy7Zt23XO9_2x7hUTl5CUkpaRlZNXUFRSVlFVU9fQ1NLW0dX7oW9gaGRsYmpmbmFpZW1ja2fv4Ojk_NPF9Zebu4enl7ePr59_wO_AoOCQ0LDwiMio6JjYuPiExKTklNS09IzMrOyc3Lz8gsI_RcUlpWV_yysqq_5V19TW1Tc0NjW3tLa1d3R2dff874UAfejHAIQYhAhDGMYIRjGGcUxgElOYxgxmMYd5LGARS1jGClaxhnVsYBNb2MYOdrGHfRzgEEc4xglOcYZzXOASV7jGDW5xh3s84BFPeMYLXvGGd3zgE18UsI_9HKCQgxRxiMMc4SjHOM4JTnKK05zhLOc4zwUuconLXOEq17jODW5yi9vc4S73uM8DHvKIxzzhKc94zgte8orXvOEt73jPBz7yic984Svf-M4PfvLrG5oE0ME"`,
)
expect(decompressed).toBe(input)
})
})
114 changes: 114 additions & 0 deletions frontend/packages/erd-core/src/utils/compressionString.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
function createReadableStreamFromBytes(
bytes: Uint8Array,
): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
})
}

/**
* Perform Deflate compression and return a Uint8Array
*/
async function deflateCompress(inputBytes: Uint8Array): Promise<Uint8Array> {
const compression = new CompressionStream('deflate')
const stream =
createReadableStreamFromBytes(inputBytes).pipeThrough(compression)
const compressedBuffer = await new Response(stream).arrayBuffer()
return new Uint8Array(compressedBuffer)
}

/**
* Perform Deflate decompression and return a Uint8Array
*/
async function deflateDecompress(inputBytes: Uint8Array): Promise<Uint8Array> {
const decompression = new DecompressionStream('deflate')
const stream =
createReadableStreamFromBytes(inputBytes).pipeThrough(decompression)
const decompressedBuffer = await new Response(stream).arrayBuffer()
return new Uint8Array(decompressedBuffer)
}

/**
* 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)
}

/**
* 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
}

/**
* Convert Base64 string to URL-safe string (`+` => `-`, `/` => `_`, remove `=`)
*/
function base64ToUrlSafe(base64: string): string {
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

/**
* 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
}

/**
* Compress string using Deflate and return as Base64
*/
async function compressToBase64(input: string): Promise<string> {
const textEncoder = new TextEncoder()
const inputBytes = textEncoder.encode(input)
const compressedBytes = await deflateCompress(inputBytes)
return bytesToBase64(compressedBytes)
}

/**
* Decompress Base64 string and return the original string
*/
async function decompressFromBase64(input: string): Promise<string> {
const textDecoder = new TextDecoder()
const compressedBytes = base64ToBytes(input)
const decompressedBytes = await deflateDecompress(compressedBytes)
return textDecoder.decode(decompressedBytes)
}

/**
* Compress string using Deflate and return as URL-safe encoded string (based on Base64)
*/
export async function compressToEncodedURIComponent(
input: string,
): Promise<string> {
const base64 = await compressToBase64(input)
return base64ToUrlSafe(base64)
}

/**
* Restore original string from URL-safe encoded string
*/
export async function decompressFromEncodedURIComponent(
input: string,
): Promise<string> {
const base64 = urlSafeToBase64(input)
return decompressFromBase64(base64)
}
1 change: 1 addition & 0 deletions frontend/packages/erd-core/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './compressionString'

0 comments on commit 70aa9c0

Please sign in to comment.