vocabulary-api.js

const http = require('voc-http');
const parseDocument = require('voc-dom');
const clone = require('lodash.clone');

/** 
 * Unofficial Promise-based API interface for Vocabulary.com.
 * 
 * Uses Vocabulary.com's JSON API enpoints where possible.
 * Falls back on HTML extraction for some features.
 * */
class VocAPI {

    /**
     * @constructor
     */
    constructor(options) {
        this.PROTOCOL = 'https';
        this.HOST = 'www.vocabulary.com';
        this.URLBASE = `${this.PROTOCOL}://${this.HOST}`;
        this.listNameCache = {};

        this.loggedIn = false;
        this.http = http;

        // option defaults
        this.options = {
            'exampleSourceMode': 'description',
            increaseImportanceForDuplicates: true, // TODO: implement feature
        };

        Object.assign(this.options, options ? options : {});
    }

    /**
     * @access private
     * @param {*} res 
     */
    static defaultResHandler(res) {
        if (res.status === 200) {
            return Promise.resolve(res.response);

        } else if (res.status !== 200) {
            return Promise.reject(res.response);
        }
    }

    /**
     * Logs the user in & sets internal cookie auth so that further methods can access
     * account-specific information.
     * 
     * @param {String} username Username for the vocabulary.com account. 
     * @param {String} password Password for the vocabulary.com account.
     */
    login(username, password) {
        const formData = {
            ".cb-autoLogon": 1,
            "autoLogon":	true,
            username,
            password
        };

        return this.http('POST', `${this.URLBASE}/login/`, {
            referer: `${this.URLBASE}/login/`
        }, VocAPI.getFormData(formData)).then(VocAPI.defaultResHandler)
            .then((r) => {this.loggedIn = true; return r});
    }

    /**
     * Returns true if logout was successful. 
     * @access private
     * @param {*} username 
     * @param {*} password 
     */
    logout(username, password) {
        return this.http('GET', `${this.URLBASE}/auth/logout.json`, {
            referer: `${this.URLBASE}/account`,
            responseType: 'json'
        }).then(VocAPI.defaultResHandler).then(r => {this.loggedIn = false; return r});
    }
    
    /**
     * Checks the login status.
     * Queries the account page. 
     * If a redirect to the login page occured, this promise fails. Otherwise it succeeds.
     * @access public
     */
    checkLogin() {
        if (!this.loggedIn) {
            const requestUrl = `${this.URLBASE}/account`;
            return this.http('GET', requestUrl, {
                    referer: this.URLBASE,
                    responseType: 'document'
                })
                .then(res => {
                    let doc = parseDocument(res.response);
                    if (doc.querySelector('body').classList.contains('top-section-account')) { 
                        // has classes 'loggedin' or 'loggedout'
                        this.loggedIn = true;
                        return true;
                    } else {
                        return Promise.reject('not logged in');
                    }
                });
         } else {
            return Promise.resolve(true);
        }
    }

    /**
     * @param {*} id 
     */
    getListName(id) {
        if (id in this.listNameCache) {
            return Promise.resolve(this.listNameCache[id]);
        } else {
            return this.getLists().then(lists => {
                let list = lists.find(l => l.wordlistid == id);
                if (list) {
                    this.listNameCache[id] = list.name;
                    return this.listNameCache[id];
                } else {
                    return Promise.reject("List not found");
                }
            })
            .catch(console.warn)
        }
    }

    getListId(name) {
       let key = Object.keys(this.listNameCache).find(id => this.listNameCache[id] === name);
       if (key) {
           return Promise.resolve(this.listNameCache[key]);
       } else {
            return this.getLists().then(lists => {
                let list = lists.find(l => l.name === name);
                if (list) {
                    this.listNameCache[list.wordlistid] = list.name;
                    return list.wordlistid;
                } else {
                    return Promise.reject(new Error("List not found"));
                }
            })
       }
    }

     /**
     * @access private
     * @param {*} id 
     */
    getListNameSync(id) {
        if (id in this.listNameCache) {
            return this.listNameCache[id];
        } else {
            return null;
        }
    }

