Source: SpreadsheetGraph/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(diary.records);
 * -> [
 *      {
 *        "start": 12345678,
 *        "end"  : 23456789,
 *        "status": "asleep",
 *        "comments": [ "comment 1", "comment 2" ],
 *      },
 *      ...
 *    ]
 *
 */
class DiarySpreadsheetGraph extends DiaryBase {

    /**
     * @param {Object} file - file contents
     * @param {Function=} serialiser - function to serialise output
     */
    constructor(file,serialiser) {

        super(file,serialiser);

        const status_matches = DiaryBase.status_matches();

        let records;
        let status_map = {};

        /**
         * Spreadsheet manager
         * @protected
         * @type {Spreadsheet}
         */
        this["spreadsheet"] = new Spreadsheet(
            this,
            [
                {
                    "sheet" : "Records",
                    "member" : "records",
                    "cells": [
                        {
                            "member": "start",
                            "type": "time",
                        },
                        {
                            "member": "end",
                            "type": "time",
                        },
                        {
                            "member": "status",
                            "type": "text",
                        },
                        {
                            "members": [ "comments" ],
                            "export": (array_element,row,offset) => row[offset] = Spreadsheet.create_cell( array_element["comments"].join("; ") ),
                            "import": (array_element,row,offset) => array_element["comments"] = row[offset]["value"].split(/\s*;\s*/),
                        },
                    ],
                },
            ],
        );

        if ( file["sheets"] && file["sheets"][0] && file["sheets"][0]["cells"] ) {

            const      twelve_hours = 12 * 60 * 60 * 1000;
            const twenty_four_hours = 24 * 60 * 60 * 1000;
            const cells = file["sheets"][0]["cells"];

            /*
             * Calculate headers
             */

            let headers = {
                col: 0,
                row: 0,
            };

            const first_row = cells[0].map(
                value => Spreadsheet.parse_timestamp(value,file["spreadsheet"])
            );
            const first_col = cells.map(
                value => Spreadsheet.parse_timestamp(value[0],file["spreadsheet"])
            );

            let first_styled_col = Infinity,
                 last_styled_col = 0,
                first_styled_row = Infinity,
                 last_styled_row = 0
            ;
            cells.forEach(
                (row,r) => row.forEach( (v,c) => {
                    if ( v["style"] ) {
                        first_styled_col = Math.min( first_styled_col, c );
                         last_styled_col = Math.max(  last_styled_col, c );
                        first_styled_row = Math.min( first_styled_row, r );
                         last_styled_row = Math.max(  last_styled_row, r );
                    }
                })
            );

            // find the longest run of numbers in the first column/row:
            const longest_runs = (
                [ first_row, first_col ]
                .map( line => {

                    let runs = (typeof(line[0])=="number" && !isNaN(line[0])) ? [[0,line[0]]] : [ [1] ];
                    for ( let n=1; n<line.length; ++n ) {

                        // fix e.g. "8pm 9 10pm":
                        if ( line[n-1] >= twelve_hours && line[n] < twelve_hours ) {
                            line[n] += twelve_hours;
                        }

                        if ( typeof(line[n]) == "number" && !isNaN(line[n]) ) {
                            runs[runs.length-1].push(line[n]);
                        } else {
                            runs.push([n+1]);
                        }

                    }

                    const longest_run = runs
                          .sort( (a,b) => b.length - a.length )
                    [0];

                    const times = longest_run.slice(1);

                    let run_type;
                    if ( times.length > 4 ) {
                        if ( times.some( v => v < 0 || v >= twenty_four_hours ) ) {
                            run_type = 1; // date
                        } else if ( times.length > 23 ) {
                            run_type = 2; // time
                        }
                    } else {
                        const now = new Date().getTime();
                        if ( times.length && times.every( v => v >= twenty_four_hours && v <= now ) ) {
                            run_type = 1; // date
                        }
                    }

                    return {
                        lines: 1,
                        start: longest_run[0],
                        times: times,
                        type : run_type,
                    };

                })
            );

            // calculate explicit headers:
            switch ( longest_runs[0].type ) {

            case 0: // first row is unknown
                if ( longest_runs[1].type ) { // first column is known
                    headers.col = longest_runs[1];
                }
                break;

            case 1: // first row is a date header
                switch ( longest_runs[1].type ) {
                case 2: // first column is a time header
                    headers.col = longest_runs[1];
                    // fall through
                case 0: // first column is unknown
                    headers.row = longest_runs[0];
                    break;
                default: // both headers are date headers
                    if ( longest_runs[0].times.length > longest_runs[1].times.length ) {
                        headers.row = longest_runs[0];
                    } else {
                        headers.col = longest_runs[1];
                    }
                }
                break;

            case 2: // first row is a time header
                switch ( longest_runs[1].type ) {
                case 1: // first column is a date header
                    headers.col = longest_runs[1];
                    // fall through
                case 0: // first column is unknown
                    headers.row = longest_runs[0];
                    break;
                default: // both headers are time headers
                    if ( (longest_runs[0].times.length%24) < (longest_runs[1].times.length%24) ) {
                        headers.row = longest_runs[0];
                    } else {
                        headers.col = longest_runs[1];
                    }
                }
                break;

            }

            // guess implicit headers:

            if ( !headers.row ) {
                const offset = headers.col?1:0;
                if (
                    offset
                    ? headers.col.type == 1 // col is a date header, so this must be time
                    : first_row.length >= 24 // col is empty and this is long enough to be a time
                ) {
                    let times = [];
                    const length = first_row.length - offset;
                    const length_24 = length - (length%24);
                    for ( let n=0; n!=length_24; ++n ) times.push( n * (twenty_four_hours)/length_24 );
                    headers.row = {
                        lines: 0,
                        start: offset,
                        times: times,
                        type : 2,
                    };
                } else {
                    // col is a time header, so this must be date
                    let times = [];
                    const length = first_row.length - offset;
                    const now = new Date().getTime();
                    const today = now - ( now % (twenty_four_hours) );
                    for ( let n=Math.max( offset+1, first_styled_col ); n <= last_styled_col; ++n ) {
                        times.push( today - (last_styled_col-n) * (twenty_four_hours) );
                    }
                    headers.row = {
                        lines: 0,
                        start: offset,
                        times: times,
                        type : 2,
                    };
                }
            }

            if ( !headers.col ) {
                const offset = headers.row.lines;
                if ( headers.row.type == 1 ) { // col must be a time header
                    let times = [];
                    const length = first_col.length - offset;
                    const length_24 = length - (length%24);
                    for ( let n=0; n!=length_24; ++n ) times.push( n * (twenty_four_hours)/length_24 );
                    headers.col = {
                        lines: 0,
                        start: offset,
                        times: times,
                        type : 2,
                    };
                } else {
                    // row is a time header, so this must be date
                    let times = [];
                    const length = first_col.length - offset;
                    const now = new Date().getTime();
                    const today = now - ( now % (twenty_four_hours) );
                    const start = Math.max( offset, first_styled_row );
                    for ( let n=start; n <= last_styled_row; ++n ) {
                        times.push( today - (last_styled_row-n) * (twenty_four_hours) );
                    }
                    headers.col = {
                        lines: 0,
                        start: start,
                        times: times,
                        type : 2,
                    };
                }
            }
            if ( !headers.row.times.length || !headers.col.times.length ) return this.invalid(file);

            /*
             * Convert data to sleep diary
             */

            let segments = [];
            headers.col.times.forEach( (col_offset,r) => {
                const col = cells[headers.col.start+r];
                headers.row.times.forEach( (row_offset,c) => {
                    const cell = col[headers.row.start+c] || { "style": "" };
                    segments.push({
                        "start": col_offset + row_offset,
                        "style": cell["style"]||"",
                        "comments": cell["value"] ? [ cell["value"] ] : [],
                    });
                })
            });
            if ( !segments.length ) return this.invalid(file);
            segments = segments.sort( (a,b) => a.start - b.start );

            records = [
                { "style": NaN }, // dummy value, removed later
            ];
            segments
                .forEach(segment => {
                    if ( segment["style"] == records[records.length-1]["style"] ) {
                        records[records.length-1]["comments"] = (
                            records[records.length-1]["comments"].concat( segment["comments"] )
                        );
                    } else {
                        records[records.length-1]["end"] = segment["start"] - 1;
                        records.push(segment);
                    }
                });
            //records[records.length-1]["end"] = (Math.floor(segments[segments.length-1]["start"]/(twenty_four_hours))+1)*(twenty_four_hours)-1; // NO!  Event is still ongoing
            records.shift();

            let styles = {};
            records.forEach( record => {
                const style = styles[record["style"]] = styles[record["style"]] || { records: [], duration: 0 };
                style.records.push( record );
                if ( record["end"] && record["start"] ) {
                    style.duration += record["end"] - record["start"];
                }
            });

            /*
             * Look for legend text
             */

            let legend_styles = [];
            let legend_texts  = [];
            cells.forEach( (row,r) => {
                row.forEach( (cell,c) => {
                    if (
                           r <  headers.col.start - headers.row.lines
                        || r >= headers.col.start+headers.col.times.length
                        || c <  headers.row.start - headers.col.lines
                        || c >= headers.row.start+headers.row.times.length
                    ) {
                        if ( cell["value"] ) legend_texts .push( [ r, c, cell["value"] ] );
                        if ( cell["style"] ) legend_styles.push( [ r, c, cell["style"] ] );
                    }
                });
            });

            let legend = [];
            legend_styles.forEach(
                style => legend = legend.concat( legend_texts.map( text => [
                    Math.abs(text[0]-style[0]) + Math.abs(text[1]-style[1]),
                    style[2],
                    text [2],
                ]) )
            );

            /*
             * Assign statuses to styles
             */

            let unused_statuses = {};
            status_matches.forEach( m => unused_statuses[m[0]] = new RegExp(m[1],'i') );

            // explicit statuses:
            let legend_seen_texts = {};
            legend
                .sort( (a,b) => a[0] - b[0] )
                .forEach( l => {
                    if ( styles[l[1]] && !legend_seen_texts[l[2]] ) {
                        legend_seen_texts[l[2]] = 1;
                        let status = (
                            Object.keys(unused_statuses)
                                .find( key => l[2].search(unused_statuses[key]) != -1 )
                        );
                        if ( status ) {
                            delete unused_statuses[status];
                        } else {
                            status = l[2];
                        }
                        status_map[status] = l[1];
                        styles[l[1]].records.forEach( record => {
                            delete record["style"];
                            record["status"] = status;
                        });
                        delete styles[l[1]];
                    }
                })
            ;

            // Add implicit statuses
            let unknown_statuses = 0;
            Object.values(styles)
                .sort( (a,b) => b.duration - a.duration )
                .forEach( style => {
                    let status = status_matches.find( s => unused_statuses[s[0]] );
                    if ( status ) {
                        status = status[0];
                        delete unused_statuses[status];
                    } else {
                        status = "Unknown status #" + (++unknown_statuses);
                    }
                    style.records.forEach( record => {
                        status_map[status] = record["style"];
                        record["status"] = status;
                        delete record["style"];
                    });
                });

        } else if ( this.initialise_from_common_formats(file) ) {

            return;

        } else {

            records = (
                file["to"]("Standard")["records"]
                    .map( record => {
                        let ret = { "comments": [] };
                        [ "start", "end", "status", "comments" ].forEach(
                            m => record.hasOwnProperty(m) && ( ret[m] = record[m] )
                        );
                        status_map[record["status"]] = status_matches.find( s => s[0] == record["status"] )[2];
                        return ret;
                    })
            );

        }

        /**
         * Individual records from the sleep diary
         * @type {Array}
         */
        this["records"] = records;

        /**
         * Map status strings to styles
         * @type {Object}
         */
        this["status_map"] = status_map;

    }

