Source: DiaryBase.js

/*
 * Copyright 2020-2022 Sleepdiary Developers <sleepdiary@pileofstuff.org>
 *
 * Permission is hereby granted, free of charge, to any person
 * obtaining a copy of this software and associated documentation
 * files (the "Software"), to deal in the Software without
 * restriction, including without limitation the rights to use, copy,
 * modify, merge, publish, distribute, sublicense, and/or sell copies
 * of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
 * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
 * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

"use strict";

/**
 * @define {boolean} debugging mode
 * @package
 */
const DEBUG = false;

/**
 * Functions for converting from the current type to some other type
 * @private
 */
const sleepdiary_converters = {};

/**
 * @typedef {{
 *   name        : string,
 *   constructor : Function,
 *   title       : string,
 *   url         : string
 * }} DiaryEngine
 */
let DiaryEngine;

/**
 * List of known engines for sleep diaries
 * @type Array<DiaryEngine>
 * @tutorial List supported engines
 * @public
 */
const sleepdiary_engines = [];

/**
 * Default timezone for this system
 *
 * This is constant in normal operation, but can be overridden
 * by unit tests
 */
let system_timezone = (
    tz => ( tz == "UTC" ) ? "Etc/GMT" : tz
)( new Intl.DateTimeFormat().resolvedOptions().timeZone );

/**
 * @class Base class for sleep diary engines
 *
 * @unrestricted
 * @abstract
 */
class DiaryBase {

    /**
     * @param {string|Object} file - object containing the file
     * @param {Function=} serialiser - function to serialise output
     */
    constructor(file,serialiser) {
        if ( serialiser ) {
            /**
             * Serialise a value for output
             * @type {Function}
             */
            this.serialiser = serialiser;
        }
    }

    /*
     * Abstract functions
     */

    /**
     * Name of the current file format
     * @nocollapse
     * @public
     */
    static ["file_format"]() { return "DiaryBase" }

    /**
     * Merge another diary into this one
     *
     * @public
     *
     * @param {DiaryBase} other - diary to merge in
     *
     * @example
     *   diary.merge(my_data);
     */
    ["merge"](other) { return this; }

    /*
     * Functions that may or may not need to be overridden in descendent classes
     */

    /**
     * Create a deep copy of the current object
     */
    ["clone"]() {
        return new_sleep_diary(
            {
                "file_format": "storage-line",
                "contents": {
                    "file_format": this["file_format"](),
                    "contents"   : JSON.parse(
                        JSON.stringify(
                            this,
                            (key,value) => ( key == "spreadsheet" ) ? undefined : value
                        )
                    ),
                }
            },
            this.serialiser
        );
    }

    /**
     * Convert a value to some other format
     *
     * <p>Supported formats:</p>
     *
     * <ul>
     *   <li><tt>url</tt> - contents serialised for inclusion in a URL</li>
     *   <li><tt>json</tt> - contents serialised to JSON</li>
     *   <li><tt>storage-line</tt> - contents serialised for inclusion in a newline-separated list of diaries</li>
     *   <li><tt>Standard</tt> - Standard format</li>
     *   <li><em>(other formats)</em> - the name of any other diary format</li>
     * </ul>
     *
     * <p>[to_async()]{@link DiaryBase#to_async} supports more formats
     * and should be used where possible.  You should only call this
     * function directly if you want to guarantee synchronous execution.</p>
     *
     * @public
     *
     * @param {string} to_format - requested format
     * @return {*} diary data in new format
     *
     * @example
     * console.log( diary.to("NewFormat") );
     */
    ["to"](to_format) {

        switch ( to_format ) {

        case this["file_format"]():
            return this;

        case "url":
            return "sleepdiary=" + encodeURIComponent(JSON.stringify(
                {
                    "file_format": this["file_format"](),
                    "contents"   : this,
                },
                (key,value) => ( key == "spreadsheet" ) ? undefined : value
            ));

        case "json":
            return JSON.stringify(
                this,
                (key,value) => ( key == "spreadsheet" ) ? undefined : value
            );

        case "storage-line":
            return "storage-line:" + this["file_format"]() + ':' + JSON.stringify(
                this,
                (key,value) => ( key == "spreadsheet" ) ? undefined : value
            );

        default:
            if ( sleepdiary_converters.hasOwnProperty(to_format) ) {
                return new sleepdiary_converters[to_format](
                    this["to"]("Standard"),
                    this.serialiser,
                );
            } else {
                throw Error( this["file_format"]() + " cannot be converted to " + to_format);
            }

        }

    }

