feat(redis): add decoded Unicode JSON view
This commit is contained in:
1 parent
8b02ba003b
commit
a3eaac3413
12 files changed
+648
-17
No files matched your search
@@ -52,7 +52,7 @@ import {
|
||||
import { decompressRedisValue, isGzipMagic, type RedisDecompressAlgorithm } from "@/lib/redis/redisCompression";
|
||||
import { canFullHighlightRedisText, findRedisTextMatches, nextRedisSearchMatchIndex, REDIS_VALUE_SEARCH_MATCH_LIMIT, renderRedisTextSearchHtml, redisValueSearchStatus } from "@/lib/redis/redisValueSearch";
|
||||
import TextContentSearchBar from "@/components/common/TextContentSearchBar.vue";
|
||||
import { formatJsonSource } from "@/lib/common/safeJsonFormat";
|
||||
import { decodeJsonUnicodeEscapes, formatJsonSource, mapDisplayToRaw } from "@/lib/common/safeJsonFormat";
|
||||
import { unixSecondsToCalendarDateTime } from "@/components/ui/date-time-picker/dateTimePicker";
|
||||
import { applyRedisExpiryPolicy, type RedisExpiryMode, redisExpiryModeForTtl, validateRedisExpiry } from "@/lib/redis/redisExpiry";
|
||||
|
||||
@@ -77,6 +77,7 @@ const redisExpiryTransport = {
|
||||
const emit = defineEmits<{ deleted: [keyRaw: string]; loaded: [value: RedisValue] }>();
|
||||
|
||||
const REDIS_JSON_WRAP_STORAGE_KEY = "dbx-redis-json-word-wrap";
|
||||
const REDIS_JSON_UNICODE_MODE_STORAGE_KEY = "dbx-redis-json-unicode-mode";
|
||||
const REDIS_VALUE_FORMAT_STORAGE_KEY = "dbx-redis-value-format";
|
||||
// Versioned after moving the setting into the refresh menu so the previous
|
||||
// always-on default does not carry into the new manual-refresh default.
|
||||
@@ -169,6 +170,8 @@ const isResizingZsetColumns = ref(false);
|
||||
const stringValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
|
||||
const memberValueView = ref<RedisValueFormat>(readPreferredRedisValueFormat());
|
||||
const redisJsonWordWrap = ref(readRedisJsonWordWrap());
|
||||
const redisJsonUnicodeMode = ref(readRedisJsonUnicodeMode());
|
||||
const redisJsonDecoded = computed(() => redisJsonUnicodeMode.value === "decoded");
|
||||
const redisJsonHighlighter = ref<JsonHighlighter>();
|
||||
const showHashFieldTtlDialog = ref(false);
|
||||
const editingHashField = ref<string | null>(null);
|
||||
@@ -465,8 +468,11 @@ const selectedMemberCanEdit = computed(() => selectedMemberContext.value?.canEdi
|
||||
const canEditCurrentStringFormat = computed(() => Boolean(stringValueDetail.value?.editable) && (stringValueView.value === "utf8" || stringValueView.value === "json"));
|
||||
const showStringEditActions = computed(() => canEditCurrentStringFormat.value);
|
||||
const originalStringEditValue = computed(() => (stringBlob.value ? rawRedisValueText(stringBlob.value) : ""));
|
||||
const stringJsonRawBaseline = ref("");
|
||||
const stringJsonDraftBaseline = ref("");
|
||||
const redisJsonRawBaseline = ref("");
|
||||
const redisJsonDraftBaseline = ref("");
|
||||
const memberJsonRawBaseline = ref("");
|
||||
const memberJsonDraftBaseline = ref("");
|
||||
// Keep the comparison semantics from the last editable String view. A draft is
|
||||
// retained when the user switches to a read-only representation such as Hex.
|
||||
@@ -1058,6 +1064,25 @@ function setRedisJsonWordWrap(value: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** How JSON string tokens are shown in the JSON editors: source (`\uXXXX`) or decoded text. */
|
||||
type RedisJsonUnicodeMode = "raw" | "decoded";
|
||||
|
||||
function readRedisJsonUnicodeMode(): RedisJsonUnicodeMode {
|
||||
try {
|
||||
return localStorage.getItem(REDIS_JSON_UNICODE_MODE_STORAGE_KEY) === "raw" ? "raw" : "decoded";
|
||||
} catch {
|
||||
return "decoded";
|
||||
}
|
||||
}
|
||||
|
||||
function rememberRedisJsonUnicodeMode(mode: RedisJsonUnicodeMode) {
|
||||
try {
|
||||
localStorage.setItem(REDIS_JSON_UNICODE_MODE_STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// Ignore storage failures; the toggle still works for the current session.
|
||||
}
|
||||
}
|
||||
|
||||
function readPreferredRedisValueFormat(): RedisValueFormat {
|
||||
try {
|
||||
const stored = localStorage.getItem(REDIS_VALUE_FORMAT_STORAGE_KEY);
|
||||
@@ -1068,17 +1093,70 @@ function readPreferredRedisValueFormat(): RedisValueFormat {
|
||||
}
|
||||
}
|
||||
|
||||
function formatJsonText(raw: string): string | null {
|
||||
function formatJsonText(raw: string, decodeUnicode: boolean): string | null {
|
||||
try {
|
||||
// Keep Redis JSON baselines source-preserving (duplicate keys, number text).
|
||||
return formatJsonSource(raw, 2);
|
||||
// Keep Redis JSON baselines source-preserving (duplicate keys, number text),
|
||||
// then apply the display-only unicode decode when the user chose "decoded".
|
||||
const formatted = formatJsonSource(raw, 2);
|
||||
return decodeUnicode ? decodeJsonUnicodeEscapes(formatted) : formatted;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonDraftForEditor(raw: string): string {
|
||||
return formatJsonText(raw) ?? raw;
|
||||
function jsonDraftForEditor(raw: string, decodeUnicode: boolean): string {
|
||||
return formatJsonText(raw, decodeUnicode) ?? raw;
|
||||
}
|
||||
|
||||
/** Apply the display-only unicode decode to an already-formatted JSON baseline. */
|
||||
function jsonDraftBaseline(formattedText: string, decodeUnicode: boolean): string {
|
||||
return decodeUnicode ? decodeJsonUnicodeEscapes(formattedText) : formattedText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical JSON baseline for an editor surface: the source-preserving raw
|
||||
* formatted text (what saves write back to Redis) plus its display baseline.
|
||||
* Keeping the raw baseline separately lets decoded-mode saves map the user's
|
||||
* draft back onto the source so untouched escapes are never rewritten.
|
||||
*/
|
||||
function jsonDraftPairForEditor(raw: string): { rawBaseline: string; displayBaseline: string } {
|
||||
const rawBaseline = formatJsonText(raw, false) ?? raw;
|
||||
return { rawBaseline, displayBaseline: jsonDraftBaseline(rawBaseline, redisJsonDecoded.value) };
|
||||
}
|
||||
|
||||
/** Convert a JSON editor draft from the previous unicode display mode to the new one. */
|
||||
function convertJsonDraftAcrossMode(draft: string, rawBaseline: string, decoded: boolean): string {
|
||||
return decoded ? decodeJsonUnicodeEscapes(draft) : mapDisplayToRaw(rawBaseline, draft);
|
||||
}
|
||||
|
||||
/** Re-derive the JSON editor surfaces from their raw baselines after the mode changes. */
|
||||
function setRedisJsonUnicodeMode(mode: RedisJsonUnicodeMode) {
|
||||
redisJsonUnicodeMode.value = mode;
|
||||
rememberRedisJsonUnicodeMode(mode);
|
||||
const decoded = mode === "decoded";
|
||||
stringJsonDraftBaseline.value = jsonDraftBaseline(stringJsonRawBaseline.value, decoded);
|
||||
redisJsonDraftBaseline.value = jsonDraftBaseline(redisJsonRawBaseline.value, decoded);
|
||||
memberJsonDraftBaseline.value = jsonDraftBaseline(memberJsonRawBaseline.value, decoded);
|
||||
if (!data.value) return;
|
||||
// Convert the active surface's draft through the mode transform so unsaved
|
||||
// edits survive the toggle; when the draft is clean this equals the baseline.
|
||||
if (data.value.data.kind === "string" && stringValueView.value === "json" && stringValueDetail.value?.json) {
|
||||
editValue.value = convertJsonDraftAcrossMode(editValue.value, stringJsonRawBaseline.value, decoded);
|
||||
} else if (data.value.data.kind === "json") {
|
||||
editValue.value = convertJsonDraftAcrossMode(editValue.value, redisJsonRawBaseline.value, decoded);
|
||||
} else if (selectedMemberDetail.value?.json && memberValueView.value === "json") {
|
||||
memberEditValue.value = convertJsonDraftAcrossMode(memberEditValue.value, memberJsonRawBaseline.value, decoded);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the raw draft that Save should write for a JSON editor surface.
|
||||
* In decoded mode the editor holds display text, so the draft is mapped back
|
||||
* onto the raw baseline (preserving untouched escapes); in raw mode the editor
|
||||
* already holds source text and passes through unchanged.
|
||||
*/
|
||||
function jsonDraftForSave(rawBaseline: string, displayValue: string): string {
|
||||
return redisJsonDecoded.value ? mapDisplayToRaw(rawBaseline, displayValue) : displayValue;
|
||||
}
|
||||
|
||||
function rememberRedisValueFormat(format: RedisValueFormat) {
|
||||
@@ -1097,7 +1175,7 @@ function setStringValueFormat(format: RedisValueFormat) {
|
||||
stringValueView.value = format;
|
||||
if (stringValueDetail.value && canRenderRedisValueFormat(stringValueDetail.value, format)) {
|
||||
if (format === "json") {
|
||||
editValue.value = editValue.value === originalStringEditValue.value ? stringJsonDraftBaseline.value : jsonDraftForEditor(editValue.value);
|
||||
editValue.value = editValue.value === originalStringEditValue.value ? stringJsonDraftBaseline.value : jsonDraftForEditor(editValue.value, redisJsonDecoded.value);
|
||||
stringDraftFormat.value = "json";
|
||||
} else if (format === "utf8") {
|
||||
// Keep a dirty JSON draft marked as json so save still compact-writes after a tab switch.
|
||||
@@ -1118,7 +1196,7 @@ function setMemberValueFormat(format: RedisValueFormat) {
|
||||
memberValueView.value = format;
|
||||
if (canRenderRedisValueFormat(selectedMemberDetail.value, format)) {
|
||||
if (format === "json") {
|
||||
memberEditValue.value = memberEditValue.value === selectedMemberDetail.value.rawText ? memberJsonDraftBaseline.value : jsonDraftForEditor(memberEditValue.value);
|
||||
memberEditValue.value = memberEditValue.value === selectedMemberDetail.value.rawText ? memberJsonDraftBaseline.value : jsonDraftForEditor(memberEditValue.value, redisJsonDecoded.value);
|
||||
if (selectedMemberContext.value?.kind === "hash" && selectedMemberCanEdit.value) memberDraftFormat.value = "json";
|
||||
} else if (format === "utf8") {
|
||||
// Dirty JSON drafts keep format "json" so save/normalize still runs after leaving the JSON tab.
|
||||
@@ -1287,12 +1365,15 @@ async function load(options: { background?: boolean; notifyParent?: boolean; pre
|
||||
if (loadedValue.data.kind === "string") {
|
||||
const detail = formatRedisMemberDetail(loadedValue.data.content, { allowJsonText: true });
|
||||
stringValueView.value = preferredRedisValueFormat(loadedValue.data.content, readPreferredRedisValueFormat(), { allowJsonText: true });
|
||||
stringJsonDraftBaseline.value = detail.json?.formattedText ?? "";
|
||||
stringJsonRawBaseline.value = detail.json?.formattedText ?? "";
|
||||
stringJsonDraftBaseline.value = jsonDraftBaseline(stringJsonRawBaseline.value, redisJsonDecoded.value);
|
||||
editValue.value = stringValueView.value === "json" && detail.json ? stringJsonDraftBaseline.value : detail.rawText;
|
||||
stringDraftFormat.value = stringValueView.value === "json" ? "json" : "utf8";
|
||||
clearSelectedMember();
|
||||
} else if (loadedValue.data.kind === "json") {
|
||||
redisJsonDraftBaseline.value = jsonDraftForEditor(redisJsonValueText(loadedValue.data));
|
||||
const pair = jsonDraftPairForEditor(redisJsonValueText(loadedValue.data));
|
||||
redisJsonRawBaseline.value = pair.rawBaseline;
|
||||
redisJsonDraftBaseline.value = pair.displayBaseline;
|
||||
editValue.value = redisJsonDraftBaseline.value;
|
||||
stringDraftFormat.value = "utf8";
|
||||
clearSelectedMember();
|
||||
@@ -1340,7 +1421,7 @@ async function saveString() {
|
||||
let value = editValue.value;
|
||||
// Compact whenever this draft is/was JSON-edited, even if the user switched tabs before Save.
|
||||
if (stringValueView.value === "json" || stringDraftFormat.value === "json") {
|
||||
const normalized = normalizeRedisJsonDraft(value);
|
||||
const normalized = normalizeRedisJsonDraft(jsonDraftForSave(stringJsonRawBaseline.value, value));
|
||||
if (!normalized.ok) {
|
||||
toast(t("redis.jsonFormatError"), 3000);
|
||||
return;
|
||||
@@ -1364,7 +1445,7 @@ function discardStringEdit() {
|
||||
|
||||
async function saveJson() {
|
||||
if (!data.value || data.value.data.kind !== "json" || !redisJsonValueChanged.value || savingJson.value) return;
|
||||
const normalized = normalizeRedisJsonDraft(editValue.value);
|
||||
const normalized = normalizeRedisJsonDraft(jsonDraftForSave(redisJsonRawBaseline.value, editValue.value));
|
||||
if (!normalized.ok) {
|
||||
toast(t("redis.jsonFormatError"), 3000);
|
||||
return;
|
||||
@@ -1533,7 +1614,8 @@ function selectMember(title: string, value: unknown, context: RedisMemberContext
|
||||
selectedMemberContext.value = context;
|
||||
isEditingMember.value = false;
|
||||
memberValueView.value = preferredRedisValueFormat(value, readPreferredRedisValueFormat(), { allowJsonText: true });
|
||||
memberJsonDraftBaseline.value = detail.json?.formattedText ?? "";
|
||||
memberJsonRawBaseline.value = detail.json?.formattedText ?? "";
|
||||
memberJsonDraftBaseline.value = jsonDraftBaseline(memberJsonRawBaseline.value, redisJsonDecoded.value);
|
||||
memberEditValue.value = memberValueView.value === "json" && detail.json ? memberJsonDraftBaseline.value : detail.rawText;
|
||||
memberDraftFormat.value = context.kind === "hash" && context.canEdit && memberValueView.value === "json" && detail.json ? "json" : null;
|
||||
}
|
||||
@@ -1722,7 +1804,7 @@ async function saveMemberEdit() {
|
||||
let writeValue = memberEditValue.value;
|
||||
// Hash JSON drafts may still be open under UTF-8 after a format switch; keep compact writes.
|
||||
if (savingHashJson) {
|
||||
const normalized = normalizeRedisJsonDraft(writeValue);
|
||||
const normalized = normalizeRedisJsonDraft(jsonDraftForSave(memberJsonRawBaseline.value, writeValue));
|
||||
if (!normalized.ok) {
|
||||
toast(t("redis.jsonFormatError"), 3000);
|
||||
return;
|
||||
@@ -2469,6 +2551,10 @@ defineExpose({ focusSearch });
|
||||
Gzip
|
||||
</Button>
|
||||
<span class="flex-1" />
|
||||
<div v-if="stringValueView === 'json' && stringValueDetail.json" class="flex shrink-0 overflow-hidden rounded-md border bg-muted/20 p-0.5">
|
||||
<Button variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': !redisJsonDecoded }" @click="setRedisJsonUnicodeMode('raw')">{{ t("redis.jsonViewRaw") }}</Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': redisJsonDecoded }" @click="setRedisJsonUnicodeMode('decoded')">{{ t("redis.jsonViewDecoded") }}</Button>
|
||||
</div>
|
||||
<label v-if="isTextRedisFormat(stringValueView)" class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<WrapText class="h-3.5 w-3.5" />
|
||||
{{ t("redis.wordWrap") }}
|
||||
@@ -2553,6 +2639,10 @@ defineExpose({ focusSearch });
|
||||
<div v-else-if="redisKind === 'json'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex h-9 items-center gap-2 border-b px-4 text-xs shrink-0">
|
||||
<span class="flex-1" />
|
||||
<div class="flex shrink-0 overflow-hidden rounded-md border bg-muted/20 p-0.5">
|
||||
<Button variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': !redisJsonDecoded }" @click="setRedisJsonUnicodeMode('raw')">{{ t("redis.jsonViewRaw") }}</Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': redisJsonDecoded }" @click="setRedisJsonUnicodeMode('decoded')">{{ t("redis.jsonViewDecoded") }}</Button>
|
||||
</div>
|
||||
<label class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<WrapText class="h-3.5 w-3.5" />
|
||||
{{ t("redis.wordWrap") }}
|
||||
@@ -3173,6 +3263,10 @@ defineExpose({ focusSearch });
|
||||
</Button>
|
||||
</div>
|
||||
<span class="flex-1" />
|
||||
<div v-if="memberValueView === 'json' && selectedMemberDetail.json" class="flex shrink-0 overflow-hidden rounded-md border bg-muted/20 p-0.5">
|
||||
<Button variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': !redisJsonDecoded }" @click="setRedisJsonUnicodeMode('raw')">{{ t("redis.jsonViewRaw") }}</Button>
|
||||
<Button variant="ghost" size="sm" class="h-6 shrink-0 rounded-[5px] px-2 text-xs" :class="{ 'bg-background shadow-sm': redisJsonDecoded }" @click="setRedisJsonUnicodeMode('decoded')">{{ t("redis.jsonViewDecoded") }}</Button>
|
||||
</div>
|
||||
<label v-if="isTextRedisFormat(memberValueView)" class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<WrapText class="h-3.5 w-3.5" />
|
||||
{{ t("redis.wordWrap") }}
|
||||
|
||||
@@ -4275,6 +4275,8 @@ export default {
|
||||
copyInsertStatementBinary: "Cannot generate insert statement for binary data",
|
||||
editMember: "Edit",
|
||||
jsonView: "JSON view",
|
||||
jsonViewRaw: "Raw",
|
||||
jsonViewDecoded: "Decoded",
|
||||
rawContent: "Raw content",
|
||||
wordWrap: "Word wrap",
|
||||
decompressedView: "Decompressed",
|
||||
|
||||
@@ -4115,6 +4115,8 @@ export default withEnglishFallback({
|
||||
copied: "Copiado",
|
||||
editMember: "Editar",
|
||||
jsonView: "Vista JSON",
|
||||
jsonViewRaw: "Original",
|
||||
jsonViewDecoded: "Decodificado",
|
||||
rawContent: "Contenido original",
|
||||
wordWrap: "Ajuste de línea",
|
||||
decompressedView: "Descomprimido",
|
||||
|
||||
@@ -4115,6 +4115,8 @@ export default withEnglishFallback({
|
||||
copyInsertStatementBinary: "Impossibile generare l'istruzione di inserimento per dati binari",
|
||||
editMember: "Modifica",
|
||||
jsonView: "Visualizzazione JSON",
|
||||
jsonViewRaw: "Originale",
|
||||
jsonViewDecoded: "Decodificato",
|
||||
rawContent: "Contenuto grezzo",
|
||||
wordWrap: "A capo automatico",
|
||||
decompressedView: "Decompresso",
|
||||
|
||||
@@ -4145,6 +4145,8 @@ export default withEnglishFallback({
|
||||
copyInsertStatementBinary: "バイナリデータのINSERT文は生成できません",
|
||||
editMember: "編集",
|
||||
jsonView: "JSONビュー",
|
||||
jsonViewRaw: "原文",
|
||||
jsonViewDecoded: "デコード",
|
||||
rawContent: "生コンテンツ",
|
||||
wordWrap: "折り返し",
|
||||
decompressedView: "解凍ビュー",
|
||||
|
||||
@@ -3777,6 +3777,8 @@ export default withEnglishFallback({
|
||||
copyInsertStatementBinary: "바이너리 데이터에 대한 INSERT 구문을 생성할 수 없습니다",
|
||||
editMember: "편집",
|
||||
jsonView: "JSON 보기",
|
||||
jsonViewRaw: "원본",
|
||||
jsonViewDecoded: "디코딩",
|
||||
rawContent: "원시 내용",
|
||||
wordWrap: "자동 줄바꿈",
|
||||
decompressedView: "압축 해제 보기",
|
||||
|
||||
@@ -4117,6 +4117,8 @@ export default withEnglishFallback({
|
||||
copyInsertStatementBinary: "Não é possível gerar instrução de inserção para dados binários",
|
||||
editMember: "Editar",
|
||||
jsonView: "Visão JSON",
|
||||
jsonViewRaw: "Original",
|
||||
jsonViewDecoded: "Decodificado",
|
||||
rawContent: "Conteúdo bruto",
|
||||
wordWrap: "Quebra de linha",
|
||||
decompressedView: "Descomprimido",
|
||||
|
||||
@@ -4263,6 +4263,8 @@ export default withEnglishFallback({
|
||||
copyInsertStatementBinary: "二进制数据无法生成插入语句",
|
||||
editMember: "编辑",
|
||||
jsonView: "JSON 视图",
|
||||
jsonViewRaw: "原始",
|
||||
jsonViewDecoded: "解码",
|
||||
rawContent: "原始内容",
|
||||
wordWrap: "自动换行",
|
||||
decompressedView: "解压视图",
|
||||
|
||||
@@ -3461,6 +3461,8 @@ export default withEnglishFallback({
|
||||
copyInsertStatementBinary: "無法為二進位資料產生 INSERT 語句",
|
||||
editMember: "編輯",
|
||||
jsonView: "JSON 檢視",
|
||||
jsonViewRaw: "原始",
|
||||
jsonViewDecoded: "解碼",
|
||||
rawContent: "原始內容",
|
||||
wordWrap: "自動換行",
|
||||
decompressedView: "解壓檢視",
|
||||
|
||||
@@ -153,7 +153,7 @@ describe("native RedisJSON editor", () => {
|
||||
const normalizeCall = calls.find((call) => calledName(call) === "normalizeRedisJsonDraft");
|
||||
const redisJsonSetCall = calls.find((call) => calledName(call) === "api.redisJsonSet");
|
||||
|
||||
expect(normalizeCall?.arguments.map((argument) => argument.getText())).toEqual(["editValue.value"]);
|
||||
expect(normalizeCall?.arguments.map((argument) => argument.getText())).toEqual(["jsonDraftForSave(redisJsonRawBaseline.value, editValue.value)"]);
|
||||
expect(redisJsonSetCall?.arguments.map((argument) => argument.getText())).toEqual(["props.connectionId", "props.db", "props.keyRaw", "normalized.compactText"]);
|
||||
});
|
||||
|
||||
@@ -224,7 +224,10 @@ describe("native RedisJSON editor", () => {
|
||||
|
||||
expect(valueTextCalls(load).map((call) => call.arguments.map((argument) => argument.getText()))).toContainEqual(["loadedValue.data"]);
|
||||
expect(valueTextCalls(exportStatements).map((call) => call.arguments.map((argument) => argument.getText()))).toContainEqual(["data.value.data"]);
|
||||
expect(assignmentsIn(load).some((assignment) => assignment.left.getText() === "redisJsonDraftBaseline.value" && assignment.right.getText() === "jsonDraftForEditor(redisJsonValueText(loadedValue.data))")).toBe(true);
|
||||
expect(callsIn(load).map(calledName)).toContain("jsonDraftPairForEditor");
|
||||
expect(callsIn(load).map(calledName)).toContain("redisJsonValueText");
|
||||
expect(assignmentsIn(load).some((assignment) => assignment.left.getText() === "redisJsonRawBaseline.value" && assignment.right.getText() === "pair.rawBaseline")).toBe(true);
|
||||
expect(assignmentsIn(load).some((assignment) => assignment.left.getText() === "redisJsonDraftBaseline.value" && assignment.right.getText() === "pair.displayBaseline")).toBe(true);
|
||||
expect(assignmentsIn(discard).some((assignment) => assignment.left.getText() === "editValue.value" && assignment.right.getText() === "redisJsonDraftBaseline.value")).toBe(true);
|
||||
|
||||
for (const node of [load, discard, exportStatements]) {
|
||||
@@ -269,3 +272,67 @@ describe("native RedisJSON editor", () => {
|
||||
expect(detailSave).toContain("context.member, context.score, writeValue, context.score");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Redis JSON unicode display mode", () => {
|
||||
it("exposes a raw/decoded toggle on every JSON editor surface", () => {
|
||||
// String JSON, native RedisJSON, and member JSON edit surfaces each render
|
||||
// the same two-segment "原始 / 解码" control wired to setRedisJsonUnicodeMode.
|
||||
expect(viewerSource.match(/setRedisJsonUnicodeMode\('raw'\)/g)).toHaveLength(3);
|
||||
expect(viewerSource.match(/setRedisJsonUnicodeMode\('decoded'\)/g)).toHaveLength(3);
|
||||
expect(viewerSource.match(/t\("redis\.jsonViewRaw"\)/g)).toHaveLength(3);
|
||||
expect(viewerSource.match(/t\("redis\.jsonViewDecoded"\)/g)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("defaults to decoded and persists the choice under its own storage key", () => {
|
||||
expect(findFunction("readRedisJsonUnicodeMode").getText()).toContain('=== "raw" ? "raw" : "decoded"');
|
||||
expect(findVariableInitializer("redisJsonUnicodeMode").getText()).toBe("ref(readRedisJsonUnicodeMode())");
|
||||
expect(viewerSource).toContain('const REDIS_JSON_UNICODE_MODE_STORAGE_KEY = "dbx-redis-json-unicode-mode"');
|
||||
});
|
||||
|
||||
it("derives JSON editor baselines through the display-only unicode decode", () => {
|
||||
expect(findFunction("formatJsonText").getText()).toContain("decodeJsonUnicodeEscapes(formatted)");
|
||||
expect(findFunction("jsonDraftBaseline").getText()).toContain("decodeJsonUnicodeEscapes(formattedText)");
|
||||
expect(findFunction("jsonDraftPairForEditor").getText()).toContain("jsonDraftBaseline(rawBaseline, redisJsonDecoded.value)");
|
||||
expect(findFunction("setRedisJsonUnicodeMode").getText()).toContain("rememberRedisJsonUnicodeMode(mode)");
|
||||
expect(findFunction("setRedisJsonUnicodeMode").getText()).toContain("stringJsonDraftBaseline.value = jsonDraftBaseline(stringJsonRawBaseline.value, decoded)");
|
||||
expect(findFunction("setRedisJsonUnicodeMode").getText()).toContain("redisJsonDraftBaseline.value = jsonDraftBaseline(redisJsonRawBaseline.value, decoded)");
|
||||
expect(findFunction("setRedisJsonUnicodeMode").getText()).toContain("memberJsonDraftBaseline.value = jsonDraftBaseline(memberJsonRawBaseline.value, decoded)");
|
||||
});
|
||||
|
||||
it("converts the active draft across a Raw/Decoded toggle instead of resetting it", () => {
|
||||
// Toggling must not drop unsaved edits: the draft goes through the mode
|
||||
// transform (decode when entering decoded, map back to raw when leaving it).
|
||||
expect(findFunction("convertJsonDraftAcrossMode").getText()).toContain("decoded ? decodeJsonUnicodeEscapes(draft) : mapDisplayToRaw(rawBaseline, draft)");
|
||||
|
||||
const setMode = findFunction("setRedisJsonUnicodeMode").getText();
|
||||
expect(setMode).toContain("editValue.value = convertJsonDraftAcrossMode(editValue.value, stringJsonRawBaseline.value, decoded)");
|
||||
expect(setMode).toContain("editValue.value = convertJsonDraftAcrossMode(editValue.value, redisJsonRawBaseline.value, decoded)");
|
||||
expect(setMode).toContain("memberEditValue.value = convertJsonDraftAcrossMode(memberEditValue.value, memberJsonRawBaseline.value, decoded)");
|
||||
// The reset-to-baseline assignments must not reappear; they drop unsaved edits.
|
||||
expect(setMode).not.toContain("editValue.value = stringJsonDraftBaseline.value");
|
||||
expect(setMode).not.toContain("editValue.value = redisJsonDraftBaseline.value");
|
||||
expect(setMode).not.toContain("memberEditValue.value = memberJsonDraftBaseline.value");
|
||||
});
|
||||
|
||||
it("routes every JSON editor save through the raw draft mapping so escapes are preserved", () => {
|
||||
// Save writes the mapped canonical raw draft, never the decoded display text:
|
||||
// opening {"name":"张"} in decoded mode and saving must store the escape.
|
||||
const jsonDraftForSave = findFunction("jsonDraftForSave");
|
||||
expect(jsonDraftForSave.getText()).toContain("mapDisplayToRaw(rawBaseline, displayValue)");
|
||||
expect(jsonDraftForSave.getText()).toContain("redisJsonDecoded.value ? mapDisplayToRaw(rawBaseline, displayValue) : displayValue");
|
||||
|
||||
// String JSON draft, native RedisJSON, and hash member JSON draft all go
|
||||
// through jsonDraftForSave before the source-preserving normalize/compact.
|
||||
const saveString = findFunction("saveString").getText();
|
||||
const saveJson = findFunction("saveJson").getText();
|
||||
const saveMemberEdit = findFunction("saveMemberEdit").getText();
|
||||
expect(saveString).toContain("normalizeRedisJsonDraft(jsonDraftForSave(stringJsonRawBaseline.value, value))");
|
||||
expect(saveJson).toContain("normalizeRedisJsonDraft(jsonDraftForSave(redisJsonRawBaseline.value, editValue.value))");
|
||||
expect(saveMemberEdit).toContain("normalizeRedisJsonDraft(jsonDraftForSave(memberJsonRawBaseline.value, writeValue))");
|
||||
|
||||
// Raw baselines (what saves write back to) are captured on load/select.
|
||||
expect(findFunction("load").getText()).toContain('stringJsonRawBaseline.value = detail.json?.formattedText ?? ""');
|
||||
expect(findFunction("load").getText()).toContain("const pair = jsonDraftPairForEditor(redisJsonValueText(loadedValue.data))");
|
||||
expect(findFunction("selectMember").getText()).toContain('memberJsonRawBaseline.value = detail.json?.formattedText ?? ""');
|
||||
});
|
||||
});
|
||||
+172
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatJsonSource, isLosslessJsonNumber, parseJsonPreservingLargeNumbers, safeJsonFormat, stringifyJsonPreservingLargeNumbers } from "../safeJsonFormat";
|
||||
import { decodeJsonUnicodeEscapes, formatJsonSource, isLosslessJsonNumber, mapDisplayToRaw, parseJsonPreservingLargeNumbers, safeJsonFormat, stringifyJsonPreservingLargeNumbers } from "../safeJsonFormat";
|
||||
|
||||
describe("safeJsonFormat", () => {
|
||||
it("preserves large integers exceeding MAX_SAFE_INTEGER", () => {
|
||||
@@ -151,3 +151,174 @@ describe("formatJsonSource", () => {
|
||||
expect(() => formatJsonSource('{"a":1} trailing')).toThrow(SyntaxError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeJsonUnicodeEscapes", () => {
|
||||
it("decodes non-ASCII escapes in both keys and values", () => {
|
||||
const input = '{"\\u59d3\\u540d":"\\u5f20\\u4e09"}';
|
||||
expect(decodeJsonUnicodeEscapes(input)).toBe('{"姓名":"张三"}');
|
||||
});
|
||||
|
||||
it("combines surrogate pairs into a single code point", () => {
|
||||
expect(decodeJsonUnicodeEscapes('{"e":"\\ud83d\\ude00"}')).toBe('{"e":"😀"}');
|
||||
});
|
||||
|
||||
it("keeps ASCII control escapes verbatim so the output stays valid JSON", () => {
|
||||
const input = '{"a":"\\u0000\\u000a\\u005f"}';
|
||||
expect(decodeJsonUnicodeEscapes(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("keeps an escaped-backslash literal \\uXXXX un-decoded", () => {
|
||||
// JSON source `"\\u5f20"` has content `张` (backslash + "u5f20"), not 张.
|
||||
const input = '{"text":"\\\\u5f20"}';
|
||||
expect(decodeJsonUnicodeEscapes(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("decodes a real escape that follows an escaped backslash", () => {
|
||||
// JSON source `"\\张"` has content `\` + 张; the backslash stays escaped.
|
||||
const input = '{"a":"\\\\\\u5f20"}';
|
||||
expect(decodeJsonUnicodeEscapes(input)).toBe('{"a":"\\\\张"}');
|
||||
});
|
||||
|
||||
it("keeps unpaired surrogate escapes verbatim", () => {
|
||||
expect(decodeJsonUnicodeEscapes('{"a":"\\ud83d"}')).toBe('{"a":"\\ud83d"}');
|
||||
expect(decodeJsonUnicodeEscapes('{"a":"\\ude00"}')).toBe('{"a":"\\ude00"}');
|
||||
});
|
||||
|
||||
it("is a no-op on literal UTF-8 and preserves structure byte-for-byte", () => {
|
||||
const input = '{\n "name": "张三",\n "n": 9007199254740992,\n "b": [true, null]\n}';
|
||||
expect(decodeJsonUnicodeEscapes(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("leaves the source-preserving formatter contract untouched", () => {
|
||||
// formatJsonSource still preserves escapes by default; decode is opt-in.
|
||||
const escaped = '{"name":"\\u5f20\\u4e09"}';
|
||||
expect(formatJsonSource(escaped)).toBe(escaped);
|
||||
expect(decodeJsonUnicodeEscapes(escaped)).toBe('{"name":"张三"}');
|
||||
});
|
||||
|
||||
it("escaped quote then unicode escape inside a string", () => {
|
||||
const raw = '{"v":"say \\"\\u5f20"}';
|
||||
expect(decodeJsonUnicodeEscapes(raw)).toBe('{"v":"say \\"张"}');
|
||||
});
|
||||
|
||||
it("escaped backslash then unicode escape inside a string", () => {
|
||||
const raw = '{"v":"say \\\\\\u5f20"}';
|
||||
expect(decodeJsonUnicodeEscapes(raw)).toBe('{"v":"say \\\\张"}');
|
||||
});
|
||||
|
||||
it("escaped quote inside a string token does not terminate the token", () => {
|
||||
const raw = '{"a":"x\\"y\\u4e2d","b":1}';
|
||||
expect(decodeJsonUnicodeEscapes(raw)).toBe('{"a":"x\\"y中","b":1}');
|
||||
});
|
||||
|
||||
it("reviewer scenario: escaped backslash + escaped quote before a unicode escape", () => {
|
||||
const raw = '{"v":"say \\\\\\"\\u5f20"}';
|
||||
expect(decodeJsonUnicodeEscapes(raw)).toBe('{"v":"say \\\\\\"张"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapDisplayToRaw", () => {
|
||||
it("returns the raw source unchanged when the draft equals the decoded baseline", () => {
|
||||
const raw = '{"name":"\\u5f20\\u4e09"}';
|
||||
expect(mapDisplayToRaw(raw, decodeJsonUnicodeEscapes(raw))).toBe(raw);
|
||||
});
|
||||
|
||||
it("preserves untouched escapes when editing a sibling field (save-path core case)", () => {
|
||||
const raw = '{"name":"\\u5f20","age":30}';
|
||||
// decoded baseline: {"name":"张","age":30}; user edits age 30 -> 31
|
||||
const current = '{"name":"张","age":31}';
|
||||
const mapped = mapDisplayToRaw(raw, current);
|
||||
expect(mapped).toBe('{"name":"\\u5f20","age":31}');
|
||||
expect(decodeJsonUnicodeEscapes(mapped)).toBe(current);
|
||||
});
|
||||
|
||||
it("preserves untouched escapes in a multi-region edit (change only b)", () => {
|
||||
const raw = '{"a":"\\u5f20","b":"\\u4e2d"}';
|
||||
const edited = '{"a":"张","b":"文"}';
|
||||
const mapped = mapDisplayToRaw(raw, edited);
|
||||
expect(mapped).toBe('{"a":"\\u5f20","b":"文"}');
|
||||
expect(decodeJsonUnicodeEscapes(mapped)).toBe(edited);
|
||||
});
|
||||
|
||||
it("keeps a structural insert (new key) verbatim", () => {
|
||||
const raw = '{"a":"\\u5f20"}';
|
||||
const edited = '{"a":"张","b":1}';
|
||||
const mapped = mapDisplayToRaw(raw, edited);
|
||||
expect(mapped).toBe('{"a":"\\u5f20","b":1}');
|
||||
expect(decodeJsonUnicodeEscapes(mapped)).toBe(edited);
|
||||
});
|
||||
|
||||
it("keeps JSON escape sequences typed in decoded mode verbatim so value semantics survive", () => {
|
||||
// Decoded mode only inlines unicode escapes; an inserted `\\` is already the
|
||||
// JSON source the user wants (value h\i, one literal backslash) and must not
|
||||
// be re-escaped into h\\i (two literal backslashes) on the way to raw.
|
||||
const raw = '{"v":"hi"}';
|
||||
const edited = '{"v":"h\\\\i"}';
|
||||
const mapped = mapDisplayToRaw(raw, edited);
|
||||
expect(mapped).toBe('{"v":"h\\\\i"}');
|
||||
expect(JSON.parse(mapped)).toEqual({ v: "h\\i" });
|
||||
expect(formatJsonSource(mapped)).toBe(mapped);
|
||||
});
|
||||
|
||||
it("keeps an escaped quote typed in decoded mode verbatim", () => {
|
||||
const raw = '{"v":"a"}';
|
||||
const edited = '{"v":"a \\"b"}';
|
||||
const mapped = mapDisplayToRaw(raw, edited);
|
||||
expect(mapped).toBe('{"v":"a \\"b"}');
|
||||
expect(JSON.parse(mapped)).toEqual({ v: 'a "b' });
|
||||
});
|
||||
|
||||
it("keeps a control escape typed in decoded mode verbatim", () => {
|
||||
const raw = '{"v":"a"}';
|
||||
const edited = '{"v":"a\\nb"}';
|
||||
const mapped = mapDisplayToRaw(raw, edited);
|
||||
expect(mapped).toBe('{"v":"a\\nb"}');
|
||||
expect(JSON.parse(mapped)).toEqual({ v: "a\nb" });
|
||||
});
|
||||
|
||||
it('save-path regression: decoded edit with \\\\, \\" and \\n keeps identical JSON semantics', () => {
|
||||
// Open {"name":"张","v":"a"} in decoded mode, then edit v to a value that
|
||||
// contains an escaped backslash, an escaped quote, and an escaped newline.
|
||||
const raw = '{"name":"\\u5f20","v":"a"}';
|
||||
const edited = '{"name":"张","v":"a\\\\b \\"c\\nd"}';
|
||||
const mapped = mapDisplayToRaw(raw, edited);
|
||||
// The untouched name escape survives; the v value keeps the user's exact JSON source.
|
||||
expect(mapped).toBe('{"name":"\\u5f20","v":"a\\\\b \\"c\\nd"}');
|
||||
// Identical value semantics between the decoded draft and the bytes that save.
|
||||
expect(JSON.parse(mapped)).toEqual(JSON.parse(edited));
|
||||
// And decoding the stored bytes reproduces the decoded draft exactly.
|
||||
expect(decodeJsonUnicodeEscapes(mapped)).toBe(edited);
|
||||
});
|
||||
|
||||
it("round-trips surrogate pairs and preserves their escapes", () => {
|
||||
const raw = '{"e":"\\ud83d\\ude00"}';
|
||||
const baseline = decodeJsonUnicodeEscapes(raw); // {"e":"😀"}
|
||||
expect(mapDisplayToRaw(raw, baseline)).toBe(raw);
|
||||
const edited = '{"e":"x😀y"}';
|
||||
const mapped = mapDisplayToRaw(raw, edited);
|
||||
expect(mapped).toBe('{"e":"x\\ud83d\\ude00y"}');
|
||||
expect(decodeJsonUnicodeEscapes(mapped)).toBe(edited);
|
||||
});
|
||||
|
||||
it("keeps surrogate pairs intact when replacing an escaped emoji", () => {
|
||||
const raw = '{"e":"\\ud83d\\ude00"}';
|
||||
const edited = '{"e":"😁"}';
|
||||
const mapped = mapDisplayToRaw(raw, edited);
|
||||
expect(mapped).toBe(edited);
|
||||
expect(JSON.parse(mapped)).toEqual({ e: "😁" });
|
||||
expect(decodeJsonUnicodeEscapes(mapped)).toBe(edited);
|
||||
});
|
||||
|
||||
it("open+save without edits keeps the exact stored text (bytes preserved)", () => {
|
||||
const raw = '{"name":"\\u5f20","nested":{"list":[1,2],"note":"\\u4e2d"}}';
|
||||
expect(formatJsonSource(mapDisplayToRaw(raw, decodeJsonUnicodeEscapes(raw)))).toBe(formatJsonSource(raw));
|
||||
});
|
||||
|
||||
it("handles a full value replacement in decoded mode", () => {
|
||||
const raw = '{"name":"\\u5f20"}';
|
||||
const edited = '{"name":"李四"}';
|
||||
const mapped = mapDisplayToRaw(raw, edited);
|
||||
expect(mapped).toBe('{"name":"李四"}');
|
||||
expect(decodeJsonUnicodeEscapes(mapped)).toBe(edited);
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,289 @@ export function formatJsonSource(text: string, indent?: number): string {
|
||||
return writer.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode non-ASCII unicode escapes (`\uXXXX`) inside JSON string tokens (keys
|
||||
* and values) so text like `{"name":"张三"}` renders as `{"name":"张三"}`.
|
||||
*
|
||||
* - Code points >= 0x80 are decoded to their characters; ASCII control escapes
|
||||
* (U+0000, U+000A, ...) are kept verbatim so the output stays valid JSON.
|
||||
* - Surrogate pairs combine into a single code point; unpaired surrogates stay
|
||||
* escaped rather than emitting invalid lone surrogate characters.
|
||||
* - Escaped backslashes are respected, so a literal `"\\u5f20"` (whose content
|
||||
* is the text `张`) is never mis-decoded, and an escaped quote `\"` is
|
||||
* consumed inside a string token so it never terminates the token.
|
||||
*
|
||||
* This is a view-layer transform only and must never be written back to a
|
||||
* database on its own.
|
||||
*/
|
||||
export function decodeJsonUnicodeEscapes(text: string): string {
|
||||
return decodeJsonSource(text).display;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of walking a JSON source with {@link decodeJsonSource}: the decoded
|
||||
* display text plus the raw-source offset of every display code unit, which lets
|
||||
* callers map display edits back onto the source without touching escapes.
|
||||
*/
|
||||
export interface JsonUnicodeDecodedSource {
|
||||
/** Decoded display text (what the JSON editor shows in decoded mode). */
|
||||
display: string;
|
||||
/**
|
||||
* displayToRaw[i] is the raw-source offset where display code unit i
|
||||
* originates, for i in [0, display.length]; displayToRaw[display.length] is
|
||||
* raw.length. Surrogate-pair display units both point at the pair's start.
|
||||
*/
|
||||
displayToRaw: number[];
|
||||
}
|
||||
|
||||
function decodeJsonSource(text: string): JsonUnicodeDecodedSource {
|
||||
let display = "";
|
||||
const displayToRaw: number[] = [];
|
||||
let index = 0;
|
||||
const push = (ch: string, rawStart: number) => {
|
||||
// Iterate code units, not code points, so surrogate-pair characters record
|
||||
// an offset for each of their two units and stay aligned with display.length.
|
||||
for (let unitIndex = 0; unitIndex < ch.length; unitIndex += 1) {
|
||||
display += ch[unitIndex];
|
||||
displayToRaw.push(rawStart);
|
||||
}
|
||||
};
|
||||
|
||||
while (index < text.length) {
|
||||
if (text[index] !== '"') {
|
||||
push(text[index], index);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rebuild one JSON string token, decoding escapes inside it.
|
||||
const tokenStart = index;
|
||||
index += 1;
|
||||
push('"', tokenStart);
|
||||
while (index < text.length) {
|
||||
const character = text[index];
|
||||
if (character === '"') {
|
||||
push('"', index);
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if (character !== "\\") {
|
||||
push(character, index);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = text[index + 1];
|
||||
if (next === undefined) {
|
||||
push("\\", index);
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
const hex = text.slice(index + 2, index + 6);
|
||||
if (next !== "u" || !/^[0-9a-fA-F]{4}$/.test(hex)) {
|
||||
// Not a unicode escape (escaped backslash, escaped quote, ...); copy verbatim.
|
||||
push(`\\${next}`, index);
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
const code = parseInt(hex, 16);
|
||||
const lowEscape = text.slice(index + 6, index + 12);
|
||||
const lowMatch = /^\\u([0-9a-fA-F]{4})$/.exec(lowEscape);
|
||||
if (code >= 0xd800 && code <= 0xdbff && lowMatch) {
|
||||
const low = parseInt(lowMatch[1], 16);
|
||||
if (low >= 0xdc00 && low <= 0xdfff) {
|
||||
push(String.fromCodePoint(0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00)), index);
|
||||
index += 12;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (code >= 0xd800 && code <= 0xdfff) {
|
||||
// Unpaired surrogate: keep the escape rather than emit a lone surrogate char.
|
||||
push(`\\u${hex}`, index);
|
||||
} else if (code >= 0x80) {
|
||||
push(String.fromCharCode(code), index);
|
||||
} else {
|
||||
// ASCII control escapes stay escaped for valid JSON.
|
||||
push(`\\u${hex}`, index);
|
||||
}
|
||||
index += 6;
|
||||
}
|
||||
}
|
||||
displayToRaw.push(text.length);
|
||||
return { display, displayToRaw };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the raw JSON source for a decoded-mode editor draft.
|
||||
*
|
||||
* The editor shows {@link decodeJsonUnicodeEscapes decoded} text; this maps the
|
||||
* user's current draft back onto the canonical raw source so untouched escapes
|
||||
* and the original whitespace/formatting are preserved. Decoded mode only inlines
|
||||
* non-ASCII `\uXXXX` escapes, so every other span of the draft is already JSON
|
||||
* source and is written back verbatim — inserted `\\`, `\"`, and control escapes
|
||||
* keep their meaning instead of being re-escaped. When the draft equals the
|
||||
* decoded baseline the raw source is returned unchanged.
|
||||
*
|
||||
* The result is only valid JSON when the draft is; callers must still validate
|
||||
* with {@link formatJsonSource} before saving.
|
||||
*/
|
||||
export function mapDisplayToRaw(raw: string, displayCurrent: string): string {
|
||||
const { display: baseline, displayToRaw } = decodeJsonSource(raw);
|
||||
if (displayCurrent === baseline) return raw;
|
||||
let out = "";
|
||||
for (const op of diffText(baseline, displayCurrent)) {
|
||||
if (op.kind === "equal") {
|
||||
out += raw.slice(displayToRaw[op.aFrom], displayToRaw[op.aTo]);
|
||||
} else if (op.kind === "delete") {
|
||||
// The deleted display span's raw source is dropped with it.
|
||||
} else {
|
||||
// Inserted display text is JSON source in the decoded draft; write it back
|
||||
// verbatim so its value semantics are preserved.
|
||||
out += displayCurrent.slice(op.bFrom, op.bTo);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type DisplayDiffOp = { kind: "equal"; aFrom: number; aTo: number; bFrom: number; bTo: number } | { kind: "delete"; aFrom: number; aTo: number; bFrom: number; bTo: number } | { kind: "insert"; aFrom: number; aTo: number; bFrom: number; bTo: number };
|
||||
|
||||
// Beyond this combined size, fall back to a single replace; whole-document
|
||||
// rewrites gain nothing from escape-level preservation.
|
||||
const MAX_DIFF_UNITS = 200_000;
|
||||
// Cap the Myers edit distance so adversarial edits stay bounded in time and trace.
|
||||
const MAX_DIFF_DISTANCE = 2000;
|
||||
|
||||
function diffText(a: string, b: string): DisplayDiffOp[] {
|
||||
const source = splitCodePoints(a);
|
||||
const target = splitCodePoints(b);
|
||||
let prefix = 0;
|
||||
while (prefix < source.units.length && prefix < target.units.length && source.units[prefix] === target.units[prefix]) prefix += 1;
|
||||
let suffix = 0;
|
||||
while (suffix < source.units.length - prefix && suffix < target.units.length - prefix && source.units[source.units.length - 1 - suffix] === target.units[target.units.length - 1 - suffix]) {
|
||||
suffix += 1;
|
||||
}
|
||||
const sourceMiddleEnd = source.units.length - suffix;
|
||||
const targetMiddleEnd = target.units.length - suffix;
|
||||
const midA = source.units.slice(prefix, sourceMiddleEnd);
|
||||
const midB = target.units.slice(prefix, targetMiddleEnd);
|
||||
|
||||
const ops: DisplayDiffOp[] = [];
|
||||
if (prefix > 0) ops.push({ kind: "equal", aFrom: 0, aTo: source.offsets[prefix], bFrom: 0, bTo: target.offsets[prefix] });
|
||||
if (midA.length > 0 || midB.length > 0) {
|
||||
const replace = (): DisplayDiffOp[] => [
|
||||
{ kind: "delete", aFrom: source.offsets[prefix], aTo: source.offsets[sourceMiddleEnd], bFrom: target.offsets[prefix], bTo: target.offsets[prefix] },
|
||||
{ kind: "insert", aFrom: source.offsets[prefix], aTo: source.offsets[prefix], bFrom: target.offsets[prefix], bTo: target.offsets[targetMiddleEnd] },
|
||||
];
|
||||
const middle = midA.length + midB.length <= MAX_DIFF_UNITS ? myersDiff(midA, midB) : null;
|
||||
if (!middle) {
|
||||
ops.push(...replace());
|
||||
} else {
|
||||
for (const op of middle) {
|
||||
ops.push({
|
||||
kind: op.kind,
|
||||
aFrom: source.offsets[prefix + op.aFrom],
|
||||
aTo: source.offsets[prefix + op.aTo],
|
||||
bFrom: target.offsets[prefix + op.bFrom],
|
||||
bTo: target.offsets[prefix + op.bTo],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (suffix > 0) {
|
||||
ops.push({ kind: "equal", aFrom: source.offsets[sourceMiddleEnd], aTo: a.length, bFrom: target.offsets[targetMiddleEnd], bTo: b.length });
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
function splitCodePoints(text: string): { units: string[]; offsets: number[] } {
|
||||
const units: string[] = [];
|
||||
const offsets = [0];
|
||||
let offset = 0;
|
||||
for (const unit of text) {
|
||||
units.push(unit);
|
||||
offset += unit.length;
|
||||
offsets.push(offset);
|
||||
}
|
||||
return { units, offsets };
|
||||
}
|
||||
|
||||
type MidDiffOp = { kind: "equal" | "delete" | "insert"; aFrom: number; aTo: number; bFrom: number; bTo: number };
|
||||
|
||||
function myersDiff(a: readonly string[], b: readonly string[]): MidDiffOp[] | null {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
if (n === 0 && m === 0) return [];
|
||||
const max = n + m;
|
||||
const offset = max + 1;
|
||||
const v = new Int32Array(2 * max + 3);
|
||||
const trace: Int32Array[] = [];
|
||||
let foundD = -1;
|
||||
for (let d = 0; d <= max && d <= MAX_DIFF_DISTANCE; d += 1) {
|
||||
// Snapshot V[d-1] for backtracking: reachable k at depth d have parity d,
|
||||
// so the previous frontier lives on k with parity d-1.
|
||||
const level = new Int32Array(2 * d + 1);
|
||||
for (let k = -(d - 1); k <= d - 1; k += 2) level[k + d] = v[k + offset];
|
||||
trace.push(level);
|
||||
for (let k = -d; k <= d; k += 2) {
|
||||
const fromDown = k === -d || (k !== d && v[k - 1 + offset] < v[k + 1 + offset]);
|
||||
let x = fromDown ? v[k + 1 + offset] : v[k - 1 + offset] + 1;
|
||||
let y = x - k;
|
||||
while (x < n && y < m && a[x] === b[y]) {
|
||||
x += 1;
|
||||
y += 1;
|
||||
}
|
||||
v[k + offset] = x;
|
||||
if (x >= n && y >= m) {
|
||||
foundD = d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundD >= 0) break;
|
||||
}
|
||||
if (foundD < 0) return null;
|
||||
|
||||
const ops: MidDiffOp[] = [];
|
||||
let x = n;
|
||||
let y = m;
|
||||
for (let d = foundD; d > 0; d -= 1) {
|
||||
const level = trace[d];
|
||||
const k = x - y;
|
||||
const fromDown = k === -d || (k !== d && level[k - 1 + d] < level[k + 1 + d]);
|
||||
const prevK = fromDown ? k + 1 : k - 1;
|
||||
const prevX = level[prevK + d];
|
||||
const prevY = prevX - prevK;
|
||||
while (x > prevX && y > prevY) {
|
||||
ops.push({ kind: "equal", aFrom: x - 1, aTo: x, bFrom: y - 1, bTo: y });
|
||||
x -= 1;
|
||||
y -= 1;
|
||||
}
|
||||
if (x === prevX) {
|
||||
ops.push({ kind: "insert", aFrom: x, aTo: x, bFrom: prevY, bTo: y });
|
||||
} else {
|
||||
ops.push({ kind: "delete", aFrom: prevX, aTo: x, bFrom: y, bTo: y });
|
||||
}
|
||||
x = prevX;
|
||||
y = prevY;
|
||||
}
|
||||
ops.reverse();
|
||||
return coalesceMidOps(ops);
|
||||
}
|
||||
|
||||
function coalesceMidOps(ops: MidDiffOp[]): MidDiffOp[] {
|
||||
const out: MidDiffOp[] = [];
|
||||
for (const op of ops) {
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.kind === op.kind && last.aTo === op.aFrom && last.bTo === op.bFrom) {
|
||||
last.aTo = op.aTo;
|
||||
last.bTo = op.bTo;
|
||||
} else {
|
||||
out.push({ ...op });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function protectLargeJsonNumbers(text: string): ProtectedJsonNumbers {
|
||||
let placeholderPrefix = "__DBX_LOSSLESS_NUMBER_";
|
||||
while (text.includes(placeholderPrefix)) placeholderPrefix += "_";
|
||||
|
||||
Reference in new issue
Block a user