    /**
     * Gets definition of a word. 
     * @param {String} word the word to get a definition for
     * @returns {Definition} definition in the format. Proper return format documentation TODO, check the code
     */
    getDefinition(word) 
    {
    // response format 
    //     {
    //         "word": "string",
    //         "definition": "string", // primary definition (as given by the meta description tag)
    //         "description": "string", // the long format description unique to Vocabulary.com 
    //         "audioURL": "string", // of the form https://audio.vocab.com/1.0/us/C/12RWPXAD427B5.mp3
    //                                 // with C/.... being the code, in data-audio on the element
    //                                 // document.querySelector('.audio')
    //         "meanings": [ // groups of meanings
    //                 [       // meaning group
    //                         { // meaning "ordinal"
    //                                 "definition": "string", 
    //                                 "pos": "string",          // part of speech: noun
    //                                 "synonyms": [ "string" ], 
    //                                 "subtypes": [             // subtypes of the word
    //                                         {
    //                                                 "word": ["string"],   // subtype synonyms
    //                                                 "definition": "string" 
    //                                         }
    //                                 ],
    //                                 "supertypes":  [                      // subtypes of the word
    //                                         {
    //                                                 "word": ["string"],   // supertype synonyms
    //                                                 "definition": "string" 
    //                                         }
    //                                 ],
    //                         }
    
    //                 ]
    //         ]
    // }

        return this.http('GET', `${this.URLBASE}/dictionary/${word}`, {
            referer: `${this.URLBASE}/dictionary`,
            responseType: 'document'
        }).then(({response}) => {
            const doc = parseDocument(response);

            const outObject = {};
            outObject.word = word;

            const topPageEl = doc.querySelector('.wordPage')
            outObject.lang = topPageEl.dataset.lang;
            outObject.learnable = topPageEl.dataset.learnable;

            outObject.short = doc
                .querySelector('.short') // returns p
                .textContent;

            outObject.long = doc
                .querySelector('.long')
                .textContent;

            // get meanings
            outObject.meanings = Array.from(doc.querySelectorAll(".group")).map(group => 
                Array.from(group.querySelectorAll(".ordinal")).map(ordinal => {

                    // loop instances (synonyms, types, supertypes)
                    let instances = Array.from(ordinal.querySelectorAll("dl.instances")).reduce( (acc, instance) => 
                        {
                            instanceWordMapper = (root) => Array.from(root.querySelectorAll("a.word")).map(word => word.textContent);
                            wordDefList = () => Array.from(instance.querySelectorAll("dd")).map(wordDef => {
                                // test for word content
                                if (wordDef.querySelector("a.word")) {
                                        return ({
                                            "word": instanceWordMapper(wordDef),
                                            "definition": wordDef.querySelector(".definition").textContent
                                        });
                                    } else {
                                        return null;
                                    }
                                }).filter(a => a);
                            let title = instance.querySelector("dt");
                            if (title) {
                                title = title.textContent;
                                if (title.match(/Synonyms/)) {
                                    return {...acc, "synonyms": instanceWordMapper(instance) }
                                } else if (title.match(/Types/)) {
                                    return {...acc, "subtypes": wordDefList() };
                                } else if (title.match(/Type of/)) {
                                    return {...acc, "supertypes": wordDefList() };
                                } else if (title === "") { 
                                    // extra synonym with definition (assumed), for now we drop the def
                                    // assumed to come after synonyms, may give problems
                                    // eg the first in https://www.vocabulary.com/dictionary/subsidiary
                                    return {...acc, "synonyms": [...acc.synonyms, ...instanceWordMapper(instance)] }
                                } else {
                                    return acc;
                                }
                            } else {
                                return acc;
                            }
                        }, {} 
                    );

                    // pos anchor
                    const anchor = ordinal.querySelector(".anchor");
                    return {
                            definition: anchor.nextSibling.textContent.trim(),
                            pos: anchor.title,
                            synsetid: anchor.name,
                            ...instances
                        };
                    })
            );

            return outObject;
        })
    }

