<commit_before>"use strict";

eas.sync.Contacts = {

    createItem : function (card = null) {
        let item = {
            get id() {return this.card.getProperty("EASID", "")},
            set id(newId) {this.card.setProperty("EASID", newId)},
            get icalString() {return "CardData"},
            clone: function () { return this; } //no real clone
        };

        //actually add the card
        item.card = card ? card : Components.classes["@mozilla.org/addressbook/cardproperty;1"].createInstance(Components.interfaces.nsIAbCard);;

        return item;
    },

    promisifyAddressbook: function (addressbook) {
    /*
        Return obj with identical interface to promisifyCalendar. But we currently do not need a promise.
            adoptItem(card)
            modifyItem(newcard, existingcard)
            deleteItem(card)
            getItem(id)

        Avail API:
            addressBook.modifyCard(card);
            addressBook.getCardFromProperty("localId", ClientId, false);
            addressBook.deleteCards(cardsToDelete);
            card.setProperty('ServerId', ServerId);
    */
        let apiWrapper = {
            adoptItem: function (item) {
                /* add card to addressbook */
                addressbook.addCard(item.card);
            },

            modifyItem: function (newitem, existingitem) {
                /* modify card */
                addressbook.modifyCard(newitem.card);
            },

            deleteItem: function (item) {
                /* remove card from addressBook */
                let cardsToDelete = Components.classes["@mozilla.org/array;1"].createInstance(Components.interfaces.nsIMutableArray);
                cardsToDelete.appendElement(item.card, "");
                addressbook.deleteCards(cardsToDelete);
            },

            getItem: function (searchId) {
                /* return array of items matching */
                let items = [];
                let card = addressbook.getCardFromProperty("EASID", searchId, true); //3rd param enables case sensitivity

                if (card) {
                    items.push(eas.sync.Contacts.createItem(card));
                }

                return items;
            }
        };

        return apiWrapper;
    },

/*

These need special treatment
        0x15: 'Categories',
        0x16: 'Category',
        0x17: 'Children',
        0x18: 'Child',
        0x1D: 'Email3Address',

The following are the core properties that are used by TB:
 *   - , FamilyName

 * - Other Contact:
 *   - FaxNumber,
 *   - ,
 * - _AimScreenName
 * - WebPage2 (home)
 * - Custom1, Custom2, Custom3, Custom4

*/

    //includes all properties, which can be mapped 1-to-1
    map_TB_properties_to_EAS_properties : {
        DisplayName: 'FileAs',
        FirstName: 'FirstName',
        LastName: 'LastName',
        //NickName: 'NickName',
        PrimaryEmail: 'Email1Address',
        SecondEmail: 'Email2Address',
        WebPage1: 'WebPage',
        SpouseName: 'Spouse',
        CellularNumber: 'MobilePhoneNumber',
	    PagerNumber: 'PagerNumber',

/*
        0x06: 'AssistantName',
        0x07: 'AssistantPhoneNumber',

        0x08: 'Business2PhoneNumber',
        0x12: 'BusinessFaxNumber',
        0x20: 'Home2PhoneNumber',
        0x26: 'HomeFaxNumber',

        0x35: 'Suffix',
        0x36: 'Title',
        0x14: 'CarPhoneNumber',
        0x2A: 'MiddleName',
        0x2C: 'OfficeLocation',
        0x33: 'RadioPhoneNumber',
        0x3D: 'Alias',
        0x3E: 'WeightedRank',

        0x2D: 'OtherAddressCity',
        0x2E: 'OtherAddressCountry',
        0x2F: 'OtherAddressPostalCode',
        0x30: 'OtherAddressState',
        0x31: 'OtherAddressStreet',

        0x38: 'YomiCompanyName',
        0x39: 'YomiFirstName',
        0x3A: 'YomiLastName',
        0x3B: 'CompressedRTF',

        0x05: 'CustomerId',
        0x06: 'GovernmentId',
        0x07: 'IMAddress',
        0x08: 'IMAddress2',
        0x09: 'IMAddress3',
        0x0a: 'ManagerName',
        0x0b: 'CompanyMainPhone',
        0x0c: 'AccountName',
        0x0e: 'MMS',
        */
        HomeCity: 'HomeAddressCity',
        HomeCountry: 'HomeAddressCountry',
        HomeZipCode: 'HomeAddressPostalCode',
        HomeState: 'HomeAddressState',
        HomePhone: 'HomePhoneNumber',

        Company: 'CompanyName',
        Department: 'Department',
        JobTitle: 'JobTitle',

        WorkCity: 'BusinessAddressCity',
        WorkCountry: 'BusinessAddressCountry',
        WorkZipCode: 'BusinessAddressPostalCode',
        WorkState: 'BusinessAddressState',
        WorkPhone: 'BusinessPhoneNumber',
    },

    // --------------------------------------------------------------------------- //
    // Read WBXML and set Thunderbird item
    // --------------------------------------------------------------------------- //
    setThunderbirdItemFromWbxml: function (item, data, id, syncdata) {
        let asversion = tbSync.db.getAccountSetting(syncdata.account, "asversion");

        item.card.setProperty("EASID", id);

        //loop over all known TB properties which map 1-to-1
        for (let p=0; p < this.TB_properties.length; p++) {
            let TB_property = this.TB_properties[p];
            let EAS_property = this.map_TB_properties_to_EAS_properties[TB_property];
            let value = xmltools.checkString(data[EAS_property]);

            //is this property part of the send data?
            if (value) {
                //do we need to manipulate the value?
                switch (EAS_property) {
                    case "Email1Address":
                    case "Email2Address":
                    case "Email3Address":
                        let parsedInput = MailServices.headerParser.makeFromDisplayAddress(value);
                        let fixedValue =  (parsedInput && parsedInput[0] && parsedInput[0].email) ? parsedInput[0].email : value;
                        if (fixedValue != value) {
                            tbSync.dump("Parsing email display string via RFC 2231 and RFC 2047 ("+EAS_property+")", value + " -> " + fixedValue);
                            value = fixedValue;
                        }
                        break;
                }

                item.card.setProperty(TB_property, value);
            } else {
                //clear
                item.card.deleteProperty(TB_property);
            }
        }

        //take care of birthday and anniversary
        let dates = [];
        dates.push(["Birthday", "BirthDay", "BirthMonth", "BirthYear"]); //EAS, TB1, TB2, TB3
        dates.push(["Anniversary", "AnniversaryDay", "AnniversaryMonth", "AnniversaryYear"]);
        for (let p=0; p < dates.length; p++) {
            let value = xmltools.checkString(data[dates[p][0]]);
            if (value == "") {
                //clear
                item.card.deleteProperty(dates[p][1]);
                item.card.deleteProperty(dates[p][2]);
                item.card.deleteProperty(dates[p][3]);
            } else {
                //set
                let dateObj = new Date(value);
                item.card.setProperty(dates[p][3], dateObj.getFullYear().toString());
                item.card.setProperty(dates[p][2], (dateObj.getMonth()+1).toString());
                item.card.setProperty(dates[p][1], dateObj.getDate().toString());
            }
        }

        //take care of multiline address fields
        let streets = [];
        let seperator = String.fromCharCode(tbSync.db.getAccountSetting(syncdata.account,"seperator")); // options are 44 (,) or 10 (\n)
        streets.push(["HomeAddressStreet", "HomeAddress", "HomeAddress2"]); //EAS, TB1, TB2
        streets.push(["BusinessAddressStreet", "WorkAddress", "WorkAddress2"]);
        for (let p=0; p < streets.length; p++) {
            let value = xmltools.checkString(data[streets[p][0]]);
            if (value == "") {
                //clear
                item.card.deleteProperty(streets[p][1]);
                item.card.deleteProperty(streets[p][2]);
            } else {
                //set
                let lines = value.split(seperator);
                item.card.setProperty(streets[p][1], lines.shift());
                item.card.setProperty(streets[p][2], lines.join(seperator));
            }
        }

        //take care of photo
        if (data.Picture) {
            tbSync.addphoto(id + '.jpg', item.card, data.Picture);
        }

        //take care of notes
        if (asversion == "2.5") {
            item.card.setProperty("Notes", xmltools.checkString(data.Body));
        } else {
            if (data.Body && data.Body.Data) item.card.setProperty("Notes", xmltools.checkString(data.Body.Data));
            else item.card.setProperty("Notes", "");
        }

        //further manipulations
        if (tbSync.db.getAccountSetting(syncdata.account, "displayoverride") == "1") {
           item.card.setProperty("DisplayName", item.card.getProperty("FirstName", "") + " " + item.card.getProperty("LastName", ""));

            if (item.card.getProperty("DisplayName", "" ) == " " )
                item.card.setProperty("DisplayName", item.card.getProperty("Company", item.card.getProperty("PrimaryEmail", "")));
        }

    },

    // --------------------------------------------------------------------------- //
    //read TB event and return its data as WBXML
    // --------------------------------------------------------------------------- //
    getWbxmlFromThunderbirdItem: function (item, syncdata, isException = false) {
        let asversion = tbSync.db.getAccountSetting(syncdata.account, "asversion");
        let wbxml = tbSync.wbxmltools.createWBXML("", syncdata.type); //init wbxml with "" and not with precodes, and set initial codepage
        let nowDate = new Date();

        //loop over all known TB properties which map 1-to-1 (send empty value if not set)
        for (let p=0; p < this.TB_properties.length; p++) {
            let TB_property = this.TB_properties[p];
            let EAS_property = this.map_TB_properties_to_EAS_properties[TB_property];
            wbxml.atag(EAS_property, item.card.getProperty(TB_property,""));
        }

        //take care of birthday and anniversary
        let dates = [];
        dates.push(["Birthday", "BirthDay", "BirthMonth", "BirthYear"]);
        dates.push(["Anniversary", "AnniversaryDay", "AnniversaryMonth", "AnniversaryYear"]);
        for (let p=0; p < dates.length; p++) {
            let year = item.card.getProperty(dates[p][3], "");
            let month = item.card.getProperty(dates[p][2], "");
            let day = item.card.getProperty(dates[p][1], "");
            if (year && month && day) {
                //set
                if (month.length<2) month="0"+month;
                if (day.length<2) day="0"+day;
                wbxml.atag(dates[p][0], year + "-" + month + "-" + day + "T00:00:00.000Z");
            } else {
                //clear ??? outlook does not like empty value
                //wbxml.atag(dates[p][0], "");
            }
        }

        //take care of multiline address fields
        let streets = [];
        let seperator = String.fromCharCode(tbSync.db.getAccountSetting(syncdata.account,"seperator")); // options are 44 (,) or 10 (\n)
        streets.push(["HomeAddressStreet", "HomeAddress", "HomeAddress2"]); //EAS, TB1, TB2
        streets.push(["BusinessAddressStreet", "WorkAddress", "WorkAddress2"]);
        for (let p=0; p < streets.length; p++) {
            let s1 = item.card.getProperty(streets[p][1], "");
            let s2 = item.card.getProperty(streets[p][2], "");
            wbxml.atag(streets[p][0], s1 + seperator + s2);
        }

        //take care of photo
        if (item.card.getProperty("PhotoType", "") == "file") {
            wbxml.atag("Picture", tbSync.getphoto(item.card));
        } else {
            //clear
            wbxml.atag("Picture","");
        }

        //take care of notes
        let description = item.card.getProperty("Notes", "");
        if (asversion == "2.5") {
            wbxml.atag("Body", description);
        } else {
            wbxml.switchpage("AirSyncBase");
            wbxml.otag("Body");
                wbxml.atag("Type", "1");
                wbxml.atag("EstimatedDataSize", "" + description.length);
                wbxml.atag("Data", description);
            wbxml.ctag();
        }

        return wbxml.getBytes();
    }

}

