Source: index.js

/*
 * Bookends (>=v13.1.1) API client
 *
 * Uses code by ComplexPoint at https://www.sonnysoftware.com/phpBB3/viewtopic.php?f=2&t=4017
 * @author Christian Boulanger (cboulanger)
 */

const osascript = require('node-osascript');
const util = require('util');

/**
 * Given an event code and additional parameters, return the AppleScript command.
 * @param {String} eventCode
 * @param {mixed} param1, param2, ...
 * @return {String}
 * TODO : move into module
 */
function command(eventCode, ...parameters) {
  // "beginning with Mac OS X Snow Leopard, creator codes are ignored by the operating system"  https://en.wikipedia.org/wiki/Creator_code
  return 'tell application "Bookends" to «event XXXX' + eventCode + '» ' + parameters.join(' ');
}

/**
 * Returns the string enclosed by double quotes, with all double quotes escaped
 * @param {String} str
 */
function quote(str){
  if( ! util.isString(str) ) throw new Error("Argument must be a string.");
  return '"' + str.replace(/"/g,'\\"') + '"';
}

/**
 * Trims the result of an OSA call to Bookends, removing the quotes at the beginning/end
 * @param {String} item
 */
function removeQuotes(item) {
  return item.substring(1, item.length - 2);
}

/**
 * Execute a command via OSA and process the result. Returns a Promise
 * that resolves to an array of Strings (splitChar: String|undefined) or a
 * String (splitChar: false).
 * @param {String} cmd
 * @param {Boolean} debug If true, log diagnostic information to the console
 * @return {Promise<*>}
 */
function runOsaCmd(cmd, debug=false) {
  // error list, must be expanded
  const errors = ["No Bookends library window is open"];
  return new Promise(function(resolve, reject) {
    if (debug) console.log( " >>> OSA Command:" + cmd);
    try {
      osascript.execute(cmd, {},
        function(err, result, raw) {
          if (debug) {
            console.debug( " >>> OSA Result:" + result);
          }
          // normalize
          if (typeof result === "string") {
            result = result.normalize("NFC");
          }
          // check for errors
          if (util.isString(result) && errors.some(item => result.includes(item))) err = result;
          if (err) {
            if (debug) console.log(` >>> Error returned by callback of osascript.execute():${err}`);
            return reject(err);
          }
          resolve(result);
        }
      );
    } catch (e) {
      if (debug) console.log(` >>> Error calling osascript.execute():${e.message}`);
      reject(e);
    }
  });
}

/**
 * @module bookends-api
 */
let bookends = {
  /**
   * The reference types
   * @return {Array}
   */
  getTypes : function() {
    return [
      "Artwork",
      "Audiovisual material",
      "Book",
      "Book chapter",
      "Conference proceedings",
      "Dissertation",
      "Edited book",
      "Editorial",
      "In press",
      "Journal article",
      "Letter",
      "Map",
      "Newspaper article",
      "Patent",
      "Personal communication",
      "Review",
      "Internet"
    ];
  },

  /**
   * Returns a list of fields that Bookends uses
   * @return {Array}
   */
  getFields: function() {
    return [
      "uniqueID",
      "authors",
      "title",
      "editors",
      "journal",
      "volume",
      "pages",
      "publisher",
      "thedate",
      "location",
      "title2",
      "abstract",
      "keywords",
      "notes",
      "user1",
      "user2",
      "user3",
      "user4",
      "user5",
      "user6",
      "user7",
      "user8",
      "user9",
      "user10",
      "user11",
      "user12",
      "user13",
      "user14",
      "user15",
      "user16",
      "user17",
      "user18",
      "user19",
      "user20",
      "attachments",
      "type",
      "groups"
    ];
  },

  /**
   * Given a reference type, return the internal numeric code that Bookends uses
   * @param {String} type
   * @return {Number}
   */
  codeFromType : function(type) {
    if ( ! type || !util.isString(type)){
      throw new Error("Parameter must be a string");
    }
    let code = this.getTypes().findIndex(item => type === item);
    if (code === -1) {
      throw new Error(`Invalid type '${type}'`);
    }
    return code;
  },

  /**
   * Given a reference type code, return its string representation.
   * @param {Number} code
   * @return {String}
   */
  typeFromCode : function(code) {
    if ( ! util.isNumber(code)) {
      throw new Error("Parameter must be a number");
    }
    if ( code < 0 || code >= 40 ) {
      throw new Error("Code out of range");
    }
    if (this.getTypes()[code] === undefined) {
      throw new Error(`No type with code ${code}.`);
    }
    return this.getTypes()[code];
  },

  /**
   * Get the version number of Bookends
   * @return {Promise} A promise that resolves to a String containing the version number
   */
  getVersion : function() {
    return runOsaCmd(command('VERS'));
  },

  /**
   * Get unique ids of the selected references or references in a group
   * @param  {String} groupName Group name (can be “All”. “Hits”, “Attachments”, “Selection”, or the name of a group you created
   * @return {Promise<Number[]>}  A promise resovling with an array containing
   * the unique IDs as integer values.
   */
  getGroupReferenceIds: function(groupName) {
    if ( ! groupName || !util.isString(groupName)){
      throw new Error("Parameter must be a string");
    }
    return runOsaCmd(command('RUID', quote(groupName)))
    .then( result => removeQuotes(result).split(/\r/).map( item => parseInt(item)) );
  },

  /**
   * Get unique ids of references found with an SQL search as detailed in the User Guide.
   * @param  {String} search Search parameters as you would enter them using Refs -> SQL/Regex Search
   * @return {Promise<Number[]>}  A promise resovling with an array containing
   * the unique IDs of all found references as integer values.
   */
  findIdsWhere: function(search) {
    if ( ! search || !util.isString(search)){
      throw new Error("Parameter must be a string");
    }
    return runOsaCmd(command('SQLS', quote(search)))
    .then( result => removeQuotes(result).split(/\r/).map( item => parseInt(item)));
  },

  /**
   * Ask Bookends to return formatted references.
   * Given the unique id, you can obtain the formatted reference, as plain text or RTF.
   * Returns a promise that resolves to an Array containing the formatted references.
   * @param {Array} ids
   * @param {String} format
   * @param {Boolean} asRtf
   * @return {Promise<String[]>}
   */
  formatReferences: function(ids, format, asRtf=false) {
    if ( ! Array.isArray(ids)){
      throw new Error("First parameter must be an Array");
    }
    if ( ! format || ! util.isString(format)){
      throw new Error("Second parameter must be a non-empty String");
    }
    let cmd = command('GUID',
      quote(ids.join(',')),
      `given «class RRTF»:"${asRtf?'true':'false'}", string:"${format}"`
    );
    return runOsaCmd(cmd)
    .then(result => result.substring(1, result.length-3).split(/\r/).map(item => item.trim()));
  },

  /**
   * Get group names.
   * Returns a Promise that resolves to an array of names of all user-created groups, static and smart,
   * in the frontmost library window, sorted alphabetically by group name.
   *
   * @param {Boolean} includePath
   *  if True Bookends will return the folder hierarchy for each group, where slashes separates the folders
   *  and groups: "top folder/inner folder/group name". Note that the items will be ordered by group name,
   *  not the full path, so that zfolder/a comes before afolder/b. If a group name contains a slash (/),
   *  it will be escaped as //.
   * @return {Promise<String[]>}
   */
  getGroupNames : function(includePath=false) {
    let cmd = command('RGPN',
      `given «class PATH»:"${includePath?'true':'false'}"`
    );
    return runOsaCmd(cmd).then(result => removeQuotes(result).split(/\r/));
  },

  /**
   * Create a new static group and (optionally) populate it with references.
   * Returns a Promise that resolves to the name of the added group.  This is helpful when you specify a
   * name that is already in use and Bookends appends a number to make it unique.
   * @param {String} groupName
   * @param {Array|undefined} ids
   * @return {Promise<String>}
   */
  createStaticGroup : function(groupName, ids=[]) {
    if ( ! groupName || ! util.isString(groupName)) {
      throw new Error("First parameter must be a non-empty String");
    }
    if ( ! Array.isArray(ids)) {
      throw new Error("Second parameter must be an Array");
    }
    let cmd = command('ADDG', quote(groupName), `given string:"${ids.join(',')}"`);
    return runOsaCmd(cmd);
  },

  /**
   * Add references to an existing static group
   * Returns a Promise with the number of references added.
   * @param {String} groupName
   * @param {Array|undefined} ids
   * @return {Promise<Number>}
   */
  addToStaticGroup : function(groupName, ids=[]) {
    if ( ! groupName || ! util.isString(groupName)) {
      throw new Error("First parameter must be a non-empty String");
    }
    if ( ! Array.isArray(ids)) {
      throw new Error("Second parameter must be an Array");
    }
    let cmd = command('ADTG', quote(groupName), `given string:"${ids.join(',')}"`);
    return runOsaCmd(cmd);
  },

  /**
   * Given an array of ids and an arry of field names, return an array of objects that
   * contain the data of the corresponding references with the given fields.
   * @param {Array} ids An array with at least one id
   * @param {Array} fieldNames An array with at least one field name
   * @param {Boolean} convertType
   *    If true, convert the numeric reference type codess into their string representation
   *    (i.e. 0 => "Journal Article").
   * @return {Promise<Object[]>}  A promise resolving with to an array of json objects
   */
  readReferences : function(ids, fieldNames, convertType=true) {
    if ( ! Array.isArray(ids) || ids.length < 1 ) {
      throw new Error("First parameter must be an Array with at least one element");
    }
    if ( ! Array.isArray(fieldNames) || fieldNames.length < 1 ) {
      throw new Error("First parameter must be an Array with at least one element");
    }
    fieldNames.forEach( fieldName => {
      if ( ! this.getFields().includes(fieldName) ) {
        throw new Error(`Unknown field '${fieldName}'`);
      }
    });
    return runOsaCmd(command(
      'RJSN', quote(ids.filter(id => !!id).join(',')), 'given string:', quote(fieldNames.join(','))
    ))
    .then(result => {
      let refs;
      try {
        refs = JSON.parse(result);
      } catch (e) {
        throw new Error(result);
      }
      if( convertType ) {
        refs = refs.map( item => {
          if( typeof item.type === "number") {
            item.type = this.typeFromCode(item.type);
          }
          return item;
        });
      }
      return refs;
    });
  },

  /**
   * Given an array of object, update the references that have a matching 'uniqueID'.
   * Reference types are automatically converted into their internal numeric code.
   * @param  {Array} data An array with at least one json object
   * @return {Promise<void>}
   */
  updateReferences : function(data) {
    if ( ! Array.isArray(data) ||data.length < 1 ) {
      throw new Error("First parameter must be an Array with at least one element");
    }
    data = data.map( (item, index) => {
      if ( item.type !== undefined && typeof item.type !== "number" ) {
        try {
          item.type = this.codeFromType(item.type);
        } catch (e) {
          throw new Error(`Invalid reference type '${item.type}' in reference ${index}.`);
        }
        return item;
      }
      Object.getOwnPropertyNames(item).forEach( fieldName => {
        if ( ! this.getFields().includes(fieldName) ) {
          throw new Error(`Unknown field '${fieldName}'`);
        }
      });
      return item;
    });
    let json;
    try{
      json = JSON.stringify(data).replace(/\\/g,'\\\\');
    } catch (e) {
      throw new Error("Data cannot be serialized to JSON: " + e.message);
    }
    return runOsaCmd(command( 'SJSN', quote(json) ))
    .then(result => {
      if ( result === null) return;
      throw new Error( `updateReferences() failed.\n >>> Error:\n${result}\n >>> Data:\n${json}`);
    });
  },

  /**
   * Add a reference and/or attachment to a library
   * @param {Map} data Map of key-value pairs containing the reference data
   * @return {Promise<Number>} A Promise resolving with the numeric id of the newly created reference
   */
  addReferences: async function(data) {
    if ( ! Array.isArray(data) || data.length < 1 ) {
      throw new Error("Parameter must be an array with at least one element");
    }
    for ( let i=0; i < data.length; i++) {
      let item = data[i];
      if ( ! util.isObject(item) || item.type === undefined ) {
        throw new Error(`Invalid element ${i}: must be an object with at least the key 'type'`);
      }
      // create entry
      let result = (await runOsaCmd(command( 'ADDA', '""', `given «class RIST»:"TY - JOUR\n"` ), false));
      item.uniqueID = parseInt(result.trim());
    }
    // update values
    return this.updateReferences(data);
  },

  /**
   * Returns the dates when the references with the given ids were last modified
   * @param  {Array} ids An array of numeric ids
   * @return {Promise<Date[]>} A Promise resolving with an array of Date objects (UTC timezone), in the
   * order of the given array of ids.
   */
  modificationDates: function(ids) {
    if ( ! Array.isArray(ids) || ids.length < 1 ) {
      throw new Error("First parameter must be an Array with at least one element");
    }
    return runOsaCmd(command('RMOD', quote(ids.join(','))))
    .then(result => {
      return removeQuotes(result).split(new RegExp(String.fromCharCode(0))).map(timestamp => {
        // Need Unix (1970) milliseconds (not seconds since 1904) for JS:
        // (drop 66 years of seconds, and convert to milliseconds)
        let localDate = new Date((parseInt(timestamp, 10) - 2.0828448E+9) * 1000);
        // convert into UTC date
        let utc = new Date(localDate.getTime() + localDate.getTimezoneOffset() * 60000);
        return utc;
      });
    })
    .catch(err => {
      throw new Error("modificationDates() failed: is the database empty?");
    });
  }
};

module.exports = bookends;