utils.js

/**
 * @file Utilities used in multiple places.
 *
 * @copyright 2015-2025 Tinode LLC.
 */
'use strict';

import AccessMode from './access-mode.js';
import {
  DEL_CHAR,
  LOCAL_SEQID
} from './config.js';

// Attempt to convert date and AccessMode strings to objects.
export function jsonParseHelper(key, val) {
  // Try to convert string timestamps with optional milliseconds to Date,
  // e.g. 2015-09-02T01:45:43[.123]Z
  if (typeof val == 'string' && val.length >= 20 && val.length <= 24 && ['ts', 'touched', 'updated', 'created', 'when', 'deleted', 'expires'].includes(key)) {
    const date = new Date(val);
    if (!isNaN(date)) {
      return date;
    }
  } else if (key === 'acs' && typeof val === 'object') {
    return new AccessMode(val);
  }
  return val;
}

// Checks if URL is a relative url, i.e. has no 'scheme://', including the case of missing scheme '//'.
// The scheme is expected to be RFC-compliant, e.g. [a-z][a-z0-9+.-]*
// example.html - ok
// https:example.com - not ok.
// http:/example.com - not ok.
// ' ↲ https://example.com' - not ok. (↲ means carriage return)
export function isUrlRelative(url) {
  return url && !/^\s*([a-z][a-z0-9+.-]*:|\/\/)/im.test(url);
}

function isValidDate(d) {
  return (d instanceof Date) && !isNaN(d) && (d.getTime() != 0);
}

// RFC3339 formater of Date
export function rfc3339DateString(d) {
  if (!isValidDate(d)) {
    return undefined;
  }

  const pad = function(val, sp) {
    sp = sp || 2;
    return '0'.repeat(sp - ('' + val).length) + val;
  };

  const millis = d.getUTCMilliseconds();
  return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate()) +
    'T' + pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds()) +
    (millis ? '.' + pad(millis, 3) : '') + 'Z';
}

// Recursively merge src's own properties to dst.
// Array and Date objects are shallow-copied.
export function mergeObj(dst, src) {
  if (typeof src != 'object') {
    if (src === undefined) {
      return dst;
    }
    if (src === DEL_CHAR) {
      return undefined;
    }
    return src;
  }
  // JS is crazy: typeof null is 'object'.
  if (src === null) {
    return dst;
  }

  // Handle Date
  if (src instanceof Date && !isNaN(src)) {
    return (!dst || !(dst instanceof Date) || isNaN(dst) || dst < src) ? src : dst;
  }

  // Access mode
  if (src instanceof AccessMode) {
    return new AccessMode(src);
  }

  // Handle Array
  if (src instanceof Array) {
    return src;
  }

  if (!dst || dst === DEL_CHAR) {
    dst = src.constructor();
  }

  for (let prop in src) {
    if (src.hasOwnProperty(prop) && (prop != '_noForwarding')) {
      try {
        dst[prop] = mergeObj(dst[prop], src[prop]);
      } catch (err) {
        console.warn("Error merging property:", prop, err);
      }
    }
  }
  return dst;
}

// Update object stored in a cache. Returns updated value.
export function mergeToCache(cache, key, newval) {
  cache[key] = mergeObj(cache[key], newval);
  return cache[key];
}

// Strips all values from an object of they evaluate to false or if their name starts with '_'.
// Used on all outgoing object before serialization to string.
export function simplify(obj) {
  Object.keys(obj).forEach((key) => {
    if (key[0] == '_') {
      // Strip fields like "obj._key".
      delete obj[key];
    } else if (!obj[key]) {
      // Strip fields which evaluate to false.
      delete obj[key];
    } else if (Array.isArray(obj[key]) && obj[key].length == 0) {
      // Strip empty arrays.
      delete obj[key];
    } else if (!obj[key]) {
      // Strip fields which evaluate to false.
      delete obj[key];
    } else if (obj[key] instanceof Date) {
      // Strip invalid or zero date.
      if (!isValidDate(obj[key])) {
        delete obj[key];
      }
    } else if (typeof obj[key] == 'object') {
      simplify(obj[key]);
      // Strip empty objects.
      if (Object.getOwnPropertyNames(obj[key]).length == 0) {
        delete obj[key];
      }
    }
  });
  return obj;
};


