import type { BinaryHexViewRow } from "@/lib/dataGrid/binaryHexViewer"; import { buildBinaryHexViewRows } from "@/lib/dataGrid/binaryHexViewer"; import { formatJsonSource, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat"; import type { RedisBlob, RedisCollectionPage, RedisHashItem, RedisListItem, RedisSetItem, RedisValue, RedisZsetItem } from "@/lib/backend/api"; import { parseJavaSerializedDetail, type RedisJavaSerializedDetail } from "@/lib/redis/javaSerialized"; export type RedisValueFormat = "utf8" | "ascii" | "binary" | "json" | "javaserialize" | "hex" | "base64"; export type RedisMemberDetailFormat = "json" | "text"; export const REDIS_VALUE_FORMAT_DISPLAY_ORDER: RedisValueFormat[] = ["utf8", "ascii", "binary", "json", "javaserialize", "hex", "base64"]; export interface RedisMemberDetail { text: string; rawText: string; rawLabel: string; utf8Text: string; asciiText: string; binaryText: string; format: RedisMemberDetailFormat; json?: RedisJsonDetail; javaSerialized?: RedisJavaSerializedDetail; availableFormats: RedisValueFormat[]; defaultFormat: RedisValueFormat; byteCount: number; base64Text: string; hexRows: BinaryHexViewRow[]; editable: boolean; binary: boolean; } export interface RedisJsonDetail { rawText: string; formattedText: string; value: unknown; } export type RedisJsonDraftNormalizationResult = | { ok: true; compactText: string; } | { ok: false; error: "invalid_json"; }; export interface RedisMemberDetailOptions { allowJsonText?: boolean; } export type RedisMemberDetailKind = "list" | "set" | "hash" | "zset" | "stream"; export type RedisCollectionItem = RedisListItem | RedisSetItem | RedisHashItem | RedisZsetItem; export const REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH = 360; export const REDIS_MEMBER_DETAIL_SHEET_MAX_WIDTH = 900; export function canEditRedisMemberDetail(kind: RedisMemberDetailKind, value?: unknown): boolean { if (kind === "stream") return false; if (value == null) return true; return !isRedisBlob(value) || value.encoding !== "binary"; } export function clampRedisMemberDetailSheetWidth(width: number, viewportWidth: number): number { const viewportMax = Math.max(REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH, viewportWidth - 32); return Math.min(Math.min(REDIS_MEMBER_DETAIL_SHEET_MAX_WIDTH, viewportMax), Math.max(REDIS_MEMBER_DETAIL_SHEET_MIN_WIDTH, width)); } export function isRedisBlob(value: unknown): value is RedisBlob { return typeof value === "object" && value !== null && "raw_base64" in value && "encoding" in value; } export function decodeRedisBlob(blob: RedisBlob): Uint8Array { return base64ToBytes(blob.raw_base64); } export function redisBlobText(blob: RedisBlob): string | null { if (blob.encoding === "binary") return null; return decodeUtf8Bytes(decodeRedisBlob(blob)); } export function redisBlobRawText(blob: RedisBlob): string { return redisBlobText(blob) ?? escapeRedisBytes(decodeRedisBlob(blob)); } export function redisBlobDisplayText(blob: RedisBlob): string { return sanitizeRedisDisplayText(redisBlobRawText(blob)); } export function formatRedisMemberDetail(value: unknown, options: RedisMemberDetailOptions = {}): RedisMemberDetail { if (isRedisBlob(value)) return formatRedisBlobDetail(value, options); if (typeof value === "string") { const bytes = new TextEncoder().encode(value); const json = options.allowJsonText ? (parseRedisJsonDetail(value) ?? undefined) : undefined; const textFormats: RedisValueFormat[] = ["utf8", "ascii", "binary"]; return { text: sanitizeRedisDisplayText(value), rawText: value, rawLabel: isAsciiBytes(bytes) ? "ASCII" : "UTF-8", utf8Text: value, asciiText: asciiBytesToText(bytes), binaryText: binaryBytesToText(bytes), format: "text", json, availableFormats: formatOrderForValue("utf8", textFormats, json ? ["json"] : []), defaultFormat: "utf8", byteCount: bytes.byteLength, base64Text: bytesToBase64(bytes), hexRows: buildBinaryHexViewRows(bytes), editable: true, binary: false, }; } try { const formattedText = JSON.stringify(value, null, 2); const bytes = new TextEncoder().encode(formattedText); return { text: formattedText, rawText: formattedText, rawLabel: "Raw", utf8Text: formattedText, asciiText: asciiBytesToText(bytes), binaryText: binaryBytesToText(bytes), format: "json", json: { rawText: formattedText, formattedText, value }, availableFormats: formatOrderForValue("json", ["utf8"], ["json"]), defaultFormat: "json", byteCount: bytes.byteLength, base64Text: bytesToBase64(bytes), hexRows: buildBinaryHexViewRows(bytes), editable: false, binary: false, }; } catch { const text = String(value); const bytes = new TextEncoder().encode(text); return { text, rawText: text, rawLabel: "Raw", utf8Text: text, asciiText: asciiBytesToText(bytes), binaryText: binaryBytesToText(bytes), format: "text", availableFormats: formatOrderForValue("utf8", ["utf8"], []), defaultFormat: "utf8", byteCount: bytes.byteLength, base64Text: bytesToBase64(bytes), hexRows: buildBinaryHexViewRows(bytes), editable: false, binary: false, }; } } export function formatRedisStringValue(value: unknown): string { if (isRedisBlob(value)) return formatRedisMemberDetail(value).text; if (typeof value !== "string") return String(value ?? ""); return formatRedisJsonString(value) ?? sanitizeRedisDisplayText(value); } export function formatRedisCommandResult(value: unknown): string { if (typeof value === "string") return formatRedisStringValue(value); return JSON.stringify(value, null, 2); } /** RedisJSON source text stays out of JavaScript's numeric representation. */ export function redisJsonValueText(value: { value: string }): string { return value.value; } /** * Detect if a value is a cluster-aggregated INFO response: * `[[addr, infoText], ...]` where each `infoText` starts with `"# "` * (the INFO section marker). This is the format produced by the backend * in `redis_driver.rs::execute_command` for cluster-mode INFO. */ function isRedisClusterInfoValue(value: unknown): value is [string, string][] { return Array.isArray(value) && value.length > 0 && (value as unknown[]).every((item) => Array.isArray(item) && item.length === 2 && typeof item[0] === "string" && typeof item[1] === "string" && item[1].startsWith("# ")); } /** * Format a Redis command result for the **command console terminal** (RedisKeyBrowser.vue). * Unlike `formatRedisCommandResult` (used by the query result table UI), this function * renders cluster-aggregated INFO output as plain text (not JSON), matching the native * redis-cli display style. * * - Cluster INFO `[[addr, infoText], ...]` → `"{addr}\n{infoText}"` per node, joined by newlines. * - Plain string → passthrough (handles single-node INFO text correctly). * - Everything else → JSON.stringify (arrays, objects, etc.). */ export function formatRedisConsoleValue(value: unknown): string { if (isRedisClusterInfoValue(value)) { return value.map(([addr, info]) => `${addr}\n${info}`).join("\n"); } if (typeof value === "string") return formatRedisStringValue(value); return JSON.stringify(value, null, 2); } export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null { if (typeof value !== "string") return null; const trimmed = value.trim(); if (!trimmed) return null; try { // Pretty-print from source tokens so duplicate members and number spellings // stay intact when Redis string/hash values open in the JSON editor. const formattedText = formatJsonSource(trimmed, 2); const parsed = parseJsonPreservingLargeNumbers(trimmed); return { rawText: value, formattedText, value: parsed, }; } catch { return null; } } /** * Validates a JSON editor draft and produces the compact text Redis should * store. Source-preserving minification keeps high-precision numbers and * duplicate object members intact. */ export function normalizeRedisJsonDraft(text: string): RedisJsonDraftNormalizationResult { try { return { ok: true, compactText: formatJsonSource(text) }; } catch { return { ok: false, error: "invalid_json" }; } } export function preferredRedisValueFormat(value: unknown, preferred?: RedisValueFormat | null, options: RedisMemberDetailOptions = {}): RedisValueFormat { const detail = formatRedisMemberDetail(value, options); if (preferred && detail.availableFormats.includes(preferred) && shouldReuseRedisValueFormatPreference(detail, preferred)) return preferred; return detail.defaultFormat; } export function canRenderRedisValueFormat(detail: RedisMemberDetail, format: RedisValueFormat): boolean { switch (format) { case "json": return Boolean(detail.json); case "javaserialize": return Boolean(detail.javaSerialized); default: return true; } } export function redisMemberCopyText(value: unknown): string { return isRedisBlob(value) ? redisBlobRawText(value) : formatRedisMemberDetail(value).rawText; } export function getRedisMemberSelectionKey(title: string, value: unknown, identity = title): string { if (isRedisBlob(value)) return `${identity}\n${value.raw_base64}`; const detail = formatRedisMemberDetail(value); return `${identity}\n${detail.rawText}`; } export function redisValueCollectionItems(value: RedisValue): RedisCollectionItem[] { switch (value.data.kind) { case "list": case "set": case "hash": case "zset": return value.data.items; default: return []; } } export function redisCollectionPageItems(page: RedisCollectionPage): RedisCollectionItem[] { return page.items; } export function redisValueCollectionTotal(value: RedisValue): number | null { switch (value.data.kind) { case "list": case "set": case "hash": case "zset": return value.data.total; default: return null; } } export function redisValueCollectionScanCursor(value: RedisValue): number | undefined { switch (value.data.kind) { case "list": case "set": case "hash": case "zset": return value.data.scan_cursor; default: return undefined; } } export function redisValueSize(value: RedisValue): number { switch (value.data.kind) { case "string": return decodeRedisBlob(value.data.content).byteLength; case "json": return new TextEncoder().encode(redisJsonValueText(value.data)).byteLength; case "list": case "set": case "hash": case "zset": return value.data.total; case "stream": return value.data.entries.length; default: return 0; } } export function redisValuePreview(value: RedisValue): string { switch (value.data.kind) { case "string": return previewText(redisBlobRawText(value.data.content)); case "json": return previewText(redisJsonValueText(value.data)); case "list": { const first = value.data.items[0]; return first ? previewText(redisBlobRawText(first.value)) : ""; } case "set": { const first = value.data.items[0]; return first ? previewText(redisBlobRawText(first.member)) : ""; } case "hash": { const first = value.data.items[0]; return first ? previewText(`${redisBlobRawText(first.field)} ${redisBlobRawText(first.value)}`) : ""; } case "zset": { const first = value.data.items[0]; return first ? previewText(`${first.score} ${redisBlobRawText(first.member)}`) : ""; } case "stream": { const first = value.data.entries[0]; if (!first) return ""; const joined = first.fields.map(({ field, value: entryValue }) => `${field} ${entryValue}`).join(" "); return previewText(joined); } default: return ""; } } export function redisValueCopyText(value: RedisValue, collectionItems: RedisCollectionItem[] = redisValueCollectionItems(value)): string { switch (value.data.kind) { case "string": return redisBlobRawText(value.data.content); case "json": { const rawText = redisJsonValueText(value.data); try { return formatJsonSource(rawText, 2); } catch { return rawText; } } case "list": return JSON.stringify( (collectionItems as RedisListItem[]).map((item) => redisBlobRawText(item.value)), null, 2, ); case "set": return JSON.stringify( (collectionItems as RedisSetItem[]).map((item) => redisBlobRawText(item.member)), null, 2, ); case "hash": return JSON.stringify( (collectionItems as RedisHashItem[]).map((item) => ({ field: redisBlobRawText(item.field), value: redisBlobRawText(item.value), })), null, 2, ); case "zset": return JSON.stringify( (collectionItems as RedisZsetItem[]).map((item) => ({ score: item.score, member: redisBlobRawText(item.member), })), null, 2, ); case "stream": return JSON.stringify( value.data.entries.map((entry) => ({ id: entry.id, fields: entry.fields.map((field) => ({ field: field.field, value: field.value, })), })), null, 2, ); default: return JSON.stringify(value.data, null, 2); } } export function sanitizeRedisDisplayText(value: string): string { let output = ""; for (const ch of value) { if (ch === "\n" || ch === "\r" || ch === "\t") { output += ch; continue; } if (ch >= " " && ch !== "\u007f" && !isUtf8ControlCharacter(ch)) { output += ch; } } return output; } export function highlightRedisJsonDetail(json: string): string { const escaped = json.replace(/&/g, "&").replace(//g, ">"); return escaped.replace(/("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, (match) => { let cls = "json-number"; if (match.startsWith('"')) cls = match.endsWith(":") ? "json-key" : "json-string"; else if (match === "true" || match === "false") cls = "json-boolean"; else if (match === "null") cls = "json-null"; return `${match}`; }); } function formatRedisBlobDetail(blob: RedisBlob, options: RedisMemberDetailOptions): RedisMemberDetail { const bytes = decodeRedisBlob(blob); const strictUtf8Text = blob.encoding === "binary" ? null : decodeUtf8Bytes(bytes); const utf8Text = strictUtf8Text ?? decodeUtf8BytesLossy(bytes); const asciiText = asciiBytesToText(bytes); const binaryText = binaryBytesToText(bytes); const rawText = strictUtf8Text ?? binaryText; const javaSerialized = parseJavaSerializedDetail(bytes); const defaultFormat: RedisValueFormat = javaSerialized ? "javaserialize" : strictUtf8Text != null ? "utf8" : "hex"; const json = options.allowJsonText && strictUtf8Text != null ? (parseRedisJsonDetail(strictUtf8Text) ?? undefined) : undefined; const textFormats: RedisValueFormat[] = strictUtf8Text != null ? ["utf8", "ascii", "binary"] : ["binary"]; const extraFormats: RedisValueFormat[] = []; if (json) extraFormats.push("json"); if (javaSerialized) extraFormats.push("javaserialize"); return { text: redisBlobDisplayText(blob), rawText, rawLabel: blob.encoding === "binary" ? "Binary" : isAsciiBytes(bytes) ? "ASCII" : "UTF-8", utf8Text, asciiText, binaryText, format: "text", json, javaSerialized: javaSerialized ?? undefined, availableFormats: formatOrderForValue(defaultFormat, textFormats, extraFormats), defaultFormat, byteCount: bytes.byteLength, base64Text: blob.raw_base64, hexRows: buildBinaryHexViewRows(bytes), editable: strictUtf8Text != null, binary: strictUtf8Text == null, }; } function formatRedisJsonString(value: string): string | null { return parseRedisJsonDetail(value)?.formattedText ?? null; } function formatOrderForValue(defaultFormat: RedisValueFormat, textFormats: RedisValueFormat[], extraFormats: RedisValueFormat[]): RedisValueFormat[] { const availableFormats: RedisValueFormat[] = [...textFormats]; availableFormats.push(...extraFormats); availableFormats.push("hex", "base64"); return [defaultFormat, ...availableFormats.filter((format) => format !== defaultFormat)]; } function shouldReuseRedisValueFormatPreference(detail: RedisMemberDetail, format: RedisValueFormat): boolean { if (!detail.editable) return true; return format === "utf8" || format === "json"; } function previewText(value: string): string { const singleLine = value.replace(/\s+/g, " ").trim(); return singleLine.length > 160 ? `${singleLine.slice(0, 160)}…` : singleLine; } function isUtf8ControlCharacter(ch: string): boolean { return /\p{Cc}/u.test(ch); } function escapeRedisBytes(bytes: Uint8Array): string { let output = ""; for (const byte of bytes) { if (byte === 0x5c) { output += "\\\\"; continue; } if (byte >= 0x20 && byte <= 0x7e) { output += String.fromCharCode(byte); continue; } output += `\\x${byte.toString(16).padStart(2, "0")}`; } return output; } function decodeUtf8Bytes(bytes: Uint8Array): string | null { try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return null; } } function decodeUtf8BytesLossy(bytes: Uint8Array): string { return new TextDecoder("utf-8").decode(bytes); } function asciiBytesToText(bytes: Uint8Array): string { let output = ""; for (const byte of bytes) { if (byte === 0x0a) { output += "\n"; continue; } if (byte === 0x0d) { output += "\r"; continue; } if (byte === 0x09) { output += "\t"; continue; } if (byte >= 0x20 && byte <= 0x7e) { output += String.fromCharCode(byte); continue; } output += `\\x${byte.toString(16).padStart(2, "0")}`; } return output; } function binaryBytesToText(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(2).padStart(8, "0")).join(""); } function base64ToBytes(value: string): Uint8Array { if (typeof atob !== "function") throw new Error("Base64 decoding is unavailable in this runtime"); const binary = atob(value); return Uint8Array.from(binary, (char) => char.charCodeAt(0)); } function bytesToBase64(bytes: Uint8Array): string { if (typeof btoa !== "function") throw new Error("Base64 encoding is unavailable in this runtime"); let binary = ""; bytes.forEach((byte) => { binary += String.fromCharCode(byte); }); return btoa(binary); } function isAsciiBytes(bytes: Uint8Array): boolean { return bytes.every((byte) => byte <= 0x7f); }