      /** 
      * @typedef {Object} VocListDescription Voc API's detailed outbound representation of a list.
      * This is a direct mapping of Vocabulary.com's API
      * @property {number} wordlistid the word list id
      * @property {string} name the title of the list
      * @property {string} description the description of the list
      * @property {boolean} shared whether the list is shared (public) or not (private) 
      * @property {string} createdate date the list was created
      * @property {string} modifieddate date the list was modified
      * @property {string} activitydate date the list was last played
      * @property {number} wordcount the count of words in the list
      * @property {boolean} owner whether the requester is the owner of the list
      * @property {number} p progress of learning this list
      * @property {number} ap progress of the learning activity on this list (guessed)
      */

    /**
     * Returns a list of the lists of the logged in user.
     * @returns {VocListDescription[]} a list of word lists. Proper return format documentation TODO, try it out & see what happens!
     */
    getLists() {
        /* example output
         * [ { wordlistid: 2137002,
                name: 'In The Wild',
                shared: false,
                createdate: '2018-01-25T20:33:41.325Z',
                modifieddate: '2018-12-22T11:14:36.656Z',
                wordcount: 171,
                description: 'Words found in random places on the internet.',
                owner: true,
                activitydate: '2018-12-22T11:14:36.656Z',
                p: 0.34783363,
                ap: 0.19866072 },
         */
        return this.http('GET', `${this.URLBASE}/lists/byprofile.json`, {
                referer: `${this.URLBASE}/dictionary/hacker`,
                responseType: 'json'
            }).then((res) => {
                // options: name, createdate, wordcount, activitydate TODO: make options
                let sortBy = "modifieddate";
                if (res.status === 200) {
                    const lists = res.response.result.wordlists
                        .filter(wl => wl.owner)
                        .sort((a,b) => a[sortBy] > b[sortBy] ? -1 : 1); // high to low
                    
                    // fill cache with names
                    lists.forEach(wl => {
                        this.listNameCache[wl.wordlistid] = wl.name;
                    })
                    return Promise.resolve(lists);
                } else {
                    throw new Error(res.responseText);
                }
            });
   }

    /**
     * Gets the learning progress of a word
     * @param {String} word the word for which to retrieve progress
     * @returns {Object} progress object. Proper return format documentation TODO, try it out & see what happens! 
     */
    getProgress(word) {
        return this.http("POST", `${this.URLBASE}/progress/progress.json`, {
                referer: `${this.URLBASE}/dictionary/${word}`,
                responseType: 'json' 
            }, `word=${word}`).then((res) => {
                if (res.status === 200) {
                    if (res.response.lrn === false ) {
                        return Promise.reject('Not learnable');
                    } else {
                        const prog = res.response;
                        const response =  {
                            "word": prog.word,
                            "progress": prog.prg,
                            "priority": prog.pri,
                            "lists": prog.ld, // [{"current":false,"listId":2137002,"wordcount":87,"priority":5,"progress":0.410468,"name":"In The Wild"}]
                            "pos": prog.pos,
                            // I think prog.dif is individual diff, prog.diff is global diff
                            "diff": prog.dif ? prog.dif : prog.diff,
                            "def": prog.def
                            };
                        return response;
                    }
                } else {
                    throw new Error('Bad progress response from API');
                }
        });
    }

    /**
     * Sets the priority for learning a word
     * @param {*} word 
     * @param {*} priority afaik: -1 for low priority (or auto?), 0 high priority
     * @returns {Promise}
     */
    setPriority(word, priority) {


        return this.http('POST', `${this.URLBASE}/progress/setpriority.json`, {
            referer: `${this.URLBASE}/dictionary/${word}`
        }, VocAPI.getFormData({word: word, priority: priority})).then(VocAPI.defaultResHandler);
        // todo: check response & adjust response handler for bad requests/responses
    }

    /**
     * @access private
     * @param {Object} res http response from autoComplete api
     * @returns {Meaning[]} a list of possible word meanings
     */
    static autoCompleteMapper(res) {
        let suggestions = [];
        let doc = parseDocument(res.response);
        let lis = doc.querySelectorAll('li');
        for (let i = 0; i < lis.length; i ++) {
            let el = lis[i];
            let suggestion = {};
            suggestion.lang = el.getAttribute('lang');
            suggestion.synsetid = el.getAttribute('synsetid');
            suggestion.word = el.getAttribute('word');
            suggestion.frequency = el.getAttribute('freq');
            suggestion.definition = el.firstChild.children[2].textContent;
            suggestions.push(suggestion);
        }

        return Promise.resolve(suggestions);
    }

