core_json-utils.js

'use strict';

/**
 * @fileoverview JSON manipulation utilities for nested i18n structures.
 * Handles flattening, unflattening, deep merging, and ICU message detection.
 * @module json-utils
 */

const fs = require('./fs-adapter');
const path = require('path');

const KEY_SEPARATOR = ':::';

/**
 * @param {string} filePath
 * @param {boolean} [verbose=false]
 * @returns {Object|null}
 */
function readJsonFileSync(filePath, verbose = false) {
  try {
    return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
  } catch (err) {
    if (verbose) {
      console.warn(`Warning: Cannot parse ${filePath}: ${err.message}`);
    }
    return null;
  }
}

/**
 * @param {string} filePath
 * @param {boolean} [verbose=false]
 * @returns {Promise<Object|null>}
 */
async function readJsonFile(filePath, verbose = false) {
  try {
    const content = await fs.readFile(filePath, 'utf-8');
    return JSON.parse(content);
  } catch (err) {
    if (verbose) {
      console.warn(`Warning: Cannot parse ${filePath}: ${err.message}`);
    }
    return null;
  }
}

/**
 * @param {string} filePath
 * @param {Object} data
 * @returns {Promise<void>}
 */
async function writeJsonFile(filePath, data) {
  await fs.mkdir(path.dirname(filePath), { recursive: true });
  await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
}

function navigateToParent(obj, keyPath) {
  let current = obj;
  for (let i = 0; i < keyPath.length - 1; i++) {
    current[keyPath[i]] ??= {};
    current = current[keyPath[i]];
  }
  return current;
}

function resolveFinalKey(current, key, value) {
  if (current[key] && current[key] !== value) {
    return `${key}_${hashText(value)}`;
  }
  return key;
}

function setNestedValue(obj, keyPath, value) {
  const current = navigateToParent(obj, keyPath);
  const originalKey = keyPath.at(-1);
  const existed = Object.hasOwn(current, originalKey);
  const finalKey = resolveFinalKey(current, originalKey, value);
  current[finalKey] = value;
  return { path: [...keyPath.slice(0, -1), finalKey], isNew: !existed };
}

function getNestedValue(obj, keyPath) {
  let current = obj;
  for (const key of keyPath) {
    if (!current || typeof current !== 'object') {
      return undefined;
    }
    current = current[key];
  }
  return current;
}

const isNestedObject = v => typeof v === 'object' && v !== null;
const buildKey = (prefix, key) => (prefix ? `${prefix}.${key}` : key);

function flattenKeys(obj, prefix = '') {
  return Object.entries(obj).flatMap(([key, value]) => {
    const fullKey = buildKey(prefix, key);
    return isNestedObject(value) ? flattenKeys(value, fullKey) : [fullKey];
  });
}

const escapeKey = key => (key.includes('.') ? key.replaceAll('.', KEY_SEPARATOR) : key);
const unescapeKey = key => (key.includes(KEY_SEPARATOR) ? key.replaceAll(KEY_SEPARATOR, '.') : key);

const isPlainObject = v => typeof v === 'object' && v !== null && !Array.isArray(v);

/**
 * Flattens nested JSON to dot-notation keys
 * @param {Object} obj
 * @param {string} [prefix='']
 * @param {Object} [result={}]
 * @returns {Record<string, string>}
 * @example
 * flattenJson({ user: { name: 'John' } }) // { 'user.name': 'John' }
 */
function flattenJson(obj, prefix = '', result = {}) {
  for (const [key, value] of Object.entries(obj)) {
    const fullKey = buildKey(prefix, escapeKey(key));
    if (isPlainObject(value)) {
      flattenJson(value, fullKey, result);
    } else {
      result[fullKey] = value;
    }
  }
  return result;
}

function ensurePath(obj, parts) {
  return parts.slice(0, -1).reduce((current, part) => {
    if (!Object.hasOwn(current, part) || !isPlainObject(current[part])) {
      current[part] = {};
    }
    return current[part];
  }, obj);
}

/**
 * Restores nested structure from dot-notation keys
 * @param {Record<string, string>} obj
 * @returns {Object}
 * @example
 * unflattenJson({ 'user.name': 'John' }) // { user: { name: 'John' } }
 */
function unflattenJson(obj) {
  const result = {};
  for (const [key, value] of Object.entries(obj)) {
    const parts = key.split('.').map(unescapeKey);
    ensurePath(result, parts)[parts.at(-1)] = value;
  }
  return result;
}

function mergeValue(result, key, value) {
  if (Array.isArray(value)) {
    return value;
  }
  if (isPlainObject(value)) {
    return mergeDeep(result[key] ?? {}, value);
  }
  return Object.hasOwn(result, key) ? result[key] : value;
}

/**
 * Deep merges source into target, preserving existing values
 * @param {Object} target
 * @param {Object} source
 * @returns {Object}
 */
function mergeDeep(target, source) {
  const result = { ...target };
  for (const [key, value] of Object.entries(source)) {
    result[key] = mergeValue(result, key, value);
  }
  return result;
}

function hashText(text) {
  let hash = 0;
  for (let i = 0; i < text.length; i++) {
    const char = text.charCodeAt(i);
    hash = (hash << 5) - hash + char;
    hash |= 0;
  }
  return Math.abs(hash).toString(36).substring(0, 6);
}

/**
 * Detects ICU MessageFormat syntax (plural, select, selectordinal)
 * @param {*} value
 * @returns {boolean}
 */
function isICUMessage(value) {
  if (typeof value !== 'string') {
    return false;
  }
  return /\{[^}]+,\s*(plural|select|selectordinal)\s*,/.test(value);
}

function normalizeData(data, format) {
  const isFlat = format === 'flat' || typeof Object.values(data)[0] === 'string';
  return isFlat ? unflattenJson(data) : data;
}

module.exports = {
  readJsonFileSync,
  readJsonFile,
  writeJsonFile,
  setNestedValue,
  getNestedValue,
  flattenKeys,
  flattenJson,
  unflattenJson,
  mergeDeep,
  isICUMessage,
  normalizeData,
};