eas.sync.Contacts.TB_properties = Object.keys(eas.sync.Contacts.map_TB_properties_to_EAS_properties);
<commit_msg>add support for contacts2 group, nickname<commit_after>"use strict";

eas.sync.Contacts = {

    createItem : function (card = null) {
        let item = {
            get id() {return this.card.getProperty("EASID", "")},
            set id(newId) {this.card.setProperty("EASID", newId)},
            get icalString() {return "CardData"},
            clone: function () { return this; } //no real clone
        };

        //actually add the card
        item.card = card ? card : Components.classes["@mozilla.org/addressbook/cardproperty;1"].createInstance(Components.interfaces.nsIAbCard);;

        return item;
    },

    promisifyAddressbook: function (addressbook) {
    /*
        Return obj with identical interface to promisifyCalendar. But we currently do not need a promise.
            adoptItem(card)
            modifyItem(newcard, existingcard)
            deleteItem(card)
            getItem(id)

        Avail API:
            addressBook.modifyCard(card);
            addressBook.getCardFromProperty("localId", ClientId, false);
            addressBook.deleteCards(cardsToDelete);
            card.setProperty('ServerId', ServerId);
    */
        let apiWrapper = {
            adoptItem: function (item) {
                /* add card to addressbook */
                addressbook.addCard(item.card);
            },

            modifyItem: function (newitem, existingitem) {
                /* modify card */
                addressbook.modifyCard(newitem.card);
            },

            deleteItem: function (item) {
                /* remove card from addressBook */
                let cardsToDelete = Components.classes["@mozilla.org/array;1"].createInstance(Components.interfaces.nsIMutableArray);
                cardsToDelete.appendElement(item.card, "");
                addressbook.deleteCards(cardsToDelete);
            },

            getItem: function (searchId) {
                /* return array of items matching */
                let items = [];
                let card = addressbook.getCardFromProperty("EASID", searchId, true); //3rd param enables case sensitivity

                if (card) {
                    items.push(eas.sync.Contacts.createItem(card));
                }

                return items;
            }
        };

        return apiWrapper;
    },

/*

These need special treatment
        0x15: 'Categories',
        0x16: 'Category',
        0x17: 'Children',
        0x18: 'Child',
        0x1D: 'Email3Address',

The following are the core properties that are used by TB:
 *   - , FamilyName

 * - Other Contact:
 *   - FaxNumber,
 *   - ,
 * - _AimScreenName
 * - WebPage2 (home)
 * - Custom1, Custom2, Custom3, Custom4

*/

    //includes all properties, which can be mapped 1-to-1
    map_TB_properties_to_EAS_properties : {
        DisplayName: 'FileAs',
        FirstName: 'FirstName',
        LastName: 'LastName',
        PrimaryEmail: 'Email1Address',
        SecondEmail: 'Email2Address',
        WebPage1: 'WebPage',
        SpouseName: 'Spouse',
        CellularNumber: 'MobilePhoneNumber',
	    PagerNumber: 'PagerNumber',

/*
        0x06: 'AssistantName',
        0x07: 'AssistantPhoneNumber',

        0x08: 'Business2PhoneNumber',
        0x12: 'BusinessFaxNumber',
        0x20: 'Home2PhoneNumber',
        0x26: 'HomeFaxNumber',

        0x35: 'Suffix',
        0x36: 'Title',
        0x14: 'CarPhoneNumber',
        0x2A: 'MiddleName',
        0x2C: 'OfficeLocation',
        0x33: 'RadioPhoneNumber',
        0x3D: 'Alias',
        0x3E: 'WeightedRank',

        0x2D: 'OtherAddressCity',
        0x2E: 'OtherAddressCountry',
        0x2F: 'OtherAddressPostalCode',
        0x30: 'OtherAddressState',
        0x31: 'OtherAddressStreet',

        0x38: 'YomiCompanyName',
        0x39: 'YomiFirstName',
        0x3A: 'YomiLastName',
        0x3B: 'CompressedRTF',

        */
        HomeCity: 'HomeAddressCity',
        HomeCountry: 'HomeAddressCountry',
        HomeZipCode: 'HomeAddressPostalCode',
        HomeState: 'HomeAddressState',
        HomePhone: 'HomePhoneNumber',

        Company: 'CompanyName',
        Department: 'Department',
        JobTitle: 'JobTitle',

        WorkCity: 'BusinessAddressCity',
        WorkCountry: 'BusinessAddressCountry',
        WorkZipCode: 'BusinessAddressPostalCode',
        WorkState: 'BusinessAddressState',
        WorkPhone: 'BusinessPhoneNumber',
    },

    map_TB_properties_to_EAS_properties2 : {
/*        0x05: 'CustomerId',
        0x06: 'GovernmentId',
        0x07: 'IMAddress',
        0x08: 'IMAddress2',
        0x09: 'IMAddress3',
        0x0a: 'ManagerName',
        0x0b: 'CompanyMainPhone',
        0x0c: 'AccountName',
        0x0e: 'MMS',
        */
        NickName: 'NickName'
    },

    // --------------------------------------------------------------------------- //
    // Read WBXML and set Thunderbird item
    // --------------------------------------------------------------------------- //
    setThunderbirdItemFromWbxml: function (item, data, id, syncdata) {
        let asversion = tbSync.db.getAccountSetting(syncdata.account, "asversion");

        item.card.setProperty("EASID", id);

        //loop over all known TB properties which map 1-to-1 (two EAS sets Contacts and Contacts2)
        for (let set=0; set < 2; set++) {
            let properties = (set == 0) ? this.TB_properties : this.TB_properties2;

            for (let p=0; p < properties.length; p++) {
                let TB_property = properties[p];
                let EAS_property = (set == 0) ? this.map_TB_properties_to_EAS_properties[TB_property] : this.map_TB_properties_to_EAS_properties2[TB_property];
                let value = xmltools.checkString(data[EAS_property]);

                //is this property part of the send data?
                if (value) {
                    //do we need to manipulate the value?
                    switch (EAS_property) {
                        case "Email1Address":
                        case "Email2Address":
                        case "Email3Address":
                            let parsedInput = MailServices.headerParser.makeFromDisplayAddress(value);
                            let fixedValue =  (parsedInput && parsedInput[0] && parsedInput[0].email) ? parsedInput[0].email : value;
                            if (fixedValue != value) {
                                tbSync.dump("Parsing email display string via RFC 2231 and RFC 2047 ("+EAS_property+")", value + " -> " + fixedValue);
                                value = fixedValue;
                            }
                            break;
                    }

                    item.card.setProperty(TB_property, value);
                } else {
                    //clear
                    item.card.deleteProperty(TB_property);
                }
            }
        }

        //take care of birthday and anniversary
        let dates = [];
        dates.push(["Birthday", "BirthDay", "BirthMonth", "BirthYear"]); //EAS, TB1, TB2, TB3
        dates.push(["Anniversary", "AnniversaryDay", "AnniversaryMonth", "AnniversaryYear"]);
        for (let p=0; p < dates.length; p++) {
            let value = xmltools.checkString(data[dates[p][0]]);
            if (value == "") {
                //clear
                item.card.deleteProperty(dates[p][1]);
                item.card.deleteProperty(dates[p][2]);
                item.card.deleteProperty(dates[p][3]);
            } else {
                //set
                let dateObj = new Date(value);
                item.card.setProperty(dates[p][3], dateObj.getFullYear().toString());
                item.card.setProperty(dates[p][2], (dateObj.getMonth()+1).toString());
                item.card.setProperty(dates[p][1], dateObj.getDate().toString());
            }
        }

        //take care of multiline address fields
        let streets = [];
        let seperator = String.fromCharCode(tbSync.db.getAccountSetting(syncdata.account,"seperator")); // options are 44 (,) or 10 (\n)
        streets.push(["HomeAddressStreet", "HomeAddress", "HomeAddress2"]); //EAS, TB1, TB2
        streets.push(["BusinessAddressStreet", "WorkAddress", "WorkAddress2"]);
        for (let p=0; p < streets.length; p++) {
            let value = xmltools.checkString(data[streets[p][0]]);
            if (value == "") {
                //clear
                item.card.deleteProperty(streets[p][1]);
                item.card.deleteProperty(streets[p][2]);
            } else {
                //set
                let lines = value.split(seperator);
                item.card.setProperty(streets[p][1], lines.shift());
                item.card.setProperty(streets[p][2], lines.join(seperator));
            }
        }

        //take care of photo
        if (data.Picture) {
            tbSync.addphoto(id + '.jpg', item.card, data.Picture);
        }

        //take care of notes
        if (asversion == "2.5") {
            item.card.setProperty("Notes", xmltools.checkString(data.Body));
        } else {
            if (data.Body && data.Body.Data) item.card.setProperty("Notes", xmltools.checkString(data.Body.Data));
            else item.card.setProperty("Notes", "");
        }

        //further manipulations
        if (tbSync.db.getAccountSetting(syncdata.account, "displayoverride") == "1") {
           item.card.setProperty("DisplayName", item.card.getProperty("FirstName", "") + " " + item.card.getProperty("LastName", ""));

            if (item.card.getProperty("DisplayName", "" ) == " " )
                item.card.setProperty("DisplayName", item.card.getProperty("Company", item.card.getProperty("PrimaryEmail", "")));
        }

    },

    // --------------------------------------------------------------------------- //
    //read TB event and return its data as WBXML
    // --------------------------------------------------------------------------- //
    getWbxmlFromThunderbirdItem: function (item, syncdata, isException = false) {
        let asversion = tbSync.db.getAccountSetting(syncdata.account, "asversion");
        let wbxml = tbSync.wbxmltools.createWBXML("", syncdata.type); //init wbxml with "" and not with precodes, and set initial codepage
        let nowDate = new Date();

        //loop over all known TB properties which map 1-to-1 (send empty value if not set)
        for (let p=0; p < this.TB_properties.length; p++) {
            let TB_property = this.TB_properties[p];
            let EAS_property = this.map_TB_properties_to_EAS_properties[TB_property];
            wbxml.atag(EAS_property, item.card.getProperty(TB_property,""));
        }

        //take care of birthday and anniversary
        let dates = [];
        dates.push(["Birthday", "BirthDay", "BirthMonth", "BirthYear"]);
        dates.push(["Anniversary", "AnniversaryDay", "AnniversaryMonth", "AnniversaryYear"]);
        for (let p=0; p < dates.length; p++) {
            let year = item.card.getProperty(dates[p][3], "");
            let month = item.card.getProperty(dates[p][2], "");
            let day = item.card.getProperty(dates[p][1], "");
            if (year && month && day) {
                //set
                if (month.length<2) month="0"+month;
                if (day.length<2) day="0"+day;
                wbxml.atag(dates[p][0], year + "-" + month + "-" + day + "T00:00:00.000Z");
            } else {
                //clear ??? outlook does not like empty value
                //wbxml.atag(dates[p][0], "");
            }
        }

        //take care of multiline address fields
        let streets = [];
        let seperator = String.fromCharCode(tbSync.db.getAccountSetting(syncdata.account,"seperator")); // options are 44 (,) or 10 (\n)
        streets.push(["HomeAddressStreet", "HomeAddress", "HomeAddress2"]); //EAS, TB1, TB2
        streets.push(["BusinessAddressStreet", "WorkAddress", "WorkAddress2"]);
        for (let p=0; p < streets.length; p++) {
            let s1 = item.card.getProperty(streets[p][1], "");
            let s2 = item.card.getProperty(streets[p][2], "");
            wbxml.atag(streets[p][0], s1 + seperator + s2);
        }

        //take care of photo
        if (item.card.getProperty("PhotoType", "") == "file") {
            wbxml.atag("Picture", tbSync.getphoto(item.card));
        } else {
            //clear
            wbxml.atag("Picture","");
        }

        //take care of notes
        let description = item.card.getProperty("Notes", "");
        if (asversion == "2.5") {
            wbxml.atag("Body", description);
        } else {
            wbxml.switchpage("AirSyncBase");
            wbxml.otag("Body");
                wbxml.atag("Type", "1");
                wbxml.atag("EstimatedDataSize", "" + description.length);
                wbxml.atag("Data", description);
            wbxml.ctag();
        }

        //loop over all known TB properties of EAS group Contacts2 (send empty value if not set)
        wbxml.switchpage("Contacts2");
        for (let p=0; p < this.TB_properties2.length; p++) {
            let TB_property = this.TB_properties2[p];
            let EAS_property = this.map_TB_properties_to_EAS_properties2[TB_property];
            wbxml.atag(EAS_property, item.card.getProperty(TB_property,""));
        }

        return wbxml.getBytes();
    }

}