    /**
     * Convert a value to some other format
     *
     * <p>Supported formats:</p>
     *
     * <ul>
     *   <li><tt>output</tt> - contents serialised for output (e.g. to a file)</li>
     *   <li><tt>spreadsheet</tt> - binary data that can be loaded by a spreadsheet program</li>
     *   <li><em>(formats supported by [to()]{@link DiaryBase#to})</em></li>
     * </ul>
     *
     * <p>See also [to()]{@link DiaryBase#to}, a lower-level function
     * that supports formats that can be generated synchronously.  You
     * can use that function if a Promise interface would be
     * cumbersome or unnecessary in a given piece of code.</p>
     *
     * @public
     *
     * @param {string} to_format - requested format
     * @return {Promise|Object} Promise that returns the converted diary
     *
     * @example
     *   diary.to_async("NewFormat").then( reformatted => console.log( reformatted_diary ) );
     */
    ["to_async"](to_format) {

        switch ( to_format ) {

        case "spreadsheet":
            if ( !this["spreadsheet"]["synchronise"]() ) {
                throw Error("Could not synchronise data");
            }
            return this["spreadsheet"]["serialise"]();

        default:
            const ret = this["to"](to_format);
            return ret["then"] ? ret : { "then": callback => callback(ret) };
        }

    }

    /**
     * Serialise data for output
     * @protected
     */
    serialise(data) {
        return this.serialiser ? this.serialiser(data) : data;
    }

    /*
     * Construction helpers
     */

    /**
     * Register a new engine
     *
     * @public
     *
     * @param {Function} constructor - sleep diary engine
     *
     * @example
     *   DiaryBase.register(MyClass);
     */
    static register( constructor ) {
        let engine = constructor["prototype"]["format_info"]();
        engine["constructor"] = constructor;
        sleepdiary_engines.push(engine);
        if ( engine["url"][0] == '/' ) {
            engine["url"] = "https://sleepdiary.github.io/core" + engine["url"];
        }
        if ( engine.name != "Standard" ) {
            sleepdiary_converters[engine.name] = engine.constructor;
        }

    };

    /**
     * Indicates the file is not valid in our file format
     * @param {string|Object} file - file contents, or filename/contents pairs (for archive files)
     * @protected
     */
    invalid(file) {
        throw null;
    }

    /**
     * Indicates the file is a corrupt file in the specified format
     * @param {string|Object} file - file contents, or filename/contents pairs (for archive files)
     * @param {string} message - optional error message
     * @protected
     */
    corrupt(file,message) {
        if ( message ) {
            throw new Error( `Does not appear to be a valid ${this["file_format"]()} file:\n${message}` );
        } else {
            throw new Error( `Does not appear to be a valid ${this["file_format"]()} file` );
        }
    }

    /*
     * Attempt to initialise an object from common file formats
     * @param {Object} file - file to initialise from
     * @return {boolean} - whether parsing was successful
     */
    initialise_from_common_formats(file) {
        switch ( file["file_format"]() ) {

        case "url":
            file = file["contents"];
            if ( this["file_format"]() == file["file_format"] ) {
                Object.keys(file["contents"]).forEach( key => this[key] = file["contents"][key] );
                return true;
            } else {
                return this.invalid(file);
            }

        case "string":
        case "spreadsheet":
            if ( this["spreadsheet"]["load"](file) ) return true;
            // FALL THROUGH

        case "archive":
        case "array":
            this.invalid(file);

        }

        return false;
    }


    /*
     * Utility functions
     */

