Source: DiaryLoader.js

/**
 * High-level reader interface
 *
 * @public
 * @unrestricted
 *
 */
class DiaryLoader {

    static resources() {
        return [
            [
                self["JSZip"],
                "https://cdn.jsdelivr.net/npm/jszip@3.6.x/dist/jszip.min.js"
            ],
            [
                self["tc"],
                "https://cdn.jsdelivr.net/npm/tzdata@1.0.x/tzdata.js",
                "https://cdn.jsdelivr.net/npm/timezonecomplete@5.12.x/dist/timezonecomplete.min.js"
            ],
            [
                self["ExcelJS"],
                "https://cdn.jsdelivr.net/npm/exceljs@4.2.x/dist/exceljs.min.js"
            ]
        ];
    }

    static load_resources() {
        try {
            let files = [];
            DiaryLoader.resources().forEach( resource => {
                if ( !resource[0] ) files = files.concat( resource.slice(1) );
            });
            if ( self.importScripts ) {
                self.importScripts.apply( self, files );
            } else {
                files.forEach( url => {
                    let script = document.createElement("script");
                    script.src = url;
                    document.head.appendChild(script);
                });
            }
        } catch (e) {}
    }

    /**
     * @param {Function=} success_callback - called when a new file is loaded successfully
     * @param {Function=} error_callback - called when a file cannot be loaded
     * @param {number=}  hash_parse_policy - how to handle URL hashes:
     *
     * <ul>
     *  <li> <tt>0</tt> or <tt>undefined</tt> - always parse the URL hash
     *  <li> <tt>1</tt> - parse each URL hash once, skip it if e.g. the user navigates away then clicks <em>back</em>
     *  <li> <tt>2</tt> - never parse the URL hash
     * </ul>
     *
     * @example
     * function my_success_callback( diary, source ) {
     *   console.log( "Loaded diary", diary, source );
     * }
     * function my_error_callback( raw, source ) {
     *   console.log( "Could not load diary", raw, source );
     * }
     * let loader = new DiaryLoader(my_success_callback,my_error_callback);
     */
    constructor( success_callback, error_callback, hash_parse_policy ) {

        this["success_callback"] = success_callback || ( () => {} );
        this["error_callback"] = error_callback || ( () => {} );

        let load_interval, this_ = this;

        function generate_init_callback( source ) {
            return () => {
                if ( !(history.state||{})["sleepdiary-core-processed"] ) {
                    location.hash.replace(
                        /(?:^#|[?&])(sleep-?diary=[^&]*)/g,
                        (_,diary) => {
                            history.replaceState(
                                Object.assign(
                                    { "sleepdiary-core-processed": hash_parse_policy },
                                    /** @type {Object} */(history.state||{})
                                ),
                                '',
                            );
                            this_["load"]({
                                "file_format": "url",
                                "contents": diary
                            }, source )
                        }
                    );
                }
            }
        }

        if ( hash_parse_policy != 2 ) {
            load_interval = setInterval(
                () => {
                    if ( self["tc"] ) {
                        clearInterval(load_interval);
                        self.addEventListener(
                            'hashchange',
                            generate_init_callback("hashchange"),
                            false
                        );
                        generate_init_callback("hash")();
                    }
                },
                100
            );
        }

        /*
         * TODO: localStorage
         * 1. decide which localStorage key(s) to examine
         * 2. decide how they will be encoded - URL encoded?  Base 64?
         * 3. do something like:
        const localStorage_key = "...";
        function process_localStorage(item) {
            process_diary(decode(item),...);
        }
        function check_storage_change(changes, area) {
            if ( area == "local" &&
                 changes.hasOwnProperty(localStorage_key) &&
                 changes[localStorage_key].hasOwnProperty("newValue")
               ) {
                process_localStorage(changes[localStorage_key].newValue);
            }
        }
        browser.storage.onChanged.addListener(check_storage_change);
        if ( localStorage.hasItem(localStorage_key) ) {
            process_localStorage(localStorage.getItem(localStorage_key));
        }
        */

        DiaryLoader.load_resources();

    }

    /**
     * Load a sleep diary from some source
     * @param {Event|FileList|string|Object} raw - raw data to load
     * @param {(Event|FileList|string|Object)=} source - identifier passed to the callbacks
     *
     * @example
     * my_file_input.addEventListener( "change", event => diary_loader.load(event) );
     */
    ["load"](raw,source) {

        if ( DiaryLoader.resources().some( resource => !resource[0] ) ) {
            return setTimeout( () => this["load"](raw,source), 100 );
        }

        const jszip = self["JSZip"];

        // wait for JSZip to load:
        if ( !jszip ) {
            return setTimeout( () => this["load"](raw,source), 100 );
        }

        if ( typeof(raw) == "string" && !raw.search(/^(blob|data):/) ) {
            let xhr = new XMLHttpRequest;
            xhr.responseType = 'blob';
            xhr.onload = () => this["load"]([xhr.response],source);
            xhr.open('GET', /** @type {string} */(raw));
            return xhr.send();
        }

        if ( !source ) source = raw;

        if ( raw.target && raw.target.files ) raw = raw.target.files;

        if ( raw.replace ) {
            raw.replace(/^storage-line:([^:]+):(.*)/, (_,file_format,json) => {
                raw = {
                    "file_format": "storage-line",
                    "contents": {
                        "file_format": file_format,
                        "contents"   : JSON.parse(json),
                    },
                };
            });
        }

        if ( typeof(raw) != "string" && raw.length ) { // looks array-like (e.g. FileList)

            Array.from(/** @type {!Iterable<*>} */(raw)).forEach( file => {

                let file_reader = new FileReader(),
                    zip = new jszip()
                ;

                // extract the file contents:
                file_reader.onload =
                    () => Spreadsheet.buffer_to_spreadsheet(file_reader.result).then(

                        spreadsheet => this["load"]( spreadsheet, source ),

                        () => zip["loadAsync"](file_reader.result).then(

                            zip => {
                                // convert the zip file to an object containing file names and contents:
                                let files = {},
                                    keys = Object.keys(zip["files"]),
                                    next_key = () => {
                                        if ( keys.length ) {
                                            zip["file"](keys[0])["async"]("string").then(
                                                content => {
                                                    files[keys[0]] = content;
                                                    keys.shift();
                                                    next_key();
                                                });
                                        } else {
                                            this["load"](
                                                {
                                                    "file_format": "archive",
                                                    "contents": files,
                                                },
                                                source
                                            );
                                        }
                                    };
                                next_key();
                            },

                            () => {
                                const real_error_callback = this["error_callback"];
                                try {
                                    this["error_callback"] = () => {};
                                    this["load"](
                                        {
                                            "file_format": "array",
                                            "contents"   : file_reader.result,
                                        },
                                        source
                                    );
                                    this["error_callback"] = real_error_callback;
                                } catch (e) {
                                    this["error_callback"] = real_error_callback;
                                    // not a zip file - try processing it as plain text:
                                    file_reader.onload = () => this["load"](
                                        {
                                            "file_format": "string",
                                            "contents"   : file_reader.result,
                                        },
                                        source
                                    );
                                    file_reader.readAsText(file);
                                }
                            }

                        )
                    );

                file_reader.readAsArrayBuffer(file);

            });

        } else {

            let diary;
            try {
                diary = self["new_sleep_diary"]( raw, DiaryLoader["serialiser"] );
            } catch (e) {
                this[  "error_callback"]( raw  , source );
                throw e;
            }
            if ( diary ) {
                this["success_callback"]( diary, source );
            } else {
                this[  "error_callback"]( raw  , source );
            }

        }

    }

    static ["serialiser"](data) {
        switch ( data["file_format"]() ) {
        case "array":
            return data["contents"];
        case "string":
            return btoa(unescape(encodeURIComponent(data["contents"])));
        case "archive":
            const callback = (resolve,reject) => {
                const jszip = self["JSZip"];
                if ( !jszip ) {
                    return setTimeout( () => callback(resolve,reject), 100 );
                }
                let zip = new jszip();
                Object.keys(data["contents"]).forEach(
                    filename => zip["file"](filename,data["contents"][filename])
                );
                return zip["generateAsync"]({"type": "base64", "compression": "DEFLATE"}).then(resolve,reject);
            }
            DiaryLoader.load_resources();
            return new Promise(callback);
        default:
            throw Error("Unsupported output format: " + data["file_format"]());
        }
    }

    static ["to_url"](serialised) {
        if ( typeof(serialised) == "string" ) {
            return 'data:application/octet-stream;base64,'+serialised;
        } else {
            if ( serialised["file_format"] && serialised["contents"] ) {
                if ( serialised["file_format"]() == "archive" ) {
                    serialised = JSON.stringify(serialised);
                } else {
                    serialised = serialised["contents"];
                }
            }
            return URL.createObjectURL(new Blob([serialised]));
        }
    }

};