<commit_before>import Component from 'ember-component';
import computed from 'ember-computed';
import formatMarkdown from 'ghost-admin/utils/format-markdown';
import run from 'ember-runloop';
import {assign} from 'ember-platform';
import {copy} from 'ember-metal/utils';
import {isEmpty} from 'ember-utils';

const MOBILEDOC_VERSION = '0.3.1';

export const BLANK_DOC = {
    version: MOBILEDOC_VERSION,
    markups: [],
    atoms: [],
    cards: [
        ['card-markdown', {
            cardName: 'card-markdown',
            markdown: ''
        }]
    ],
    sections: [[10, 0]]
};

export default Component.extend({

    classNames: ['gh-markdown-editor'],
    classNameBindings: [
        '_isFullScreen:gh-markdown-editor-full-screen',
        '_isSplitScreen:gh-markdown-editor-side-by-side'
    ],

    // Public attributes
    autofocus: false,
    imageMimeTypes: null,
    isFullScreen: false,
    mobiledoc: null,
    options: null,
    placeholder: '',
    uploadedImageUrls: null,

    // Closure actions
    onChange() {},
    onFullScreenToggle() {},
    onImageFilesSelected() {},
    onPreviewToggle() {},
    onSplitScreenToggle() {},
    showMarkdownHelp() {},

    // Internal attributes
    markdown: null,

    // Private
    _editor: null,
    _isFullScreen: false,
    _isSplitScreen: false,
    _isUploading: false,
    _statusbar: null,
    _toolbar: null,
    _uploadedImageUrls: null,

    simpleMDEOptions: computed('options', function () {
        let options = this.get('options') || {};
        let defaultOptions = {
            // use our Showdown config with sanitization for previews
            previewRender(markdown) {
                return formatMarkdown(markdown);
            },

            // Ghost-specific SimpleMDE toolbar config - allows us to create a
            // bridge between SimpleMDE buttons and Ember actions
            toolbar: [
                'bold', 'italic', 'heading', '|',
                'quote', 'unordered-list', 'ordered-list', '|',
                'link',
                {
                    name: 'image',
                    action: () => {
                        this._openImageFileDialog();
                    },
                    className: 'fa fa-picture-o',
                    title: 'Upload Image(s)'
                },
                '|',
                {
                    name: 'preview',
                    action: () => {
                        this._togglePreview();
                    },
                    className: 'fa fa-eye no-disable',
                    title: 'Toggle Preview (Cmd-P)'
                },
                {
                    name: 'side-by-side',
                    action: () => {
                        this.send('toggleSplitScreen');
                    },
                    className: 'fa fa-columns no-disable no-mobile',
                    title: 'Toggle Side by Side'
                },
                {
                    name: 'fullscreen',
                    action: () => {
                        this.send('toggleFullScreen');
                    },
                    className: 'fa fa-arrows-alt no-disable no-mobile',
                    title: 'Toggle Fullscreen'
                },
                '|',
                {
                    name: 'spellcheck',
                    action: () => {
                        this._toggleSpellcheck();
                    },
                    className: 'fa fa-check',
                    title: 'Toggle Spellcheck'
                },
                {
                    name: 'guide',
                    action: () => {
                        this.showMarkdownHelp();
                    },
                    className: 'fa fa-question-circle',
                    title: 'Markdown Guide'
                }
            ],

            // disable shortcuts for side-by-side and fullscreen because they
            // trigger interal SimpleMDE methods that will result in broken
            // layouts
            shortcuts: {
                toggleFullScreen: null,
                togglePreview: null,
                toggleSideBySide: null
            },

            // only include the number of words in the status bar
            status: ['words']
        };

        return assign(defaultOptions, options);
    }),

    // extract markdown content from single markdown card
    didReceiveAttrs() {
        this._super(...arguments);
        let mobiledoc = this.get('mobiledoc') || copy(BLANK_DOC, true);

        let uploadedImageUrls = this.get('uploadedImageUrls');
        if (!isEmpty(uploadedImageUrls) && uploadedImageUrls !== this._uploadedImageUrls) {
            this._uploadedImageUrls = uploadedImageUrls;

            // must be done afterRender to avoid double modify of mobiledoc in
            // a single render
            run.scheduleOnce('afterRender', this, () => {
                this._insertImages(uploadedImageUrls);
                // reset the file input so the same file can be selected again
                this.$('input[type=file]').val('');
            });
        }

        // eslint-disable-next-line ember-suave/prefer-destructuring
        let markdown = mobiledoc.cards[0][1].markdown;
        this.set('markdown', markdown);

        // use internal values to avoid updating bound values
        if (!isEmpty(this.get('isFullScreen'))) {
            this.set('_isFullScreen', this.get('isFullScreen'));
        }
        if (!isEmpty(this.get('isSplitScreen'))) {
            this.set('_isSplitScreen', this.get('isSplitScreen'));
        }

        this._updateButtonState();
    },

    _insertImages(urls) {
        let cm = this._editor.codemirror;

        // loop through urls and generate image markdown
        let images = urls.map((url) => {
            let filename = url.split('/').pop();
            let alt = filename;

            // if we have a normal filename.ext, set alt to filename -ext
            if (filename.lastIndexOf('.') > 0) {
                alt = filename.slice(0, filename.lastIndexOf('.'));
            }

            return `![${alt}](${url})`;
        });
        let text = images.join(' ');

        // clicking the image toolbar button will lose the selection so we use
        // the captured selection to re-select here
        if (this._imageInsertSelection) {
            // we want to focus but not re-position
            this.send('focusEditor', null);

            // re-select and clear the captured selection so drag/drop still
            // inserts at the correct place
            cm.setSelection(
                this._imageInsertSelection.anchor,
                this._imageInsertSelection.head
            );
            this._imageInsertSelection = null;
        }

        // focus editor and place cursor at end if not already focused
        if (!cm.hasFocus()) {
            this.send('focusEditor');
        }

        // insert at cursor or replace selection then position cursor at end
        // of inserted text
        cm.replaceSelection(text, 'end');
    },

    // mark the split-pane/full-screen/spellcheck buttons active when they're active
    _updateButtonState() {
        if (this._editor) {
            let fullScreenButton = this._editor.toolbarElements.fullscreen;
            let sideBySideButton = this._editor.toolbarElements['side-by-side'];
            let spellcheckButton = this._editor.toolbarElements.spellcheck;

            if (this.get('_isFullScreen')) {
                fullScreenButton.classList.add('active');
            } else {
                fullScreenButton.classList.remove('active');
            }

            if (this.get('_isSplitScreen')) {
                sideBySideButton.classList.add('active');
            } else {
                sideBySideButton.classList.remove('active');
            }

            if (this._editor.codemirror.getOption('mode') === 'spell-checker') {
                spellcheckButton.classList.add('active');
            } else {
                spellcheckButton.classList.remove('active');
            }
        }
    },

    // set up the preview auto-update and scroll sync
    _connectSplitPreview() {
        let cm = this._editor.codemirror;
        let editor = this._editor;
        /* eslint-disable ember-suave/prefer-destructuring */
        let editorPane = this.$('.gh-markdown-editor-pane')[0];
        let previewPane = this.$('.gh-markdown-editor-preview')[0];
        let previewContent = this.$('.gh-markdown-editor-preview-content')[0];
        /* eslint-enable ember-suave/prefer-destructuring */

        this._editorPane = editorPane;
        this._previewPane = previewPane;
        this._previewContent = previewContent;

        // from SimpleMDE -------
        let sideBySideRenderingFunction = function() {
            previewContent.innerHTML = editor.options.previewRender(
                editor.value(),
                previewContent
            );
        };

        cm.sideBySideRenderingFunction = sideBySideRenderingFunction;

        sideBySideRenderingFunction();
        cm.on('update', cm.sideBySideRenderingFunction);

        // Refresh to fix selection being off (#309)
        cm.refresh();
        // ----------------------

        this._onEditorPaneScroll = this._scrollHandler.bind(this);
        editorPane.addEventListener('scroll', this._onEditorPaneScroll, false);
        this._scrollSync();
    },

    _scrollHandler() {
        if (!this._scrollSyncTicking) {
            requestAnimationFrame(this._scrollSync.bind(this));
        }
        this._scrollSyncTicking = true;
    },

    _scrollSync() {
        let editorPane = this._editorPane;
        let previewPane = this._previewPane;
        let height = editorPane.scrollHeight - editorPane.clientHeight;
        let ratio = parseFloat(editorPane.scrollTop) / height;
        let move = (previewPane.scrollHeight - previewPane.clientHeight) * ratio;

        previewPane.scrollTop = move;
        this._scrollSyncTicking = false;
    },

    _disconnectSplitPreview() {
        let cm = this._editor.codemirror;

        cm.off('update', cm.sideBySideRenderingFunction);
        cm.refresh();

        this._editorPane.removeEventListener('scroll', this._onEditorPaneScroll, false);
        delete this._previewPane;
        delete this._previewPaneContent;
        delete this._onEditorPaneScroll;
    },

    _openImageFileDialog() {
        // capture the current selection before it's lost by clicking the
        // file input button
        this._imageInsertSelection = {
            anchor: this._editor.codemirror.getCursor('anchor'),
            head: this._editor.codemirror.getCursor('head')
        };

        // trigger the dialog via gh-file-input, when a file is selected it will
        // trigger the onImageFilesSelected closure action
        this.$('input[type="file"]').click();
    },

    // wrap SimpleMDE's built-in preview toggle so that we can trigger a closure
    // action that can apply our own classes higher up in the DOM
    _togglePreview() {
        this.onPreviewToggle(!this._editor.isPreviewActive());
        this._editor.togglePreview();
    },

    _toggleSpellcheck() {
        let cm = this._editor.codemirror;

        if (cm.getOption('mode') === 'spell-checker') {
            cm.setOption('mode', 'markdown');
        } else {
            cm.setOption('mode', 'spell-checker');
        }

        this._updateButtonState();
    },

    willDestroyElement() {
        if (this.get('_isSplitScreen')) {
            this._disconnectSplitPreview();
        }

        this._super(...arguments);
    },

    actions: {
        // put the markdown into a new mobiledoc card, trigger external update
        updateMarkdown(markdown) {
            let mobiledoc = copy(BLANK_DOC, true);
            mobiledoc.cards[0][1].markdown = markdown;
            this.onChange(mobiledoc);
        },

        // store a reference to the simplemde editor so that we can handle
        // focusing and image uploads
        setEditor(editor) {
            this._editor = editor;

            // disable CodeMirror's drag/drop handling as we want to handle that
            // in the parent gh-editor component
            this._editor.codemirror.setOption('dragDrop', false);

            // HACK: move the toolbar & status bar elements outside of the
            // editor container so that they can be aligned in fixed positions
            let container = this.$().closest('.gh-editor').find('.gh-editor-footer');
            this._toolbar = this.$('.editor-toolbar');
            this._statusbar = this.$('.editor-statusbar');
            this._toolbar.appendTo(container);
            this._statusbar.appendTo(container);

            this._updateButtonState();
        },

        // used by the title input when the TAB or ENTER keys are pressed
        focusEditor(position = 'bottom') {
            this._editor.codemirror.focus();

            if (position === 'bottom') {
                this._editor.codemirror.execCommand('goDocEnd');
            } else if (position === 'top') {
                this._editor.codemirror.execCommand('goDocStart');
            }

            return false;
        },

        toggleFullScreen() {
            let isFullScreen = !this.get('_isFullScreen');

            this.set('_isFullScreen', isFullScreen);
            this._updateButtonState();
            this.onFullScreenToggle(isFullScreen);

            // leave split screen when exiting full screen mode
            if (!isFullScreen && this.get('_isSplitScreen')) {
                this.send('toggleSplitScreen');
            }
        },

        toggleSplitScreen() {
            let isSplitScreen = !this.get('_isSplitScreen');
            let previewButton = this._editor.toolbarElements.preview;

            this.set('_isSplitScreen', isSplitScreen);
            this._updateButtonState();

            // set up the preview rendering and scroll sync
            // afterRender is needed so that necessary components have been
            // added/removed and editor pane length has settled
            if (isSplitScreen) {
                // disable the normal SimpleMDE preview if it's active
                if (this._editor.isPreviewActive()) {
                    let preview = this._editor.toolbar.find((button) => {
                        return button.name === 'preview';
                    });

                    preview.action(this._editor);
                }

                previewButton.classList.add('disabled');
                run.scheduleOnce('afterRender', this, this._connectSplitPreview);
            } else {
                previewButton.classList.remove('disabled');
                run.scheduleOnce('afterRender', this, this._disconnectSplitPreview);
            }

            this.onSplitScreenToggle(isSplitScreen);

            // go fullscreen when entering split screen mode
            if (isSplitScreen && !this.get('_isFullScreen')) {
                this.send('toggleFullScreen');
            }
        },

        // put the toolbar/statusbar elements back so that SimpleMDE doesn't throw
        // errors when it tries to remove them
        destroyEditor() {
            let container = this.$('.gh-markdown-editor-pane');
            this._toolbar.appendTo(container);
            this._statusbar.appendTo(container);
            this._editor = null;
        }
    }
});
<commit_msg>🎨 remove Fullscreen button from editor toolbar (#714)<commit_after>import Component from 'ember-component';
import computed from 'ember-computed';
import formatMarkdown from 'ghost-admin/utils/format-markdown';
import run from 'ember-runloop';
import {assign} from 'ember-platform';
import {copy} from 'ember-metal/utils';
import {isEmpty} from 'ember-utils';

const MOBILEDOC_VERSION = '0.3.1';

export const BLANK_DOC = {
    version: MOBILEDOC_VERSION,
    markups: [],
    atoms: [],
    cards: [
        ['card-markdown', {
            cardName: 'card-markdown',
            markdown: ''
        }]
    ],
    sections: [[10, 0]]
};

export default Component.extend({

    classNames: ['gh-markdown-editor'],
    classNameBindings: [
        '_isFullScreen:gh-markdown-editor-full-screen',
        '_isSplitScreen:gh-markdown-editor-side-by-side'
    ],

    // Public attributes
    autofocus: false,
    imageMimeTypes: null,
    isFullScreen: false,
    mobiledoc: null,
    options: null,
    placeholder: '',
    uploadedImageUrls: null,

    // Closure actions
    onChange() {},
    onFullScreenToggle() {},
    onImageFilesSelected() {},
    onPreviewToggle() {},
    onSplitScreenToggle() {},
    showMarkdownHelp() {},

    // Internal attributes
    markdown: null,

    // Private
    _editor: null,
    _isFullScreen: false,
    _isSplitScreen: false,
    _isUploading: false,
    _statusbar: null,
    _toolbar: null,
    _uploadedImageUrls: null,

    simpleMDEOptions: computed('options', function () {
        let options = this.get('options') || {};
        let defaultOptions = {
            // use our Showdown config with sanitization for previews
            previewRender(markdown) {
                return formatMarkdown(markdown);
            },

            // Ghost-specific SimpleMDE toolbar config - allows us to create a
            // bridge between SimpleMDE buttons and Ember actions
            toolbar: [
                'bold', 'italic', 'heading', '|',
                'quote', 'unordered-list', 'ordered-list', '|',
                'link',
                {
                    name: 'image',
                    action: () => {
                        this._openImageFileDialog();
                    },
                    className: 'fa fa-picture-o',
                    title: 'Upload Image(s)'
                },
                '|',
                {
                    name: 'preview',
                    action: () => {
                        this._togglePreview();
                    },
                    className: 'fa fa-eye no-disable',
                    title: 'Toggle Preview'
                },
                {
                    name: 'side-by-side',
                    action: () => {
                        this.send('toggleSplitScreen');
                    },
                    className: 'fa fa-columns no-disable no-mobile',
                    title: 'Toggle Side-by-side Preview'
                },
                '|',
                {
                    name: 'spellcheck',
                    action: () => {
                        this._toggleSpellcheck();
                    },
                    className: 'fa fa-check',
                    title: 'Toggle Spellcheck'
                },
                {
                    name: 'guide',
                    action: () => {
                        this.showMarkdownHelp();
                    },
                    className: 'fa fa-question-circle',
                    title: 'Markdown Guide'
                }
            ],

            // disable shortcuts for side-by-side and fullscreen because they
            // trigger interal SimpleMDE methods that will result in broken
            // layouts
            shortcuts: {
                toggleFullScreen: null,
                togglePreview: null,
                toggleSideBySide: null
            },

            // only include the number of words in the status bar
            status: ['words']
        };

        return assign(defaultOptions, options);
    }),

    // extract markdown content from single markdown card
    didReceiveAttrs() {
        this._super(...arguments);
        let mobiledoc = this.get('mobiledoc') || copy(BLANK_DOC, true);

        let uploadedImageUrls = this.get('uploadedImageUrls');
        if (!isEmpty(uploadedImageUrls) && uploadedImageUrls !== this._uploadedImageUrls) {
            this._uploadedImageUrls = uploadedImageUrls;

            // must be done afterRender to avoid double modify of mobiledoc in
            // a single render
            run.scheduleOnce('afterRender', this, () => {
                this._insertImages(uploadedImageUrls);
                // reset the file input so the same file can be selected again
                this.$('input[type=file]').val('');
            });
        }

        // eslint-disable-next-line ember-suave/prefer-destructuring
        let markdown = mobiledoc.cards[0][1].markdown;
        this.set('markdown', markdown);

        // use internal values to avoid updating bound values
        if (!isEmpty(this.get('isFullScreen'))) {
            this.set('_isFullScreen', this.get('isFullScreen'));
        }
        if (!isEmpty(this.get('isSplitScreen'))) {
            this.set('_isSplitScreen', this.get('isSplitScreen'));
        }

        this._updateButtonState();
    },

    _insertImages(urls) {
        let cm = this._editor.codemirror;

        // loop through urls and generate image markdown
        let images = urls.map((url) => {
            let filename = url.split('/').pop();
            let alt = filename;

            // if we have a normal filename.ext, set alt to filename -ext
            if (filename.lastIndexOf('.') > 0) {
                alt = filename.slice(0, filename.lastIndexOf('.'));
            }

            return `![${alt}](${url})`;
        });
        let text = images.join(' ');

        // clicking the image toolbar button will lose the selection so we use
        // the captured selection to re-select here
        if (this._imageInsertSelection) {
            // we want to focus but not re-position
            this.send('focusEditor', null);

            // re-select and clear the captured selection so drag/drop still
            // inserts at the correct place
            cm.setSelection(
                this._imageInsertSelection.anchor,
                this._imageInsertSelection.head
            );
            this._imageInsertSelection = null;
        }

        // focus editor and place cursor at end if not already focused
        if (!cm.hasFocus()) {
            this.send('focusEditor');
        }

        // insert at cursor or replace selection then position cursor at end
        // of inserted text
        cm.replaceSelection(text, 'end');
    },

    // mark the split-pane/full-screen/spellcheck buttons active when they're active
    _updateButtonState() {
        if (this._editor) {
            let sideBySideButton = this._editor.toolbarElements['side-by-side'];
            let spellcheckButton = this._editor.toolbarElements.spellcheck;

            if (this.get('_isSplitScreen')) {
                sideBySideButton.classList.add('active');
            } else {
                sideBySideButton.classList.remove('active');
            }

            if (this._editor.codemirror.getOption('mode') === 'spell-checker') {
                spellcheckButton.classList.add('active');
            } else {
                spellcheckButton.classList.remove('active');
            }
        }
    },

    // set up the preview auto-update and scroll sync
    _connectSplitPreview() {
        let cm = this._editor.codemirror;
        let editor = this._editor;
        /* eslint-disable ember-suave/prefer-destructuring */
        let editorPane = this.$('.gh-markdown-editor-pane')[0];
        let previewPane = this.$('.gh-markdown-editor-preview')[0];
        let previewContent = this.$('.gh-markdown-editor-preview-content')[0];
        /* eslint-enable ember-suave/prefer-destructuring */

        this._editorPane = editorPane;
        this._previewPane = previewPane;
        this._previewContent = previewContent;

        // from SimpleMDE -------
        let sideBySideRenderingFunction = function() {
            previewContent.innerHTML = editor.options.previewRender(
                editor.value(),
                previewContent
            );
        };

        cm.sideBySideRenderingFunction = sideBySideRenderingFunction;

        sideBySideRenderingFunction();
        cm.on('update', cm.sideBySideRenderingFunction);

        // Refresh to fix selection being off (#309)
        cm.refresh();
        // ----------------------

        this._onEditorPaneScroll = this._scrollHandler.bind(this);
        editorPane.addEventListener('scroll', this._onEditorPaneScroll, false);
        this._scrollSync();
    },

    _scrollHandler() {
        if (!this._scrollSyncTicking) {
            requestAnimationFrame(this._scrollSync.bind(this));
        }
        this._scrollSyncTicking = true;
    },

    _scrollSync() {
        let editorPane = this._editorPane;
        let previewPane = this._previewPane;
        let height = editorPane.scrollHeight - editorPane.clientHeight;
        let ratio = parseFloat(editorPane.scrollTop) / height;
        let move = (previewPane.scrollHeight - previewPane.clientHeight) * ratio;

        previewPane.scrollTop = move;
        this._scrollSyncTicking = false;
    },

    _disconnectSplitPreview() {
        let cm = this._editor.codemirror;

        cm.off('update', cm.sideBySideRenderingFunction);
        cm.refresh();

        this._editorPane.removeEventListener('scroll', this._onEditorPaneScroll, false);
        delete this._previewPane;
        delete this._previewPaneContent;
        delete this._onEditorPaneScroll;
    },

    _openImageFileDialog() {
        // capture the current selection before it's lost by clicking the
        // file input button
        this._imageInsertSelection = {
            anchor: this._editor.codemirror.getCursor('anchor'),
            head: this._editor.codemirror.getCursor('head')
        };

        // trigger the dialog via gh-file-input, when a file is selected it will
        // trigger the onImageFilesSelected closure action
        this.$('input[type="file"]').click();
    },

    // wrap SimpleMDE's built-in preview toggle so that we can trigger a closure
    // action that can apply our own classes higher up in the DOM
    _togglePreview() {
        this.onPreviewToggle(!this._editor.isPreviewActive());
        this._editor.togglePreview();
    },

    _toggleSpellcheck() {
        let cm = this._editor.codemirror;

        if (cm.getOption('mode') === 'spell-checker') {
            cm.setOption('mode', 'markdown');
        } else {
            cm.setOption('mode', 'spell-checker');
        }

        this._updateButtonState();
    },

    willDestroyElement() {
        if (this.get('_isSplitScreen')) {
            this._disconnectSplitPreview();
        }

        this._super(...arguments);
    },

    actions: {
        // put the markdown into a new mobiledoc card, trigger external update
        updateMarkdown(markdown) {
            let mobiledoc = copy(BLANK_DOC, true);
            mobiledoc.cards[0][1].markdown = markdown;
            this.onChange(mobiledoc);
        },

        // store a reference to the simplemde editor so that we can handle
        // focusing and image uploads
        setEditor(editor) {
            this._editor = editor;

            // disable CodeMirror's drag/drop handling as we want to handle that
            // in the parent gh-editor component
            this._editor.codemirror.setOption('dragDrop', false);

            // HACK: move the toolbar & status bar elements outside of the
            // editor container so that they can be aligned in fixed positions
            let container = this.$().closest('.gh-editor').find('.gh-editor-footer');
            this._toolbar = this.$('.editor-toolbar');
            this._statusbar = this.$('.editor-statusbar');
            this._toolbar.appendTo(container);
            this._statusbar.appendTo(container);

            this._updateButtonState();
        },

        // used by the title input when the TAB or ENTER keys are pressed
        focusEditor(position = 'bottom') {
            this._editor.codemirror.focus();

            if (position === 'bottom') {
                this._editor.codemirror.execCommand('goDocEnd');
            } else if (position === 'top') {
                this._editor.codemirror.execCommand('goDocStart');
            }

            return false;
        },

        toggleFullScreen() {
            let isFullScreen = !this.get('_isFullScreen');

            this.set('_isFullScreen', isFullScreen);
            this._updateButtonState();
            this.onFullScreenToggle(isFullScreen);

            // leave split screen when exiting full screen mode
            if (!isFullScreen && this.get('_isSplitScreen')) {
                this.send('toggleSplitScreen');
            }
        },

        toggleSplitScreen() {
            let isSplitScreen = !this.get('_isSplitScreen');
            let previewButton = this._editor.toolbarElements.preview;

            this.set('_isSplitScreen', isSplitScreen);
            this._updateButtonState();

            // set up the preview rendering and scroll sync
            // afterRender is needed so that necessary components have been
            // added/removed and editor pane length has settled
            if (isSplitScreen) {
                // disable the normal SimpleMDE preview if it's active
                if (this._editor.isPreviewActive()) {
                    let preview = this._editor.toolbar.find((button) => {
                        return button.name === 'preview';
                    });

                    preview.action(this._editor);
                }

                previewButton.classList.add('disabled');
                run.scheduleOnce('afterRender', this, this._connectSplitPreview);
            } else {
                previewButton.classList.remove('disabled');
                run.scheduleOnce('afterRender', this, this._disconnectSplitPreview);
            }

            this.onSplitScreenToggle(isSplitScreen);

            // go fullscreen when entering split screen mode
            this.send('toggleFullScreen');
        },

        // put the toolbar/statusbar elements back so that SimpleMDE doesn't throw
        // errors when it tries to remove them
        destroyEditor() {
            let container = this.$('.gh-markdown-editor-pane');
            this._toolbar.appendTo(container);
            this._statusbar.appendTo(container);
            this._editor = null;
        }
    }
});
<|endoftext|>


<commit_before>/*
 * A fairly reliable indicator as to whether or not BPM is currently
 * running in a frame.
 */
// Firefox is funny about window/.self/.parent/.top, such that comparing
// references is unreliable. frameElement is the only test I've found so
// far that works consistently.
var running_in_frame = (window !== window.top || window.frameElement);

// As a note, this regexp is a little forgiving in some respects and strict in
// others. It will not permit text in the [] portion, but alt-text quotes don't
// have to match each other.
//
//                        <   emote      >   <    alt-text     >
var emote_regexp = /\[\]\((\/[\w:!#\/\-]+)\s*(?:["']([^"]*)["'])?\)/g;

// this!==window on Opera, and doesn't have this object for some reason
var Node = find_global("Node");

function preserve_scroll(node, callback) {
    // Move up through the DOM and see if there's a container element that
    // scrolls, so we can keep track of how the size of its contents change.
    // Also, this is really expensive.
    var container = locate_matching_ancestor(node, function(element) {
        var style = window.getComputedStyle(element);
        if(style && (style.overflowY === "auto" || style.overflowY === "scroll")) {
            return true;
        } else {
            return false;
        }
    });

    if(container) {
        var top = container.scrollTop;
        var height = container.scrollHeight;
        // visible height + amount hidden > total height
        // + 1 just for a bit of safety
        var at_bottom = (container.clientHeight + top + 1 >= height);
    }

    callback();

    // If the parent element has gotten higher due to our emotes, and it was at
    // the bottom before, scroll it down by the delta.
    if(container && at_bottom && top && container.scrollHeight > height) {
        var delta = container.scrollHeight - height;
        container.scrollTop = container.scrollTop + delta;
    }
}

function make_emote(match, parts, name, info) {
    // Build emote. (Global emotes are always -in)
    var element = document.createElement("span");
    element.classList.add("bpflag-in");

    // Some lies for alt-text.
    element.setAttribute("href", match[1]);
    if(match[2]) {
        // Note: the quotes aren't captured by the regexp
        element.title = match[2];
    }

    element.classList.add("bpm-emote");
    element.setAttribute("data-bpm_emotename", name);
    element.setAttribute("data-bpm_srname", info.source_name);
    element.setAttribute("data-bpm_state", "eT"); // TODO: "n" flag

    element.classList.add(info.css_class);
    add_flags(element, parts);

    return element;
}

var discordDisruptiveClasses = ['member-username', 'channel-name', 'user-name', 'username', 'channel-voice-states', 'typing'];
function discordClassInDisruptiveEmotes(node) {
    return discordDisruptiveClasses.filter(function(className) {
        return node.className.indexOf(className) >= 0;
    }).length > 0;
}
function disableCodeBlocks(node) {
    return node.tagName === 'CODE';
}

function disableEmotesInSearch(node) {
    return node.classList.contains('search');
}

function createDisablePredicate(store) {
    var predicates = [];
    if(store.prefs.disableDisruptiveEmotes) {
        predicates.push(discordClassInDisruptiveEmotes);
    }
    if(store.prefs.disableEmotesInCodeBlocks) {
        predicates.push(disableCodeBlocks);
    }

    predicates.push(disableEmotesInSearch);

    function disabledEmotesPredicate(node) {
        var i = 0;
        while(i < predicates.length) {
            if(predicates[i](node)) return true;
            i++;
        }
        return false;
    }
    //We prefer to return null here as passing a no-op will require us to
    //unnecessarily percolate the check up the entire DOM tree
    //This requires a check but I consider the tradeoff worth it.
    return predicates.length > 0 ? disabledEmotesPredicate : null;
}

/*
 * Searches elements recursively for [](/emotes), and converts them.
 */
function process_text(store, root) {
    // List of nodes to delete. Would probably not work well to remove nodes
    // while walking the DOM
    var deletion_list = [];

    var nodes_processed = 0;
    var emotes_matched = 0;

    //May be null if we have no disabled predicates.  Check this below
    var disabledPredicate = createDisablePredicate(store);

    walk_dom(root, Node.TEXT_NODE, function(node) {
        nodes_processed++;

        var parent = node.parentNode;
        //If we're replacing a text node in an element that is disabled
        //via our preferences, return
        if(disabledPredicate !== null &&
            locate_matching_ancestor(parent, disabledPredicate, false)) return;
        // <span> elements to apply alt-text to
        var emote_elements = [];
        emote_regexp.lastIndex = 0;

        var new_elements = [];
        var end_of_prev = 0; // End index of previous emote match
        var match;

        // Locate every emote we can in this text node. Each time through,
        // append the text before it and our new emote node to new_elements.
        while(match = emote_regexp.exec(node.data)) {
            emotes_matched++;

            // Don't normalize case for emote lookup
            var parts = match[1].split("-");
            var name = parts[0];
            var info = store.lookup_emote(name, false);

            if(info === null) {
                continue;
            }
            if(store.is_disabled(info)) {
                continue;
            }

            // Keep text between the last emote and this one (or the start
            // of the text element)
            var before_text = node.data.slice(end_of_prev, match.index);
            if(before_text) {
                new_elements.push(document.createTextNode(before_text));
            }

            var element = make_emote(match, parts, name, info);

            new_elements.push(element);
            emote_elements.push(element);

            // Next text element will start after this emote
            end_of_prev = match.index + match[0].length;
        }

        // If length == 0, then there were no emote matches to begin with,
        // and we should just leave it alone
        if(new_elements.length) {
            // There were emotes, so grab the last bit of text at the end too
            var end_text = node.data.slice(end_of_prev);
            if(end_text) {
                new_elements.push(document.createTextNode(end_text));
            }

            preserve_scroll(parent, function() {
                // Insert all our new nodes
                for(var i = 0; i < new_elements.length; i++) {
                    parent.insertBefore(new_elements[i], node);
                }

                // Remove original text node. FIXME: since we delay this, are we
                // affecting the scroll-fixing code by resizing stuff later?
                deletion_list.push(node);

                // Convert alt text and such. We want to do this after we insert
                // our new nodes (so that the alt-text element goes to the right
                // place) but before we rescroll.
                if(store.prefs.showAltText) {
                    for(var i = 0; i < emote_elements.length; i++) {
                        process_alt_text(emote_elements[i]);
                    }
                }
            });
        }
    }, function() {
        // Code run after the entire tree has been walked- delete the text
        // nodes that we deferred. FIXME: this is dumb, we should be removing
        // stuff as we go so the scrollfix code works right, and so huge pages
        // behave more sanely (you'll see emotes appear, then MUCH LATER the
        // text disappear).
        if(nodes_processed) {
            log_debug("Processed", nodes_processed, "node(s) and matched", emotes_matched, "emote(s)");
        }
        for(var i = 0; i < deletion_list.length; i++) {
            var node = deletion_list[i];
            node.parentNode.removeChild(node);
        }
    });
}

/*
 * Main function when running globally.
 */
function run_global(store) {
    //Discord should always have emote search enabled
    if(store.prefs.enableGlobalSearch || platform == "discord-ext") {
        // Never inject the search box into frames. Too many sites fuck up
        // entirely if we do. Instead, we do some cross-frame communication.
        if(running_in_frame) {
            init_frame_search(store);
        } else {
            init_search_box(store);
            setup_global_icon(store);
        }
    }

    process_text(store, document.body);

    observe_document(function(nodes) {
        for(var i = 0; i < nodes.length; i++) {
            if(nodes[i].nodeType !== Node.ELEMENT_NODE) {
                // Not interested in other kinds of nodes.
                // FIXME: this makes no sense
                continue;
            }
            process_text(store, nodes[i]);
        }
    });
}

function global_main(store) {
    if(!store.prefs.enableGlobalEmotes) {
        return;
    }

    // Check against domain blacklist
    for(var i = 0; i < DOMAIN_BLACKLIST.length; i++) {
        if(document.location.hostname === DOMAIN_BLACKLIST[i] ||
           ends_with(document.location.hostname, DOMAIN_BLACKLIST[i])) {
            log_warning("Refusing to run on '" + document.location.hostname + "': domain is blacklisted (probably broken)");
            return;
        }
    }

    log_info("Running globally");

    init_css(store);
    _checkpoint("css");

    with_dom(function() {
        run_global(store);
        _checkpoint("done");
    });
}
<commit_msg>Add search bar to disruptive emote locations<commit_after>/*
 * A fairly reliable indicator as to whether or not BPM is currently
 * running in a frame.
 */
// Firefox is funny about window/.self/.parent/.top, such that comparing
// references is unreliable. frameElement is the only test I've found so
// far that works consistently.
var running_in_frame = (window !== window.top || window.frameElement);

// As a note, this regexp is a little forgiving in some respects and strict in
// others. It will not permit text in the [] portion, but alt-text quotes don't
// have to match each other.
//
//                        <   emote      >   <    alt-text     >
var emote_regexp = /\[\]\((\/[\w:!#\/\-]+)\s*(?:["']([^"]*)["'])?\)/g;

// this!==window on Opera, and doesn't have this object for some reason
var Node = find_global("Node");

function preserve_scroll(node, callback) {
    // Move up through the DOM and see if there's a container element that
    // scrolls, so we can keep track of how the size of its contents change.
    // Also, this is really expensive.
    var container = locate_matching_ancestor(node, function(element) {
        var style = window.getComputedStyle(element);
        if(style && (style.overflowY === "auto" || style.overflowY === "scroll")) {
            return true;
        } else {
            return false;
        }
    });

    if(container) {
        var top = container.scrollTop;
        var height = container.scrollHeight;
        // visible height + amount hidden > total height
        // + 1 just for a bit of safety
        var at_bottom = (container.clientHeight + top + 1 >= height);
    }

    callback();

    // If the parent element has gotten higher due to our emotes, and it was at
    // the bottom before, scroll it down by the delta.
    if(container && at_bottom && top && container.scrollHeight > height) {
        var delta = container.scrollHeight - height;
        container.scrollTop = container.scrollTop + delta;
    }
}

function make_emote(match, parts, name, info) {
    // Build emote. (Global emotes are always -in)
    var element = document.createElement("span");
    element.classList.add("bpflag-in");

    // Some lies for alt-text.
    element.setAttribute("href", match[1]);
    if(match[2]) {
        // Note: the quotes aren't captured by the regexp
        element.title = match[2];
    }

    element.classList.add("bpm-emote");
    element.setAttribute("data-bpm_emotename", name);
    element.setAttribute("data-bpm_srname", info.source_name);
    element.setAttribute("data-bpm_state", "eT"); // TODO: "n" flag

    element.classList.add(info.css_class);
    add_flags(element, parts);

    return element;
}

var discordDisruptiveClasses = ['member-username', 'channel-name', 'user-name', 'username', 'channel-voice-states', 'typing', 'search-bar'];
function discordClassInDisruptiveEmotes(node) {
    return discordDisruptiveClasses.filter(function(className) {
        return node.className.indexOf(className) >= 0;
    }).length > 0;
}
function disableCodeBlocks(node) {
    return node.tagName === 'CODE';
}

function disableEmotesInSearch(node) {
    return node.classList.contains('search');
}

function createDisablePredicate(store) {
    var predicates = [];
    if(store.prefs.disableDisruptiveEmotes) {
        predicates.push(discordClassInDisruptiveEmotes);
    }
    if(store.prefs.disableEmotesInCodeBlocks) {
        predicates.push(disableCodeBlocks);
    }

    predicates.push(disableEmotesInSearch);

    function disabledEmotesPredicate(node) {
        var i = 0;
        while(i < predicates.length) {
            if(predicates[i](node)) return true;
            i++;
        }
        return false;
    }
    //We prefer to return null here as passing a no-op will require us to
    //unnecessarily percolate the check up the entire DOM tree
    //This requires a check but I consider the tradeoff worth it.
    return predicates.length > 0 ? disabledEmotesPredicate : null;
}

/*
 * Searches elements recursively for [](/emotes), and converts them.
 */
function process_text(store, root) {
    // List of nodes to delete. Would probably not work well to remove nodes
    // while walking the DOM
    var deletion_list = [];

    var nodes_processed = 0;
    var emotes_matched = 0;

    //May be null if we have no disabled predicates.  Check this below
    var disabledPredicate = createDisablePredicate(store);

    walk_dom(root, Node.TEXT_NODE, function(node) {
        nodes_processed++;

        var parent = node.parentNode;
        //If we're replacing a text node in an element that is disabled
        //via our preferences, return
        if(disabledPredicate !== null &&
            locate_matching_ancestor(parent, disabledPredicate, false)) return;
        // <span> elements to apply alt-text to
        var emote_elements = [];
        emote_regexp.lastIndex = 0;

        var new_elements = [];
        var end_of_prev = 0; // End index of previous emote match
        var match;

        // Locate every emote we can in this text node. Each time through,
        // append the text before it and our new emote node to new_elements.
        while(match = emote_regexp.exec(node.data)) {
            emotes_matched++;

            // Don't normalize case for emote lookup
            var parts = match[1].split("-");
            var name = parts[0];
            var info = store.lookup_emote(name, false);

            if(info === null) {
                continue;
            }
            if(store.is_disabled(info)) {
                continue;
            }

            // Keep text between the last emote and this one (or the start
            // of the text element)
            var before_text = node.data.slice(end_of_prev, match.index);
            if(before_text) {
                new_elements.push(document.createTextNode(before_text));
            }

            var element = make_emote(match, parts, name, info);

            new_elements.push(element);
            emote_elements.push(element);

            // Next text element will start after this emote
            end_of_prev = match.index + match[0].length;
        }

        // If length == 0, then there were no emote matches to begin with,
        // and we should just leave it alone
        if(new_elements.length) {
            // There were emotes, so grab the last bit of text at the end too
            var end_text = node.data.slice(end_of_prev);
            if(end_text) {
                new_elements.push(document.createTextNode(end_text));
            }

            preserve_scroll(parent, function() {
                // Insert all our new nodes
                for(var i = 0; i < new_elements.length; i++) {
                    parent.insertBefore(new_elements[i], node);
                }

                // Remove original text node. FIXME: since we delay this, are we
                // affecting the scroll-fixing code by resizing stuff later?
                deletion_list.push(node);

                // Convert alt text and such. We want to do this after we insert
                // our new nodes (so that the alt-text element goes to the right
                // place) but before we rescroll.
                if(store.prefs.showAltText) {
                    for(var i = 0; i < emote_elements.length; i++) {
                        process_alt_text(emote_elements[i]);
                    }
                }
            });
        }
    }, function() {
        // Code run after the entire tree has been walked- delete the text
        // nodes that we deferred. FIXME: this is dumb, we should be removing
        // stuff as we go so the scrollfix code works right, and so huge pages
        // behave more sanely (you'll see emotes appear, then MUCH LATER the
        // text disappear).
        if(nodes_processed) {
            log_debug("Processed", nodes_processed, "node(s) and matched", emotes_matched, "emote(s)");
        }
        for(var i = 0; i < deletion_list.length; i++) {
            var node = deletion_list[i];
            node.parentNode.removeChild(node);
        }
    });
}

/*
 * Main function when running globally.
 */
function run_global(store) {
    //Discord should always have emote search enabled
    if(store.prefs.enableGlobalSearch || platform == "discord-ext") {
        // Never inject the search box into frames. Too many sites fuck up
        // entirely if we do. Instead, we do some cross-frame communication.
        if(running_in_frame) {
            init_frame_search(store);
        } else {
            init_search_box(store);
            setup_global_icon(store);
        }
    }

    process_text(store, document.body);

    observe_document(function(nodes) {
        for(var i = 0; i < nodes.length; i++) {
            if(nodes[i].nodeType !== Node.ELEMENT_NODE) {
                // Not interested in other kinds of nodes.
                // FIXME: this makes no sense
                continue;
            }
            process_text(store, nodes[i]);
        }
    });
}

function global_main(store) {
    if(!store.prefs.enableGlobalEmotes) {
        return;
    }

    // Check against domain blacklist
    for(var i = 0; i < DOMAIN_BLACKLIST.length; i++) {
        if(document.location.hostname === DOMAIN_BLACKLIST[i] ||
           ends_with(document.location.hostname, DOMAIN_BLACKLIST[i])) {
            log_warning("Refusing to run on '" + document.location.hostname + "': domain is blacklisted (probably broken)");
            return;
        }
    }

    log_info("Running globally");

    init_css(store);
    _checkpoint("css");

    with_dom(function() {
        run_global(store);
        _checkpoint("done");
    });
}
<|endoftext|>


<commit_before>/*
 *   This Source Code is subject to the terms of the Mozilla Public License
 *   version 2.0 (the 'License'). You can obtain a copy of the License at
 *   http://mozilla.org/MPL/2.0/.
 */

/*
 *   Originally Liberated from
 *   https://github.com/lmorchard/fireriver/blob/master/lib/main.js#L369
 *   & https://dxr.mozilla.org/mozilla-central/source/browser/components/uitour/UITour.jsm
 */

const Metrics = require('./metrics');
const notify = require('./notify');
const { setTimeout, clearTimeout } = require('sdk/timers');
const tabs = require('sdk/tabs');
const querystring = require('sdk/querystring');
const store = require('sdk/simple-storage').storage;
const _ = require('sdk/l10n').get;

const NUM_STARS = 5; // Number of survey stars
const TEN_MINUTES = 60 * 1000 * 10;

module.exports = {init, destroy, launchSurvey};

// set timecheck flags for initial run.
if (store.surveyChecks === undefined) store.surveyChecks = {};
['twoDaysSent', 'oneWeekSent', 'threeWeeksSent', 'monthAndAHalfSent', 'eol'].forEach(k => {
  if (store.surveyChecks[k] === undefined) store.surveyChecks[k] = {};
});

function init() {
  if (checkForCompletedExperiments()) { return; }
  // wait about 10 minutes before prompting user for survey
  setTimeout(() => {
    // Only check/ask for survey if the user has addons installed.
    if (store.installedAddons && Object.keys(store.installedAddons).length) {
      const experiment = getRandomExperiment();
      const interval = checkInstallDate(experiment.installDate, experiment.addon_id);
      if (!interval) return;
      launchSurvey({experiment, interval});
    }
  }, TEN_MINUTES);
}

function checkForCompletedExperiments() {
  if (store.installedAddons) {
    const now = Date.now();
    const ids = Object.keys(store.installedAddons);
    const eolSurveys = [];
    for (let id of ids) { // eslint-disable-line prefer-const
      let x = store.installedAddons[id]; // eslint-disable-line prefer-const
      if (x.active &&
          (new Date(x.completed)).getTime() < now &&
          !(id in store.surveyChecks.eol)) {
        eolSurveys.push(x);
      }
    }
    const experiment = eolSurveys[Math.floor(Math.random() * eolSurveys.length)];
    if (experiment) {
      tabs.once('open', tab => { // eslint-disable-line no-unused-vars
        setTimeout(() =>
          launchSurvey({
            experiment,
            interval: 'eol',
            label: _('survey_launch_survey_label', experiment.title),
            persistence: 10
          }),
          1000
        );
      });
      return true;
    }
  }
  return false;
}

function destroy() {
  delete store.surveyChecks;
}

function getRandomExperiment() {
  const installedKeys = Object.keys(store.installedAddons);
  const randomIndex = Math.floor(Math.random() * installedKeys.length);
  return store.installedAddons[installedKeys[randomIndex]];
}

// check if the addon is due for a survey at the current interval
function checkInstallDate(installDate, addonId) {
  const dateDiff = Math.round((new Date() - new Date(installDate)) / (1000 * 60 * 60 * 24));

  if (!(addonId in store.surveyChecks.twoDaysSent) && dateDiff >= 2) {
    store.surveyChecks.twoDaysSent[addonId] = true;
    return 2;
  } else if (!(addonId in store.surveyChecks.oneWeekSent) && dateDiff >= 7) {
    store.surveyChecks.oneWeekSent[addonId] = true;
    return 7;
  } else if (!(addonId in store.surveyChecks.threeWeeksSent) && dateDiff >= 21) {
    store.surveyChecks.threeWeeksSent[addonId] = true;
    return 21;
  } else if (!(addonId in store.surveyChecks.monthAndAHalfSent) && dateDiff >= 46) {
    store.surveyChecks.monthAndAHalfSent[addonId] = true;
    return 46;
  }

  return false;
}

function launchSurvey(options) {
  const { experiment, interval } = options;
  showRating(options)
    .then(
      rating => {
        if (!rating) { return Promise.resolve(); }
        Metrics.pingTelemetry(experiment.addon_id, `rated_${rating}`);
        return showSurveyButton(options)
          .then(clicked => {
            if (clicked) {
              const urlParams = {
                id: experiment.addon_id,
                cid: store.clientUUID,
                installed: Object.keys(store.installedAddons),
                rating,
                interval
              };
              const url = `${experiment.survey_url}?${querystring.stringify(urlParams)}`;
              tabs.open(url);
            }
          });
      }
    )
    .catch(() => {});
}

function createRatingUI(win, cb) {
  // Create the fragment holding the rating UI.
  const frag = win.document.createDocumentFragment();

  // Build the star rating.
  const ratingContainer = win.document.createElement('hbox');
  ratingContainer.id = 'star-rating-container';
  ratingContainer.setAttribute('style', 'margin-bottom: 2px');

  function ratingListener(evt) {
    cb(Number(evt.target.getAttribute('data-score'), 10));
  }

  for (let i = 0; i < NUM_STARS; i++) {
    // Create a star rating element.
    const ratingElement = win.document.createElement('toolbarbutton');

    // Style it.
    const starIndex = NUM_STARS - i;
    ratingElement.className = 'plain star-x';
    ratingElement.id = 'star' + starIndex;
    ratingElement.setAttribute('data-score', starIndex);

    // Add the click handler.
    ratingElement.addEventListener('click', ratingListener);

    // Add it to the container.
    ratingContainer.appendChild(ratingElement);
  }

  frag.appendChild(ratingContainer);

  return frag;
}

function showRating(options) {
  return new Promise((resolve) => {
    const experiment = options.experiment;
    const uiTimeout = setTimeout(uiClosed, options.duration || 60000);
    let experimentRating = null;

    const { notifyBox, box } = notify.createNotificationBox({
      label: options.label || _('survey_show_rating_label', experiment.title),
      image: experiment.thumbnail,
      child: win => createRatingUI(win, uiClosed),
      persistence: options.persistence,
      pulse: true,
      callback: () => {
        if (options.interval === 'eol' && experimentRating === null) {
          store.surveyChecks.eol[experiment.addon_id] = true;
        }
        clearTimeout(uiTimeout);
        resolve(experimentRating);
      }
    });

    function uiClosed(rating) {
      experimentRating = rating;
      notifyBox.removeNotification(box);
    }
  });
}

function showSurveyButton(options) {
  return new Promise((resolve) => {
    let clicked = false;
    const { experiment, duration } = options;
    const uiTimeout = setTimeout(uiClosed, duration || 60000);
    const { notifyBox, box } = notify.createNotificationBox({
      label: _('survey_rating_thank_you', experiment.title),
      image: experiment.thumbnail,
      buttons: [{
        label: _('survey_rating_survey_button'),
        callback: () => { clicked = true; }
      }],
      callback: () => {
        clearTimeout(uiTimeout);
        resolve(clicked);
      }
    });
    function uiClosed() {
      notifyBox.removeNotification(box);
    }
    const button = box.getElementsByClassName('notification-button')[0];
    if (button) {
      button.setAttribute('style', 'background: #0095dd; color: #fff; height: 30px; font-size: 13px; border-radius: 2px; border: 0px; text-shadow: 0 0px; box-shadow: 0 0px;');
    }
  });
}
<commit_msg>fix miscolored button on win-10 addon<commit_after>/*
 *   This Source Code is subject to the terms of the Mozilla Public License
 *   version 2.0 (the 'License'). You can obtain a copy of the License at
 *   http://mozilla.org/MPL/2.0/.
 */

/*
 *   Originally Liberated from
 *   https://github.com/lmorchard/fireriver/blob/master/lib/main.js#L369
 *   & https://dxr.mozilla.org/mozilla-central/source/browser/components/uitour/UITour.jsm
 */

const Metrics = require('./metrics');
const notify = require('./notify');
const { setTimeout, clearTimeout } = require('sdk/timers');
const tabs = require('sdk/tabs');
const querystring = require('sdk/querystring');
const store = require('sdk/simple-storage').storage;
const _ = require('sdk/l10n').get;

const NUM_STARS = 5; // Number of survey stars
const TEN_MINUTES = 60 * 1000 * 10;

module.exports = {init, destroy, launchSurvey};

// set timecheck flags for initial run.
if (store.surveyChecks === undefined) store.surveyChecks = {};
['twoDaysSent', 'oneWeekSent', 'threeWeeksSent', 'monthAndAHalfSent', 'eol'].forEach(k => {
  if (store.surveyChecks[k] === undefined) store.surveyChecks[k] = {};
});

function init() {
  if (checkForCompletedExperiments()) { return; }
  // wait about 10 minutes before prompting user for survey
  setTimeout(() => {
    // Only check/ask for survey if the user has addons installed.
    if (store.installedAddons && Object.keys(store.installedAddons).length) {
      const experiment = getRandomExperiment();
      const interval = checkInstallDate(experiment.installDate, experiment.addon_id);
      if (!interval) return;
      launchSurvey({experiment, interval});
    }
  }, TEN_MINUTES);
}

function checkForCompletedExperiments() {
  if (store.installedAddons) {
    const now = Date.now();
    const ids = Object.keys(store.installedAddons);
    const eolSurveys = [];
    for (let id of ids) { // eslint-disable-line prefer-const
      let x = store.installedAddons[id]; // eslint-disable-line prefer-const
      if (x.active &&
          (new Date(x.completed)).getTime() < now &&
          !(id in store.surveyChecks.eol)) {
        eolSurveys.push(x);
      }
    }
    const experiment = eolSurveys[Math.floor(Math.random() * eolSurveys.length)];
    if (experiment) {
      tabs.once('open', tab => { // eslint-disable-line no-unused-vars
        setTimeout(() =>
          launchSurvey({
            experiment,
            interval: 'eol',
            label: _('survey_launch_survey_label', experiment.title),
            persistence: 10
          }),
          1000
        );
      });
      return true;
    }
  }
  return false;
}

function destroy() {
  delete store.surveyChecks;
}

function getRandomExperiment() {
  const installedKeys = Object.keys(store.installedAddons);
  const randomIndex = Math.floor(Math.random() * installedKeys.length);
  return store.installedAddons[installedKeys[randomIndex]];
}

// check if the addon is due for a survey at the current interval
function checkInstallDate(installDate, addonId) {
  const dateDiff = Math.round((new Date() - new Date(installDate)) / (1000 * 60 * 60 * 24));

  if (!(addonId in store.surveyChecks.twoDaysSent) && dateDiff >= 2) {
    store.surveyChecks.twoDaysSent[addonId] = true;
    return 2;
  } else if (!(addonId in store.surveyChecks.oneWeekSent) && dateDiff >= 7) {
    store.surveyChecks.oneWeekSent[addonId] = true;
    return 7;
  } else if (!(addonId in store.surveyChecks.threeWeeksSent) && dateDiff >= 21) {
    store.surveyChecks.threeWeeksSent[addonId] = true;
    return 21;
  } else if (!(addonId in store.surveyChecks.monthAndAHalfSent) && dateDiff >= 46) {
    store.surveyChecks.monthAndAHalfSent[addonId] = true;
    return 46;
  }

  return false;
}

function launchSurvey(options) {
  const { experiment, interval } = options;
  showRating(options)
    .then(
      rating => {
        if (!rating) { return Promise.resolve(); }
        Metrics.pingTelemetry(experiment.addon_id, `rated_${rating}`);
        return showSurveyButton(options)
          .then(clicked => {
            if (clicked) {
              const urlParams = {
                id: experiment.addon_id,
                cid: store.clientUUID,
                installed: Object.keys(store.installedAddons),
                rating,
                interval
              };
              const url = `${experiment.survey_url}?${querystring.stringify(urlParams)}`;
              tabs.open(url);
            }
          });
      }
    )
    .catch(() => {});
}

function createRatingUI(win, cb) {
  // Create the fragment holding the rating UI.
  const frag = win.document.createDocumentFragment();

  // Build the star rating.
  const ratingContainer = win.document.createElement('hbox');
  ratingContainer.id = 'star-rating-container';
  ratingContainer.setAttribute('style', 'margin-bottom: 2px');

  function ratingListener(evt) {
    cb(Number(evt.target.getAttribute('data-score'), 10));
  }

  for (let i = 0; i < NUM_STARS; i++) {
    // Create a star rating element.
    const ratingElement = win.document.createElement('toolbarbutton');

    // Style it.
    const starIndex = NUM_STARS - i;
    ratingElement.className = 'plain star-x';
    ratingElement.id = 'star' + starIndex;
    ratingElement.setAttribute('data-score', starIndex);

    // Add the click handler.
    ratingElement.addEventListener('click', ratingListener);

    // Add it to the container.
    ratingContainer.appendChild(ratingElement);
  }

  frag.appendChild(ratingContainer);

  return frag;
}

function showRating(options) {
  return new Promise((resolve) => {
    const experiment = options.experiment;
    const uiTimeout = setTimeout(uiClosed, options.duration || 60000);
    let experimentRating = null;

    const { notifyBox, box } = notify.createNotificationBox({
      label: options.label || _('survey_show_rating_label', experiment.title),
      image: experiment.thumbnail,
      child: win => createRatingUI(win, uiClosed),
      persistence: options.persistence,
      pulse: true,
      callback: () => {
        if (options.interval === 'eol' && experimentRating === null) {
          store.surveyChecks.eol[experiment.addon_id] = true;
        }
        clearTimeout(uiTimeout);
        resolve(experimentRating);
      }
    });

    function uiClosed(rating) {
      experimentRating = rating;
      notifyBox.removeNotification(box);
    }
  });
}

function showSurveyButton(options) {
  return new Promise((resolve) => {
    let clicked = false;
    const { experiment, duration } = options;
    const uiTimeout = setTimeout(uiClosed, duration || 60000);
    const { notifyBox, box } = notify.createNotificationBox({
      label: _('survey_rating_thank_you', experiment.title),
      image: experiment.thumbnail,
      buttons: [{
        label: _('survey_rating_survey_button'),
        callback: () => { clicked = true; }
      }],
      callback: () => {
        clearTimeout(uiTimeout);
        resolve(clicked);
      }
    });
    function uiClosed() {
      notifyBox.removeNotification(box);
    }
    const button = box.getElementsByClassName('notification-button')[0];
    if (button) {
      button.setAttribute('style', 'background: #0095dd; color: #fff; height: 30px; font-size: 13px; border-radius: 2px; border: 0px; text-shadow: 0 0px; box-shadow: 0 0 0 20px #0095dd inset');
    }
  });
}
<|endoftext|>


<commit_before>import { isEqual } from '@ember/utils';
import { assign, merge } from '@ember/polyfills';
import { copy } from '@ember/object/internals';
import { setDiff } from 'ember-m3/util';

const emberAssign = assign || merge;

class M3SchemaInterface {
  constructor(modelData) {
    this.modelData = modelData;
  }

  getAttr(name) {
    return this.modelData.getAttr(name);
  }
}

export default class M3ModelData {
  constructor(
    modelName,
    id,
    clientId,
    storeWrapper,
    parentModelData,
    parentKey,
    parentValueIsArray,
    embeddedInternalModel
  ) {
    this.modelName = modelName;
    this.clientId = clientId;
    this.id = id;
    this.storeWrapper = storeWrapper;
    this.isDestroyed = false;
    this._data = null;
    this._attributes = null;
    this.__inFlightAttributes = null;

    this._parentModelData = parentModelData;
    this.__childModelDatas = null;
    if (parentKey !== undefined && parentKey !== null) {
      let parentChildren = this._parentModelData._childModelDatas;
      if (parentValueIsArray) {
        parentChildren[parentKey] = parentChildren[parentKey] || [];
        parentChildren[parentKey].push(this);
      } else {
        parentChildren[parentKey] = this;
      }
    }
    this._embeddedInternalModel = embeddedInternalModel;

    this.schemaInterface = new M3SchemaInterface(this);
  }

  // PUBLIC API

  getResourceIdentifier() {
    return {
      id: this.id,
      type: this.modelName,
      clientId: this.clientId,
    };
  }

  pushData(jsonApiResource, calculateChange, notifyRecord = false) {
    let changedKeys;

    if (calculateChange) {
      changedKeys = this._changedKeys(jsonApiResource.attributes);
    }

    if (jsonApiResource.attributes !== undefined) {
      this._data = jsonApiResource.attributes;
    }
    if (this.__attributes !== null) {
      // only do if we have attribute changes
      this._updateChangedAttributes();
    }

    if (jsonApiResource.id) {
      this.id = jsonApiResource.id + '';
    }

    if (this.__childModelDatas) {
      let nestedKeys = Object.keys(this._childModelDatas);
      for (let i = 0; i < nestedKeys.length; ++i) {
        let childKey = nestedKeys[i];
        let childModelData = this._childModelDatas[childKey];
        let newAttrs = this._data[childKey];

        if (newAttrs === null || typeof newAttrs !== 'object' || Array.isArray(childModelData)) {
          // changing from nested model -> primitive we don't update inline,
          // just discard the child model data similarly we don't push changes
          // down to arrays of nested models because we don't associate an
          // individual model data with its position in an array
          //
          // this also means that nested m3 models within arrays are not
          // re-used between pushes of data
          delete this._childModelDatas[childKey];
        } else {
          childModelData.pushData({ attributes: this._data[childKey] }, true, true);
        }
      }
    }

    if (notifyRecord) {
      this._embeddedInternalModel.record._notifyProperties(changedKeys);
    }

    return changedKeys;
  }

  willCommit() {
    this._inFlightAttributes = this._attributes;
    this._attributes = null;

    if (this.__childModelDatas) {
      let nestedKeys = Object.keys(this._childModelDatas);
      for (let i = 0; i < nestedKeys.length; ++i) {
        let childKey = nestedKeys[i];
        let childModelData = this._childModelDatas[childKey];
        if (!Array.isArray(childModelData)) {
          // we don't re-use nested models within arrays so there's no need to
          // propagate willCommit/didCommit
          childModelData.willCommit();
        }
      }
    }
  }

  hasChangedAttributes() {
    return this.__attributes !== null && Object.keys(this.__attributes).length > 0;
  }

  addToHasMany() {}

  removeFromHasMany() {}

  /*
    Returns an object, whose keys are changed properties, and value is an
    [oldProp, newProp] array.

    @method changedAttributes
    @private
  */
  changedAttributes() {
    let serverState = this._data;
    let localChanges = this._attributes;
    let inFlightData = this._inFlightAttributes;
    let newData = emberAssign(copy(inFlightData), localChanges);
    let _changedAttributes = Object.create(null);
    let newDataKeys = Object.keys(newData);

    for (let i = 0, length = newDataKeys.length; i < length; i++) {
      let key = newDataKeys[i];
      _changedAttributes[key] = [serverState[key], newData[key]];
    }

    if (this.__childModelDatas) {
      let nestedKeys = Object.keys(this._childModelDatas);
      for (let i = 0; i < nestedKeys.length; ++i) {
        let childKey = nestedKeys[i];
        let childModelData = this._childModelDatas[childKey];
        if (Array.isArray(childModelData)) {
          let changes = null;
          for (let j = 0; j < childModelData.length; ++j) {
            let individualChildModelData = childModelData[j];
            let childChangedAttributes = individualChildModelData.changedAttributes();
            if (Object.keys(childChangedAttributes).length > 0) {
              if (changes == null) {
                changes = new Array(childModelData.length);
              }
              changes[j] = childChangedAttributes;
            }
          }
          if (changes !== null) {
            _changedAttributes[childKey] = changes;
          }
        } else {
          let childChangedAttributes = childModelData.changedAttributes();
          if (Object.keys(childChangedAttributes).length > 0) {
            _changedAttributes[childKey] = childChangedAttributes;
          }
        }
      }
    }

    return _changedAttributes;
  }

  rollbackAttributes(notifyRecord = false) {
    let dirtyKeys;
    if (this.hasChangedAttributes()) {
      dirtyKeys = Object.keys(this._attributes);
      this._attributes = null;
    }

    this._inFlightAttributes = null;

    if (this.__childModelDatas) {
      let nestedKeys = Object.keys(this._childModelDatas);
      for (let i = 0; i < nestedKeys.length; ++i) {
        let childKey = nestedKeys[i];
        let childModelData = this._childModelDatas[childKey];
        if (Array.isArray(childModelData)) {
          for (let j = 0; j < childModelData.length; ++j) {
            childModelData[j].rollbackAttributes(true);
          }
        } else {
          childModelData.rollbackAttributes(true);
        }
      }
    }

    if (notifyRecord) {
      this._embeddedInternalModel.record._notifyProperties(dirtyKeys);
    }

    return dirtyKeys;
  }

  didCommit(jsonApiResource, notifyRecord = false) {
    let attributes;
    if (jsonApiResource) {
      attributes = jsonApiResource.attributes;
    }
    let changedKeys = this._changedKeys(attributes);

    emberAssign(this._data, this._inFlightAttributes);
    if (attributes !== undefined) {
      this._data = attributes;
    }

    this._inFlightAttributes = null;

    this._updateChangedAttributes();

    if (this.__childModelDatas) {
      let nestedKeys = Object.keys(this._childModelDatas);
      for (let i = 0; i < nestedKeys.length; ++i) {
        let childKey = nestedKeys[i];
        let childModelData = this._childModelDatas[childKey];
        let newAttrs = this._data[childKey];

        if (newAttrs === null || typeof newAttrs !== 'object' || Array.isArray(childModelData)) {
          // we don't re-use nested models within arrays so there's no need to
          // propagate willCommit/didCommit
          delete this._childModelDatas[childKey];
        } else {
          childModelData.didCommit({ attributes: this._data[childKey] }, true);
        }
      }
    }

    if (notifyRecord) {
      this._embeddedInternalModel.record._notifyProperties(changedKeys);
    }

    return changedKeys;
  }

  getHasMany() {}

  setHasMany() {}

  commitWasRejected() {
    let keys = Object.keys(this._inFlightAttributes);
    if (keys.length > 0) {
      let attrs = this._attributes;
      for (let i = 0; i < keys.length; i++) {
        if (attrs[keys[i]] === undefined) {
          attrs[keys[i]] = this._inFlightAttributes[keys[i]];
        }
      }
    }
    this._inFlightAttributes = null;

    if (this.__childModelDatas) {
      let nestedKeys = Object.keys(this._childModelDatas);
      for (let i = 0; i < nestedKeys.length; ++i) {
        let childKey = nestedKeys[i];
        let childModelData = this._childModelDatas[childKey];
        if (Array.isArray(childModelData)) {
          for (let j = 0; j < childModelData.length; ++j) {
            childModelData[j].commitWasRejected();
          }
        } else {
          childModelData.commitWasRejected();
        }
      }
    }
  }

  getBelongsTo() {}

  setBelongsTo() {}

  setAttr(key, value) {
    let originalValue;

    if (key in this._inFlightAttributes) {
      originalValue = this._inFlightAttributes[key];
    } else {
      originalValue = this._data[key];
    }
    // If we went back to our original value, we shouldn't keep the attribute around anymore
    if (value === originalValue) {
      delete this._attributes[key];
    } else {
      // Add the new value to the changed attributes hash
      this._attributes[key] = value;
    }
  }

  getAttr(key) {
    if (key in this._attributes) {
      return this._attributes[key];
    } else if (key in this._inFlightAttributes) {
      return this._inFlightAttributes[key];
    } else {
      return this._data[key];
    }
  }

  hasAttr(key) {
    return key in this._attributes || key in this._inFlightAttributes || key in this._data;
  }

  unloadRecord() {
    if (this.isDestroyed) {
      return;
    }
    this.destroy();
  }

  isRecordInUse() {
    return this.storeWrapper.isRecordInUse(this.modelName, this.id, this.clientId);
  }

  removeFromInverseRelationships() {}

  clientDidCreate() {}

  // INTERNAL API

  get _childModelDatas() {
    if (this.__childModelDatas === null) {
      this.__childModelDatas = Object.create(null);
    }
    return this.__childModelDatas;
  }

  get _attributes() {
    if (this.__attributes === null) {
      this.__attributes = Object.create(null);
    }
    return this.__attributes;
  }

  set _attributes(v) {
    this.__attributes = v;
  }

  destroy() {
    this.isDestroyed = true;
    this.storeWrapper.disconnectRecord(this.modelName, this.id, this.clientId);
  }

  get _data() {
    if (this.__data === null) {
      this.__data = Object.create(null);
    }
    return this.__data;
  }

  set _data(v) {
    this.__data = v;
  }

  get _inFlightAttributes() {
    if (this.__inFlightAttributes === null) {
      this.__inFlightAttributes = Object.create(null);
    }
    return this.__inFlightAttributes;
  }

  set _inFlightAttributes(v) {
    this.__inFlightAttributes = v;
  }

  /*
    Checks if the attributes which are considered as changed are still
    different to the state which is acknowledged by the server.

    This method is needed when data for the internal model is pushed and the
    pushed data might acknowledge dirty attributes as confirmed.

    @method updateChangedAttributes
    @private
   */
  _updateChangedAttributes() {
    let changedAttributes = this.changedAttributes();
    let changedAttributeNames = Object.keys(changedAttributes);
    let attrs = this._attributes;

    for (let i = 0, length = changedAttributeNames.length; i < length; i++) {
      let attribute = changedAttributeNames[i];
      let data = changedAttributes[attribute];
      let oldData = data[0];
      let newData = data[1];

      if (oldData === newData) {
        delete attrs[attribute];
      }
    }
  }

  _changedKeys(updates) {
    let changedKeys = [];

    if (updates) {
      let original, i, value, key;
      let keys = Object.keys(updates);
      let length = keys.length;
      let hasAttrs = this.hasChangedAttributes();
      let attrs;
      if (hasAttrs) {
        attrs = this._attributes;
      }

      original = emberAssign(Object.create(null), this._data);
      original = emberAssign(original, this._inFlightAttributes);

      for (i = 0; i < length; i++) {
        key = keys[i];
        value = updates[key];

        // A value in _attributes means the user has a local change to
        // this attributes. We never override this value when merging
        // updates from the backend so we should not sent a change
        // notification if the server value differs from the original.
        if (hasAttrs === true && attrs[key] !== undefined) {
          continue;
        }

        if (!isEqual(original[key], value)) {
          // checking via `isEqual` means we'll treat all array and object properties as changed
          changedKeys.push(key);
        }
      }

      let omittedKeys = setDiff(Object.keys(original).sort(), Object.keys(updates).sort());
      changedKeys = changedKeys.concat(omittedKeys);
    }

    return changedKeys;
  }

  toString() {
    return `<${this.modelName}:${this.id}>`;
  }
}
<commit_msg>Move internal API to internal API section<commit_after>import { isEqual } from '@ember/utils';
import { assign, merge } from '@ember/polyfills';
import { copy } from '@ember/object/internals';
import { setDiff } from 'ember-m3/util';

const emberAssign = assign || merge;

class M3SchemaInterface {
  constructor(modelData) {
    this.modelData = modelData;
  }

  getAttr(name) {
    return this.modelData.getAttr(name);
  }
}

export default class M3ModelData {
  constructor(
    modelName,
    id,
    clientId,
    storeWrapper,
    parentModelData,
    parentKey,
    parentValueIsArray,
    embeddedInternalModel
  ) {
    this.modelName = modelName;
    this.clientId = clientId;
    this.id = id;
    this.storeWrapper = storeWrapper;
    this.isDestroyed = false;
    this._data = null;
    this._attributes = null;
    this.__inFlightAttributes = null;

    this._parentModelData = parentModelData;
    this.__childModelDatas = null;
    if (parentKey !== undefined && parentKey !== null) {
      let parentChildren = this._parentModelData._childModelDatas;
      if (parentValueIsArray) {
        parentChildren[parentKey] = parentChildren[parentKey] || [];
        parentChildren[parentKey].push(this);
      } else {
        parentChildren[parentKey] = this;
      }
    }
    this._embeddedInternalModel = embeddedInternalModel;

    this.schemaInterface = new M3SchemaInterface(this);
  }

  // PUBLIC API

  getResourceIdentifier() {
    return {
      id: this.id,
      type: this.modelName,
      clientId: this.clientId,
    };
  }

  pushData(jsonApiResource, calculateChange, notifyRecord = false) {
    let changedKeys;

    if (calculateChange) {
      changedKeys = this._changedKeys(jsonApiResource.attributes);
    }

    if (jsonApiResource.attributes !== undefined) {
      this._data = jsonApiResource.attributes;
    }
    if (this.__attributes !== null) {
      // only do if we have attribute changes
      this._updateChangedAttributes();
    }

    if (jsonApiResource.id) {
      this.id = jsonApiResource.id + '';
    }

    if (this.__childModelDatas) {
      let nestedKeys = Object.keys(this._childModelDatas);
      for (let i = 0; i < nestedKeys.length; ++i) {
        let childKey = nestedKeys[i];
        let childModelData = this._childModelDatas[childKey];
        let newAttrs = this._data[childKey];

        if (newAttrs === null || typeof newAttrs !== 'object' || Array.isArray(childModelData)) {
          // changing from nested model -> primitive we don't update inline,
          // just discard the child model data similarly we don't push changes
          // down to arrays of nested models because we don't associate an
          // individual model data with its position in an array
          //
          // this also means that nested m3 models within arrays are not
          // re-used between pushes of data
          delete this._childModelDatas[childKey];
        } else {
          childModelData.pushData({ attributes: this._data[childKey] }, true, true);
        }
      }
    }

    if (notifyRecord) {
      this._embeddedInternalModel.record._notifyProperties(changedKeys);
    }

    return changedKeys;
  }

  willCommit() {
    this._inFlightAttributes = this._attributes;
    this._attributes = null;

    if (this.__childModelDatas) {
      let nestedKeys = Object.keys(this._childModelDatas);
      for (let i = 0; i < nestedKeys.length; ++i) {
        let childKey = nestedKeys[i];
        let childModelData = this._childModelDatas[childKey];
        if (!Array.isArray(childModelData)) {
          // we don't re-use nested models within arrays so there's no need to
          // propagate willCommit/didCommit
          childModelData.willCommit();
        }
      }
    }
  }

  hasChangedAttributes() {
    return this.__attributes !== null && Object.keys(this.__attributes).length > 0;
  }

  addToHasMany() {}

  removeFromHasMany() {}

  didCommit(jsonApiResource, notifyRecord = false) {
    let attributes;
    if (jsonApiResource) {
      attributes = jsonApiResource.attributes;
    }
    let changedKeys = this._changedKeys(attributes);

    emberAssign(this._data, this._inFlightAttributes);
    if (attributes !== undefined) {
      this._data = attributes;
    }

    this._inFlightAttributes = null;

    this._updateChangedAttributes();

    if (this.__childModelDatas) {
      let nestedKeys = Object.keys(this._childModelDatas);
      for (let i = 0; i < nestedKeys.length; ++i) {
        let childKey = nestedKeys[i];
        let childModelData = this._childModelDatas[childKey];
        let newAttrs = this._data[childKey];

        if (newAttrs === null || typeof newAttrs !== 'object' || Array.isArray(childModelData)) {
          // we don't re-use nested models within arrays so there's no need to
          // propagate willCommit/didCommit
          delete this._childModelDatas[childKey];
        } else {
          childModelData.didCommit({ attributes: this._data[childKey] }, true);
        }
      }
    }

    if (notifyRecord) {
      this._embeddedInternalModel.record._notifyProperties(changedKeys);
    }

    return changedKeys;
  }

  getHasMany() {}

  setHasMany() {}

  commitWasRejected() {
    let keys = Object.keys(this._inFlightAttributes);
    if (keys.length > 0) {
      let attrs = this._attributes;
      for (let i = 0; i < keys.length; i++) {
        if (attrs[keys[i]] === undefined) {
          attrs[keys[i]] = this._inFlightAttributes[keys[i]];
        }
      }
    }
    this._inFlightAttributes = null;

    if (this.__childModelDatas) {
      let nestedKeys = Object.keys(this._childModelDatas);
      for (let i = 0; i < nestedKeys.length; ++i) {
        let childKey = nestedKeys[i];
        let childModelData = this._childModelDatas[childKey];
        if (Array.isArray(childModelData)) {
          for (let j = 0; j < childModelData.length; ++j) {
            childModelData[j].commitWasRejected();
          }
        } else {
          childModelData.commitWasRejected();
        }
      }
    }
  }

  getBelongsTo() {}

  setBelongsTo() {}

  setAttr(key, value) {
    let originalValue;

    if (key in this._inFlightAttributes) {
      originalValue = this._inFlightAttributes[key];
    } else {
      originalValue = this._data[key];
    }
    // If we went back to our original value, we shouldn't keep the attribute around anymore
    if (value === originalValue) {
      delete this._attributes[key];
    } else {
      // Add the new value to the changed attributes hash
      this._attributes[key] = value;
    }
  }

  getAttr(key) {
    if (key in this._attributes) {
      return this._attributes[key];
    } else if (key in this._inFlightAttributes) {
      return this._inFlightAttributes[key];
    } else {
      return this._data[key];
    }
  }

  hasAttr(key) {
    return key in this._attributes || key in this._inFlightAttributes || key in this._data;
  }

  unloadRecord() {
    if (this.isDestroyed) {
      return;
    }
    this.destroy();
  }

  isRecordInUse() {
    return this.storeWrapper.isRecordInUse(this.modelName, this.id, this.clientId);
  }

  removeFromInverseRelationships() {}

  clientDidCreate() {}

  // INTERNAL API

  /*
    Returns an object, whose keys are changed properties, and value is an
    [oldProp, newProp] array.

    @method changedAttributes
    @private
  */
  changedAttributes() {
    let serverState = this._data;
    let localChanges = this._attributes;
    let inFlightData = this._inFlightAttributes;
    let newData = emberAssign(copy(inFlightData), localChanges);
    let _changedAttributes = Object.create(null);
    let newDataKeys = Object.keys(newData);

    for (let i = 0, length = newDataKeys.length; i < length; i++) {
      let key = newDataKeys[i];
      _changedAttributes[key] = [serverState[key], newData[key]];
    }

    if (this.__childModelDatas) {
      let nestedKeys = Object.keys(this._childModelDatas);
      for (let i = 0; i < nestedKeys.length; ++i) {
        let childKey = nestedKeys[i];
        let childModelData = this._childModelDatas[childKey];
        if (Array.isArray(childModelData)) {
          let changes = null;
          for (let j = 0; j < childModelData.length; ++j) {
            let individualChildModelData = childModelData[j];
            let childChangedAttributes = individualChildModelData.changedAttributes();
            if (Object.keys(childChangedAttributes).length > 0) {
              if (changes == null) {
                changes = new Array(childModelData.length);
              }
              changes[j] = childChangedAttributes;
            }
          }
          if (changes !== null) {
            _changedAttributes[childKey] = changes;
          }
        } else {
          let childChangedAttributes = childModelData.changedAttributes();
          if (Object.keys(childChangedAttributes).length > 0) {
            _changedAttributes[childKey] = childChangedAttributes;
          }
        }
      }
    }

    return _changedAttributes;
  }

  rollbackAttributes(notifyRecord = false) {
    let dirtyKeys;
    if (this.hasChangedAttributes()) {
      dirtyKeys = Object.keys(this._attributes);
      this._attributes = null;
    }

    this._inFlightAttributes = null;

    if (this.__childModelDatas) {
      let nestedKeys = Object.keys(this._childModelDatas);
      for (let i = 0; i < nestedKeys.length; ++i) {
        let childKey = nestedKeys[i];
        let childModelData = this._childModelDatas[childKey];
        if (Array.isArray(childModelData)) {
          for (let j = 0; j < childModelData.length; ++j) {
            childModelData[j].rollbackAttributes(true);
          }
        } else {
          childModelData.rollbackAttributes(true);
        }
      }
    }

    if (notifyRecord) {
      this._embeddedInternalModel.record._notifyProperties(dirtyKeys);
    }

    return dirtyKeys;
  }

  get _childModelDatas() {
    if (this.__childModelDatas === null) {
      this.__childModelDatas = Object.create(null);
    }
    return this.__childModelDatas;
  }

  get _attributes() {
    if (this.__attributes === null) {
      this.__attributes = Object.create(null);
    }
    return this.__attributes;
  }

  set _attributes(v) {
    this.__attributes = v;
  }

  destroy() {
    this.isDestroyed = true;
    this.storeWrapper.disconnectRecord(this.modelName, this.id, this.clientId);
  }

  get _data() {
    if (this.__data === null) {
      this.__data = Object.create(null);
    }
    return this.__data;
  }

  set _data(v) {
    this.__data = v;
  }

  get _inFlightAttributes() {
    if (this.__inFlightAttributes === null) {
      this.__inFlightAttributes = Object.create(null);
    }
    return this.__inFlightAttributes;
  }

  set _inFlightAttributes(v) {
    this.__inFlightAttributes = v;
  }

  /*
    Checks if the attributes which are considered as changed are still
    different to the state which is acknowledged by the server.

    This method is needed when data for the internal model is pushed and the
    pushed data might acknowledge dirty attributes as confirmed.

    @method updateChangedAttributes
    @private
   */
  _updateChangedAttributes() {
    let changedAttributes = this.changedAttributes();
    let changedAttributeNames = Object.keys(changedAttributes);
    let attrs = this._attributes;

    for (let i = 0, length = changedAttributeNames.length; i < length; i++) {
      let attribute = changedAttributeNames[i];
      let data = changedAttributes[attribute];
      let oldData = data[0];
      let newData = data[1];

      if (oldData === newData) {
        delete attrs[attribute];
      }
    }
  }

  _changedKeys(updates) {
    let changedKeys = [];

    if (updates) {
      let original, i, value, key;
      let keys = Object.keys(updates);
      let length = keys.length;
      let hasAttrs = this.hasChangedAttributes();
      let attrs;
      if (hasAttrs) {
        attrs = this._attributes;
      }

      original = emberAssign(Object.create(null), this._data);
      original = emberAssign(original, this._inFlightAttributes);

      for (i = 0; i < length; i++) {
        key = keys[i];
        value = updates[key];

        // A value in _attributes means the user has a local change to
        // this attributes. We never override this value when merging
        // updates from the backend so we should not sent a change
        // notification if the server value differs from the original.
        if (hasAttrs === true && attrs[key] !== undefined) {
          continue;
        }

        if (!isEqual(original[key], value)) {
          // checking via `isEqual` means we'll treat all array and object properties as changed
          changedKeys.push(key);
        }
      }

      let omittedKeys = setDiff(Object.keys(original).sort(), Object.keys(updates).sort());
      changedKeys = changedKeys.concat(omittedKeys);
    }

    return changedKeys;
  }

  toString() {
    return `<${this.modelName}:${this.id}>`;
  }
}
<|endoftext|>


<commit_before>var app = angular.module('CommentsApp', []);

app.controller('StarRatingController', ['$scope', '$rootScope', '$http', '$location', function ($scope, $rootScope, $http, $location) {
    $scope.starRating = function (vote) {
        console.log(vote)
    }
}]);

app.controller('CommentFormController', ['$scope', '$rootScope', '$http', '$location', function ($scope, $rootScope, $http, $location) {
    $scope.id = parseInt($location.search().id);
    $scope.context = $location.path().toLowerCase().contains('course') ? 'Course' : $location.path().toLowerCase().contains('teacher') ? 'Teacher' : '';
    $scope.commentBody = '';
    $scope.loading = false;
    $scope.postComment = function () {
        $scope.loading = true;
        $http.post('comment/add' + $scope.context + 'Comment', {id: $scope.id, body: $scope.commentBody})
            .then(function (response) {
                $scope.commentBody = "";
                $scope.loading = false;
                $rootScope.$broadcast('newComment', response.data);
            });
    };
}]);

app.controller('CommentsController', ['$scope', '$rootScope', '$http', '$location', function ($scope, $rootScope, $http, $location) {
    $scope.id = parseInt($location.search().id);
    $scope.context = $location.path().toLowerCase().contains('course') ? 'Course' : $location.path().toLowerCase().contains('teacher') ? 'Teacher' : '';
    $scope.max = 5;
    $scope.offset = 0;
    $scope.comments = [];
    $scope.loading = false;

    $scope.voteUp = function (id, index) {
        switch ($scope.comments[index].voted) {
            case -1:
                $scope.comments[index].negativeVotes--;
            case 0:
                $scope.comments[index].positiveVotes++;
            case 1:
            default:
                break;
        }
        $scope.comments[index].voted = 1;

        $http.post("comment/voteUp", {id: id})
            .then(function (response) {
                $scope.comments[index] = response.data;
                $scope.reload();
            });
    };

    $scope.voteDown = function (id, index) {
        switch ($scope.comments[index].voted) {
            case 1:
                $scope.comments[index].positiveVotes--;
            case 0:
                $scope.comments[index].negativeVotes++;
            case -1:
            default:
                break;
        }
        $scope.comments[index].voted = -1;

        $http.post("comment/voteDown", {id: id})
            .then(function (response) {
                $scope.comments[index] = response.data;
                $scope.reload();
            });
    };

    $scope.loadMore = function () {
        $scope.loading = true;
        $http.get($scope.context + '/comments', {
                params: {id: $scope.id, max: $scope.max, offset: $scope.offset}
            })
            .then(function (response) {
                $scope.comments = $scope.comments.concat(response.data);
                $scope.offset = $scope.comments.length;
                $scope.loading = false;
            });
    };

    $scope.reload = function () {
        $scope.loading = true;
        $http.get($scope.context + '/comments', {
                params: {id: $scope.id, max: $scope.offset, offset: 0}
            })
            .then(function (response) {
                $scope.comments = response.data;
                $scope.offset = $scope.comments.length;
                $scope.loading = false;
            });
    };

    $scope.$on('newComment', function (event, postedComment) {
        $scope.comments = [postedComment].concat($scope.comments);
        $scope.offset++;
        $scope.reload();
    });

    $scope.loadMore();
}]);

app.config(['$locationProvider', function ($locationProvider) {
    $locationProvider.html5Mode(true);
}]);<commit_msg>Fixing non html5 links.<commit_after>var app = angular.module('CommentsApp', []);

app.controller('StarRatingController', ['$scope', '$rootScope', '$http', '$location', function ($scope, $rootScope, $http, $location) {
    $scope.starRating = function (vote) {
        console.log(vote)
    }
}]);

app.controller('CommentFormController', ['$scope', '$rootScope', '$http', '$location', function ($scope, $rootScope, $http, $location) {
    $scope.id = parseInt($location.search().id);
    $scope.context = $location.path().toLowerCase().contains('course') ? 'Course' : $location.path().toLowerCase().contains('teacher') ? 'Teacher' : '';
    $scope.commentBody = '';
    $scope.loading = false;
    $scope.postComment = function () {
        $scope.loading = true;
        $http.post('comment/add' + $scope.context + 'Comment', {id: $scope.id, body: $scope.commentBody})
            .then(function (response) {
                $scope.commentBody = "";
                $scope.loading = false;
                $rootScope.$broadcast('newComment', response.data);
            });
    };
}]);

app.controller('CommentsController', ['$scope', '$rootScope', '$http', '$location', function ($scope, $rootScope, $http, $location) {
    $scope.id = parseInt($location.search().id);
    $scope.context = $location.path().toLowerCase().contains('course') ? 'Course' : $location.path().toLowerCase().contains('teacher') ? 'Teacher' : '';
    $scope.max = 5;
    $scope.offset = 0;
    $scope.comments = [];
    $scope.loading = false;

    $scope.voteUp = function (id, index) {
        switch ($scope.comments[index].voted) {
            case -1:
                $scope.comments[index].negativeVotes--;
            case 0:
                $scope.comments[index].positiveVotes++;
            case 1:
            default:
                break;
        }
        $scope.comments[index].voted = 1;

        $http.post("comment/voteUp", {id: id})
            .then(function (response) {
                $scope.comments[index] = response.data;
                $scope.reload();
            });
    };

    $scope.voteDown = function (id, index) {
        switch ($scope.comments[index].voted) {
            case 1:
                $scope.comments[index].positiveVotes--;
            case 0:
                $scope.comments[index].negativeVotes++;
            case -1:
            default:
                break;
        }
        $scope.comments[index].voted = -1;

        $http.post("comment/voteDown", {id: id})
            .then(function (response) {
                $scope.comments[index] = response.data;
                $scope.reload();
            });
    };

    $scope.loadMore = function () {
        $scope.loading = true;
        $http.get($scope.context + '/comments', {
                params: {id: $scope.id, max: $scope.max, offset: $scope.offset}
            })
            .then(function (response) {
                $scope.comments = $scope.comments.concat(response.data);
                $scope.offset = $scope.comments.length;
                $scope.loading = false;
            });
    };

    $scope.reload = function () {
        $scope.loading = true;
        $http.get($scope.context + '/comments', {
                params: {id: $scope.id, max: $scope.offset, offset: 0}
            })
            .then(function (response) {
                $scope.comments = response.data;
                $scope.offset = $scope.comments.length;
                $scope.loading = false;
            });
    };

    $scope.$on('newComment', function (event, postedComment) {
        $scope.comments = [postedComment].concat($scope.comments);
        $scope.offset++;
        $scope.reload();
    });

    $scope.loadMore();
}]);

app.config(['$locationProvider', function ($locationProvider) {
    $locationProvider.html5Mode({enabled: true, rewriteLinks: false});
}]);<|endoftext|>