    /**
     * Gives word suggestions for a search term. All suggestions are valid vocabulary.com words. The searchTerm can be a partial word, or slightly misspelled.
     * @param {*} searchTerm searchterm
     * @returns {Meaning[]} a list of possible word meanings (from different words)
     */
    autoComplete(searchTerm) {
        // TODO: re-test in browser
        // TOOD: test with illegal input (empty...)
        return this.http('GET', `${this.URLBASE}/dictionary/autocomplete?${VocAPI.getFormData({search: searchTerm})}`)
        .then(VocAPI.autoCompleteMapper);
    }

    /**
     * @typedef {Object} Meaning
     * @property {String} word 
     * @property {definition} frequency Frequency of the usage of this meaning (possibly, not sure)
     * @property {String} synsetid id that identifies this particular meaning
     * @property {String} lang language, always "en" so far
     * @property {number} frequency Frequency of the usage of this meaning (possibly, not sure)
     * @property {String} definition the short definition of this meaning
     */

     /**
     * Gives a list of learnable meanings (definitions) of the specific word
     * @param {String} word the word for which to retrieve meanings.
     * @returns {Meaning[]} a list of learnable meanings of the specific word
     */
    getMeanings(word) {
        // TODO: re-test in browser
        // TODO: these only give the learnable meanings. Definition should give full
        return this.http('GET', `${this.URLBASE}/dictionary/autocomplete?${VocAPI.getFormData({search: `word:"${word}"`})}`)
        .then(VocAPI.autoCompleteMapper);
    } 

    /**
     * @access private
     * @param {String} text
     * @returns {Object}
     * {
     *  "format":"list",
     *  "words":[{"word":"test","def":"standardized procedure for measuring sensitivity or aptitude","diff":290,"freq":58.08562}]
     *  "notfound": ['word1'],
     *  "notlearnable": ['word2']}}
     *  NOTE not learnable words are also included in 'words'
     */
    grabWords(text) {
        return this.http('POST', `${this.URLBASE}/lists/vocabgrabber/grab.json`, {
            referer: `${this.URLBASE}`,
            responseType: 'json'
        }, VocAPI.getFormData({text: text}))
        .then(VocAPI.defaultResHandler);
    }

    /**
     * Attempts to correct the word with the nearest available word in Vocabulary.com.
     * Rejects if no word was found.
     * @param {String} word the word to correct
     */
    correctWord(word) {
        return this.autoComplete(word).then(suggestions => {
            if (suggestions) {
                return Promise.resolve(VocAPI.getSimilarFrom(suggestions.map(w => w.word), word));
            } else {
                return Promise.reject('not found in Vocabulary.com');
            }
        });
    }

    /**
     * Provides a measure for the similarity of two words.
     * @access private
     * @param {*} word1 
     * @param {*} word2
     * @returns float between 0 and 1. 1 = one included in the other, 0 = completely unsimilar 
     */
    static similarity(word1, word2) {
        /* test:
        > sim2('speak','spozc')
        0.4000000000000001
        > sim2('pre-eminent','pre-eminently')
        1
        > sim2('pre-eminentitious','pre-eminently')
        0.8461538461538463
        > sim2('cooks','cooking')
        0.8
        */

        // TODO: should give slightly more weight to first letters for inflections/conjugations 
            // eg. speaks ~ speak
            // pre-eminent ~ pre-eminently
            // but this linear model should work too 
        let similarity = 1;
        let minLen = Math.min(word1.length, word2.length);
        let step = 1 / ((minLen === 0) ? 1 : minLen); 
        let i = 0, j = 0;
        while ( i < word1.length && j < word2.length) {
            if (word1[i] !== word2[j]) {
               similarity -= step; 
            }
            i++; j++;
        }
        return similarity;
    }

