Source: SleepAsAndroid/engine.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";

/**
 * @public
 * @unrestricted
 * @augments DiaryBase
 *
 * @example
 * let diary = new_sleep_diary(contents_of_my_file));
 *
 * console.log( this.records );
 * -> [ // "records" is an array of objects:
 *      {
 *
 *        // normalised event times - use these where possible:
 *        "start" : 12345678,
 *        "end" : 23456789,
 *        "alarm" : 23456789,
 *        "duration" : 11111111, // "Hours" hours minus "LenAdjust" minutes
 *
 *        // native event times - see README.md#date-issues
 *        "Id" : "12345678",
 *        "Tz" : "Europe/London",
 *        "From" : {
 *          "string"   : "01. 02. 2003 04:05",
 *          "year"     : 2003,
 *          "month"    : 2,
 *          "day"      : 1,
 *          "hour"     : 4,
 *          "minute"   : 5,
 *        },
 *        "To" : {
 *          "string"   : "01. 02. 2003 05:06",
 *          "year"     : 2003,
 *          "month"    : 2,
 *          "day"      : 1,
 *          "hour"     : 5,
 *          "minute"   : 6,
 *        },
 *        "Sched" : {
 *          "string"   : "01. 02. 2003 06:07",
 *          "year"     : 2003,
 *          "month"    : 2,
 *          "day"      : 1,
 *          "hour"     : 6,
 *          "minute"   : 7,
 *        },
 *        "Hours" : 1.1,
 *
 *        "Rating" : 2.5,
 *        "Comment" : {
 *          "string": "comment string with a #tag",
 *          "tags"  : [{ "count": 1, "value": "tag" }],
 *          "notags": "comment string with a"
 *        },
 *        "Framerate" : "10000",
 *        "Snore" : null,
 *        "Noise" : null,
 *        "Cycles" : null,
 *        "DeepSleep" : null,
 *        "LenAdjust" : null,
 *        "Geo" : "",
 *        "times" : [
 *          {
 *            "header_string": "04:40",
 *            "hours"        : 4,
 *            "minutes"      : 40,
 *            "actigraphy"   : 0.5,
 *            "noise"        : 10.0,
 *          },
 *          ...
 *        ],
 *        "events" : [
 *          {
 *            "label"    : "LUX",
 *            "timestamp": 123456789,
 *            "value"    : 10.0,
 *          },
 *        ]
 *      },
 *      ...
 *    ]
 *
 * console.log( this.prefs );
 * -> { key/value pairs, format not guaranteed by this library }
 *
 * console.log( this.alarms );
 * -> [ list of alarms, format not guaranteed by this library ]
 *
 */
class DiarySleepAsAndroid extends DiaryBase {