eas.sync.Contacts.TB_properties = Object.keys(eas.sync.Contacts.map_TB_properties_to_EAS_properties);
eas.sync.Contacts.TB_properties2 = Object.keys(eas.sync.Contacts.map_TB_properties_to_EAS_properties2);
<|endoftext|>


<commit_before><commit_msg>fix deprecation warnings<commit_after><|endoftext|>


<commit_before>/**
    @external Viz
    @see https://github.com/d3plus/d3plus-viz#Viz
*/
import {nest} from "d3-collection";
import {accessor, assign, configPrep, constant, elem} from "d3plus-common";
import {Circle, Path, Rect} from "d3plus-shape";
import {Viz} from "d3plus-viz";

const tau = Math.PI * 2;

/**
    @class Radar
    @extends Plot
    @desc Creates a radar visualization based on an array of data.
*/
export default class Radar extends Viz {

  /**
      @memberof Radar
      @desc Invoked when creating a new class instance, and overrides any default parameters inherited from Plot.
      @private
  */
  constructor() {
    super();

    this._axisConfig = {
      fill: constant("none"),
      stroke: constant("#CCC"),
      strokeWidth: constant(1)
    };
    this._hover = true;
    this._levels = 6;
    this._radarPadding = 100;
    this._shape = constant("Path");
    this._shapeConfig = assign(this._shapeConfig, {
      Circle: {
        r: accessor("r", 0)
      },
      Path: {}
    });
    this._value = accessor("value");
  }

  /**
      Extends the draw behavior of the abstract Plot class.
      @private
  */
  _draw(callback) {
    super._draw(callback);
    const height = this._height - this._margin.top - this._margin.bottom,
          width = this._width - this._margin.left - this._margin.right;

    const radius = (Math.min(height, width) - this._radarPadding) / 2,
          transform = `translate(${width / 2}, ${height / 2})`;

    const maxValue = Math.max(...this._filteredData.map((d, i) => this._value(d, i))),
          nestedAxisData = nest()
        .key(this._groupBy[1])
        .entries(this._filteredData),
          nestedGroupData = nest()
        .key(this._groupBy[0])
        .entries(this._filteredData);

    const groupBy = this._groupBy[0](this._filteredData[0]),
          item = this._filteredData[0];

    let id = "";
    for (const property in this._filteredData[0]) {
      if (groupBy === item[property]) {
        id = property;
        break;
      }
    }

    const circularAxis = Array.from(Array(this._levels).keys()).map(d => ({
      id: d,
      r: radius * ((d + 1) / this._levels)
    }));

    new Circle()
      .data(circularAxis)
      .select(
        elem("g.d3plus-Radar-radial-circles", {
          parent: this._select,
          enter: {transform},
          update: {transform}
        }).node()
      )
      .config(this._axisConfig)
      .render();

    const totalAxis = nestedAxisData.length;
    const polarAxis = nestedAxisData
      .map((d, i) => {
        const width = 100;
        const fontSize =
          this._shapeConfig.labelConfig.fontSize &&
            this._shapeConfig.labelConfig.fontSize(d, i) ||
          11;

        const lineHeight = fontSize * 1.4;
        const height = lineHeight * 2;
        const padding = 10,
              quadrant = parseInt(360 - 360 / totalAxis * i / 90, 10) % 4 + 1,
              radians = tau / totalAxis * i;

        let angle = 360 / totalAxis * i;

        let textAnchor = "start";
        let x = padding;

        if (quadrant === 2 || quadrant === 3) {
          x = -width - padding;
          textAnchor = "end";
          angle += 180;
        }

        const labelBounds = {
          x,
          y: -height / 2,
          width,
          height
        };

        return {
          id: d.key,
          angle,
          textAnchor,
          labelBounds,
          rotateAnchor: [-x, height / 2],
          x: radius * Math.cos(radians),
          y: radius * Math.sin(radians)
        };
      })
      .sort((a, b) => a.key - b.key);

    new Rect()
      .data(polarAxis)
      .rotate(d => d.angle)
      .width(0)
      .height(0)
      .x(d => d.x)
      .y(d => d.y)
      .label(d => d.id)
      .labelBounds(d => d.labelBounds)
      .labelConfig({
        padding: 0,
        textAnchor: d => d.data.textAnchor,
        rotateAnchor: d => d.data.data.rotateAnchor,
        fontColor: "black",
        verticalAlign: "middle"
      })
      .select(
        elem("g.d3plus-Radar-text", {
          parent: this._select,
          enter: {transform},
          update: {transform}
        }).node()
      )
      .render();

    new Path()
      .data(polarAxis)
      .d(d => `M${0},${0} ${-d.x},${-d.y}`)
      .select(
        elem("g.d3plus-Radar-axis", {
          parent: this._select,
          enter: {transform},
          update: {transform}
        }).node()
      )
      .config(this._axisConfig)
      .render();

    const groupData = nestedGroupData.map(h => {
      const q = h.values.map((d, i) => {
        const r = this._value(d, i) / maxValue * radius,
              radians = tau / totalAxis * i;
        return {
          x: r * Math.cos(radians),
          y: r * Math.sin(radians)
        };
      });

      const d = `M ${q[0].x} ${q[0].y} ${q
        .map(l => `L ${l.x} ${l.y}`)
        .join(" ")} L ${q[0].x} ${q[0].y}`;

      return {[id]: h.key, d};
    });

    this._shapes.push(
      new Path()
        .data(groupData)
        .d(d => d.d)
        .select(
          elem("g.d3plus-Radar-items", {
            parent: this._select,
            enter: {transform},
            update: {transform}
          }).node()
        )
        .config(configPrep.bind(this)(this._shapeConfig, "shape", "Path"))
        .render()
    );

    return this;
  }

  /**
      @memberof Radar
      @desc If *value* is specified, sets the padding of the chart and returns the current class instance. If *value* is not specified, returns the current radarPadding. By default, the radarPadding is 100.
      @param {Number} [*value* = 100]
      @chainable
  */
  radarPadding(_) {
    return arguments.length
      ? (this._radarPadding = _, this)
      : this._radarPadding;
  }

  /**
      @memberof Radar
      @desc If *value* is specified, sets the value accessor to the specified function or number and returns the current class instance. If *value* is not specified, returns the current value accessor.
      @param {Function|String} *value*
      @example
function value(d) {
  return d.value;
}
  */
  value(_) {
    return arguments.length
      ? (this._value = typeof _ === "function" ? _ : accessor(_), this)
      : this._value;
  }

}
<commit_msg>converts radar chart to multi-level visualization<commit_after>/**
    @external Viz
    @see https://github.com/d3plus/d3plus-viz#Viz
*/
import {nest} from "d3-collection";
import {accessor, assign, configPrep, constant, elem} from "d3plus-common";
import {Circle, Path, Rect} from "d3plus-shape";
import {Viz} from "d3plus-viz";