    /**
     * Convert a string to a number with leading zeros
     *
     * @param {number} n - number to pad
     * @param {number=} [length=2] - length of the output string
     *
     * @example
     * DiaryBase.zero_pad( 1    ) // ->   "01"
     * DiaryBase.zero_pad( 1, 4 ) // -> "0001"
     */
    static zero_pad( n, length ) {
        let zeros = '';
        if ( n ) {
            for ( let m=Math.pow( 10, (length||2)-1 ); m>n; m/=10 ) zeros += '0';
        } else {
            for ( let m=1; m<(length||2); ++m ) zeros += '0';
        }
        return zeros + n;
    }

    /**
     * parse an XML string to a DOM
     *
     * @param {string} string - XML string to parse
     * @protected
     *
     * @example
     *   let xml = DiaryBase.parse_xml("<foo>");
     */
    static parse_xml( string ) {

        let dom_parser;
        try {
            dom_parser = self.DOMParser;
            if ( !dom_parser ) throw "";
        } catch (e) {
            dom_parser = require("@xmldom/xmldom").DOMParser;
        }

        return new dom_parser().parseFromString(string, "application/xml");

    }

    /**
     * parse a timestamp in various formats
     *
     * @param {Object|number} value - value to analyse
     * @param {number=} epoch_offset - milliseconds between the unix epoch and the native offset
     * @return {number} Unix timestamp in milliseconds, or NaN if not parseable
     * @public
     *
     * @example
     *   let xml = DiaryBase.parse_xml("<foo>");
     */
    static parse_timestamp(value,epoch_offset) {

        const hours_to_milliseconds = 60 * 60 * 1000;

        if ( value === null ||
             value === undefined
           ) {
            return NaN;
        }

        if ( value["getTime"] ) {
            let time = value["getTime"]();
            if ( time <= 24*hours_to_milliseconds ) time += (epoch_offset||0);
            return time;
        }

        if ( value.match ) {

            const match = value.match(
                /^((?:19|20)[0-9]{2})[-.]?([0-9]{2})[-.]?([0-9]{2})[T ]?([0-9]{2})[.:]?([0-9]{2})(?:[.:]?([0-9]{2}))?Z?$/
            );
            if ( match ) {
                // YYYY-MM-DDThh:mm:ss.mmmmZ
                return new Date(
                    // new Date(match[1],match[2],...) // NO!  Would parse dates in local time
                       match[1]       +'-'+ match[2]       +'-'+ match[3]+'T'
                    + (match[4]||'00')+':'+(match[5]||'00')+':'+(match[6]||'00')
                    +'.'+(match[7]||0)+'Z'
                ).getTime();
            }

            if ( !value.search(/^[0-9]{4,}$/) ) {
                // string looks like a large number
                value = parseInt(value,10);
            }

        }

        if ( typeof(value) == "number" ) {
            // convert the number to milliseconds:
            const power_correction = 12 - Math.floor(Math.log10(/** @type {number} */(value)));
            return (
                value
                    * Math.pow(
                        10,
                        ( value && Math.abs(power_correction) > 2 )
                        ? power_correction
                        : 0
                    )
            );
        }

        // only string manipulation allowed beyond this point:
        if ( !value || !value.search ) return NaN;

        // treat e.g. "MidNight - 01:00" as "midnight", but leave "2010-11-12T13:14Z" alone:
        let cleaned_value = (
          ( value.search( /[a-su-y]/i ) == -1 && value.search( /-.*-/ ) != -1 )
          ? value
          : value.replace( /\s*(-|to).*/, "" )
        ).replace(/([ap])\s*(m)/i, "$1$2").toLowerCase();

        // common strings that don't match any pattern:
        if ( cleaned_value.search(/midnight/i) != -1 ) return 0;
        if ( cleaned_value.search(/midd?ay|noon|12pm/i) != -1 ) return 12*hours_to_milliseconds;

        // the value is e.g. "1am" or "2pm":
        let hour_match = cleaned_value.match(/^([0-9]*)(:([0-9]*))?\s*([ap])m$/);
        if ( hour_match ) {
            return (
                parseInt(hour_match[1],10) +
                parseInt(hour_match[3]||'0',10)/60 +
                ( hour_match[4] == 'p' ? 12 : 0 )
            ) * hours_to_milliseconds;
        }

        // the value is e.g. "00:00" or "14:30":
        let hhmmss_match = cleaned_value.match(/^([0-9]*)(:([0-9]*))?(:([0-9]*))?$/);
        if ( hhmmss_match ) {
            return (
                parseInt(hhmmss_match[1],10) +
                parseInt(hhmmss_match[3]||'0',10)/60 +
                parseInt(hhmmss_match[5]||'0',10)/3600
            ) * hours_to_milliseconds
        }

        // try to parse this as a date string:
        return (
            Date.parse(cleaned_value) ||
            Date.parse(        value)
        );

    }