    ["to"](to_format) {

        switch ( to_format ) {

        case "output":
            throw Error("Please use to_async() to generate output for SpreadsheetGraph");

        case "Standard":
            return new DiaryStandard({ "records": this["records"] },this.serialiser);

        default:

            return super["to"](to_format);

        }

    }

    ["to_async"](to_format) {

        switch ( to_format ) {

        case "output":

            const one_minute        =           60 * 1000;
            const twenty_four_hours = 24 * 60 * 60 * 1000;

            const records    = this["records"];
            const status_map = this["status_map"];

            // find the longest duration that all records are divisible by:
            const cell_duration = (
                [ 60, 30, 15, 5 ]
                    .map( cd => cd*one_minute )
                    .find(
                        cd => !records.filter( r => r["start"] % cd ).length
                    )
                || 5*one_minute
            );

            let min_day = Infinity;
            let max_day = 0;
            records.forEach( r => {
                min_day = Math.min( min_day, r["start"] );
                max_day = Math.max( max_day, r["end"  ]||(r["start"]+1) );
            });

            if ( min_day == Infinity ) min_day = max_day;

            min_day -= min_day % twenty_four_hours;
            max_day -= max_day % twenty_four_hours;

            const cells = [ [Spreadsheet.create_cell()] ];
            let number_formats = [ "YYYY-MM-DD" ],
                widths = [ 11 ],
                styles = [ { "font": { "size": 10 } } ]
            ;

            // Add headers
            for ( let n=0; n!=twenty_four_hours; n += cell_duration ) {
                cells[0].push( Spreadsheet.create_cell(n/twenty_four_hours,"#FFEEEEEE,#FFEEEEEE") )
                number_formats.push("H:MM");
                widths.push(5);
                styles.push( { "font": { "size": 10 } } );

            };
            for ( let n=min_day; n<=max_day; n += twenty_four_hours ) {
                cells.push( [Spreadsheet.create_cell(new Date(n),"#FFEEEEEE,#FFEEEEEE")] )
            };

            // Add body:
            records.forEach( r => {

                const start = r["start"];
                const end   = r["end"  ]||(start+1);
                const style = status_map[r["status"]];

                const cell_count = Math.max( ( end - start ) / cell_duration );

                const comments = (r["comments"]||[]).slice(0);
                const comments_per_cell = Math.ceil( comments.length / cell_count );

                for ( let n = start; n < Math.max(end,start+1); n += cell_duration ) {
                    const time = n % twenty_four_hours;
                    const date = n - time;
                    cells
                        [ ( date - min_day ) / twenty_four_hours + 1 ]
                        [ Math.floor( time / cell_duration ) + 1 ]
                        = Spreadsheet.create_cell(
                            comments.splice( 0, comments_per_cell ).join("; "),
                            style
                        );
                }

            });

            // Add legend:

            let legend = {};
            records.forEach( r => {
                const record_status = r["status"];
                if ( status_map[record_status] ) {
                    let item = legend[record_status] = legend[record_status] || [ 0, status_map[record_status], record_status ];
                    item[0] += r["end"] - r["start"];
                }
            });

            Object.values(legend)
                .sort( (a,b) => b[0] - a[0] )
                .forEach( (item,n) => {
                    let row = cells[ n + 1 ] = cells[ n + 1 ] || [];
                    row[ cells[0].length + 2 ] = Spreadsheet.create_cell(undefined,item[1]);
                    row[ cells[0].length + 3 ] = Spreadsheet.create_cell(item[2]);
                });

            this["spreadsheet"]["sheets"][0] = {
                "name": "Sleep Graph",
                "number_formats": number_formats,
                "cells": cells,
                "widths": widths,
                "styles": styles,
                "options": {
                    "properties" : {
                        "defaultRowHeight": 12.5,
                    },
                    "pageSetup" : {
                        "paperSize" : 9, // A4
                        "orientation" : "landscape",
                    },
                }
            };

            return this["spreadsheet"]["serialise"]().then(
                contents => this.serialise({
                    "file_format": () => "array",
                    "contents": contents,
                })
            );

        default:

            return super["to_async"](to_format);

        }

    }

    ["merge"](other) {

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

        function create_id(record) {
            return (
                [ "status", "start", "end" ]
                .map( member => record[member] )
                .join()
            );
        }

        let existing_ids = {};
        this["records"].forEach( r => existing_ids[create_id(r)] = 1 );

        this["records"] = this["records"].concat(
            other["records"].filter( r => !existing_ids.hasOwnProperty(create_id(r)) )
        );

        const other_status_map = other["status_map"];
        let         status_map = this ["status_map"];
        Object.keys(other_status_map)
            .forEach( status => {
                if ( !status_map.hasOwnProperty(status) ) {
                    status_map[status] = other_status_map[status];
                }
            });

        return this;

    }

    ["file_format"]() { return "SpreadsheetGraph"; }
    ["format_info"]() {
        return {
            "name": "SpreadsheetGraph",
            "title": "Spreadsheet Graph",
            "url": "/src/SpreadsheetGraph",
            "extension": ".xlsx",
            "icon": "mdi-file-excel-outline",
            "timezone": "no",
        }
    }

}

DiaryBase.register(DiarySpreadsheetGraph);