const tau = Math.PI * 2;

/**
    @class Radar
    @extends Plot
    @desc Creates a radar visualization based on an array of data.
*/
export default class Radar extends Viz {

  /**
      @memberof Radar
      @desc Invoked when creating a new class instance, and overrides any default parameters inherited from Plot.
      @private
  */
  constructor() {
    super();

    this._axisConfig = {
      fill: constant("none"),
      stroke: constant("#CCC"),
      strokeWidth: constant(1)
    };
    this._discrete = "size";
    this._hover = true;
    this._levels = 6;
    this._radarPadding = 100;
    this._shape = constant("Path");
    this._shapeConfig = assign(this._shapeConfig, {
      Circle: {
        r: accessor("r", 0)
      },
      Path: {}
    });
    this._size = accessor("size");
    this._value = accessor("value");
  }

  /**
      Extends the draw behavior of the abstract Plot class.
      @private
  */
  _draw(callback) {
    super._draw(callback);
    const height = this._height - this._margin.top - this._margin.bottom,
          width = this._width - this._margin.left - this._margin.right;

    const depth = this._depth || 0,
          radius = (Math.min(height, width) - this._radarPadding) / 2,
          transform = `translate(${width / 2}, ${height / 2})`;

    const nestedAxisData = nest()
        .key(this._size)
        .entries(this._filteredData),
          nestedGroupData = nest()
        .key(this._groupBy[depth])
        .key(this._size)
        .entries(this._filteredData);

    const maxValue = Math.max(...nestedGroupData.map(h => h.values.map(d => d.values.reduce((sum, x, i) => sum + this._value(x, i), 0))).flat());

    const group = this._groupBy[depth](this._filteredData[0]),
          item = this._filteredData[0];

    let id = "";
    for (const property in this._filteredData[0]) {
      if (group === item[property]) {
        id = property;
        break;
      }
    }

    const circularAxis = Array.from(Array(this._levels).keys()).map(d => ({
      id: d,
      r: radius * ((d + 1) / this._levels)
    }));

    new Circle()
      .data(circularAxis)
      .select(
        elem("g.d3plus-Radar-radial-circles", {
          parent: this._select,
          enter: {transform},
          update: {transform}
        }).node()
      )
      .config(this._axisConfig)
      .render();

    const totalAxis = nestedAxisData.length;
    const polarAxis = nestedAxisData
      .map((d, i) => {
        const width = 100;
        const fontSize =
          this._shapeConfig.labelConfig.fontSize &&
            this._shapeConfig.labelConfig.fontSize(d, i) ||
          11;

        const lineHeight = fontSize * 1.4;
        const height = lineHeight * 2;
        const padding = 10,
              quadrant = parseInt(360 - 360 / totalAxis * i / 90, 10) % 4 + 1,
              radians = tau / totalAxis * i;

        let angle = 360 / totalAxis * i;

        let textAnchor = "start";
        let x = padding;

        if (quadrant === 2 || quadrant === 3) {
          x = -width - padding;
          textAnchor = "end";
          angle += 180;
        }

        const labelBounds = {
          x,
          y: -height / 2,
          width,
          height
        };

        return {
          id: d.key,
          angle,
          textAnchor,
          labelBounds,
          rotateAnchor: [-x, height / 2],
          x: radius * Math.cos(radians),
          y: radius * Math.sin(radians)
        };
      })
      .sort((a, b) => a.key - b.key);

    new Rect()
      .data(polarAxis)
      .rotate(d => d.angle)
      .width(0)
      .height(0)
      .x(d => d.x)
      .y(d => d.y)
      .label(d => d.id)
      .labelBounds(d => d.labelBounds)
      .labelConfig({
        padding: 0,
        textAnchor: d => d.data.textAnchor,
        rotateAnchor: d => d.data.data.rotateAnchor,
        fontColor: "black",
        verticalAlign: "middle"
      })
      .select(
        elem("g.d3plus-Radar-text", {
          parent: this._select,
          enter: {transform},
          update: {transform}
        }).node()
      )
      .render();

    new Path()
      .data(polarAxis)
      .d(d => `M${0},${0} ${-d.x},${-d.y}`)
      .select(
        elem("g.d3plus-Radar-axis", {
          parent: this._select,
          enter: {transform},
          update: {transform}
        }).node()
      )
      .config(this._axisConfig)
      .render();

    const groupData = nestedGroupData.map(h => {
      const q = h.values.map((d, i) => {
        const value = d.values.reduce((sum, x, j) => sum + this._value(x, j), 0);
        const r = value / maxValue * radius,
              radians = tau / totalAxis * i;
        return {
          x: r * Math.cos(radians),
          y: r * Math.sin(radians)
        };
      });

      const d = `M ${q[0].x} ${q[0].y} ${q
        .map(l => `L ${l.x} ${l.y}`)
        .join(" ")} L ${q[0].x} ${q[0].y}`;

      return {[id]: h.key, d};
    });

    this._shapes.push(
      new Path()
        .data(groupData)
        .d(d => d.d)
        .select(
          elem("g.d3plus-Radar-items", {
            parent: this._select,
            enter: {transform},
            update: {transform}
          }).node()
        )
        .config(configPrep.bind(this)(this._shapeConfig, "shape", "Path"))
        .render()
    );

    return this;
  }

  /**
      @memberof Radar
      @desc If *value* is specified, sets the padding of the chart and returns the current class instance. If *value* is not specified, returns the current radarPadding. By default, the radarPadding is 100.
      @param {Number} [*value* = 100]
      @chainable
  */
  radarPadding(_) {
    return arguments.length
      ? (this._radarPadding = _, this)
      : this._radarPadding;
  }

  /**
      @memberof Radar
      @desc If *value* is specified, sets the sum accessor to the specified function or number and returns the current class instance. If *value* is not specified, returns the current size accessor.
      @param {String} *value*
      @chainable
  */
  size(_) {
    return arguments.length ? (this._size = typeof _ === "function" ? _ : accessor(_), this) : this._size;
  }

  /**
      @memberof Radar
      @desc If *value* is specified, sets the value accessor to the specified function or number and returns the current class instance. If *value* is not specified, returns the current value accessor.
      @param {Function|String} *value*
      @example
function value(d) {
  return d.value;
}
  */
  value(_) {
    return arguments.length
      ? (this._value = typeof _ === "function" ? _ : accessor(_), this)
      : this._value;
  }

}
<|endoftext|>


<commit_before>var $ = function (selector) {
    return document.querySelector(selector);
};
var $$ = function (selector) {
    return document.querySelectorAll(selector);
};

var nick, room;

var connectionTime;

function encodeHTML(string) {
    var tempDiv = document.createElement("div");
    tempDiv.textContent = string;
    return tempDiv.innerHTML;
}

function decodeHTML(string) {
    var tempDiv = document.createElement("div");
    tempDiv.innerHTML = string;
    return tempDiv.textContent;
}

function addEmotes(string) {
    return string.replace(/:y:/g, "")
                  .replace(/:y1:/g, "")
                  .replace(/:y2:/g, "")
                  .replace(/:y3:/g, "")
                  .replace(/:gourd:/g, "")
                  .replace(/:gourd1:/g, "")
                  .replace(/:gourd2:/g, "")
                  .replace(/:gourd3:/g, "")
                  .replace(/:approve:/g, "")
                  .replace(/HARFITT/g, "\
H\
A\
R\
F\
I\
T\
T");
}

if (localStorage.lastNick) {
    $("#nick_input").value = decodeHTML(localStorage.lastNick);
}

if (urlRoomName) {
    $("#chatroom").value = decodeURIComponent(urlRoomName);
    $("#chatroom").setAttribute("readonly", "true");
} else if (localStorage.lastRoom) {
    $("#chatroom").value = decodeHTML(localStorage.lastRoom);
} else {
    $("#chatroom").value = "yeya";
}

$("#nick_input").focus();

$("#nick_input").onkeydown = function (e) {
    if (e.which === 13) { // enter
        if (!$("#chatroom").getAttribute("readonly")) {
            $("#chatroom").focus();
        } else {
            chooseNick();
        }
    }
};

$("#chatroom").onkeydown = $("#enter").onclick = function (e) {
    if (e.which === 13 || e.which === 1) { // also accept 1 to make it work with enter button's onclick
        chooseNick();
    }
};

function chooseNick() {
    nick = encodeHTML($("#nick_input").value.substring(0, 100));
    room = encodeHTML($("#chatroom").value.toLowerCase().substring(0, 100));
    if (nick.split(" ").join("").length && room.split(" ").join("").length) {
        connectionTime = new Date().getTime();
        localStorage.lastNick = nick;
        localStorage.lastRoom = room;
        $("#welcome").style.opacity = "0";
        setTimeout(function () {
            $("#welcome").style.display = "none";
        }, 300);
        main();
    } else {
        alert("Choose a nickname and room");
        $("#nick_input").focus();
    }
}

