@garysui/json-ops
Version:
TypeScript utilities for JSON operations: flatten, diff, apply, and more
442 lines • 16.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.replaceUndefined = replaceUndefined;
exports.restoreUndefined = restoreUndefined;
exports.flat = flat;
exports.unflat = unflat;
exports.sortKeys = sortKeys;
exports.diff = diff;
exports.apply = apply;
const UNDEF_MARKER = "__UNDEFINED__";
function replaceUndefined(input) {
if (input === undefined)
return UNDEF_MARKER;
if (Array.isArray(input)) {
return input.map(replaceUndefined);
}
else if (typeof input === "object" && input !== null) {
const result = {};
for (const [k, v] of Object.entries(input)) {
result[k] = v === undefined ? UNDEF_MARKER : replaceUndefined(v);
}
return result;
}
return input;
}
function restoreUndefined(input) {
if (input === UNDEF_MARKER)
return undefined;
if (Array.isArray(input)) {
return input.map(restoreUndefined);
}
else if (typeof input === "object" && input !== null) {
const result = {};
for (const [k, v] of Object.entries(input)) {
result[k] = v === UNDEF_MARKER ? undefined : restoreUndefined(v);
}
return result;
}
return input;
}
function flat(obj) {
if (obj === undefined) {
throw new Error("Input to flat must not contain undefined. Use replaceUndefined beforehand.");
}
if (typeof obj !== 'object' || obj === null) {
return [{ "": obj }];
}
if (Array.isArray(obj)) {
if (obj.length === 0)
return [{ "@": [] }];
return obj.flatMap((val, i) => {
if (Array.isArray(val) && val.length === 0) {
return [{ [`@${i}@`]: [] }];
}
return flat(val).map(e => {
const [k, v] = Object.entries(e)[0];
return { [`@${i}${k}`]: v };
});
});
}
if (typeof obj === 'object') {
if (Object.keys(obj).length === 0)
return [{ ".": {} }];
return Object.entries(obj).flatMap(([k, v]) => flat(v).map(e => {
const [subk, subv] = Object.entries(e)[0];
return { [`.${k}${subk}`]: subv };
}));
}
throw new Error("Unhandled input in flat function.");
}
function unflat(entries) {
if (entries.length === 1 && entries[0][""] !== undefined)
return entries[0][""];
if (entries.length === 1 && "." in entries[0])
return {};
if (entries.length === 1 && "@" in entries[0])
return [];
// Group entries by their root segment
const groups = new Map();
for (const entry of entries) {
const [path, value] = Object.entries(entry)[0];
// Extract the root segment
let rootSegment;
let remainingPath;
if (path.startsWith('.')) {
// Property access: .prop or .prop.more or .prop@0 etc
const match = path.match(/^(\.[^.\@]+)(.*)/);
if (match) {
rootSegment = match[1];
remainingPath = match[2];
}
else {
// Handle standalone '.'
rootSegment = '.';
remainingPath = '';
}
}
else if (path.startsWith('@')) {
// Array access: @0 or @0.more etc
const match = path.match(/^(@\d*)(.*)/);
if (match) {
rootSegment = match[1];
remainingPath = match[2];
}
else {
// Handle standalone '@'
rootSegment = '@';
remainingPath = '';
}
}
else if (path === '') {
// Direct value assignment with empty string
rootSegment = '';
remainingPath = '';
}
else {
// Other cases
rootSegment = path;
remainingPath = '';
}
if (!groups.has(rootSegment)) {
groups.set(rootSegment, []);
}
// Create new entry with remaining path
if (remainingPath === '') {
groups.get(rootSegment).push({ "": value });
}
else {
groups.get(rootSegment).push({ [remainingPath]: value });
}
}
// Determine if result should be an array or object
const hasArrayIndices = Array.from(groups.keys()).some(key => key.startsWith('@') && key !== '@');
const hasProperties = Array.from(groups.keys()).some(key => key.startsWith('.') && key !== '.');
if (hasArrayIndices && !hasProperties) {
// Pure array
const result = [];
for (const [rootSegment, subEntries] of groups) {
if (rootSegment.startsWith('@') && rootSegment !== '@') {
const index = parseInt(rootSegment.slice(1), 10);
result[index] = unflat(subEntries);
}
else if (rootSegment === '@') {
// Empty array marker
return [];
}
}
return result;
}
else {
// Object (or mixed, treat as object)
const result = {};
for (const [rootSegment, subEntries] of groups) {
if (rootSegment.startsWith('.') && rootSegment !== '.') {
const key = rootSegment.slice(1);
result[key] = unflat(subEntries);
}
else if (rootSegment.startsWith('@') && rootSegment !== '@') {
// Mixed case: object with numeric array-like properties
const index = rootSegment.slice(1);
result[index] = unflat(subEntries);
}
else if (rootSegment === '.') {
// Empty object marker - should be handled by early return
}
else if (rootSegment === '@') {
// Handle empty array in mixed context
result[rootSegment] = [];
}
}
return result;
}
}
function sortKeys(obj) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(item => sortKeys(item));
}
const result = {};
const keys = Object.keys(obj).sort();
for (const key of keys) {
result[key] = sortKeys(obj[key]);
}
return result;
}
function getType(value) {
if (value === null)
return 'null';
if (Array.isArray(value))
return 'array';
if (typeof value === 'object')
return 'object';
return 'primitive';
}
function diffInternal(a, b, path, arrayReplace) {
const operations = [];
// Handle undefined by converting to marker
const processedA = replaceUndefined(a);
const processedB = replaceUndefined(b);
const typeA = getType(processedA);
const typeB = getType(processedB);
// If types are different, use SET operation
if (typeA !== typeB) {
const finalPath = path === '' ? '' : path;
operations.push({
type: 'set',
path: finalPath,
value: restoreUndefined(processedB)
});
return operations;
}
// Both are primitives or null
if (typeA === 'primitive' || typeA === 'null') {
if (processedA !== processedB) {
const finalPath = path === '' ? '' : path;
operations.push({
type: 'set',
path: finalPath,
value: restoreUndefined(processedB)
});
}
return operations;
}
// Both are objects (not arrays)
if (typeA === 'object') {
const objA = processedA;
const objB = processedB;
const keysA = new Set(Object.keys(objA));
const keysB = new Set(Object.keys(objB));
// Get all unique keys sorted
const allKeys = new Set([...keysA, ...keysB]);
const sortedKeys = Array.from(allKeys).sort();
for (const key of sortedKeys) {
const inA = keysA.has(key);
const inB = keysB.has(key);
const newPath = path === '' ? `.${key}` : `${path}.${key}`;
if (!inA && inB) {
// Key in B but not in A - ADD
const flatValue = flat(replaceUndefined(objB[key]));
for (const entry of flatValue) {
const [subPath, value] = Object.entries(entry)[0];
const fullPath = subPath === '' ? newPath : `${newPath}${subPath}`;
operations.push({
type: 'add',
path: fullPath,
value: restoreUndefined(value)
});
}
}
else if (inA && !inB) {
// Key in A but not in B - REMOVE
operations.push({
type: 'remove',
path: newPath
});
}
else if (inA && inB) {
// Keys in both - recurse
operations.push(...diffInternal(objA[key], objB[key], newPath, arrayReplace));
}
}
return operations;
}
// Both are arrays
if (typeA === 'array') {
const arrA = processedA;
const arrB = processedB;
// If arrayReplace is true and lengths differ, replace entire array
if (arrayReplace && arrA.length !== arrB.length) {
const finalPath = path === '' ? '' : path;
operations.push({
type: 'set',
path: finalPath,
value: restoreUndefined(processedB)
});
return operations;
}
// Simple strategy: compare element by element
const maxLen = Math.max(arrA.length, arrB.length);
for (let i = 0; i < maxLen; i++) {
const newPath = path === '' ? `@${i}` : `${path}@${i}`;
if (i >= arrA.length) {
// Element exists in B but not in A - ADD
const flatValue = flat(replaceUndefined(arrB[i]));
for (const entry of flatValue) {
const [subPath, value] = Object.entries(entry)[0];
const fullPath = subPath === '' ? newPath : `${newPath}${subPath}`;
operations.push({
type: 'add',
path: fullPath,
value: restoreUndefined(value)
});
}
}
else if (i >= arrB.length) {
// Element exists in A but not in B - REMOVE
operations.push({
type: 'remove',
path: newPath
});
}
else {
// Both exist - recurse
operations.push(...diffInternal(arrA[i], arrB[i], newPath, arrayReplace));
}
}
return operations;
}
return operations;
}
function diff(a, b, arrayReplace = false) {
return diffInternal(a, b, '', arrayReplace);
}
function apply(input, operations) {
// Step 1: Sort keys and flatten the input object
const sorted = sortKeys(input);
const flattened = flat(replaceUndefined(sorted));
// Step 2: Convert to map for easier manipulation
const pathMap = new Map();
for (const entry of flattened) {
const [path, value] = Object.entries(entry)[0];
pathMap.set(path, value);
}
// Step 3: Apply each operation in sequence
for (const operation of operations) {
switch (operation.type) {
case 'add':
pathMap.set(operation.path, replaceUndefined(operation.value));
break;
case 'set':
// First, remove all child paths that start with this path
const pathsToRemoveForSet = [];
for (const [existingPath] of pathMap.entries()) {
if (existingPath === operation.path ||
existingPath.startsWith(operation.path + '.') ||
existingPath.startsWith(operation.path + '@')) {
pathsToRemoveForSet.push(existingPath);
}
}
for (const pathToRemove of pathsToRemoveForSet) {
pathMap.delete(pathToRemove);
}
// Then, flatten the new value and add all its paths
const flatValue = flat(replaceUndefined(operation.value));
for (const entry of flatValue) {
const [subPath, value] = Object.entries(entry)[0];
const fullPath = subPath === '' ? operation.path :
operation.path === '' ? subPath : `${operation.path}${subPath}`;
pathMap.set(fullPath, value);
}
break;
case 'remove':
// Remove the path itself
pathMap.delete(operation.path);
// Also remove all child paths that start with this path
const pathsToRemove = [];
for (const [existingPath] of pathMap.entries()) {
if (existingPath.startsWith(operation.path + '.') ||
existingPath.startsWith(operation.path + '@')) {
pathsToRemove.push(existingPath);
}
}
for (const pathToRemove of pathsToRemove) {
pathMap.delete(pathToRemove);
}
break;
}
}
// Step 4: Preserve empty structures from the original input
// Collect all structure paths from the original input
const originalStructures = new Set();
for (const entry of flattened) {
const [path] = Object.entries(entry)[0];
// If this is already an empty structure marker, add it
if (path.endsWith('.') || path.endsWith('@')) {
originalStructures.add(path);
}
// For non-empty paths, extract all possible parent structure paths
let currentPath = '';
let i = 0;
while (i < path.length) {
const char = path[i];
if (char === '.') {
// Found an object property separator
if (currentPath !== '') {
originalStructures.add(currentPath + '.');
}
currentPath += char;
}
else if (char === '@') {
// Found an array index start
if (currentPath !== '') {
originalStructures.add(currentPath + '@');
}
// Skip to the end of the number
currentPath += char;
i++;
while (i < path.length && /\d/.test(path[i])) {
currentPath += path[i];
i++;
}
i--; // Back up one since the loop will increment
}
else {
currentPath += char;
}
i++;
}
}
// Handle conflicts between empty markers and actual content
// Remove empty markers when there are actual properties/elements
for (const [path] of pathMap.entries()) {
if (path.endsWith('@') || path.endsWith('.')) {
const prefix = path.slice(0, -1); // Remove the trailing marker
const hasProperties = Array.from(pathMap.keys()).some(p => p !== path && p.startsWith(prefix) && (p.includes('.') || p.includes('@')));
if (hasProperties) {
pathMap.delete(path); // Remove empty marker when there are actual elements
}
}
}
// Add empty markers for structures that existed in input but have no properties after operations
for (const structurePath of originalStructures) {
if (structurePath.endsWith('.') || structurePath.endsWith('@')) {
const prefix = structurePath.slice(0, -1); // Remove the trailing marker
const hasProperties = Array.from(pathMap.keys()).some(p => p !== structurePath && p.startsWith(prefix) && (p.includes('.') || p.includes('@')));
if (!hasProperties && !pathMap.has(structurePath)) {
pathMap.set(structurePath, 0);
}
}
}
// Step 5: Convert back to object format and unflatten
const resultEntries = [];
for (const [path, value] of pathMap.entries()) {
resultEntries.push({ [path]: value });
}
// Step 6: Unflatten and restore undefined values
const unflattened = unflat(resultEntries);
return restoreUndefined(unflattened);
}
//# sourceMappingURL=index.js.map