    static isSimilar(word1, word2, threshold) {
        return VocAPI.similarity(word1, word2) > (threshold ? threshold : 0.6);
    }

    /**
     * @typedef {Object} WordSimilarity
     * @property {String} word 
     * @property {number} similarity similarity to a given word (low 0 - 1 high)
     */

    /**
     * Returns the most similar word string from an array of strings, compared to a given word
     * @access private
     * @param {String[]} arr
     * @param {String} word
     * @returns {WordSimilarity[]} wordSimilarities array of objects with a word string and similarity
     */
    static getSimilarFrom(arr, word) {
        return arr
        .filter(w => VocAPI.isSimilar(w, word))
        // if multiple are similar, select the most similar
        .reduce( (acc, cur) => {    
            let curSimil = VocAPI.similarity(cur, word);
            if (acc.similarity < curSimil) {
                return {word: cur, similarity: curSimil};
            } else { return acc; }
        }, {word: word, similarity: 0}).word;
    };

    /**
     * Bulk-correct a list of word objects.
     * @param {Word[]} words a list of word objects to be corrected
     */
    correctWords(words) {
        /*
        1. grab words
        2. run through in order with a similarity checker
            --> use the 'notfound' key to verify / preprocess ?
            --> 'notlearnable' key exists too (can add and will be in list, but not learnable)
            - combine grabword with local word that is similar (>0.6), ala list merge
            - skip local word that are highly unsimilar, add to not-supported list)
        3. return words with local comments etc + not-supported list
        */
        // convert requested words to a string
        let wordText = "";
        if (words.length > 1) {
            wordText = words.map(w => w.word).join(', ');
        } else if (words.length == 1) {
            wordText = words[0].word + ",";
        } else {
            return Promise.reject("Can't add an empty list");
        }

        return this.grabWords(wordText).then( (result) => {

            //console.log(result);

            let merge = (original, grab) => {
                // const newWord = {...original}; // TODO EcmaScript 2018. Use Babel everywhere?
                const newWord = clone(original);
                newWord.word = grab;
                return newWord;
            }

            let resultWords = result.words.map(w => w.word)
            let mergeResult = [];
            let corrected = [];
            let notfound = result.notfound ? result.notfound : [];
            let notlearnable = result.notlearnable ? result.notlearnable : [];
            let resultIndex = 0;

            let inputs = [];
            
            // loop over originally requested words
            for (let i = 0; i < words.length && resultIndex < resultWords.length; i++) {
                let original = words[i];
                let resultWord = result.words[resultIndex]; 

                // word was not found, skip it
                if (notfound.indexOf(original.word) !== -1) {
                    continue;
                }

                // explanation:
                // if there are multiple words of the same stem, vocab's grabber only takes out one and puts it 
                // in the order of the first one. The variants, if not equal to the stem, are added to the 'input' property
                // TODO: give it higher importance: more appearnces, more important
                // TODO: is desired behavior to have two examples? then change is necessary

                // build inputs
                // TODO: might be done more than once for the same resultIndex
                let currentInput = resultWord.input ? resultWord.input : []; 
                if (currentInput) { // word has more inputs
                    // add word itself for the following check
                    inputs = [resultWord.word, ...currentInput, ...inputs];
                }

                if (inputs.find(w => w === original.word) 
                    && !( currentInput.find(w => w === original.word) || resultWord.word === original.word) ) { // check if current was an input for another one before
                    continue; // skip as well
                    // TODO: add as a second instance of the word before, with new example? as an option
                }

                /* TODO: not necessary: not learnable words are in 'words' --> treat them normally
                // is a similar was found in notlearnable ==> merge & add it
                } else if (getSimilarFrom(notlearnable, original.word)) {
                    mergeResult.push(merge(original, resultWord.word));
                    continue;
                }
                */

                // not in not found, not in not learnable ==> we assume similarity by order
                mergeResult.push(merge(original, resultWord.word));

                if (original.word !== resultWord.word) {
                    corrected.push(original);
                }
                resultIndex++;
            }
            return Promise.resolve({
                words: mergeResult,
                notfound: notfound,
                notlearnable: notlearnable,
                corrected: corrected
                });
        });
    }