function main() {
    var socket = io();

    socket.emit("nick chosen", {
        nick: nick,
        room: room
    });

    socket.on("try resume", function () {
        var now = new Date().getTime();
        if (nick && room && (now - connectionTime >= 20000)) { // connected to a room at least 20 seconds ago
            socket.emit("nick chosen", {
                nick: nick,
                room: room
            });
        }
    });

    var onlineUsers = [];
    var typingList = [];
    var unreadCount = 0;

    var windowIsFocused = true;
    var fileOptsOpened = false;

    Array.prototype.remove = function (element) {
        return this.splice(this.indexOf(element), 1);
    };

    HTMLElement.prototype.scrollBottom = function () {
        return this.scrollHeight - this.scrollTop - this.offsetHeight;
    };

    var autoLinkOptions = {
        callback: function (url) {
            return "" + url + "";
        }
    };

    window.onblur = function () {
        windowIsFocused = false;
    };

    window.onfocus = function () {
        windowIsFocused = true;
        unreadCount = 0;
        updateUnreadCount();
    };

    String.prototype.replaceVars = function (map) {
        var mapKeys = Object.keys(map);
        var returnVal = this;

        for (i = 0; i < mapKeys.length; i++) {
            returnVal = returnVal.split("%" + mapKeys[i] + "%").join(map[mapKeys[i]]);
        }

        return returnVal;
    };

    HTMLElement.prototype.remove = function () {
        this.parentElement.removeChild(this);
    };

    function writeListItem(nick, content, cssClass) {
        var wasAtBottom = $("#messages").scrollBottom() === 0;

        var liNode = document.createElement("li");
        liNode.innerHTML = ("
" + (cssClass === "self" ? "" : "%nick%") + "%content%
").replaceVars({
            nick: nick,
            content: content
        });
        liNode.setAttribute("class", cssClass);

        $("#messages").appendChild(liNode);

        if (wasAtBottom || cssClass === "self") {
            $("#messages").scrollTop = $("#messages").scrollHeight;
        }

        if ((!windowIsFocused || !wasAtBottom) && cssClass === "normal") {
            playSound("sound/message.ogg");
        }

        if (!windowIsFocused && cssClass === "normal") {
            unreadCount += 1;
            updateUnreadCount();
        }

        if (cssClass.indexOf("typing") === -1) {
            moveTypersToBottom();
        } else {
            liNode.dataset.typer = nick;
        }
    }

    function playSound(url) {
        var audio = document.createElement("audio");
        audio.src = url;
        audio.play();
    }

    function updateOnlineUsers() {
        onlineUsers = onlineUsers.sort();
        $("#users").innerHTML = "Online: " + onlineUsers.join(", ");
    }

    function updateUnreadCount() {
        if (unreadCount > 0) {
            document.title = "Abungo [" + unreadCount.toString() + "]";
        } else {
            document.title = "Abungo";
        }
    }

    function updateTypingList() {
        var typers = Object.keys(typingList).sort();
        var typingElems = $$("li.typing");
        for (i = 0; i < typers.length; i++) {
            if (!$("li.typing[data-typer='" + typers[i] + "']")) {
                writeListItem(typers[i], " is typing", "normal typing");
            }
        }
        for (i = 0; i < typingElems.length; i++) {
            if (typers.indexOf(typingElems[i].dataset.typer) === -1) {
                typingElems[i].remove();
            }
        }
    }

    function moveTypersToBottom() {
        var typingElems = $$("li.typing");
        for (i = 0; i < typingElems.length; i++) {
            var typer = typingElems[i].dataset.typer;
            typingElems[i].remove();
            writeListItem(typer, " is typing", "normal typing old");
        }
    }

    function isMobile() {
        //return (navigator.userAgent.toLowerCase().indexOf("mobile") != -1);
        return (window.innerWidth <= 480 || navigator.userAgent.toLowerCase().indexOf("mobile") !== -1);
    }

    // actual script

    $("#m").focus();

    onlineUsers.push(nick);
    updateOnlineUsers();

    $("form").onsubmit = function (e) {
        e.preventDefault();
        var text = $("#m").value.substring(0, 10000);
        if ($("#m").value.split(" ").join("").length !== 0) {
            socket.emit("chat message", {
                text: text,
                nick: nick
            });
            socket.emit("stopped typing");
            writeListItem(nick, addEmotes(encodeHTML(text).autoLink(autoLinkOptions)), "self");
        }
        this.reset();
    };

    $("#m").onkeyup = function () {
        if ($("#m").value.length > 0) {
            socket.emit("typing");
        } else {
            socket.emit("stopped typing");
        }
    };

    function fileTooBig() {
        alert("The file you chose is too big. Choose a file less than 20mb in size.");
    }

    function handleFile(file) {
        if (file.size < 20971520) { // ~20mb
            var blobURL = URL.createObjectURL(file);
            socket.emit("file share", {
                file: file,
                nick: nick,
                name: file.name,
                type: file.type
            });

            if (file.type.indexOf("image/") !== -1) {
                writeListItem(nick, "", "self");
            } else if (file.type.indexOf("video/") !== -1) {
                writeListItem(nick, "<video src='" + blobURL + "' controls></video>" + file.name + "", "self");
            } else if (file.type.indexOf("audio/") !== -1) {
                writeListItem(nick, "<audio src='" + blobURL + "' controls></audio>" + file.name + "", "self");
            } else {
                writeListItem(nick, "" + file.name + "", "self");
            }

            closeFileOpts();
        } else {
            fileTooBig();
        }
    }

    $("#attach_file input[type=file]").onchange = function () {
        var files = this.files;

        var inc = files.length;
        while (inc) {
            handleFile(files[inc - 1]);
            inc--;
        }
    };

    document.body.addEventListener("dragover", function (e) {
        e.stopPropagation();
        e.preventDefault();
        e.dataTransfer.dropEffect = "copy";
    }, false);

    document.body.addEventListener("drop", function (e) {
        e.stopPropagation();
        e.preventDefault();

        var files = e.dataTransfer.files;

        var inc = files.length;
        while (inc) {
            handleFile(files[inc - 1]);
            inc--;
        }
    }, false);

    function closeFileOpts() {
        $("#fileoptions").classList.remove("opened");
        $("#fileoptions").classList.remove("booth");
        if (videoStream) {
            videoStream.stop();
        }
        fileOptsOpened = false;
    }

    function openFileOpts() {
        $("#fileoptions").classList.add("opened");
        fileOptsOpened = true;
    }

    $("#fileshare").onclick = function () {
        if (!isMobile()) {
            if (fileOptsOpened) {
                closeFileOpts();
            } else {
                openFileOpts();
            }
        } else {
            $("#fileoptions input[type=file]").click();
        }
    };

    var videoStream;

    navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;

    $("#take_photo").onclick = function () {
        $("#fileoptions").classList.add("booth");

        navigator.getUserMedia({
            video: true
        }, function (stream) {
            videoStream = stream;
            $("#photobooth video").src = URL.createObjectURL(videoStream);
        }, function () {
            alert("Please allow webcam access to use this feature");
        });
    };

    $("#photobooth input").onclick = function () {
        var canvas = document.createElement("canvas");
        var ctx = canvas.getContext("2d");
        canvas.height = 300;
        canvas.width = 400;
        ctx.drawImage($("#photobooth video"), 0, 0, 400, 300);

        var dataURI = canvas.toDataURL();

        socket.emit("file share", {
            file: dataURI,
            nick: nick,
            name: "Abungo booth capture.png",
            type: "image/png"
        });

        videoStream.stop();

        writeListItem(nick, "", "self");

        closeFileOpts();
    };

    socket.on("chat message", function (msg) {
        var text = msg.text;
        var sender = msg.nick;
        var type;

        msg.fromConsole === true ? type = "normal console" : type = "normal";

        writeListItem(sender, addEmotes(encodeHTML(text).autoLink(autoLinkOptions)), type);
    });

    socket.on("user joined", function (data) {
        var nick = data.nick;
        writeListItem(nick, " joined", "status");
        onlineUsers = data.users;
        updateOnlineUsers();
    });

    socket.on("user left", function (data) {
        var nick = data.nick;
        writeListItem(nick, " left", "status");
        onlineUsers = data.users;
        updateOnlineUsers();
    });

    socket.on("kick", function (reason) {
        alert("You have been kicked: " + reason);
        location.reload();
    });

    socket.on("still kicked", function () {
        alert("Please wait 5 minutes after getting kicked");
        history.back();
    });

    socket.on("typing", function (nick) {
        typingList[nick] = true;
        updateTypingList();
    });

    socket.on("stopped typing", function (nick) {
        delete typingList[nick];
        updateTypingList();
    });

    socket.on("server stopping", function () {
        alert("Server is stopping. Your messages will not be sent until the server is back online and you refresh the page.");
    });

    socket.on("file share", function (data) {
        var blobURL;
        if (typeof data.file == "string") {
            blobURL = data.file;
        } else {
            var blob = new Blob([data.file], {
                type: data.type
            });
            blobURL = URL.createObjectURL(blob);
        }

        if (data.type.indexOf("image/") != -1) {
            writeListItem(data.nick, "", "normal");
        } else if (data.type.indexOf("video/") != -1) {
            writeListItem(data.nick, "<video src='" + blobURL + "' controls></video>" + data.name + "", "normal");
        } else if (data.type.indexOf("audio/") != -1) {
            writeListItem(data.nick, "<audio src='" + blobURL + "' controls></audio>" + data.name + "", "normal");
        } else {
            writeListItem(data.nick, "" + data.name + "", "normal");
        }
    });

    socket.on("brainwash", function () {
        $("#messages").innerHTML = "";
    });

    socket.on("file too big", fileTooBig);

    $("#current_room").innerHTML = "Room: " + room;

    window.onbeforeunload = function () {
        socket.disconnect();
    }
}<commit_msg>Add notifications<commit_after>var $ = function (selector) {
    return document.querySelector(selector);
};
var $$ = function (selector) {
    return document.querySelectorAll(selector);
};

var nick, room;

var connectionTime;

function encodeHTML(string) {
    var tempDiv = document.createElement("div");
    tempDiv.textContent = string;
    return tempDiv.innerHTML;
}

function decodeHTML(string) {
    var tempDiv = document.createElement("div");
    tempDiv.innerHTML = string;
    return tempDiv.textContent;
}

function addEmotes(string) {
    return string.replace(/:y:/g, "")
                  .replace(/:y1:/g, "")
                  .replace(/:y2:/g, "")
                  .replace(/:y3:/g, "")
                  .replace(/:gourd:/g, "")
                  .replace(/:gourd1:/g, "")
                  .replace(/:gourd2:/g, "")
                  .replace(/:gourd3:/g, "")
                  .replace(/:approve:/g, "")
                  .replace(/HARFITT/g, "\
H\
A\
R\
F\
I\
T\
T");
}

if (localStorage.lastNick) {
    $("#nick_input").value = decodeHTML(localStorage.lastNick);
}

if (urlRoomName) {
    $("#chatroom").value = decodeURIComponent(urlRoomName);
    $("#chatroom").setAttribute("readonly", "true");
} else if (localStorage.lastRoom) {
    $("#chatroom").value = decodeHTML(localStorage.lastRoom);
} else {
    $("#chatroom").value = "yeya";
}

$("#nick_input").focus();

$("#nick_input").onkeydown = function (e) {
    if (e.which === 13) { // enter
        if (!$("#chatroom").getAttribute("readonly")) {
            $("#chatroom").focus();
        } else {
            chooseNick();
        }
    }
};

$("#chatroom").onkeydown = $("#enter").onclick = function (e) {
    if (e.which === 13 || e.which === 1) { // also accept 1 to make it work with enter button's onclick
        chooseNick();
    }
};