    /**
     * return values that exist in the second argument but not the first
     *
     * @param {Array} list1 - first list of values
     * @param {Array} list2 - second list of values
     * @param {Array|function(*)} unique_id - function that returns the unique ID for a list item
     * @return {Array}
     * @protected
     *
     * @example
     *   let filtered = DiaryBase.unique(["a","b"],["b","c"],l=>l);
     *   -> ["c"]
     */
    static unique( list1, list2, unique_id ) {
        if ( typeof(unique_id) != "function" ) {
            const keys = unique_id;
            unique_id = r => keys.map( k => r[k] ).join("\uE000")
        }
        let list1_ids = {};
        list1.forEach( l => list1_ids[/** @type (function(*)) */(unique_id)(l)] = 1 );
        return list2.filter( l => !list1_ids.hasOwnProperty(/** @type (function(*)) */(unique_id)(l)) )
    }

    /**
     * Get the main object for managing timezones
     *
     * This object may change in future - prefer date() whenever possible.
     */
    static tc() {
        let ret;
        try {
            ret = self["tc"];
            if ( !ret ) throw "";
        } catch (e) {
            ret = require("timezonecomplete");
        }
        return ret;
    }

    /**
     * Create a DateTime object with timezone support
     *
     * @param {number|string} date - the date to parse
     * @param {string=} timezone - timezone (e.g. "Europe/London")
     * @public
     *
     * @example
     *   let date = DiaryBase.date(123456789,"Etc/GMT");
     */
    static date( date, timezone ) {
        const tc = DiaryBase.tc(),
              ret = new tc["DateTime"](date||0,tc["zone"]("Etc/UTC"));
        return timezone ? ret["toZone"](tc["zone"](timezone)) : ret;
    }

    /**
     * Get the time of the next DST change after the specified date
     *
     * @param {number|string} date - the date to parse
     * @param {string=} timezone - timezone (e.g. "Europe/London")
     * @return {number} - tranisition time, or Infinity if no transitions are expected
     * @public
     */
    static next_dst_change( date, timezone ) {
        const tzdata = DiaryBase.tc()["TzDatabase"]["instance"]();
        /*
         * As of 2021-06-10, timezonecomplete has a weird bug
         * that causes it to miscalculate DST changes shortly before
         * the change itself.  This works around that issue:
         */
        for ( let time = date - 1000*60*60*48; time < date; time += 1000*60*60 ) {
            try {
                const ret = tzdata["nextDstChange"]( timezone, time );
                if ( !ret ) return Infinity;
                if ( ret > date ) return ret;
            } catch ( e ) {
                /*
                 * timezonecomplete has been seen to throw timezonecomplete.InvalidTimeZoneData
                 * when calling e.g. tzdata["nextDstChange"]( "Europe/London", 0 )

                 */
                return Infinity;
            }
        }
        return tzdata["nextDstChange"]( timezone, date ) || Infinity;
    }