    /**
     * @param {String} wordToLearn word
     */
    startLearning(wordToLearn) {
        return this.http('POST', `${this.URLBASE}/progress/startlearning.json`, 
                {referer: `${this.URLBASE}/dictionary/${wordToLearn}`},`word=${wordToLearn}`)
                .then(VocAPI.defaultResHandler);
    }

    /**
     * @param {String} listId list to start learning
     */
    startLearningList(listId) {
        // TODO: test + use in double priority checker with option to auto-start learning if that is not yet so
        return this.http('POST', `${this.URLBASE}/lists/${listId}/start.json`, 
        {referer: `${this.URLBASE}/lists/${listId}`})
        .then(VocAPI.defaultResHandler);
    }

    setExampleSourceMode(mode) {
        if (/^(description|example|combined|none)$/.test(mode)) {
            this.options.exampleSourceMode = mode;
        }
    }

    /**
     * Maps word objects from this API interface's format to voc.com's format
     * Adds some obvious info like date added
     * @access private
     * @param {Word} w word
     * @param {string} mode one of "description", "example" or "none". 
     * Decides how source link information should be saved in vocabulary.com.
     * - "description" (default) adds a string like "Added from URL: https://en.wikipedia.org/wiki/Exigent_circumstance on Saturday 9 June 2018 at 18:17." to the description
     * - "example" is a hacky and experimental option that inserts the title of the source as a direct example source. But caution: this is definitely not inteded by vocabulary.com. It looks nice, but links are not clickable.
     * - "combined" both approaches are used simultaneously
     * - "none" does not try to add any metadata about the source
     * The global default setting can be set by using t
     */
    wordMapper(w, sourceMode) {
        let nw = {
        "word": w.word,
        "lang": "en"
        }
        w.description ? nw["description"] = w.description : false;
        const now = new Date();
        const pad = (c) => (c+'').length === 1 ? '0' + c : c+'';

        // add example
        if (w.example) {
            // limit example lenght to 500 to prevent errors (vocab restriction)
            // TODO: possibly, if > 500 chars, find word and  extract 500 chars around the word with ... [rest] word [rest] ...
            w.example = w.example.slice(0, 500);
            nw.example = { "text": w.example };
        }
        
        // puts date in word comment depending on options
        if (!this.options || this.options.exampleSourceMode && (this.options.exampleSourceMode === 'description' || this.options.exampleSourceMode === 'combined')) {
            const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
            const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
            const hhmm = pad(now.getHours()) + ':' + pad(now.getMinutes());
            const dateString = `${days[now.getDay()]} ${now.getDate()} ${months[now.getMonth()]} ${now.getFullYear()} at ${hhmm}.`;
            let locationString = "";
            if (w.location) {
                if (w.title) {
                    locationString = `Added from "${w.title}" (${w.location.trim()}) on ${dateString}`; 
                } else {
                    locationString = `Added from URL: ${w.location} on ${dateString}`;
                }
            }
            const isolatedDateString = `Added on ${dateString}`;
            
            // add description and URL if present
            if (w.description) {
                nw.description = w.description;
                if (locationString) {
                    nw.description += '\n' + locationString;
                } else {
                    nw.description += '\n' + isolatedDateString;
                }
            } else {
                if (locationString) {
                    nw.description = locationString;
                } else {
                    nw.description = isolatedDateString;
                }
            }
        // puts date in source a source object
        }
        
        if (this.options.exampleSourceMode && (this.options.exampleSourceMode === 'example' || this.options.exampleSourceMode === 'combined')) {
            const date = `${now.getFullYear()}${pad(now.getMonth()+1)}${pad(now.getDate())}`;

            if (w.description) {
                nw.description = w.description;
            }

            if (w.example) {
                nw.example.source = {
                    id: 'LIT', // this special id will allow the source to be shown in lists
                                        // probably stands for non-online 'literature'
                                        // found in voc.com js code
                    locator: w.location ? w.location : undefined,
                    date: date,
                    name: w.title ? w.title : 'Untitled source'
                };
            }
        }

        // adds synsetid if present
        if (w.synsetid) {
            nw.synsetid = w.synsetid;
        }

        return nw;
    }