function chooseNick() {
    nick = encodeHTML($("#nick_input").value.substring(0, 100));
    room = encodeHTML($("#chatroom").value.toLowerCase().substring(0, 100));
    if (nick.split(" ").join("").length && room.split(" ").join("").length) {
        connectionTime = new Date().getTime();
        localStorage.lastNick = nick;
        localStorage.lastRoom = room;
        $("#welcome").style.opacity = "0";
        setTimeout(function () {
            $("#welcome").style.display = "none";
        }, 300);
        main();
    } else {
        alert("Choose a nickname and room");
        $("#nick_input").focus();
    }
}

function main() {
    var socket = io();

    socket.emit("nick chosen", {
        nick: nick,
        room: room
    });

    socket.on("try resume", function () {
        var now = new Date().getTime();
        if (nick && room && (now - connectionTime >= 20000)) { // connected to a room at least 20 seconds ago
            socket.emit("nick chosen", {
                nick: nick,
                room: room
            });
        }
    });

    var onlineUsers = [];
    var typingList = [];
    var unreadCount = 0;

    var windowIsFocused = true;
    var fileOptsOpened = false;

    Array.prototype.remove = function (element) {
        return this.splice(this.indexOf(element), 1);
    };

    HTMLElement.prototype.scrollBottom = function () {
        return this.scrollHeight - this.scrollTop - this.offsetHeight;
    };

    var autoLinkOptions = {
        callback: function (url) {
            return "" + url + "";
        }
    };

    window.onblur = function () {
        windowIsFocused = false;
    };

    window.onfocus = function () {
        windowIsFocused = true;
        unreadCount = 0;
        updateUnreadCount();
    };

    String.prototype.replaceVars = function (map) {
        var mapKeys = Object.keys(map);
        var returnVal = this;

        for (i = 0; i < mapKeys.length; i++) {
            returnVal = returnVal.split("%" + mapKeys[i] + "%").join(map[mapKeys[i]]);
        }

        return returnVal;
    };

    HTMLElement.prototype.remove = function () {
        this.parentElement.removeChild(this);
    };

    function writeListItem(nick, content, cssClass) {
        var wasAtBottom = $("#messages").scrollBottom() === 0;

        var liNode = document.createElement("li");
        liNode.innerHTML = ("
" + (cssClass === "self" ? "" : "%nick%") + "%content%
").replaceVars({
            nick: nick,
            content: content
        });
        liNode.setAttribute("class", cssClass);

        $("#messages").appendChild(liNode);

        if (wasAtBottom || cssClass === "self") {
            $("#messages").scrollTop = $("#messages").scrollHeight;
        }

        if ((!windowIsFocused || !wasAtBottom) && cssClass === "normal") {
            playSound("sound/message.ogg");
        }

        if (!windowIsFocused && cssClass === "normal") {
            unreadCount += 1;
            updateUnreadCount();

            if(window.Notification && Notification.permission !== "denied") {
                Notification.requestPermission(function(status) {
                    if (status === "granted") {
                        var n = new Notification(nick + " on #" + room, {
                            body: content,
                            icon: "/img/icon128.png"
                        });
                        window.addEventListener("focus", function(){
                            if (n) n.close();
                        });
                        n.addEventListener("click", function(){
                            window.focus();
                        });
                    }
                });
            }
        }

        if (cssClass.indexOf("typing") === -1) {
            moveTypersToBottom();
        } else {
            liNode.dataset.typer = nick;
        }
    }

    function playSound(url) {
        var audio = document.createElement("audio");
        audio.src = url;
        audio.play();
    }

    function updateOnlineUsers() {
        onlineUsers = onlineUsers.sort();
        $("#users").innerHTML = "Online: " + onlineUsers.join(", ");
    }

    function updateUnreadCount() {
        if (unreadCount > 0) {
            document.title = "Abungo [" + unreadCount.toString() + "]";
        } else {
            document.title = "Abungo";
        }
    }

    function updateTypingList() {
        var typers = Object.keys(typingList).sort();
        var typingElems = $$("li.typing");
        for (i = 0; i < typers.length; i++) {
            if (!$("li.typing[data-typer='" + typers[i] + "']")) {
                writeListItem(typers[i], " is typing", "normal typing");
            }
        }
        for (i = 0; i < typingElems.length; i++) {
            if (typers.indexOf(typingElems[i].dataset.typer) === -1) {
                typingElems[i].remove();
            }
        }
    }

    function moveTypersToBottom() {
        var typingElems = $$("li.typing");
        for (i = 0; i < typingElems.length; i++) {
            var typer = typingElems[i].dataset.typer;
            typingElems[i].remove();
            writeListItem(typer, " is typing", "normal typing old");
        }
    }

    function isMobile() {
        //return (navigator.userAgent.toLowerCase().indexOf("mobile") != -1);
        return (window.innerWidth <= 480 || navigator.userAgent.toLowerCase().indexOf("mobile") !== -1);
    }

    // actual script

    $("#m").focus();

    onlineUsers.push(nick);
    updateOnlineUsers();

    $("form").onsubmit = function (e) {
        e.preventDefault();
        var text = $("#m").value.substring(0, 10000);
        if ($("#m").value.split(" ").join("").length !== 0) {
            socket.emit("chat message", {
                text: text,
                nick: nick
            });
            socket.emit("stopped typing");
            writeListItem(nick, addEmotes(encodeHTML(text).autoLink(autoLinkOptions)), "self");
        }
        this.reset();
    };

    $("#m").onkeyup = function () {
        if ($("#m").value.length > 0) {
            socket.emit("typing");
        } else {
            socket.emit("stopped typing");
        }
    };

    function fileTooBig() {
        alert("The file you chose is too big. Choose a file less than 20mb in size.");
    }

    function handleFile(file) {
        if (file.size < 20971520) { // ~20mb
            var blobURL = URL.createObjectURL(file);
            socket.emit("file share", {
                file: file,
                nick: nick,
                name: file.name,
                type: file.type
            });

            if (file.type.indexOf("image/") !== -1) {
                writeListItem(nick, "", "self");
            } else if (file.type.indexOf("video/") !== -1) {
                writeListItem(nick, "<video src='" + blobURL + "' controls></video>" + file.name + "", "self");
            } else if (file.type.indexOf("audio/") !== -1) {
                writeListItem(nick, "<audio src='" + blobURL + "' controls></audio>" + file.name + "", "self");
            } else {
                writeListItem(nick, "" + file.name + "", "self");
            }

            closeFileOpts();
        } else {
            fileTooBig();
        }
    }

    $("#attach_file input[type=file]").onchange = function () {
        var files = this.files;

        var inc = files.length;
        while (inc) {
            handleFile(files[inc - 1]);
            inc--;
        }
    };

    document.body.addEventListener("dragover", function (e) {
        e.stopPropagation();
        e.preventDefault();
        e.dataTransfer.dropEffect = "copy";
    }, false);

    document.body.addEventListener("drop", function (e) {
        e.stopPropagation();
        e.preventDefault();

        var files = e.dataTransfer.files;

        var inc = files.length;
        while (inc) {
            handleFile(files[inc - 1]);
            inc--;
        }
    }, false);

    function closeFileOpts() {
        $("#fileoptions").classList.remove("opened");
        $("#fileoptions").classList.remove("booth");
        if (videoStream) {
            videoStream.stop();
        }
        fileOptsOpened = false;
    }

    function openFileOpts() {
        $("#fileoptions").classList.add("opened");
        fileOptsOpened = true;
    }

    $("#fileshare").onclick = function () {
        if (!isMobile()) {
            if (fileOptsOpened) {
                closeFileOpts();
            } else {
                openFileOpts();
            }
        } else {
            $("#fileoptions input[type=file]").click();
        }
    };

    var videoStream;

    navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;

    $("#take_photo").onclick = function () {
        $("#fileoptions").classList.add("booth");

        navigator.getUserMedia({
            video: true
        }, function (stream) {
            videoStream = stream;
            $("#photobooth video").src = URL.createObjectURL(videoStream);
        }, function () {
            alert("Please allow webcam access to use this feature");
        });
    };

    $("#photobooth input").onclick = function () {
        var canvas = document.createElement("canvas");
        var ctx = canvas.getContext("2d");
        canvas.height = 300;
        canvas.width = 400;
        ctx.drawImage($("#photobooth video"), 0, 0, 400, 300);

        var dataURI = canvas.toDataURL();

        socket.emit("file share", {
            file: dataURI,
            nick: nick,
            name: "Abungo booth capture.png",
            type: "image/png"
        });

        videoStream.stop();

        writeListItem(nick, "", "self");

        closeFileOpts();
    };

    socket.on("chat message", function (msg) {
        var text = msg.text;
        var sender = msg.nick;
        var type;

        msg.fromConsole === true ? type = "normal console" : type = "normal";

        writeListItem(sender, addEmotes(encodeHTML(text).autoLink(autoLinkOptions)), type);
    });

    socket.on("user joined", function (data) {
        var nick = data.nick;
        writeListItem(nick, " joined", "status");
        onlineUsers = data.users;
        updateOnlineUsers();
    });

    socket.on("user left", function (data) {
        var nick = data.nick;
        writeListItem(nick, " left", "status");
        onlineUsers = data.users;
        updateOnlineUsers();
    });

    socket.on("kick", function (reason) {
        alert("You have been kicked: " + reason);
        location.reload();
    });

    socket.on("still kicked", function () {
        alert("Please wait 5 minutes after getting kicked");
        history.back();
    });

    socket.on("typing", function (nick) {
        typingList[nick] = true;
        updateTypingList();
    });

    socket.on("stopped typing", function (nick) {
        delete typingList[nick];
        updateTypingList();
    });

    socket.on("server stopping", function () {
        alert("Server is stopping. Your messages will not be sent until the server is back online and you refresh the page.");
    });

    socket.on("file share", function (data) {
        var blobURL;
        if (typeof data.file == "string") {
            blobURL = data.file;
        } else {
            var blob = new Blob([data.file], {
                type: data.type
            });
            blobURL = URL.createObjectURL(blob);
        }

        if (data.type.indexOf("image/") != -1) {
            writeListItem(data.nick, "", "normal");
        } else if (data.type.indexOf("video/") != -1) {
            writeListItem(data.nick, "<video src='" + blobURL + "' controls></video>" + data.name + "", "normal");
        } else if (data.type.indexOf("audio/") != -1) {
            writeListItem(data.nick, "<audio src='" + blobURL + "' controls></audio>" + data.name + "", "normal");
        } else {
            writeListItem(data.nick, "" + data.name + "", "normal");
        }
    });

    socket.on("brainwash", function () {
        $("#messages").innerHTML = "";
    });

    socket.on("file too big", fileTooBig);

    $("#current_room").innerHTML = "Room: " + room;

    window.onbeforeunload = function () {
        socket.disconnect();
    }
}<|endoftext|>