    /**
     * @param {Object} file - file contents, or filename/contents pairs (for archive files)
     * @param {Function=} serialiser - function to serialise output
     */
    constructor(file,serialiser) {

        super(file,serialiser); // call the SleepDiary constructor

        /*
         * Regular expressions for fundamental data types
         */

        const              integer_type = "\"-?[0-9]*\"";
        const non_negative_integer_type =   "\"[0-9]*\"";
        const              number_type  = "\"-?[0-9]*(\\.[0-9]*)?\"";
        const non_negative_number_type  =   "\"[0-9]*(\\.[0-9]*)?\"";

        const deep_sleep_type = "\"(-[12]\\.0|0\\.[0-9]*|1\\.0)\"";

        const geo_type = "\"[0-9a-f]*\"";

        const free_text_type = "\"([^\"]|\"\")*\"";

        const event_type = "\"[-0-9E.]*|([A-Z_]*)-([0-9]*)(-([-0-9E.]*))?\"";

        // e.g. "31. 01. 2010 01:23" (with quotes):
        const datetime_type = "\"([0-9]*)\\. ([0-9]*). ([0-9]*) ([0-9]*):([0-9]*)\"";

        const time_type = "\"([0-9]*):([0-9]*)\"";

        const tag_type = "#([^ \",][^ \",]*)";

        /*
         * Fields in a single record
         */

        // line 1:
        const time_header = time_type;

        // line 2:
        const identifier_field = free_text_type;
        const   timezone_field = free_text_type;
        const       from_field = datetime_type;
        const         to_field = datetime_type;
        const      sched_field = datetime_type;
        const      hours_field = non_negative_number_type;
        const     rating_field = non_negative_number_type;
        const    comment_field = free_text_type;
        const  framerate_field = free_text_type; // usually a number, but officially unspecified
        const      snore_field = integer_type;
        const      noise_field = number_type;
        const     cycles_field = integer_type;
        const deep_sleep_field = deep_sleep_type;
        const  lenadjust_field = number_type;
        const        geo_field = geo_type;
        const       time_field = number_type;
        const      event_field = event_type;

        // line 3:
        const noise_value = number_type;

        /*
         * Lines in a single record
         */

        const line_1 =
              "Id," +
              "Tz," +
              "From," +
              "To," +
              "Sched," +
              "Hours," +
              "Rating," +
              "Comment," +
              "Framerate," +
              "Snore," +
              "Noise," +
              "Cycles," +
              "DeepSleep," +
              "LenAdjust," +
              "Geo" +
              "((," + time_header + ")*)" +
              "((,\"Event\")*)"
        ;

        const line_2 =
              "(" + identifier_field + ")," +
              "(" +   timezone_field + ")," +
              "(" +       from_field + ")," +
              "(" +         to_field + ")," +
              "(" +      sched_field + ")," +
              "(" +      hours_field + ")," +
              "(" +     rating_field + ")," +
              "(" +    comment_field + ")," +
              "(" +  framerate_field + ")," +
              "(" +      snore_field + ")," +
              "(" +      noise_field + ")," +
              "(" +     cycles_field + ")," +
              "(" + deep_sleep_field + ")," +
              "(" +  lenadjust_field + ")," +
              "(" +        geo_field + ")" +
              "((," +     time_field + ")*)" +
              "((," +    event_field + ")*)"
        ;

        const line_3 = ",,,,,,,,,,,,((," + noise_value + ")*)";

        /*
         * The complete document
         */

        const document =
              "^(" +
               line_1 + "\n" +
               line_2 + "\n" +
               "(" + line_3 + "\n)?" +
              ")*" +
              "(" +
               line_1 + "\n" +
               line_2 +
               "(\n" + line_3 + ")?" +
              ")" +
              "\n?$"
        ;

        /*
         * Compiled regular expressions
         */

        const line_1_re = new RegExp("^" + line_1 + "$");
        const line_2_re = new RegExp("^" + line_2 + "$");
        const line_3_re = new RegExp("^" + line_3 + "$");

        const document_re = new RegExp(document);

        const datetime_type_re = new RegExp(   datetime_type      );
        const     time_type_re = new RegExp(       time_type, "g" );
        const   number_type_re = new RegExp(     number_type, "g" );
        const    event_type_re = new RegExp(      event_type, "g" );
        const      tag_type_re = new RegExp(        tag_type, "g" );
        const         untag_re = new RegExp( " *" + tag_type, "g" );

        const double_quote_re = new RegExp(  "\"\"", "g" );
        const      newline_re = new RegExp( " \\\\n ", "g" );

        /*
         * Parsers for individual data types
         */

        function parse_free_text(string) {
            return string.substr( 1, string.length-2 )
                .replace( double_quote_re, "\"" )
                .replace(      newline_re, "\n" )
            ;
        }

        function parse_tags(string) {
            let ret = [];
            string.replace( tag_type_re, function(_,tag) {
                if ( tag.length > 3 ) {
                    if ( tag.substr( tag.length - 3 ) == "_2x" ) {
                        ret.push({ "count": 2, "value": tag.substr( 0, tag.length - 3 ) });
                    } else if ( tag.substr( tag.length - 3 ) == "_3x" ) {
                        ret.push({ "count": 3, "value": tag.substr( 0, tag.length - 3 ) });
                    } else {
                        ret.push({ "count": 1, "value": tag });
                    }
                }
            });
            return ret;
        }

        function untag(string) {
            return string.replace( untag_re, "" );
        }

        function parse_datetime(string) {
            let data = string.match(datetime_type_re);
            return {
                "string"   : string,
                "year"     : parseInt(data[3],10),
                "month"    : parseInt(data[2],10),
                "day"      : parseInt(data[1],10),
                "hour"     : parseInt(data[4],10),
                "minute"   : parseInt(data[5],10),
            };
        }

        function parse_timestamp(timestamp,timezone) {
            const date = DiaryBase.date(timestamp,timezone);
            return {
                "string": (
                    '"' +
                        DiaryBase.zero_pad( date["day"]() ) +
                        '. ' +
                        DiaryBase.zero_pad( date["month"]() ) +
                        '. ' +
                        date["year"]() +
                        ' ' +
                        date["hour"]() + // not padded
                        ':' +
                        DiaryBase.zero_pad( date["minute"]() ) +
                    '"'
                ),
                "year"     : date["year"](),
                "month"    : date["month"](),
                "day"      : date["day"](),
                "hour"     : date["hour"](),
                "minute"   : date["minute"](),
            };
        }

        function parse_time(string) {
            let data = string.match(time_type_re);
            return {
                "string": string,
                "hour"  : parseInt(data[1],10),
                "minute": parseInt(data[2],10),
            };
        }

        function parse_integer(string) {
            return parseInt(string.substr( 1, string.length-2 ),10);
        }

        function parse_integer_nullable(string,null_values) {
            return (
                null_values.some( value => value == string )
                    ? null
                    : parse_number(string)
            );
        }

        function parse_number_nullable(string,null_values) {
            return (
                null_values.some( value => value == string )
                    ? null
                    : parse_number(string)
            );
        }

        function parse_number(string) {
            return parseFloat(string.substr( 1, string.length-2 ));
        }

        function parse_geo_nullable(string,null_values) {
            // at the time of writing, we're not sure how to interpret this value
            return (
                null_values.some( value => value == string )
                    ? null
                    : parse_free_text(string)
            );
        }

        /*
         * Parse the documents
         */

        function parse_prefs_xml(xml) {
            let ret = {},
                root = DiaryBase.parse_xml(xml).documentElement,
                longs   = Array.from(root.getElementsByTagName("long")),
                ints    = Array.from(root.getElementsByTagName("int")),
                bools   = Array.from(root.getElementsByTagName("boolean")),
                strings = Array.from(root.getElementsByTagName("string"))
            ;

            longs  .forEach( e => ret[e.getAttribute("name")] = parseInt(e.getAttribute("value"),10) );
            ints   .forEach( e => ret[e.getAttribute("name")] = parseInt(e.getAttribute("value"),10) );
            bools  .forEach( e => ret[e.getAttribute("name")] = e.getAttribute("value") == "true" );
            strings.forEach( e => ret[e.getAttribute("name")] = e.textContent );
            return ret;
        }

        function parse_alarms_json(json) {
            return JSON.parse(json);
        }

        function parse_sleep_export_csv(text) {

            let ret = [],
                lines = text.split("\n");

            if ( lines[lines.length-1] == "" ) lines.pop();

            for ( let n=0; n!=lines.length; n += 2 ) {

                /*
                 * Read the first line
                 */

                let line1_data = lines[n].match(line_1_re),
                    times = [],
                    events = []
                ;
                if ( !line1_data ) {
                    return this.corrupt(file);
                }
                // loop through the list of times, extract hours and minutes from each:
                line1_data[1].replace( time_type_re, function(str,hours,minutes) {
                    times.push({
                        "header_string": str,
                        "hours"        : parseInt(hours,10),
                        "minutes"      : parseInt(minutes,10),
                        "actigraphy"   : null,
                        "noise"        : null,
                    });
                });
                // loop through the list of events:
                line1_data[5].replace( /"Event"/g, function() {
                    events.push({
                        "label"    : null,
                        "timestamp": null,
                    });
                });

                /*
                 * Read the second line
                 */

                let line2_data = lines[n+1].match(line_2_re);

                if ( !line2_data ) {
                    return this.corrupt(file);
                }

                let current_time = 0,
                    current_event = 0,
                    comment_text = parse_free_text(line2_data[27]),
                    record = {
                        "Id"        : parse_free_text(line2_data[ 1]),
                        "Tz"        : parse_free_text(line2_data[ 3]),
                        "From"      : parse_datetime (line2_data[ 5]),
                        "To"        : parse_datetime (line2_data[11]),
                        "Sched"     : parse_datetime (line2_data[17]),
                        "Hours"     : parse_number   (line2_data[23]),
                        "Rating"    : parse_number   (line2_data[25]),
                        "Comment"   : {
                            "string":            comment_text ,
                            "tags"  : parse_tags(comment_text),
                            "notags": untag     (comment_text),
                        },
                        "Framerate" : parse_free_text       (line2_data[29]),
                        "Snore"     : parse_integer_nullable(line2_data[31], ["\"-1\""]),
                        "Noise"     : parse_number_nullable (line2_data[32], ["\"-1.0\""]),
                        "Cycles"    : parse_integer_nullable(line2_data[34], ["\"-1\""]),
                        "DeepSleep" : parse_number_nullable (line2_data[35], ["\"-1.0\"", "\"-2.0\""]),
                        "LenAdjust" : parse_number_nullable (line2_data[37], ["\"-1.0\""]),
                        "Geo"       : parse_geo_nullable    (line2_data[39], [""]),
                        "times"     : times,
                        "events"    : events,
                    }
                ;

                function calc_date( date ) {
                    return DiaryBase.date(
                        [ "year", "month", "day" ].map( v => DiaryBase.zero_pad(date[v]) ).join('-') +
                        'T' +
                        [ "hour", "minute" ].map( v => DiaryBase.zero_pad(date[v]) ).join(':'),
                        record["Tz"]
                    )["unixUtcMillis"]();
                }

                record["start"   ] = parseInt(record["Id"],10);
                record["end"     ] = record["start"]+record["Hours"]*60*60*1000;
                record["duration"] = Math.round( (
                    record["Hours"]*60 + ( line2_data[37] == "-1.0" ? 0 : record["LenAdjust"] )
                ) * 60 * 1000 );
                record["alarm"] = Math.round(
                    (
                        record["end"]
                        + calc_date( record["Sched"] )
                        - calc_date( record["To"   ] )
                    ) / (60*1000)
                ) * (60*1000)
                ;

                // loop through times:
                line2_data[40].replace( number_type_re, function(str) {
                    times[current_time++]["actigraphy"] = parse_number(str);
                });
                // loop through events:
                line2_data[43].replace( event_type_re, function(str,label,timestamp,_,value) {
                    if ( events[current_event] ) {
                        events[current_event]["label"]     = label;
                        events[current_event]["timestamp"] = parseInt(timestamp,10);
                        if ( value ) events[current_event]["value"] = parseFloat(value);
                        ++current_event;
                    }
                });

                /*
                 * Read the third line
                 */

                let line3_data = lines[n+2] ? lines[n+2].match(line_3_re) : 0, noises = [];
                if ( line3_data ) {
                    line3_data[1].replace( number_type_re, str => noises.push(parse_number(str)) );
                    // has been observed to be false in the wild:
                    if ( times.length == noises.length ) {
                        times.forEach( (t,n) => t["noise"] = noises[n] );
                    } else {
                        console.warn(
                            "Ignoring third line data with unexpected length:",
                            line3_data
                        );
                    }
                    ++n;
                }

                ret.push(record);

            }

            return ret;

        }

        var records,
            prefs = {},
            alarms = []
        ;

        /**
         * Spreadsheet manager
         * @protected
         * @type {Spreadsheet}
         */
        this["spreadsheet"] = new Spreadsheet(this,[]);

        const contents = file["contents"];

        switch ( file["file_format"]() ) {

        case "spreadsheet":

            // Records
            if (
                 !file["sheets"].some( sheet => {

                     const cells = sheet["cells"];

                     if ( !cells.length ) return false;

                     const line1_pattern = [
                         "Id",
                         "Tz",
                         "From",
                         "To",
                         "Sched",
                         "Hours",
                         "Rating",
                         "Comment",
                         "Tags",
                         "Framerate",
                         "Snore",
                         "Noise",
                         "Cycles",
                         "DeepSleep",
                         "LenAdjust",
                         "Geo",
                     ].join();

                     records = [];

                     let line = 1, record, times, events;
                     return cells.every( row => {
                         if ( row.slice(0,16).map( cell => cell["value"] ).join() == line1_pattern ) {

                             times  = [];
                             events = [];
                             record = {
                                 "times" : times,
                                 "events": events,
                             };
                             records.push(record);
                             line = 2;
                             row.slice(16).forEach( cell => {
                                 if ( cell["value"] == "Event" ) {
                                     events.push({
                                         "label"    : null,
                                         "timestamp": null,
                                     });
                                 } else if ( typeof(cell["value"]) == "number" ) {
                                     const hour   = Math.floor( cell["value"] *     24  );
                                     const minute = Math.floor( cell["value"] * (60*24) ) % 60;
                                     times.push({
                                         "header_string": `${hour}:${DiaryBase.zero_pad(minute)}`,
                                         "hours"        : hour,
                                         "minutes"      : minute,
                                         "actigraphy"   : null,
                                         "noise"        : null,
                                     });
                                 } else {
                                     return false;
                                 }
                             });

                         } else if ( line == 2 ) {

                             line = 3;
                             [
                                 "Id",
                                 "Tz",
                                 ,
                                 ,
                                 ,
                                 "Hours",
                                 "Rating",
                                 "Comment",
                                 "Tags",
                                 "Framerate",
                                 "Snore",
                                 "Noise",
                                 "Cycles",
                                 "DeepSleep",
                                 "LenAdjust",
                                 "Geo",
                             ].forEach( (key,n) => record[key] = row[n] ? row[n]["value"] : null );

                             if ( record["Comment"] && record["Tags"] ) {
                                 record["Comment"] = {
                                     "string": record["Comment"] + " " + record["Tags"],
                                     "tags"  : parse_tags(record["Tags"]),
                                     "notags": record["Comment"],
                                 };
                             } else if ( record["Comment"] ) {
                                 record["Comment"] = {
                                     "string": record["Comment"],
                                     "tags"  : [],
                                     "notags": record["Comment"],
                                 };
                             } else if ( record["Tags"] ) {
                                 record["Comment"] = {
                                     "string": record["Tags"],
                                     "tags"  : parse_tags(record["Tags"]),
                                     "notags": "",
                                 };
                             } else {
                                 record["Comment"] = {
                                     "string": "",
                                     "tags"  : [],
                                     "notags": "",
                                 };
                             }
                             delete record["Tags"];

                             const timezone = row[1]["value"];

                             record["start"] = row[2]["value"].getTime();
                             record["From" ] = parse_timestamp( row[2]["value"].getTime(), timezone );
                             record["end"  ] = row[3]["value"].getTime();
                             record["To"   ] = parse_timestamp( row[3]["value"].getTime(), timezone );
                             record["alarm"] = row[4]["value"].getTime();
                             record["Sched"] = parse_timestamp( row[4]["value"].getTime(), timezone );
                             record["duration"] = Math.round( (
                                 record["Hours"]*60 + ( record["LenAdjust"] || 0 )
                             ) * 60 * 1000 );

                             times .forEach( (time ,n) => {
                                 if ( row[16+n] ) time["actigraphy"] = row[16+n]["value"]
                             });
                             events.forEach( (event,n) => {
                                 if ( row[16+times.length+n] ) {
                                     const parts = row[16+times.length+n]["value"].split('-');
                                     event["label"    ] = parts[0];
                                     event["timestamp"] = parts[1];
                                     if ( parts.length > 2 ) event["value"] = parts[2];
                                 }
                             });

                         } else if ( line == 3 ) {

                             line = 1;
                             times.forEach( (time ,n) => {
                                 if ( row[16+n] ) time["noise"] = row[16+n]["value"]
                             });

                         } else {

                             return false;

                         }

                         return true;

                     });

                 })

                 ||

                 !file["sheets"].some( sheet => {
                     const cells = sheet["cells"];
                     if ( !cells.length || cells[0][0]["value"] != "Alarm" ) {
                         return false;
                     }
                     alarms = cells.slice(1).map( row => JSON.parse(row[0]["value"]) );
                     return true;
                 })

                 ||

                 !file["sheets"].some( sheet => {
                     const cells = sheet["cells"];
                     if ( !cells.length || cells[0][0]["value"] != "Preferences" ) {
                         return false;
                     }
                     prefs = JSON.parse(cells[1][0]["value"]);
                     return true;
                 })

               ) {
                return this.invalid(file);
            }

            this.raw = file["spreadsheet"];
            break;

        case "string":
            if ( !document_re.test(contents) ) return this.invalid(file);
            records = parse_sleep_export_csv.call(this,contents);
            break;

        case "archive":

            if (
                contents.hasOwnProperty("sleep-export.csv") && // could be the empty string
                contents["prefs.xml"] && contents["alarms.json"] // must be truthy strings
            ) {

                records = parse_sleep_export_csv.call(this,contents["sleep-export.csv"]);
                prefs   = parse_prefs_xml            (contents["prefs.xml"]);
                alarms  = parse_alarms_json          (contents["alarms.json"]);
                break;

            } else {

                return this.invalid(file);

            }

        default:

            if ( this.initialise_from_common_formats(file) ) return;

            records =
                file["to"]("Standard")["records"]
                .filter( r => r["status"] == "asleep" )
                .map( r => ({
                    "start"     : r["start"   ],
                    "end"       : r["end"     ],
                    "duration"  : r["duration"],
                    "alarm"     : Math.round( r["end"] / (60*1000) ) * (60*1000),
                    "Id"        : (r["start"]||0).toString(),
                    "Tz"        : r["start_timezone"] || r["end_timezone"] || "Etc/GMT",
                    "From"      : parse_timestamp(r["start"], r["start_timezone"] || r["end_timezone"] ),
                    "To"        : parse_timestamp(r["end"  ], r["end_timezone"] || r["start_timezone"]),
                    "Sched"     : parse_timestamp(r["end"  ], r["end_timezone"] || r["start_timezone"]),
                    "Hours"     : ( r["end"] - r["start"] ) / (60*60*1000),
                    "Rating"    : 2.5,
                    "Comment"   : {
                        "string": (r["comments"]||[]).join("\n") + (r["tags"]||[]).map( tag => `#${tag}` ).join(' '),
                        "tags"  : (r["tags"]||[]).map( tag => ({ "count": 1, "value": tag }) ),
                        "notags": (r["comments"]||[]).join("\n")
                    },
                    "Framerate" : "10000",
                    "Snore"     : null,
                    "Noise"     : null,
                    "Cycles"    : null,
                    "DeepSleep" : null,
                    "LenAdjust" : null,
                    "Geo"       : "",
                    "times"     : [],
                    "events"    : [],
                }))
            ;

        }

        /**
         * Individual records from the sleep diary
         */
        this["records"] = records;
        /**
         * User preferences
         */
        this["prefs"  ] = prefs;
        /**
         * User alarms
         */
        this["alarms" ] = alarms;

    }

