settings-store.jsjavascript
/**
 * @file Pure settings-store logic — plain Node, no Electron. src/settings.js owns the
 * Electron-resolved file location (app.getPath('userData')) and the in-memory cache;
 * this module owns the default schema, the deep-merge rules, and the never-throw read /
 * atomic write, so the durability guarantees run anywhere Node runs. Callers pass the
 * settings.json path explicitly.
 * @module settings-store
 */

const path = require('path');
const fs = require('fs');

/**
 * Default shape. Every key the app reads must exist here so a fresh install — or a
 * settings file written by an older version — always resolves to a complete object after
 * the deep-merge in read(). Bump `version` when the shape changes incompatibly.
 *
 * Field notes:
 * files.defaultSaveFolder — null means "no explicit choice — fall back to ~/Downloads";
 * kept null rather than baking in the Downloads path so the fallback follows the user
 * across machines.
 * files.nameAffix — text added to every exported layback name, e.g. "NB_" →
 * NB_SummerNights.mp4; position is 'before' (prefix) or 'after' (suffix).
 * metadata — optional tags written into export metadata (ticket F2); empty strings = not
 * written. artworkPath is an absolute path to a JPEG/PNG used as cover artwork, or null;
 * stored as a path for now — a future pass may copy the image into userData so it
 * survives the source moving.
 * integrations — non-secret connection state only (see src/settings.js header); the
 * actual tokens live in the keychain via safeStorage, keyed by service name.
 * app.checkUpdatesOnLaunch — background update checks (launch + every 4h). ON by
 * default: staying current is the behavior every install has had, and a stranded old
 * version is a support and security liability. Turning it off silences the BACKGROUND
 * checks only — the "Check for Updates…" menu item always works.
 * app.promptedUpdateVersion — last update version the restart dialog was shown for.
 * update-downloaded re-fires every launch until the update installs; this is what keeps
 * the dialog to one appearance per version (see updater.js shouldPrompt).
 * @type {Object}
 */
const DEFAULTS = {
  version: 1,

  files: {
    defaultSaveFolder: null,
    nameAffix: { text: '', position: 'before' },
  },

  metadata: {
    artist: '',
    album: '',
    composer: '',
    grouping: '',
    comments: '',
    artworkPath: null,
  },

  integrations: {
    frameio: { connected: false, account: null },
    disco:   { connected: false, account: null },
  },

  app: {
    checkUpdatesOnLaunch: true,
    promptedUpdateVersion: null,
  },
};

/**
 * Recursively fill any keys missing from `target` with values from `defaults`. This is
 * what makes the store forward-compatible: a settings.json written before a new field
 * existed still yields a complete object, with new fields at their defaults. Plain
 * objects are merged key-by-key; arrays and primitives are taken wholesale from target.
 * @param {*} defaults Default value or schema branch.
 * @param {*} target Stored value or patch branch; undefined falls back to the default.
 * @returns {*} The merged result.
 */
function mergeDefaults(defaults, target) {
  if (!isPlainObject(defaults)) return target === undefined ? defaults : target;
  const out = {};
  for (const key of Object.keys(defaults)) {
    const d = defaults[key];
    const t = target ? target[key] : undefined;
    out[key] = isPlainObject(d) ? mergeDefaults(d, t) : (t === undefined ? d : t);
  }
  return out;
}

/**
 * Whether a value is a plain object (not null, not an array).
 * @param {*} v Candidate value.
 * @returns {boolean} True for non-null, non-array objects.
 */
function isPlainObject(v) {
  return v !== null && typeof v === 'object' && !Array.isArray(v);
}

/**
 * Read settings from disk and merge against the defaults. A missing file (fresh install)
 * or a corrupt/unparseable file both fall back to a deep copy of the defaults — read()
 * never throws, so a bad file can't brick app launch.
 * @param {string} file Absolute path of settings.json.
 * @returns {Object} Complete settings object.
 */
function read(file) {
  try {
    const raw = fs.readFileSync(file, 'utf8');
    return mergeDefaults(DEFAULTS, JSON.parse(raw));
  } catch (err) {
    if (err.code !== 'ENOENT') {
      console.error('[settings] unreadable, using defaults:', err.message);
    }
    return mergeDefaults(DEFAULTS, {});
  }
}

/**
 * Atomic write: serialize to a temp file in the same directory, then rename over the
 * real file. rename() is atomic on a single filesystem, so a crash mid-write can't leave
 * a half-written settings.json — readers see either the old file or the complete new
 * one. Creates the parent directory first (userData may not exist on first run).
 * @param {string} file Absolute path of settings.json.
 * @param {Object} data Settings object to persist.
 * @returns {void}
 */
function persist(file, data) {
  const tmp = `${file}.${process.pid}.tmp`;
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
  fs.renameSync(tmp, file);
}

module.exports = { DEFAULTS, mergeDefaults, read, persist };