// Trim whitespace, convert to lowercase, strip empty, short, and duplicate elements elements.
// If the result is an empty array, add a single element "\u2421" (Unicode Del character).
export function normalizeArray(arr) {
  let out = [];
  if (Array.isArray(arr)) {
    // Trim, throw away very short and empty tags.
    for (let i = 0, l = arr.length; i < l; i++) {
      let t = arr[i];
      if (t) {
        t = t.trim().toLowerCase();
        if (t.length > 1) {
          out.push(t);
        }
      }
    }
    out = out.sort().filter((item, pos, ary) => {
      return !pos || item != ary[pos - 1];
    });
  }
  if (out.length == 0) {
    // Add single tag with a Unicode Del character, otherwise an ampty array
    // is ambiguos. The Del tag will be stripped by the server.
    out.push(DEL_CHAR);
  }
  return out;
}

// Convert input to valid ranges of IDs.
export function normalizeRanges(ranges, maxSeq) {
  if (!Array.isArray(ranges)) {
    return [];
  }

  // Sort ranges in accending order by low, then descending by hi.
  ranges.sort((r1, r2) => {
    if (r1.low < r2.low) {
      return -1;
    }
    if (r1.low == r2.low) {
      return (r2.hi | 0) - r1.hi;
    }
    return 1;
  });

  // Remove pending messages from ranges possibly clipping some ranges.
  ranges = ranges.reduce((out, r) => {
    if (r.low < LOCAL_SEQID && r.low > 0) {
      if (!r.hi || r.hi < LOCAL_SEQID) {
        out.push(r);
      } else {
        // Clip hi to max allowed value.
        out.push({
          low: r.low,
          hi: maxSeq + 1
        });
      }
    }
    return out;
  }, []);

  // Merge overlapping ranges.
  ranges = ranges.reduce((out, r) => {
    if (out.length == 0) {
      out.push(r);
    } else {
      let prev = out[out.length - 1];
      if (r.low <= prev.hi) {
        prev.hi = Math.max(prev.hi, r.hi);
      } else {
        out.push(r);
      }
    }
    return out;
  }, []);

  return ranges;
}

// Convert array of IDs to array of ranges.
export function listToRanges(list) {
  // Sort the list in ascending order
  list.sort((a, b) => a - b);
  // Convert the array of IDs to ranges.
  return list.reduce((out, id) => {
    if (out.length == 0) {
      // First element.
      out.push({
        low: id
      });
    } else {
      let prev = out[out.length - 1];
      if ((!prev.hi && (id != prev.low + 1)) || (id > prev.hi)) {
        // New range.
        out.push({
          low: id
        });
      } else {
        // Expand existing range.
        prev.hi = prev.hi ? Math.max(prev.hi, id + 1) : id + 1;
      }
    }
    return out;
  }, []);
}

// Cuts 'clip' range out of the 'src' range.
// Returns an array with 0, 1 or 2 elements.
export function clipOutRange(src, clip) {
  if (clip.hi <= src.low || clip.low >= src.hi) {
    // Clip is completely outside of src, no intersection.
    return [src];
  }


  if (clip.low <= src.low) {
    if (clip.hi >= src.hi) {
      // The source range is completely inside the clipping range.
      return [];
    }
    // Partial clipping at the top.
    return [{
      low: clip.hi,
      hi: src.hi
    }];
  }

  // Range on the lower end.
  const result = [{
    low: src.low,
    hi: clip.low
  }];
  if (clip.hi < src.hi) {
    // Maybe a range on the higher end, if clip is completely inside the source.
    result.push({
      low: clip.hi,
      hi: src.hi
    });
  }

  return result;
}

// Cuts 'src' range to be completely within 'clip' range.
// Returns clipped range or null if 'src' is outside of 'clip'.
export function clipInRange(src, clip) {
  if (clip.hi <= src.low || clip.low >= src.hi) {
    // The src is completely outside of the clip, no intersection.
    return null;
  }

  if (src.low >= clip.low && src.hi <= clip.hi) {
    // Src is completely within the clip, return the entire src.
    return src;
  }

  // Partial overlap.
  return {
    low: Math.max(src.low, clip.low),
    hi: Math.min(src.hi, clip.hi)
  };

  return result;
}