    ["to"](to_format) {

        const headers_early = [ "Id", "Tz", "From", "To", "Sched" ];
        const headers_before_comment = [ "Hours", "Rating" ];
        const headers_after_comment  = [ "Framerate", "Snore", "Noise", "Cycles", "DeepSleep", "LenAdjust", "Geo" ];

        switch ( to_format ) {

        case "output":

            function string_or_null( string, null_value ) {
                return '"' + ( string === null ? null_value : string ) + '"';
            }

            let alarms = JSON.stringify(this["alarms"]),

                prefs = "<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n<map>" +
                Object.keys(this["prefs"]).map(
                    key => {
                        let value = this["prefs"][key];
                        switch ( typeof(value) ) {
                        case "boolean":
                            return `\n\t<boolean name="${key}" value="${value}" />\n`;
                        case "number":
                            return `\n\t<${( Math.abs(value) >= 2**31 ) ? "long" : "int" } name="${key}" value="${value}" />\n`;
                        default:
                            return `\n\t<string name="${key}"><![CDATA[${value}]]></string>\n`;
                        }
                    }
                ).join("") + "\n</map>\n",

                records = this["records"].map(
                    record => (

                        // Line 1:
                        []
                            .concat(
                                headers_early,
                                headers_before_comment,
                                ["Comment"],
                                headers_after_comment,
                                record["times" ].map( time => `,"${time["header_string"]}"` ),
                                record["events"].map( event => ",\"Event\""                 ),
                            )
                            .join(',')
                            + "\n" +

                        // line 2:
                        []
                            .concat(
                                [
                                    `"${record["Id"]}"`,
                                    `"${record["Tz"]}"`,
                                ],
                                [ "start", "end", "alarm" ].map( h => {
                                    let date = DiaryBase.date(record[h],record["Tz"]);
                                    return (
                                        '"' +
                                            DiaryBase.zero_pad( date["day"]() ) +
                                            '. ' +
                                            DiaryBase.zero_pad( date["month"]() ) +
                                            '. ' +
                                            date["year"]() +
                                            ' ' +
                                            date["hour"]() + // not padded
                                            ':' +
                                            DiaryBase.zero_pad( date["minute"]() ) +
                                            '"'
                                    );
                                }),
                                headers_before_comment.map( h => `"${record[h]}"` ),
                                [

                                    '"' + record["Comment"]["string"].replace(/"/g,`""`).replace(/\n/g," \\n ") + '"',
                                    '"' + record["Framerate"] + '"',
                                    string_or_null( record["Snore"    ], "-1" ),
                                    string_or_null( record["Noise"    ], "-1.0" ),
                                    string_or_null( record["Cycles"   ], "-1" ),
                                    string_or_null( record["DeepSleep"], "-1.0" ),
                                    string_or_null( record["LenAdjust"], "-1.0" ),
                                    string_or_null( record["Geo"      ], "" ),
                                ],
                                record["times" ].map( time => `"${time["actigraphy"]}"` ),
                                record["events"].map( event => `"${event["label"]}-${event["timestamp"]}${event.hasOwnProperty("value")?'-'+event["value"]:''}"` ),
                            ).join(',')
                            + "\n" +

                        // line 3:
                        (
                            record["times"].length
                            ?
                                ",,,,,,,,,,,,," +
                                record["times"].map( time => `"${time["noise"]}"` )
                                .join(',') + "\n"
                            : ""
                        )

                    )
                ).join("")
            ;

            return this.serialise({
                "file_format": () => "archive",
                "contents": {
                    "sleep-export.csv": records,
                    "prefs.xml"       : prefs,
                    "alarms.json"     : alarms,
                }
            });

        case "Standard":

            return new DiaryStandard({
                "records": this["records"]
                    .map(
                        record => Object.assign(
                            {
                                "status"        : "asleep",
                                "start"         :  record["start"],
                                "end"           :  record["end"  ],
                                "start_timezone":  record["Tz"],
                                "end_timezone":  record["Tz"],
                                "tags"          :  record["Comment"]["tags"].map( tag => tag["value"] ),
                                "comments"      : (
                                    record["Comment"]["notags"].length
                                        ?[record["Comment"]["notags"]]
                                        :undefined
                                ),
                            },
                            ( record["duration"] === undefined ) ? {} : { "duration" :  record["duration"] },
                        )
                    )
            }, this.serialiser);

        default:

            return super["to"](to_format);

        }

    }

    ["to_async"](to_format) {

        const headers_early = [ "Id", "Tz", "From", "To", "Sched" ];
        const headers_before_comment = [ "Hours", "Rating" ];
        const headers_after_comment  = [ "Framerate", "Snore", "Noise", "Cycles", "DeepSleep", "LenAdjust", "Geo" ];

        switch ( to_format ) {

            case "spreadsheet":

            const spreadsheet = this["spreadsheet"];

            {

                const max_times = (
                    this["records"].length
                    ? Math.max.apply( 0, this["records"].map( record => record["times"].length ) )
                    : 0
                );

                const added_sheet = spreadsheet["get_sheet"](
                    "Records",
                    headers_early.concat(
                        headers_before_comment,
                        [ "Comment", "Tags", ],
                        headers_after_comment,
                    ),
                    [
                        null,
                        null,
                        "time",
                        "time",
                        "time",
                        null,
                        null,
                        null,
                        null,
                        null,
                        null,
                        null,
                        null,
                        null,
                        null,

                    ].concat( Array(max_times).fill("duration") )
                );
                const added = added_sheet[0];
                const sheet = added_sheet[1];
                let cells = sheet["cells"];
                cells.splice(0);

                this["records"].forEach(
                    record => {

                        // this section is very similar to the equivalent loop in the "output" condition,
                        // but the details are different enough that they can't be usefully combined

                        // Line 1:
                        cells.push(
                            []
                                .concat(
                                    headers_early,
                                    headers_before_comment,
                                    [ "Comment", "Tags" ],
                                    headers_after_comment,
                                    record["times" ].map( time => ( time["hours"]*60 + time["minutes"] ) / (60*24) ),
                                    record["events"].map( event => "Event"              ),
                                )
                                .map( c => Spreadsheet.create_cell(c,"#FFEEEEEE,#FFEEEEEE") )
                        );

                        // line 2:
                        cells.push(
                            []
                                .concat(
                                    [
                                        record["Id"],
                                        record["Tz"],
                                    ],
                                    [ "start", "end", "alarm" ].map( h => new Date(record[h]) ),
                                    headers_before_comment.map( h => record[h] ),
                                    [
                                        record["Comment"]["notags"],
                                        record["Comment"][  "tags"].map(
                                            tag => `#${tag["value"]}${tag["count"]==1?"":'_'+tag["count"]+'x'}`
                                        ).join(' '),
                                        record["Framerate"],
                                        record["Snore"    ],
                                        record["Noise"    ],
                                        record["Cycles"   ],
                                        record["DeepSleep"],
                                        record["LenAdjust"],
                                        record["Geo"      ],
                                    ],
                                    record["times" ].map( time => time["actigraphy"] ),
                                    record["events"].map( event => `${event["label"]}-${event["timestamp"]}${event.hasOwnProperty("value")?'-'+event["value"]:''}` ),
                                )
                                .map( c => Spreadsheet.create_cell(c) )
                        );

                        // line 3:
                        if ( record["times"].some( time => time["noise"] ) ) {
                            cells.push(
                                [].concat(
                                    Array(13).fill(''),
                                    record["times"].map( time => time["noise"] )
                                )
                                    .map( c => Spreadsheet.create_cell(c) )
                            );
                        }

                    }
                );

                sheet["cells"] = cells;

                if ( added ) spreadsheet["sheets"].push(sheet);

            }

            {

                const added_sheet = spreadsheet["get_sheet"](
                    "Alarms",
                    [ "Alarm" ],
                    [ ""      ],
                );
                const added = added_sheet[0];
                const sheet = added_sheet[1];
                const cells = sheet["cells"];
                cells.splice(1);

                this["alarms"].forEach( alarm => cells.push([ Spreadsheet.create_cell(JSON.stringify(alarm)) ]) );
                if ( added ) spreadsheet["sheets"].push(sheet);

            }

            {

                const added_sheet = spreadsheet["get_sheet"](
                    "Preferences",
                    [ "Preferences" ],
                    [ ""            ],
                );
                const added = added_sheet[0];
                const sheet = added_sheet[1];
                const cells = sheet["cells"];
                cells.splice(1);
                cells.push([ Spreadsheet.create_cell(JSON.stringify(this["prefs"])) ]);
                if ( added ) spreadsheet["sheets"].push(sheet);

            }

            return spreadsheet["serialise"]();

        default:

            return super["to_async"](to_format);

        }

    }

    ["merge"](other) {

        other = other["to"](this["file_format"]());

        this["records"] = this["records"].concat(
            DiaryBase.unique(
                this["records"],
                other["records"],
                ["Id"]
            )
        );

        return this;

    }

    ["file_format"]() { return "SleepAsAndroid"; }
    ["format_info"]() {
        return {
            "name": "SleepAsAndroid",
            "title": "Sleep as Android",
            "url": "/src/SleepAsAndroid",
            "statuses": [ "asleep" ],
            "extension": ".zip",
            "logo": "https://docs.sleep.urbandroid.org/assets/images/logo.png",
            "timezone": "tzdata",
        }
    }

}

// Register this as a sleep diary:
DiaryBase.register(DiarySleepAsAndroid);