    /**
     * Array of status strings and associated regular expressions
     * @protected
     */
    static status_matches() {
        return [
            /* status       must match                 style (BG, FG, text) */
            [ "awake"     , "w.ke"                    , ""                    ],
            [ "asleep"    , "sle*p(?!.*aid)"          , "#FFFFFF00,#FF0000FF,#FFFFFFFF" ],
            [ "lights off", "light.*(?:out|off)"      , "#FFFFFFFF,#FF000080,#FFFFFFFF" ],
            [ "lights on" , "light.*on"               , "#FFFFFFFF,#FFE6E6FF" ],
            [ "snack"     , "snack"                   , "#FFFF7FFF,#FF7FFF7F" ],
            [ "meal"      , "meal|eat"                , "#FFFF00FF,#FF00FF00" ],
            [ "alcohol"   , "alco"                    , "#FF1FAFEF,#FFE05010,#FFFFFFFF" ],
            [ "chocolate" , "choc"                    , "#FF84C0FF,#FF7B3F00,#FFFFFFFF" ],
            [ "caffeine"  , "caffeine|coffee|tea|cola", "#FF0B3B8B,#FFF4C474" ],
            [ "drink"     , "drink"                   , "#FFDF8F5F,#FF2070a0,#FFFFFFFF" ],
            [ "sleep aid" , "sle*p.*aid|pill|tranq"   , "#FFAFFF7F,#FF500080,#FFFFFFFF" ],
            [ "exercise"  , "exercise"                , "#FF00C0C0,#FFFF3F3F" ],
            [ "toilet"    , "toilet|bathroom|loo"     , "#FF363636,#FFC9C9C9" ],
            [ "noise"     , "noise"                   , "#FF8F8F8F,#FF707070,#FFFFFFFF" ],
            [ "alarm"     , "alarm"                   , "#FF000000,#FFFF0000" ],
            [ "in bed"    , "down|(in|to).*bed"       , "#FFA0A000,#FF5f5fff" ],
            [ "out of bed", "up|out.*bed"             , "#FF0000FF,#FFFFFFCC" ],
        ];
    }

    /**
     * Complete list of allowed timezones
     * @return {Array<string>}
     */
    ["timezones"]() {
        return Object.keys(self["tzdata"]["zones"]).sort();
    }

    /**
     * Version ID for this package
     * @return {string}
     */
    ["software_version"]() { return SOFTWARE_VERSION; }

}

/**
 * Low-level reader interface
 *
 * @public
 *
 * @throws Will throw an error for unrecognised documents
 *
 * @param {string|Object} file - file contents, or filename/contents pairs (for archive files)
 * @param {Function=} serialiser - function to serialise output
 *
 * @return {Object|null} diary, or null if the document could not be parsed
 *
 * @example
 *   let diary = new_sleep_diary(contents_of_my_file));
 */
function new_sleep_diary(file,serialiser) {

    let error = new Error("This does not appear to be a sleep diary");

    let file_format = file["file_format"];

    if ( typeof(file) == "string" ) {

        file_format = "string";
        file = { "file_format": () => "string", "contents": file };

    } else if ( file_format ) {

        if ( typeof(file_format) == "string" ) {
            file["file_format"] = () => file_format;
        } else {
            file_format = file_format();
        }

        if ( file_format == "url" ) {
            file["contents"] = JSON.parse(decodeURIComponent(file["contents"].replace(/^sleep-?diary=/,'')));
        } else if ( file_format == "storage-line" ) {
            // these two formats behave identically after this point:
            file_format = "url";
        }

    } else {

        throw error;

    }

    if ( file_format == "string" ) {
        Object.assign(file,Spreadsheet.parse_csv(file["contents"]));
    }

    for ( let n=0; n!=sleepdiary_engines.length; ++n ) {
        try {
            return new sleepdiary_engines[n]["constructor"](file,serialiser);
        } catch (e) {
            if ( e ) { // SleepDiary.invalid() throws null to indicate the file is in the wrong format
                if ( DEBUG ) console.error(e);
                error = e;
            }
        }
    }

    if ( DEBUG ) {
        // this can cause false positives when e.g. DiaryLoader calls it on an ArrayBuffer,
        // before converting the file to text
        console.error( "Failed to read sleep diary", file, error );
    }
    throw error;

};