    /**
     * @typedef {Object} Word Voc API's inbound representation of a word. This is what you give to the API.
     * @property {string} word the word
     * @property {string} [location] URL location of the word
     * @property {string} [description] description to be added to be attached to the word in a word list
     * @property {string} [example] example or source text containing the word
     * @property {string} [title] the title of the web page that contained the word
     */

    /** 
     * @typedef {Object} VocExample API's detailed outbound example representation.
     * @property {string} text the example text
     * @property {number[]} offsets an array of offsets that delimit the word in the text. 0-indexed, start inclusive, end exclusive.
     */

     /**
      * @typedef {Object} VocWord Voc API's detailed outbound word representation.
      * This is a direct mapping of Vocabulary.com's API
      * @property {string} word the word
      * @property {lang} lang the language
      * @property {string} description description 
      * @property {VocExample} example example object
      * @property {string} shortdefinition a short definition
      * @property {string} definition a longer definition
      * @property {string[]} audio an array of audio codes
      * @property {string} ffreq I have no clue
      */

    /** 
      * @typedef {Object} VocList Voc API's detailed outbound representation of a list.
      * This is a direct mapping of Vocabulary.com's API
      * @property {number} wordlistid the word list id
      * @property {string} name the title of the list
      * @property {string} description the description of the list
      * @property {VocWord[]} words an array of words in Vocabulary.com's format 
      * @property {boolean} shared whether the list is shared (public) or not (private) 
      * @property {string} createdate example object
      * @property {string} modifieddate a short definition
      * @property {number} wordcount the count of words in the list
      * @property {number} learnable the count of learnable words in the list
      * @property {boolean} unlearnable the count of unlearnable words in the list
      * @property {boolean} owner whether the requester is the owner of the list
      */

    /** 
    * Add a given list to an existing list, given by name. The first (most recent) list with that name will be used. 
    * Convenience method that combines getListName and addToList.
    * @param {Word[]} words an array of words to add to the list
    * @param {number} listName name of the list
    * @returns {{"status": status, "result": listId}} statusObject 0 is ok
    */  
    addToListName(words, listName) {
        return this.getListId(listName).then((id) => this.addToList(words, id));
    }

    /** 
    * @param {Word[]} words an array of words to add to the list
    * @param {number} listId id of the list
    * @returns {{"original": String, "corrected": String, "listId": String}} response object 
    */ 
    addToList(words, listId) {
        // single word: use single word correction
        if (words && words.length === 1) {
            let inword = words[0];
            return this.correctWord(inword.word).then((word) => {
                let outword = Object.assign({}, inword);
                if (inword.word !== word) {
                    console.log(`${inword.word} corrected to ${word}`);
                    outword.word = word;
                }
                return this.http('POST', `${this.URLBASE}/lists/save.json`, {
                    referer: `${this.URLBASE}/dictionary/${words[0]}`,
                    responseType: "json" 
                }, VocAPI.getFormData({
                    "addwords": JSON.stringify([outword].map(this.wordMapper.bind(this))),
                    "id": listId 
                }))
                .then(VocAPI.defaultResHandler)
                .then((res) => {
                    if (res.status === 1) {
                        throw new Error(res.error);
                    } else {
                        return {
                            original: inword,
                            corrected: word,
                            listId: res.result
                        }
                    }
                })
                // increase priority of duplicates
                // TODO: warn that setting priority requires wordlist to be in learning program
                .then( (result) => {
                    // side-effect request...
                    if (this.options.increaseImportanceForDuplicates) {
                        this.getList(result.listId)
                        .then((listSrc) => {
                            // check if already existing in list / TODO do with progress for single?
                            let fi = listSrc.words.find(w => w === result.corrected);
                            if (fi) {
                                this.setPriority(result.corrected, 1).then(); 
                            }
                        });
                    }

                    return result;
                });
            });


        // multiple words: use bulk correction
        // TODO: progress & result handling like above
        } else if (words && words.length > 1) {
            return this.correctWords(words).then((result) => {
                return this.http('POST', `${this.URLBASE}/lists/save.json`, {
                    referer: `${this.URLBASE}/dictionary/${result.words[0]}`,
                    responseType: 'json' 
                }, VocAPI.getFormData({
                    "addwords": JSON.stringify(result.words.map(this.wordMapper.bind(this))),
                    "id": listId 
                }))
                .then(VocAPI.defaultResHandler)
                .then((res) => {
                    if (res.status === 1) {
                        throw new Error(res.error);
                    } else {
                        return {
                            // original: inword, TODO: variant for multiple? (not necessary)
                            // corrected: word,
                            listId: res.result
                        }
                    }
                })  
            });
        }
    }