<commit_before>
import BrickView from './View/Brick'
import EventManager from './EventManager'
import Form from './Form'
import Viewable from './Viewable'

/**
 * Abstract element of the pipe
 * @abstract
 */
export default class Brick extends Viewable {
  /**
   * Returns brick meta.
   * @abstract
   * @return {object}
   */
  static getMeta () {
    throw new Error(`Brick static method 'getMeta' has not been overridden.`)
  }

  /**
   * Constructor
   */
  constructor () {
    super()
    this._pipe = null
    this._viewPrototype = BrickView

    this._settingsForm = new Form()
    this._settingsForm.setDelegate(this)

    this._alias = null
    this._hidden = false
  }

  /**
   * Returns the pipe this brick is part of.
   * @return {?Pipe} Pipe instance or null, if brick isn't part of a pipe
   */
  getPipe () {
    return this._pipe
  }

  /**
   * Returns true, if this brick is part of a pipe.
   * @return {boolean}
   */
  hasPipe () {
    return this._pipe !== null
  }

  /**
   * Sets the pipe this brick is part of.
   * @param {?Pipe} pipe Pipe instance this brick is part of
   * @return {Brick} Fluent interface
   */
  setPipe (pipe) {
    this._pipe = pipe
    return this
  }

  /**
   * Returns brick meta.
   * @return {object}
   */
  getMeta () {
    return this.constructor.getMeta()
  }

  /**
   * Returns the brick alias or the title if alias is not set.
   * @return {string} Brick title
   */
  getTitle () {
    return this._alias === null
      ? this.getMeta().title
      : this._alias
  }

  /**
   * Returns the brick alias, if any.
   * @return {?string} Brick alias or null
   */
  getAlias () {
    return this._alias
  }

  /**
   * Sets the brick alias.
   * @param {?string} alias Brick alias or null to clear it
   * @return {Brick} Fluent interface
   */
  setAlias (alias) {
    this._alias = alias
    return this
  }

  /**
   * Returns true, if brick is set to be hidden.
   * @return {boolean}
   */
  isHidden () {
    return this._hidden
  }

  /**
   * Sets wether this brick is hidden.
   * @param {boolean} [hidden=true] True, if brick is hidden
   * @return {Brick} Fluent interface
   */
  setHidden (hidden = true) {
    if (this._hidden !== hidden) {
      this._hidden = hidden
      this.hasPipe() && this.getPipe().brickVisibilityDidChange(this, hidden)
    }
    return this
  }

  /**
   * Returns the brick settings form.
   * @return {Form} Settings form
   */
  getSettingsForm () {
    return this._settingsForm
  }

  /**
   * Convenience method for returning the fields of the setting form.
   * @return {Field[]}
   */
  getSettings () {
    return this._settingsForm.getFields()
  }

  /**
   * Adds multiple setting fields to the brick.
   * @param {Field[]|object[]} fieldsOrSpecs Array of field instances or
   * spec objects
   * @return {Brick} Fluent interface
   */
  addSettings (fieldsOrSpecs) {
    this._settingsForm.addFields(fieldsOrSpecs)
    return this
  }

  /**
   * Convenience method for returning the setting field for given name.
   * @param {string} name Setting name
   * @return {?Setting} Setting field or null, if it does not exist
   */
  getSetting (name) {
    return this._settingsForm.getField(name)
  }

  /**
   * Convenience method for adding a setting field to the brick.
   * @param {Field|object} fieldOrSpec Field instance or spec object
   * @throws {Error} If field name is already assigned.
   * @return {Brick} Fluent interface
   */
  addSetting (fieldOrSpec) {
    this._settingsForm.addField(fieldOrSpec)
    return this
  }

  /**
   * Convenience method for returning a named setting value.
   * @param {string} name Setting name
   * @throws {Error} If no setting is assigned to given setting name.
   * @return {mixed} Setting value
   */
  getSettingValue (name) {
    return this._settingsForm.getFieldValue(name)
  }

  /**
   * Convenience method for setting a named setting.
   * @param {string} name Setting name
   * @param {mixed} value Setting value
   * @throws {Error} If no setting is assigned to given setting name.
   * @return {Brick} Fluent interface
   */
  setSettingValue (name, value) {
    this._settingsForm.setFieldValue(name, value)
    return this
  }

  /**
   * Convenience method for returning the current setting value for each visible
   * setting field.
   * @return {object} Object mapping names to setting values
   */
  getSettingValues () {
    return this._settingsForm.getFieldValues()
  }

  /**
   * Applies each setting value in given map.
   * @param {object} namedValues Map of names pointing to setting values
   * @throws {Error} If no setting is assigned to given setting name.
   * @return {Brick} Fluent interface
   */
  setSettingValues (namedValues) {
    this._settingsForm.setFieldValues(namedValues)
    return this
  }

  /**
   * Returns true, if the brick is valid.
   * @return {boolean} True, if valid
   */
  isValid () {
    return this._settingsForm.isValid()
  }

  /**
   * Returns true, if the brick input is randomizable.
   * @return {boolean}
   */
  isRandomizable () {
    return this._settingsForm.isRandomizable()
  }

  /**
   * Randomizes the brick input.
   * @return {Brick} Fluent interface
   */
  randomize () {
    this._settingsForm.randomize()
    return this
  }

  /**
   * Triggered when a settings form field value has changed.
   * @param {Form} form Sender form
   * @param {Field} field Field instance that has been changed
   * @param {mixed} value New field value
   */
  formValueDidChange (form, field, value) {
    if (form === this._settingsForm) {
      // Trigger settings change event
      this.settingValueDidChange(field, value)
      // Notify pipe, if any
      this.hasPipe() && this.getPipe().brickSettingDidChange(this)
    }
  }

  /**
   * Triggered when a setting field has changed.
   * @protected
   * @param {Field} setting Sender setting field
   * @param {mixed} value New field value
   */
  settingValueDidChange (setting, value) {
    // Override method
  }

  /**
   * Triggered when view has been created.
   * @protected
   */
  didCreateView (view) {
    // Add settings form as a subview
    view.addSubview(this._settingsForm.getView())
  }

  /**
   * Triggered when menu item has been clicked.
   * @param {BrickView} view Sender
   * @param {string} name Menu item name
   */
  viewMenuItemDidClick (view, name) {
    // Track action
    EventManager.trigger('brickMenuItemClick', { brick: this, menuItem: name })

    // Decide what to do
    switch (name) {
      case 'remove':
        this.getPipe().removeBrick(this)
        break
      case 'hide':
        this.setHidden(true)
        break
      case 'duplicate':
        // TODO Create a UI to communicate that brick duplication is not
        // possible with invalid settings.
        if (this.isValid()) {
          this.getPipe().duplicateBrick(this)
        }
        break
      case 'randomize':
        this.randomize()
        break
    }
  }

  /**
   * Triggered when replace button has been clicked.
   * @param {BrickView} view Sender
   * @protected
   */
  viewReplaceButtonDidClick (view) {
    if (this.hasPipe()) {
      // Track action
      EventManager.trigger('brickReplaceButtonClick', { brick: this })
      // Forward request to pipe
      this.getPipe().brickReplaceButtonDidClick(this)
    }
  }

  /**
   * Serializes brick to a JSON serializable value.
   * @return {mixed} Serialized data
   */
  serialize () {
    const data = {}

    // Brick name
    data.name = this.getMeta().name

    // Settings, if any
    const settingsData = this._settingsForm.serializeValues()
    if (Object.keys(settingsData)) {
      data.settings = settingsData
    }

    // Hidden state, if hidden
    if (this.isHidden()) {
      data.hidden = true
    }

    return data
  }

  /**
   * Creates a copy of this brick.
   * @throws {Error} If brick is invalid and thus can't be copied.
   * @return {Brick} Brick copy instance
   */
  copy () {
    if (!this.isValid()) {
      throw new Error(`Invalid bricks can't be copied.`)
    }
    const copy = new this.constructor()
    copy.setAlias(this.getAlias())
    copy.setHidden(this.isHidden())
    copy.setSettingValues(this.getSettingValues())
    return copy
  }

  /**
   * Extracts brick from serialized data.
   * @param {mixed} data Serialized data
   * @param {BrickFactory} brickFactory Brick factory instance
   * @throws {Error} If data is malformed.
   * @return {Brick} Extracted brick
   */
  static extract (data, brickFactory) {
    // Read brick name
    if (typeof data.name !== 'string') {
      throw new Error(
        `Malformed brick data: Attribute 'name' is expected to be a string`)
    }

    const name = data.name

    // Check if this is a known brick type
    if (!brickFactory.exists(name)) {
      throw new Error(
        `Malformed brick data: Unknown brick with name '${name}'`)
    }

    // Create brick instance
    const brick = brickFactory.create(name)

    // Read and apply alias
    if (typeof data.alias !== 'undefined' &&
        typeof data.alias !== 'string') {
      throw new Error(
        `Malformed brick data: ` +
        `Optional attribute 'alias' is expected to be string`)
    }

    if (data.alias !== undefined) {
      brick.setAlias(data.alias)
    }

    // Read and apply hidden state
    if (typeof data.hidden !== 'undefined' &&
        typeof data.hidden !== 'boolean') {
      throw new Error(
        `Malformed brick data: ` +
        `Optional attribute 'hidden' is expected to be boolean`)
    }

    if (data.hidden === true) {
      brick.setHidden(true)
    }

    // Read and apply reverse state
    if (typeof data.reverse !== 'undefined' &&
        typeof data.reverse !== 'boolean') {
      throw new Error(
        `Malformed brick data: ` +
        `Optional attribute 'reverse' is expected to be boolean`)
    }

    if (data.reverse === true) {
      if (typeof brick.setReverse !== 'function') {
        throw new Error(
          `Malformed brick data: Brick with name '${name}' can't be reversed`)
      }
      brick.setReverse(true)
    }

    // Apply setting values
    if (data.settings !== undefined) {
      brick.getSettingsForm().extract(data.settings)
    }

    return brick
  }
}
<commit_msg>Fixes Brick serialization issue removing the settings array if no settings are being applied<commit_after>
import BrickView from './View/Brick'
import EventManager from './EventManager'
import Form from './Form'
import Viewable from './Viewable'

