Source: jquery.parsetable.js

/**
 * The jQuery core object
 * @external jQuery
 * @see {@link http://api.jquery.com/jQuery/}
 */

/**
 * The jQuery plugin namespace.
 * @external "jQuery.fn"
 * @name "jQuery.fn"
 * @memberof external jQuery
 * @see {@link http://learn.jquery.com/plugins/|jQuery Plugins}
 */

/** @namespace ParseTablePlugin */

/**
 * Anonymous jQuery plugin wrapper function
 * @function ParseTablePlugin~anonymousWrapper
 * @param {external:jQuery} $ jQuery core object
 * @param {Window} window window object
 * @param {HTMLElement} document document object
 * @param {undefined} undefined used to have ethalon of undefined
 * @access private
 */
;(function ( $, window, document, undefined ) {

    'use strict';
    /**
     * @constant {string}
     * @default
     */
    var pluginName = 'parseTable';

    /**
     * @callback ParseTablePlugin~onSuccess Called when user chooses one of parsed tables and 
     * confirms, that this is data, that he likes
     * @param {Array.<Array.<string|number>>} data Array of rows, each row is array of 
     * cell values - either strings or numbers. Newlines are always single \n.
     */

    /**
     * @callback ParseTablePlugin~onCancel Called when user cancels ParseTable dialog
     */
    
    /**
     * @callback ParseTablePlugin~onPostProcess Called after table is parsed and before 
     * result is shown to user for confirmation. You can handle this callback and modify 
     * data on the fly. 
     * @param {Array.<Array.<Array.<string|number>>>} data Array of found tables, each table is 
     * array of rows, each row is array of cell values - either strings or numbers. 
     * Newlines are always single \n.
     * @returns {Array.<Array.<Array.<string|number>>>} same as data argument
     */

    /**
     * @class ParseTablePlugin~Settings
     */
    var defaults = {
        /**
         * Text for dialog header
         * @member {string} ParseTablePlugin~Settings#pasteHeader
         * @default 'Paste your table here'
         */
        pasteHeader: 'Paste your table here',
        /**
         * Text for textarea placeholder
         * @member {string} ParseTablePlugin~Settings#pasteLabel
         * @default 'Paste table here (Ctrl-V)'
         */
        pasteLabel: 'Paste table here (Ctrl-V)',
        /**
         * Text for hint under the textarea
         * @member {string} ParseTablePlugin~Settings#pasteHint
         * @default 'You can paste mixed data with several tables, just do Ctrl-A Ctrl-C Ctrl-V from your document'
         */
        pasteHint: 'You can paste mixed data with several tables, just do Ctrl-A Ctrl-C Ctrl-V from your document',
        /**
         * Text for message, that is shown if pasted content is not a table
         * @member {string} ParseTablePlugin~Settings#pasteError
         * @default 'This contents does not look like a table'
         */
        pasteError: 'This contents does not look like a table',
        /**
         * Text for Ok button on dialog with textarea. This button is rarely used, because
         * dialog submits immediately after table is pasted, but still this button
         * is there for better UX - user should understand the outcome.
         * @member {string} ParseTablePlugin~Settings#pasteOk
         * @default 'Ok'
         */
        pasteOk: 'Ok',
        /**
         * Text for Cancel button on dialog with textarea.
         * @member {string} ParseTablePlugin~Settings#pasteCancel
         * @default 'Cancel'
         */
        pasteCancel: 'Cancel',
        /**
         * Text for confirmation dialog in case if only one table was found.
         * @member {string} ParseTablePlugin~Settings#confirmHeaderSingle
         * @default 'Confirm, that information is good'
         */
        confirmHeaderSingle: 'Confirm, that information is good',
        /**
         * Text for for confirmation dialog in case if several tables were found.
         * @member {string} ParseTablePlugin~Settings#confirmHeaderMultiple
         * @default 'Choose one of found tables'
         */
        confirmHeaderMultiple: 'Choose one of found tables',
        /**
         * Text for hint on confirmation dialog in case if only one table was found
         * @member {string} ParseTablePlugin~Settings#confirmLabelSingle
         * @default 'Following information was found, check it and confirm:'
         */
        confirmLabelSingle: 'Following information was found, check it and confirm:',
        /**
         * Text for hint on confirmation dialog in case if several tables were found
         * @member {string} ParseTablePlugin~Settings#confirmLabelMultiple
         * @default 'Following information was found, choose correct one, check it and confirm:'
         */
        confirmLabelMultiple: 'Following information was found, choose correct one, check it and confirm:',
        /**
         * Text for confirmation button on confirmation dialog. This button will appear once for
         * each found table.
         * @member {string} ParseTablePlugin~Settings#confirmOk
         * @default 'Use this information'
         */
        confirmOk: 'Use this information',
        /**
         * Text for Cancel button on confirmation dialog.
         * @member {string} ParseTablePlugin~Settings#confirmCancel
         * @default 'Cancel'
         */
        confirmCancel: 'Cancel',
        /**
         * Text for "progress" dialog, that will appear, if parsing takes long time
         * @member {string} ParseTablePlugin~Settings#wait
         * @default 'Please wait...'
         */
        wait: 'Please wait...',
        /**
         * Callback for successful action - when user finally clicks "Use this information" button
         * This property should be overriden (otherwise plugin is rather useless)
         * @member {ParseTablePlugin~onSuccess} ParseTablePlugin~Settings#success
         * @default alert('override me');
         */
        success: function(){
            alert('override me');
        },
        /**
         * Callback for cancel action. This property is optional. You will sometimes
         * need this callback when you track user behaviour - when user closes pasteTable dialog
         * @member {ParseTablePlugin~onCancel} ParseTablePlugin~Settings#cancel
         * @default does nothing
         */
        cancel: function(){},
        /**
         * Optional callback, can be used to process data after table was parsed and before
         * it is shown to user.
         * @member {ParseTablePlugin~onPostProcess} ParseTablePlugin~Settings#postProcess
         * @default return data;
         */
        postProcess: function(data){
            return data;
        },
        /**
         * Whether to open dialog immediately after page loads.
         * @member {boolean} ParseTablePlugin~Settings#openOnPageLoad
         * @default false
         */
        openOnPageLoad: false,
        /**
         * Allow new lines in result data. If set to false, then newlines will be replaced with 
         * single space char
         * @member {boolean} ParseTablePlugin~Settings#allowNewLines
         * @default true
         */
        allowNewLines: true
    };

    /**
     * @class ParseTablePlugin~Plugin
     * @access private
     */
    function Plugin ( element, options ) {
            this.element = element;
            this.settings = $.extend( {}, defaults, options );
            this._defaults = defaults;
            this._name = pluginName;
            this.init();
    }

    $.extend(Plugin.prototype, {
        /**
         * Adds click event handler to target element -- to call 
         * ParseTablePlugin~Plugin#showPasteDialog
         * If {@link ParseTablePlugin~Settings#openOnPageLoad} is set to true 
         * then adds another handler on $(document).ready -- same action
         * @function ParseTablePlugin~Plugin#init
         * @access private
         */
        init: function () {
            var plugin = this;
            $(this.element).click(function(){
                plugin.showPasteDialog();
                return false;
            });
            if (plugin.settings.openOnPageLoad){
                var element = this.element;
                $(document).ready(function(){
                    if ($(element).is(':visible')){
                        plugin.showPasteDialog();
                    }
                });
            }
        },
        /**
         * Shows paste dialog (dialog with textarea)
         * @function ParseTablePlugin~Plugin#showPasteDialog
         * @param {string} [content] Some HTML content to show in textarea 
         * This is used when user clicks "Cancel" button on confirmation
         * dialog, or when table can not be parsed
         * @param {string} [message] Text for red message on paste dialog (error message)
         * @access private
         */
        showPasteDialog: function (content, message) {
            var plugin = this;
            // initialize dialog
            var dialog = $('<div><div></div><span style="color: red;"></span><div style="margin-bottom: -50px; height: 50px; width: 100%; text-align: center; font-size: 2em; opacity: 0.3;"></div><iframe style="width: 100%"></iframe><div style="text-align: center;"><span></span><br/><input type="button" name="ok" value=""/><input type="button" name="cancel" value=""/></div></div>');
            dialog.dialog({
                title: plugin.settings.pasteHeader,
                modal: true,
                minWidth: Math.max(600, Math.round($(window).width()*0.7)),
                // call cancel callback
                close: function() { plugin.settings.cancel(); },
                // jQueryUI has some fixed overlay z-index. This function traverses through
                // elements looking for topmost z-index and puts our dialog's z-index a bit
                // higher
                open: function(){plugin.fixOverlayZindex(dialog);},
            });
            dialog.find('div ~ span ~ div:first').text(plugin.settings.pasteLabel);
            dialog.find('div:last > span').text(plugin.settings.pasteHint);
            var iframe = dialog.find('iframe')[0];
            var doc;
            if (iframe.contentDocument) {
                doc = iframe.contentDocument;
            } else if (iframe.contentWindow) {
                doc = iframe.contentWindow.document;
            } else if (iframe.document) {
                doc = iframe.document;
            }
            // create CONTENTEDITABLE iframe
            doc.write ('<body style="margin: 0; padding: 0;" CONTENTEDITABLE>');
            if (null != content){
                doc.body.innerHTML = content;
            }
            if (null != message){
                dialog.find('span').text(message);
            }
            // handle Cancel button - close dialog. Settings#cancel callback will be 
            // called by jQueryUI due to close property of dialog settings
            dialog
                .find('input[name="cancel"]')
                .val(plugin.settings.pasteCancel)
                .click(function(){
                    dialog.dialog('close');
                });
            // handle Ok button - proceed to parsing table
            dialog
                .find('input[name="ok"]')
                .val(plugin.settings.pasteOk)
                .click(function(){
                    plugin.parseTable(doc.body.innerHTML, dialog);
                });
            // used to track user behaviour on a dirty checking basis
            var previousContents = doc.body.innerHTML;
            // focus and select content right away to make it possible use
            // Ctrl-V without extra clicks
            doc.body.focus();
            doc.execCommand('selectAll', false, null);
            // track Esc button and close dialog when Esc is pressed
            var trackEsc = false;
            doc.body.onkeydown = function(e){
                if ((e.keyCode === 27) && !e.altKey && !e.ctrlKey && !e.shiftKey){
                    if (dialog.is(':visible')){
                        trackEsc = true;
                    }
                }
            };
            doc.body.onkeyup = function(e){
                // Esc handler
                if ((e.keyCode === 27) && !e.altKey && !e.ctrlKey && !e.shiftKey && trackEsc){
                    dialog.dialog('close');
                    trackEsc = false;
                } else {
                    trackEsc = false;
                }
                // dirty check on content. If content changes - proceed to parseTable
                if (doc.body.innerHTML !== previousContents)
                {
                    previousContents = doc.body.innerHTML;
                    plugin.parseTable(previousContents, dialog);
                }

            };
        },
        /**
         * Parses table using ParseTablePlugin~Plugin#parseHTMLTable function and shows confirmation dialog
         * @function ParseTablePlugin~Plugin#parseTable
         * @param {string} contents HTML of WYSIWYG CONTENTEDITABLE iframe
         * @param {Dialog} dialog existing dialog.
         * @access private
         */
        parseTable: function(contents, dialog){
            var plugin = this;
            // remove cancel callback from existing dialog and close it.
            dialog.dialog('option', 'close', null);
            dialog.dialog('close');
            // configure another dialog - wait dialog (showing "Please wait..." message)
            var parsedDialog = $('<div><span></span></div>');
            parsedDialog.dialog({
                title: plugin.settings.wait,
                modal: true,
                minWidth: Math.max(600, Math.round($(window).width()*0.7)),
                // on close - show paste dialog again
                close: function(){
                    plugin.showPasteDialog(contents);
                },
                // on open - perform parse
                open: function(){
                    plugin.fixOverlayZindex(parsedDialog);
                    // let UI render "Please wait..." dialog properly
                    setTimeout(function(){
                        // parse data
                        var rawResult = plugin.parseHTMLTable(contents);
                        // call postProcess callback
                        var result = plugin.settings.postProcess(rawResult);
                        // counts found tables
                        /** @type {integer} */
                        var count = 0;
                        if (false !== result) { // parse was successful
                            var tableIndex,
                                rowIndex,
                                colIndex,
                                lineIndex,
                                lines,
                                cell,
                                i;
                            // enumerate results
                            for (tableIndex in result){
                                // no data here
                                if (result[tableIndex].length === 0) {
                                    continue;
                                }
                                count ++;
                                var div = $('<div/>').appendTo(parsedDialog);
                                // prepare table block for confirmation dialog
                                var table = $('<table class="table table-bordered"/>').appendTo(div);
                                $('<div style="text-align: center;"><input type="button" name="ok" value=""/><input type="button" name="cancel" value=""/></div>').appendTo(div);
                                div.find('input[name="ok"]').val(plugin.settings.confirmOk);
                                div.find('input[name="cancel"]').val(plugin.settings.confirmCancel);
                                div.find('input[name="ok"]').attr('data-tableindex', tableIndex);
                                var tbody = $('<tbody/>').appendTo(table);
                                var maxCells = 0;
                                // enumerate rows and find widest row
                                for (rowIndex in result[tableIndex]){
                                    maxCells = Math.max(maxCells, result[tableIndex][rowIndex].length);
                                }
                                // create table
                                for (rowIndex in result[tableIndex]){
                                    var tr = $('<tr/>').appendTo(tbody);
                                    for (i = result[tableIndex][rowIndex].length; i < maxCells; i++){
                                        result[tableIndex][rowIndex].push('');
                                    }
                                    for (colIndex in result[tableIndex][rowIndex]){
                                        var td = $('<td/>').appendTo(tr);
                                        cell = result[tableIndex][rowIndex][colIndex];
                                        if ('undefined' === typeof cell) {
                                            cell = '';
                                        }
                                        if ('string' !== typeof cell) {
                                            cell = '' + cell;
                                        }
                                        result[tableIndex][rowIndex][colIndex] = cell;
                                        lines = cell.split('\n');
                                        for (lineIndex in lines){
                                            $('<p/>').appendTo(td).text(lines[lineIndex]);
                                        }
                                    }
                                }
                            }
                            // depending on number of found tables - use appropriate texts from settings
                            parsedDialog.find('span').text((count <= 1) ?
                                plugin.settings.confirmLabelSingle :
                                plugin.settings.confirmLabelMultiple
                            );
                            parsedDialog.find('span').after('<a href="#" style="opacity: 0">debug</a>');
                            // debug information - create hidden <a> tag, which shows all the info
                            parsedDialog.find('a').click(function(){
                                parsedDialog.find('span').after('<br/>Debug information:<br/>Base64 of HTML:<br/><input type="text" onclick="this.select()" name="debug_base64" style="width: 100%" readonly="readonly"/><br/>HTML:<br/><textarea onclick="this.select()" name="debug_html" style="width: 100%; height: 150px;" readonly="readonly"></textarea><br/>Parsed JSON:<br/><textarea onclick="this.select()" name="debug_parsedjson" style="width: 100%; height: 150px;" readonly="readonly"></textarea><br/>Post processed JSON:<br/><textarea onclick="this.select()" name="debug_ppjson" style="width: 100%; height: 150px;" readonly="readonly"></textarea><br/>Cleaned (final) JSON:<br/><textarea onclick="this.select()" name="debug_cleanedjson" style="width: 100%; height: 150px;" readonly="readonly"></textarea>');
                                parsedDialog.find('input[name="debug_base64"]').val(btoa(contents));
                                parsedDialog.find('textarea[name="debug_html"]').val(contents);
                                parsedDialog.find('textarea[name="debug_parsedjson"]').val(JSON.stringify(rawResult, null, '  '));
                                parsedDialog.find('textarea[name="debug_ppjson"]').val(JSON.stringify(rawResult, null, '  '));
                               
 parsedDialog.find('textarea[name="debug_cleanedjson"]').val(JSON.stringify(rawResult, null, '  '));
                                return false;
                            });
                            // configure rest of confirmation dialog
                            parsedDialog.dialog('option', 'title', 
                                (count <= 1) ?
                                    plugin.settings.confirmHeaderSingle :
                                    plugin.settings.confirmHeaderMultiple
                            );
                            parsedDialog.find('input[name="ok"]').click(function(){
                                parsedDialog.dialog('option', 'close', null);
                                parsedDialog.dialog('close');
                                plugin.settings.success(result[this.getAttribute('data-tableindex')]);
                            });
                            parsedDialog.find('input[name="cancel"]').click(function(){
                                parsedDialog.dialog('close');
                            });
                            parsedDialog.find('input[name="ok"]').each(function(i,el){
                                el.scrollIntoView();
                                return false;
                            });
                        }
                        // if no tables were found - go back and show error
                        if (0 === count){
                            // clear close callback - we'll show paste dialog ourselves
                            parsedDialog.dialog('option', 'close', null);
                            parsedDialog.dialog('close');
                            plugin.showPasteDialog(contents, plugin.settings.pasteError);

                        }
                    }, 0);
                }
            });
        },
        /** 
         * Parses HTML looking for <table> and cleaning all the formatting rubbish
         * @function ParseTablePlugin~Plugin#parseHTMLTable
         * @param {string} content HTML as pasted to WYSIWYG CONTENTEDITABLE iframe
         * @returns {Array.<Array.<Array.<string|number>>>} array of found tables, each
         * table is array of found rows, each row is array of cell values - either
         * string or number
         * @access private
         */
        parseHTMLTable: function(content) {
            /** @type {Array.<HTMLString>} */
            var sourceTables = [];
            /** @type {Array.<HTMLString>} */
            var sourceRows = [];
            /** @type {Array.<HTMLString>} */
            var sourceCells = [];
            /** @type {Array.<Array.<Array.<string|number>>>} */
            var result = [];
            /** @type {Array.<Array.<string|number>>} */
            var resultTable = [];
            /** @type {Array.<string|number>} */
            var resultRow = [];
            // false if there are no cells, otherwise rightmost cell index 
            /** @type {boolean|number} */
            var nonEmpty = false;
            /** @type {number} */
            var sourceTableIndex;
            /** @type {number} */
            var sourceRowIndex;
            /** @type {number} */
            var sourceCellIndex;
            if (/<table[^>]*>([\s\S]*)<\/table>/igm.test(content)) {
                sourceTables = content.split(/<\/table>/i);
                for (sourceTableIndex = 0; sourceTableIndex < sourceTables.length; sourceTableIndex++){
                    resultTable = [];
                    sourceTables[sourceTableIndex] = sourceTables[sourceTableIndex].replace(/[\n\r]+/g, '');
                    sourceTables[sourceTableIndex] = sourceTables[sourceTableIndex].replace(/^.*[<]table[^>]*[>]/igm,'');
                    sourceTables[sourceTableIndex] = sourceTables[sourceTableIndex].replace(/[<]\/table[>].*$/igm,'');
                    sourceRows = [];
                    if (/<tr[^>]*>([\s\S]*)<\/tr>/igm.test(sourceTables[sourceTableIndex])) {
                        sourceRows = sourceTables[sourceTableIndex].split(/<\/tr>/i);
                        for (sourceRowIndex = 0; sourceRowIndex < sourceRows.length; sourceRowIndex++) {
                            resultRow = [];
                            nonEmpty = false;
                            sourceCells = [];
                            sourceRows[sourceRowIndex] = sourceRows[sourceRowIndex].replace(/<tbody[^>]*>/igm,'');
                            sourceRows[sourceRowIndex] = sourceRows[sourceRowIndex].replace(/<\/tbody>/igm,'');
                            sourceRows[sourceRowIndex] = sourceRows[sourceRowIndex].replace(/<tr[^>]*>/igm,'');
                            sourceRows[sourceRowIndex] = sourceRows[sourceRowIndex].replace(/<\/tr>/igm,'');
                            if (/<td[^>]*>([\s\S]*)<\/td>/igm.test(sourceRows[sourceRowIndex])) {
                                sourceCells = sourceRows[sourceRowIndex].split(/<\/td>/i);
                                for (sourceCellIndex = 0; sourceCellIndex < sourceCells.length; sourceCellIndex++) {
                                    sourceCells[sourceCellIndex] = sourceCells[sourceCellIndex]
                                        .replace(/<\?xml[^>]*[>]/ig, '')
                                        .replace(/<\/?st1[:][^>]+[>]/g, '')
                                        .replace(/<td[^>]*>\s*/igm,'')
                                        .replace(/\s+$/igm,'')
                                        .replace(/<\/td>/igm,'')
                                        .replace(/line<\?[^\/>]*\/>/igm,'')
                                        .replace(/<p[^>]*>/igm,'\n')
                                        .replace(/<\/p>/igm,'')
                                        .replace(/<span[^>]*>/igm,'')
                                        .replace(/<\/span>/igm,'')
                                        .replace(/<o[^>]*>/igm,'')
                                        .replace(/<\/o[^>]*>/igm,'')
                                        .replace(/&nbsp[;]/g,' ')
                                        .replace(/<br\/?>/g,'\n')
                                        .replace(/[\r]/g, '')
                                        .replace(/^\n+/, '')
                                        .replace(/\n+$/, '')
                                        .replace(/\n[\n \t]*\n/g, '\n')
                                        .replace(/\n\s+/g, '\n')
                                        .replace(/[ \t]+/g, ' ')
                                        .replace(/^\s+/g, '')
                                        .replace(/<colgroup[ >](.*<\/colgroup>)?[ \r\n]*/g, '')
                                        .replace(/<col(([ ]?[>])|([ ][^>]+[>]))[ \r\n]*/g, '')
                                        .replace(/<font[^>]*>/g, '')
                                        .replace(/<\/font>/g, '')
                                        .replace(/<b( [^>]+)?>/g, '')
                                        .replace(/<\/b>/g, '')
                                        .replace(/<strong>/g, '')
                                        .replace(/<\/strong>/g, '')
                                        .replace(/&amp;/g, '&')
                                        .replace(/^\s+/g, '')
                                        .replace(/\s+$/g, '')
                                        ;
                                    // replace newlines with space
                                    if (!this.settings.allowNewLines){
                                        sourceCells[sourceCellIndex] = sourceCells[sourceCellIndex]
                                            .replace(/[\n]+/g, ' ');
                                    }
                                    // add cell to result row
                                    resultRow.push(sourceCells[sourceCellIndex]);
                                    // change nonEmpty flag
                                    if (sourceCells[sourceCellIndex].length > 0) {
                                        nonEmpty = sourceCellIndex;
                                    }
                                }
                            }
                            // push nonempty rows
                            if (nonEmpty !== false) {
                                while (resultRow.length > (nonEmpty + 1)) {
                                    resultRow.pop();
                                }
                                resultTable.push(resultRow);
                            }
                        }
                    }
                    // push nonempty tables to result array
                    if (resultTable.length > 0) {
                        result.push(resultTable);
                    }
                }
            } else {
                return false;
            }
            return result;
        },
        /**
         * Walks through parent elements looking for max z-index.
         * Then sets z-index of dialog argument to be at the top of all parents.
         * @function ParseTablePlugin~Plugin#fixOverlayZindex
         * @param {Dialog} dialog
         * @access private
         */
        fixOverlayZindex: function(dialog){
            var max = 100;
            var val;
            $(this.element).parents().each(function(i,el){
                val = parseInt($(el).css('z-index'));
                if (val > max) {
                    max = val;
                }
            });
            $('.ui-widget-overlay').css('z-index', max+1);
            dialog.parent().css('z-index', max+2);
        },
    });

    /**
     * Plugin function. Should be used on elements, that can be clicked
     * (buttons, links, etc)
     * @example
     * <a id="enter-table-button" class="btn btn-xs" href="#">
     *     Paste your data here
     * </a>
     * <script>
     *     $("#enter-table-button").parseTable({
     *         // here go configuration options
     *         success: function (data) {
     *             $.ajax(...) // send data to server
     *         }
     *     });
     * </script>
     * @function external:"jQuery.fn".parseTable
     * @global
     * @name "jQuery.fn.parseTable"
     * @param {ParseTablePlugin~Settings} options
     * @returns {external:jQuery}
     * @access public
     */
    $.fn[ pluginName ] = function ( options ) {
            return this.each(function() {
                    if ( !$.data( this, 'plugin_' + pluginName ) ) {
                            $.data( this, 'plugin_' + pluginName, new Plugin( this, options ) );
                    }
            });
    };

})( jQuery, window, document );