    /** 
    * @param words an array of words to add to the new list
    * @param listName name of the new list
    * @param description description of the list
    * @param shared boolean that shows whether list should be shared or not
    */ 
    addToNewList(words, listName, description, shared) {

        let listObj = {
            "name": listName,
            "description": description,
            "action": "create",
            "shared": shared
         };

        return this.correctWords(words).then(result => {
            listObj.words = result.words.map(this.wordMapper.bind(this));
            return this.http('POST', `${this.URLBASE}/lists/save.json`,
            {
                referer: `${this.URLBASE}/lists/vocabgrabber`,
                responseType: 'json' 
            }, VocAPI.getFormData({'wordlist': JSON.stringify(listObj)}))
            .then(VocAPI.defaultResHandler)
            .then((res) => {
                if (res.status === 1) {
                    throw new Error(res.error);
                } else {
                    return {
                        // original: inword, TODO: variant for multiple? (not necessary)
                        // corrected: word,
                        listId: res.result
                    }
                }
            }) ;
        })
     }

    deleteList(listId) {
        return this.http('POST', `${this.URLBASE}/lists/delete.json`, {
            referer: `${this.URLBASE}/lists/${listId}/edit`
        }, VocAPI.getFormData({id: listId}))
        .then(VocAPI.defaultResHandler)
        .then(r => {
            // invalidate cache
            delete this.listNameCache[listId];
            // handle response
            return r;});
    }

    /**
     * Gets a list of words
     * @param {string} listId the ID of the list to get 
     * @returns {VocList} vocabulary.com word list object with a key "words" for the objects and other metadata
     */
    getList(listId) {
        // TODO
        /* example
            [{"word":"zilch","lang":"en",
                "description":"Added from URL: https://forums.macrumors.com/threads/usb-c-powerba… on Wednesday 6 June 2018 at 14:08.",
                "example":{"text":"So far, zilch.","offsets":[8,13]},"definition":"a quantity of no importance","shortdefinition":"a quantity of no importance",
                "audio":["D/15IWYVT54ZU23"],"ffreq":4.6965513531891756E-4}}]

        // error format: {status: 1, "errortype", "error", "message"}
        */
        return this.http('POST', `${this.URLBASE}/lists/load.json`, {
            referer: `${this.URLBASE}/dictionary/hack`,
            responseType: "json"
        }, VocAPI.getFormData({id: listId}))
        .then(VocAPI.defaultResHandler).then(listSrc => {
            if (listSrc.status === 1) {
                 throw new Error(listSrc.error);
            } else {
                return listSrc.result;
            }
        });
    }

    /**
     * @param {String} name name of the list
     * @returns {VocList} the requested list
     */
    getListByName(name) {
        // TODO: test
        return this.getListId(name).then(this.getList.bind(this));
    }

    /**
     * Transforms objects of the form {"key": value, "key2": value2} to the form key=value&key2=value2
     * With the values interpreted as strings. They are URL encoded in the process.
     * @access private
     * @param {Object} object 
     */
    static getFormData(object) {
        // const formData = new FormData();
        // Object.keys(object).forEach(key => formData.append(key, object[key]));
        let returnString = '';
        Object.keys(object).forEach((key, index) => returnString += `${index === 0 ? '' : '&'}${key}=${encodeURIComponent(object[key])}`)
        return returnString;
        }
}

// TODO: maybe not the best way to have node/webpack/browser script compat
if (!!module) {
    module.exports = VocAPI;
} else if (window) {
    window.VocAPI = VocAPI;
}