/**
 * Abstract element of the pipe
 * @abstract
 */
export default class Brick extends Viewable {
  /**
   * Returns brick meta.
   * @abstract
   * @return {object}
   */
  static getMeta () {
    throw new Error(`Brick static method 'getMeta' has not been overridden.`)
  }

  /**
   * Constructor
   */
  constructor () {
    super()
    this._pipe = null
    this._viewPrototype = BrickView

    this._settingsForm = new Form()
    this._settingsForm.setDelegate(this)

    this._alias = null
    this._hidden = false
  }

  /**
   * Returns the pipe this brick is part of.
   * @return {?Pipe} Pipe instance or null, if brick isn't part of a pipe
   */
  getPipe () {
    return this._pipe
  }

  /**
   * Returns true, if this brick is part of a pipe.
   * @return {boolean}
   */
  hasPipe () {
    return this._pipe !== null
  }

  /**
   * Sets the pipe this brick is part of.
   * @param {?Pipe} pipe Pipe instance this brick is part of
   * @return {Brick} Fluent interface
   */
  setPipe (pipe) {
    this._pipe = pipe
    return this
  }

  /**
   * Returns brick meta.
   * @return {object}
   */
  getMeta () {
    return this.constructor.getMeta()
  }

  /**
   * Returns the brick alias or the title if alias is not set.
   * @return {string} Brick title
   */
  getTitle () {
    return this._alias === null
      ? this.getMeta().title
      : this._alias
  }

  /**
   * Returns the brick alias, if any.
   * @return {?string} Brick alias or null
   */
  getAlias () {
    return this._alias
  }

  /**
   * Sets the brick alias.
   * @param {?string} alias Brick alias or null to clear it
   * @return {Brick} Fluent interface
   */
  setAlias (alias) {
    this._alias = alias
    return this
  }

  /**
   * Returns true, if brick is set to be hidden.
   * @return {boolean}
   */
  isHidden () {
    return this._hidden
  }

  /**
   * Sets wether this brick is hidden.
   * @param {boolean} [hidden=true] True, if brick is hidden
   * @return {Brick} Fluent interface
   */
  setHidden (hidden = true) {
    if (this._hidden !== hidden) {
      this._hidden = hidden
      this.hasPipe() && this.getPipe().brickVisibilityDidChange(this, hidden)
    }
    return this
  }

  /**
   * Returns the brick settings form.
   * @return {Form} Settings form
   */
  getSettingsForm () {
    return this._settingsForm
  }

  /**
   * Convenience method for returning the fields of the setting form.
   * @return {Field[]}
   */
  getSettings () {
    return this._settingsForm.getFields()
  }

  /**
   * Adds multiple setting fields to the brick.
   * @param {Field[]|object[]} fieldsOrSpecs Array of field instances or
   * spec objects
   * @return {Brick} Fluent interface
   */
  addSettings (fieldsOrSpecs) {
    this._settingsForm.addFields(fieldsOrSpecs)
    return this
  }

  /**
   * Convenience method for returning the setting field for given name.
   * @param {string} name Setting name
   * @return {?Setting} Setting field or null, if it does not exist
   */
  getSetting (name) {
    return this._settingsForm.getField(name)
  }

  /**
   * Convenience method for adding a setting field to the brick.
   * @param {Field|object} fieldOrSpec Field instance or spec object
   * @throws {Error} If field name is already assigned.
   * @return {Brick} Fluent interface
   */
  addSetting (fieldOrSpec) {
    this._settingsForm.addField(fieldOrSpec)
    return this
  }

  /**
   * Convenience method for returning a named setting value.
   * @param {string} name Setting name
   * @throws {Error} If no setting is assigned to given setting name.
   * @return {mixed} Setting value
   */
  getSettingValue (name) {
    return this._settingsForm.getFieldValue(name)
  }

  /**
   * Convenience method for setting a named setting.
   * @param {string} name Setting name
   * @param {mixed} value Setting value
   * @throws {Error} If no setting is assigned to given setting name.
   * @return {Brick} Fluent interface
   */
  setSettingValue (name, value) {
    this._settingsForm.setFieldValue(name, value)
    return this
  }

  /**
   * Convenience method for returning the current setting value for each visible
   * setting field.
   * @return {object} Object mapping names to setting values
   */
  getSettingValues () {
    return this._settingsForm.getFieldValues()
  }

  /**
   * Applies each setting value in given map.
   * @param {object} namedValues Map of names pointing to setting values
   * @throws {Error} If no setting is assigned to given setting name.
   * @return {Brick} Fluent interface
   */
  setSettingValues (namedValues) {
    this._settingsForm.setFieldValues(namedValues)
    return this
  }

  /**
   * Returns true, if the brick is valid.
   * @return {boolean} True, if valid
   */
  isValid () {
    return this._settingsForm.isValid()
  }

  /**
   * Returns true, if the brick input is randomizable.
   * @return {boolean}
   */
  isRandomizable () {
    return this._settingsForm.isRandomizable()
  }

  /**
   * Randomizes the brick input.
   * @return {Brick} Fluent interface
   */
  randomize () {
    this._settingsForm.randomize()
    return this
  }

  /**
   * Triggered when a settings form field value has changed.
   * @param {Form} form Sender form
   * @param {Field} field Field instance that has been changed
   * @param {mixed} value New field value
   */
  formValueDidChange (form, field, value) {
    if (form === this._settingsForm) {
      // Trigger settings change event
      this.settingValueDidChange(field, value)
      // Notify pipe, if any
      this.hasPipe() && this.getPipe().brickSettingDidChange(this)
    }
  }

  /**
   * Triggered when a setting field has changed.
   * @protected
   * @param {Field} setting Sender setting field
   * @param {mixed} value New field value
   */
  settingValueDidChange (setting, value) {
    // Override method
  }

  /**
   * Triggered when view has been created.
   * @protected
   */
  didCreateView (view) {
    // Add settings form as a subview
    view.addSubview(this._settingsForm.getView())
  }

  /**
   * Triggered when menu item has been clicked.
   * @param {BrickView} view Sender
   * @param {string} name Menu item name
   */
  viewMenuItemDidClick (view, name) {
    // Track action
    EventManager.trigger('brickMenuItemClick', { brick: this, menuItem: name })

    // Decide what to do
    switch (name) {
      case 'remove':
        this.getPipe().removeBrick(this)
        break
      case 'hide':
        this.setHidden(true)
        break
      case 'duplicate':
        // TODO Create a UI to communicate that brick duplication is not
        // possible with invalid settings.
        if (this.isValid()) {
          this.getPipe().duplicateBrick(this)
        }
        break
      case 'randomize':
        this.randomize()
        break
    }
  }

  /**
   * Triggered when replace button has been clicked.
   * @param {BrickView} view Sender
   * @protected
   */
  viewReplaceButtonDidClick (view) {
    if (this.hasPipe()) {
      // Track action
      EventManager.trigger('brickReplaceButtonClick', { brick: this })
      // Forward request to pipe
      this.getPipe().brickReplaceButtonDidClick(this)
    }
  }

  /**
   * Serializes brick to a JSON serializable value.
   * @return {mixed} Serialized data
   */
  serialize () {
    const data = {}

    // Brick name
    data.name = this.getMeta().name

    // Settings, if any
    const settingsData = this._settingsForm.serializeValues()
    if (Object.keys(settingsData).length > 0) {
      data.settings = settingsData
    }

    // Hidden state, if hidden
    if (this.isHidden()) {
      data.hidden = true
    }

    return data
  }

  /**
   * Creates a copy of this brick.
   * @throws {Error} If brick is invalid and thus can't be copied.
   * @return {Brick} Brick copy instance
   */
  copy () {
    if (!this.isValid()) {
      throw new Error(`Invalid bricks can't be copied.`)
    }
    const copy = new this.constructor()
    copy.setAlias(this.getAlias())
    copy.setHidden(this.isHidden())
    copy.setSettingValues(this.getSettingValues())
    return copy
  }

  /**
   * Extracts brick from serialized data.
   * @param {mixed} data Serialized data
   * @param {BrickFactory} brickFactory Brick factory instance
   * @throws {Error} If data is malformed.
   * @return {Brick} Extracted brick
   */
  static extract (data, brickFactory) {
    // Read brick name
    if (typeof data.name !== 'string') {
      throw new Error(
        `Malformed brick data: Attribute 'name' is expected to be a string`)
    }

    const name = data.name

    // Check if this is a known brick type
    if (!brickFactory.exists(name)) {
      throw new Error(
        `Malformed brick data: Unknown brick with name '${name}'`)
    }

    // Create brick instance
    const brick = brickFactory.create(name)

    // Read and apply alias
    if (typeof data.alias !== 'undefined' &&
        typeof data.alias !== 'string') {
      throw new Error(
        `Malformed brick data: ` +
        `Optional attribute 'alias' is expected to be string`)
    }

    if (data.alias !== undefined) {
      brick.setAlias(data.alias)
    }

    // Read and apply hidden state
    if (typeof data.hidden !== 'undefined' &&
        typeof data.hidden !== 'boolean') {
      throw new Error(
        `Malformed brick data: ` +
        `Optional attribute 'hidden' is expected to be boolean`)
    }

    if (data.hidden === true) {
      brick.setHidden(true)
    }

    // Read and apply reverse state
    if (typeof data.reverse !== 'undefined' &&
        typeof data.reverse !== 'boolean') {
      throw new Error(
        `Malformed brick data: ` +
        `Optional attribute 'reverse' is expected to be boolean`)
    }

    if (data.reverse === true) {
      if (typeof brick.setReverse !== 'function') {
        throw new Error(
          `Malformed brick data: Brick with name '${name}' can't be reversed`)
      }
      brick.setReverse(true)
    }

    // Apply setting values
    if (data.settings !== undefined) {
      brick.getSettingsForm().extract(data.settings)
    }

    return brick
  }
}
<|endoftext|>


<commit_before><commit_msg>change<commit_after><|endoftext|>