tbhelpers.js

import $ from 'jquery';
import pako from 'pako';
import SnuOwnd from 'snuownd';

/**
 * Returns a promise that resolves after the given time.
 * @param {number} ms Number of milliseconds to delay
 * @returns {Promise<void>}
 */
export const delay = ms => new Promise(resolve => setTimeout(resolve, ms));

/**
 * Debounces a given function based on a given timeout.
 * @function
 * @param {function} func input function
 * @param {number} debounceTime the amount of time used to wait in ms.
 * @returns {function} the executed function
 */
export function debounce (func, debounceTime = 100) {
    let timeout;

    return function (...args) {
        const functionCall = () => func.apply(this, args);
        clearTimeout(timeout);
        timeout = setTimeout(functionCall, debounceTime);
    };
}

/**
 * Creates a processing queue that allows items to be added one at a time, and
 * defers processing of those items until a specified delay between item
 * additions happens, or a maximum length is reached. Returns an insert function
 * to add items to the queue. Additionally, each invocation of the insert
 * function returns a promise that can be awaited on to receive the processed
 * result of the specific item added.
 *
 * Creating the queue requires giving it a processing function, which must take
 * an input array of items, and return a promise that resolves to an array of
 * corresponding results.
 *
 * @template Item Item queued for processing
 * @template Result Result value from processing an item
 * @param {(items: Item[]) => Promise<Result[]>} bulkProcess Receives an array
 * of items from the queue and returns an array of results, where each result
 * corresponds to the input item of the same index
 * @param {number} [delayTime=100] This many milliseconds must pass without an
 * item being queued before the queue is flushed
 * @param {number} [queueLimit=Infinity] When the queue reaches this size, it is
 * flushed immediately without waiting for the `delayTime` to elapse
 * @returns {(item: Item) => Promise<Result>} New function which queues an item
 * and returns a promise for the corresponding result after processing
 */
export function createDeferredProcessQueue (bulkProcess, delayTime = 100, maxQueueLength = Infinity) {
    /** @type {number} */
    let timeout;
    /** @type {{item: Item, resolve: (value: Item) => void, reject: (error: any) => void}[]} */
    let queue = [];

    const flushQueue = async () => {
        // Grab the current queue and replace it with an empty array to collect
        // further calls
        const queueSnapshot = queue;
        queue = [];

        let results;
        try {
            // Call the callback with an array of accumulated items
            results = await bulkProcess(queueSnapshot.map(call => call.item));
        } catch (error) {
            // If the call failed, return the same error to all callers
            queueSnapshot.forEach(call => call.reject(error));
            return;
        }

        // Return each result to the corresponding caller
        results.forEach((result, i) => queueSnapshot[i].resolve(result));
    };

    return item =>
        new Promise((resolve, reject) => {
            // Add this call to the queue
            queue.push({item, resolve, reject});

            // Clear any existing timeout
            clearTimeout(timeout);

            // If we've hit the maximum queue length, flush the queue immediately
            if (queue.length >= maxQueueLength) {
                flushQueue();
                return;
            }

            // Otherwise, flush the queue after the debounce delay
            timeout = setTimeout(flushQueue, delayTime);
        });
}

/**
 * Moves an item in an array from one index to another
 * https://github.com/brownieboy/array.prototype.move/blob/master/src/array-prototype-move.js
 * @function
 * @param {array} array input array
 * @param {integer} old_index
 * @param {integer} new_index
 * @returns {array} New array with moved items
 */
export function moveArrayItem (array, old_index, new_index) {
    if (array.length === 0) {
        return array;
    }
    while (old_index < 0) {
        old_index += array.length;
    }
    while (new_index < 0) {
        new_index += array.length;
    }
    if (new_index >= array.length) {
        let k = new_index - array.length;
        while (k-- + 1) {
            array.push(undefined);
        }
    }
    array.splice(new_index, 0, array.splice(old_index, 1)[0]);
    return array;
}

/**
 * Escape html entities
 * @function
 * @param {string} html input html
 * @returns {string} HTML string with escaped entities
 */
export function escapeHTML (html) {
    const entityMap = {
        '&': '&amp;',
        '<': '&lt;',
        '>': '&gt;',
        '"': '&quot;',
        '\'': '&#39;',
        '/': '&#x2F;',
    };

    return String(html).replace(/[&<>"'/]/g, s => entityMap[s]);
}

/**
 * Unescape html entities
 * @function
 * @param {string} html input html
 * @returns {string} HTML string with unescaped entities
 */
export function unescapeHTML (html) {
    const entityMap = {
        '&amp;': '&',
        '&lt;': '<',
        '&gt;': '>',
        '&quot;': '"',
        '&#39;': '\'',
        '&#x2F;': '/',
    };

    return String(html).replace(/[&<>"'/]/g, s => entityMap[s]);
}

/**
 * Give the nummeric value in milliseconds of the current date and time.
 * @function
 * @returns {integer} time value in milliseconds
 */
export function getTime () {
    return new Date().getTime();
}

/**
 * Give a random number
 * @function
 * @param {integer} maxInt Max integer
 * @returns {integer} random number
 */
export function getRandomNumber (maxInt) {
    return Math.floor(Math.random() * maxInt + 1);
}

/**
 * Convert minutes to milliseconds
 * @function
 * @param {integer} mins Minutes
 * @returns {integer} Milliseconds
 */
export function minutesToMilliseconds (mins) {
    const oneMin = 60000;
    let milliseconds = mins * 60 * 1000;

    // Never return less than one min.
    if (milliseconds < oneMin) {
        milliseconds = oneMin;
    }

    return milliseconds;
}

/**
 * Convert days to milliseconds
 * @function
 * @param {integer} days days
 * @returns {integer} Milliseconds
 */
export function daysToMilliseconds (days) {
    return days * 86400000;
}

/**
 * Convert milliseconds to days
 * @function
 * @param {integer} milliseconds milliseconds
 * @returns {integer} Days
 */
export function millisecondsToDays (milliseconds) {
    return milliseconds / 86400000;
}

/**
 * Returns the difference between days in nice format like "1 year"
 * @function
 * @param {Date} origdate
 * @param {Date} newdate
 * @returns {string} Formatted date difference
 */
export function niceDateDiff (origdate, newdate) {
    // Enter the month, day, and year below you want to use as
    // the starting point for the date calculation
    if (!newdate) {
        newdate = new Date();
    }

    const amonth = origdate.getUTCMonth() + 1;
    const aday = origdate.getUTCDate();
    const ayear = origdate.getUTCFullYear();

    const tyear = newdate.getUTCFullYear();
    const tmonth = newdate.getUTCMonth() + 1;
    const tday = newdate.getUTCDate();

    let y = 1;
    let mm = 1;
    let d = 1;
    let a2 = 0;
    let a1 = 0;
    let f = 28;

    if (tyear % 4 === 0 && tyear % 100 !== 0 || tyear % 400 === 0) {
        f = 29;
    }

    const m = [31, f, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

    let dyear = tyear - ayear;

    let dmonth = tmonth - amonth;
    if (dmonth < 0 && dyear > 0) {
        dmonth += 12;
        dyear--;
    }

    let dday = tday - aday;
    if (dday < 0) {
        if (dmonth > 0) {
            let ma = amonth + tmonth;

            if (ma >= 12) {
                ma -= 12;
            }
            if (ma < 0) {
                ma += 12;
            }
            dday += m[ma];
            dmonth--;
            if (dmonth < 0) {
                dyear--;
                dmonth += 12;
            }
        } else {
            dday = 0;
        }
    }

    let returnString = '';

    if (dyear === 0) {
        y = 0;
    }
    if (dmonth === 0) {
        mm = 0;
    }
    if (dday === 0) {
        d = 0;
    }
    if (y === 1 && mm === 1) {
        a1 = 1;
    }
    if (y === 1 && d === 1) {
        a1 = 1;
    }
    if (mm === 1 && d === 1) {
        a2 = 1;
    }
    if (y === 1) {
        if (dyear === 1) {
            returnString += `${dyear} year`;
        } else {
            returnString += `${dyear} years`;
        }
    }
    if (a1 === 1 && a2 === 0) {
        returnString += ' and ';
    }
    if (a1 === 1 && a2 === 1) {
        returnString += ', ';
    }
    if (mm === 1) {
        if (dmonth === 1) {
            returnString += `${dmonth} month`;
        } else {
            returnString += `${dmonth} months`;
        }
    }
    if (a2 === 1) {
        returnString += ' and ';
    }
    if (d === 1) {
        if (dday === 1) {
            returnString += `${dday} day`;
        } else {
            returnString += `${dday} days`;
        }
    }
    if (returnString === '') {
        returnString = '0 days';
    }
    return returnString;
}

/**
 * convert unix epoch timestamps to readable format dd-mm-yyyy hh:mm:ss UTC
 * @function
 * @param {integer} UNIX_timestamp
 * @returns {string} Formatted date in dd-mm-yyyy hh:mm:ss UTC
 */
export function timeConverterRead (UNIX_timestamp) {
    const a = new Date(UNIX_timestamp * 1000);
    const year = a.getUTCFullYear();
    const month = `0${a.getUTCMonth() + 1}`.slice(-2);
    const date = `0${a.getUTCDate()}`.slice(-2);
    const hour = `0${a.getUTCHours()}`.slice(-2);
    const min = `0${a.getUTCMinutes()}`.slice(-2);
    const sec = `0${a.getUTCSeconds()}`.slice(-2);
    return `${date}-${month}-${year} ${hour}:${min}:${sec} UTC`;
}

/**
 * convert titles to a format usable in urls
 * from r2.lib.utils import title_to_url
 * @function
 * @param {string} title
 * @returns {string} Formatted title
 */
export function title_to_url (title) {
    const max_length = 50;

    title = title.replace(/\s+/g, '_'); // remove whitespace
    title = title.replace(/\W+/g, ''); // remove non-printables
    title = title.replace(/_+/g, '_'); // remove double underscores
    title = title.replace(/^_+|_+$/g, ''); // remove trailing underscores
    title = title.toLowerCase(); // lowercase the title

    if (title.length > max_length) {
        title = title.substr(0, max_length);
        title = title.replace(/_[^_]*$/g, '');
    }

    return title || '_';
}

// Easy way to use templates. Usage example:
//    TBHelpers.template('/r/{{subreddit}}/comments/{{link_id}}/{{title}}/', {
//         'subreddit': 'toolbox',
//         'title':  title_to_url('this is a title we pulled from a post),
//         'link_id': '2kwx2o'
//     });
export function template (tpl, variables) {
    return tpl.replace(/{{([^}]+)}}/g, (match, variable) => variables[variable]);
}

/**
 * Converts a given amount of days in a "humanized version" of weeks, months and years.
 * @function
 * @param {integer} days
 * @returns {string} x year x months x weeks x day
 */
export function humaniseDays (days) {
    let str = '';
    const values = {
        ' year': 365,
        ' month': 30,
        ' week': 7,
        ' day': 1,
    };

    for (const x of Object.keys(values)) {
        const amount = Math.floor(days / values[x]);

        if (amount >= 1) {
            str += `${amount + x + (amount > 1 ? 's' : '')} `;
            days -= amount * values[x];
        }
    }
    return str.slice(0, -1);
}

/**
 * Sorts an array of objects by property value of specific properties.
 * @function
 * @param {array} arr input array
 * @param {string} prop property name
 */
export function sortBy (arr, prop) {
    return arr.sort((a, b) => {
        if (a[prop] < b[prop]) {
            return 1;
        }
        if (a[prop] > b[prop]) {
            return -1;
        }
        return 0;
    });
}

/**
 * Because normal .sort() is case sensitive.
 * @function
 * @param {array} arr input array
 */
export function saneSort (arr) {
    return arr.sort((a, b) => {
        if (a.toLowerCase() < b.toLowerCase()) {
            return -1;
        }
        if (a.toLowerCase() > b.toLowerCase()) {
            return 1;
        }
        return 0;
    });
}

/**
 * Because normal .sort() is case sensitive and we also want to sort ascending from time to time.
 * @function
 * @param {array} arr input array
 */
export function saneSortAs (arr) {
    return arr.sort((a, b) => {
        if (a.toLowerCase() > b.toLowerCase()) {
            return -1;
        }
        if (a.toLowerCase() < b.toLowerCase()) {
            return 1;
        }
        return 0;
    });
}

/**
 * Generates a regular expression that will match only a given string.
 * @param {string} text The text to match
 * @param {string} flags The flags passed to the RegExp constructor
 * @returns {RegExp}
 */
export const literalRegExp = (text, flags) => new RegExp(text.replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1'), flags);

/**
 * Replace all instances of a certaing thing for another thing.
 * @function
 * @param {string} find what to find
 * @param {string} replace what to replace
 * @param {string} str where to do it all with
 * @returns {string} shiny new string with replaced stuff
 */
export const replaceAll = (find, replace, str) => str.replace(literalRegExp(find, 'g'), replace);

/**
 * Will compare the input color to a list of known color names and return the HEX value
 * @function
 * @param {string} color input color
 * @returns {string} if a match is found the HEX color, otherwise the input string.
 */
export function colorNameToHex (color) {
    const colorUPPERCASE = color.toUpperCase();
    let returnValue;

    const htmlColors = {
        'ALICEBLUE': '#F0F8FF',
        'ANTIQUEWHITE': '#FAEBD7',
        'AQUA': '#00FFFF',
        'AQUAMARINE': '#7FFFD4',
        'AZURE': '#F0FFFF',
        'BEIGE': '#F5F5DC',
        'BISQUE': '#FFE4C4',
        'BLACK': '#000000',
        'BLANCHEDALMOND': '#FFEBCD',
        'BLUE': '#0000FF',
        'BLUEVIOLET': '#8A2BE2',
        'BROWN': '#A52A2A',
        'BURLYWOOD': '#DEB887',
        'CADETBLUE': '#5F9EA0',
        'CHARTREUSE': '#7FFF00',
        'CHOCOLATE': '#D2691E',
        'CORAL': '#FF7F50',
        'CORNFLOWERBLUE': '#6495ED',
        'CORNSILK': '#FFF8DC',
        'CRIMSON': '#DC143C',
        'CYAN': '#00FFFF',
        'DARKBLUE': '#00008B',
        'DARKCYAN': '#008B8B',
        'DARKGOLDENROD': '#B8860B',
        'DARKGRAY': '#A9A9A9',
        'DARKGREY': '#A9A9A9',
        'DARKGREEN': '#006400',
        'DARKKHAKI': '#BDB76B',
        'DARKMAGENTA': '#8B008B',
        'DARKOLIVEGREEN': '#556B2F',
        'DARKORANGE': '#FF8C00',
        'DARKORCHID': '#9932CC',
        'DARKRED': '#8B0000',
        'DARKSALMON': '#E9967A',
        'DARKSEAGREEN': '#8FBC8F',
        'DARKSLATEBLUE': '#483D8B',
        'DARKSLATEGRAY': '#2F4F4F',
        'DARKSLATEGREY': '#2F4F4F',
        'DARKTURQUOISE': '#00CED1',
        'DARKVIOLET': '#9400D3',
        'DEEPPINK': '#FF1493',
        'DEEPSKYBLUE': '#00BFFF',
        'DIMGRAY': '#696969',
        'DIMGREY': '#696969',
        'DODGERBLUE': '#1E90FF',
        'FIREBRICK': '#B22222',
        'FLORALWHITE': '#FFFAF0',
        'FORESTGREEN': '#228B22',
        'FUCHSIA': '#FF00FF',
        'GAINSBORO': '#DCDCDC',
        'GHOSTWHITE': '#F8F8FF',
        'GOLD': '#FFD700',
        'GOLDENROD': '#DAA520',
        'GRAY': '#808080',
        'GREY': '#808080',
        'GREEN': '#008000',
        'GREENYELLOW': '#ADFF2F',
        'HONEYDEW': '#F0FFF0',
        'HOTPINK': '#FF69B4',
        'INDIANRED ': '#CD5C5C',
        'INDIGO ': '#4B0082',
        'IVORY': '#FFFFF0',
        'KHAKI': '#F0E68C',
        'LAVENDER': '#E6E6FA',
        'LAVENDERBLUSH': '#FFF0F5',
        'LAWNGREEN': '#7CFC00',
        'LEMONCHIFFON': '#FFFACD',
        'LIGHTBLUE': '#ADD8E6',
        'LIGHTCORAL': '#F08080',
        'LIGHTCYAN': '#E0FFFF',
        'LIGHTGOLDENRODYELLOW': '#FAFAD2',
        'LIGHTGRAY': '#D3D3D3',
        'LIGHTGREY': '#D3D3D3',
        'LIGHTGREEN': '#90EE90',
        'LIGHTPINK': '#FFB6C1',
        'LIGHTSALMON': '#FFA07A',
        'LIGHTSEAGREEN': '#20B2AA',
        'LIGHTSKYBLUE': '#87CEFA',
        'LIGHTSLATEGRAY': '#778899',
        'LIGHTSLATEGREY': '#778899',
        'LIGHTSTEELBLUE': '#B0C4DE',
        'LIGHTYELLOW': '#FFFFE0',
        'LIME': '#00FF00',
        'LIMEGREEN': '#32CD32',
        'LINEN': '#FAF0E6',
        'MAGENTA': '#FF00FF',
        'MAROON': '#800000',
        'MEDIUMAQUAMARINE': '#66CDAA',
        'MEDIUMBLUE': '#0000CD',
        'MEDIUMORCHID': '#BA55D3',
        'MEDIUMPURPLE': '#9370DB',
        'MEDIUMSEAGREEN': '#3CB371',
        'MEDIUMSLATEBLUE': '#7B68EE',
        'MEDIUMSPRINGGREEN': '#00FA9A',
        'MEDIUMTURQUOISE': '#48D1CC',
        'MEDIUMVIOLETRED': '#C71585',
        'MIDNIGHTBLUE': '#191970',
        'MINTCREAM': '#F5FFFA',
        'MISTYROSE': '#FFE4E1',
        'MOCCASIN': '#FFE4B5',
        'NAVAJOWHITE': '#FFDEAD',
        'NAVY': '#000080',
        'OLDLACE': '#FDF5E6',
        'OLIVE': '#808000',
        'OLIVEDRAB': '#6B8E23',
        'ORANGE': '#FFA500',
        'ORANGERED': '#FF4500',
        'ORCHID': '#DA70D6',
        'PALEGOLDENROD': '#EEE8AA',
        'PALEGREEN': '#98FB98',
        'PALETURQUOISE': '#AFEEEE',
        'PALEVIOLETRED': '#DB7093',
        'PAPAYAWHIP': '#FFEFD5',
        'PEACHPUFF': '#FFDAB9',
        'PERU': '#CD853F',
        'PINK': '#FFC0CB',
        'PLUM': '#DDA0DD',
        'POWDERBLUE': '#B0E0E6',
        'PURPLE': '#800080',
        'REBECCAPURPLE': '#663399',
        'RED': '#FF0000',
        'ROSYBROWN': '#BC8F8F',
        'ROYALBLUE': '#4169E1',
        'SADDLEBROWN': '#8B4513',
        'SALMON': '#FA8072',
        'SANDYBROWN': '#F4A460',
        'SEAGREEN': '#2E8B57',
        'SEASHELL': '#FFF5EE',
        'SIENNA': '#A0522D',
        'SILVER': '#C0C0C0',
        'SKYBLUE': '#87CEEB',
        'SLATEBLUE': '#6A5ACD',
        'SLATEGRAY': '#708090',
        'SLATEGREY': '#708090',
        'SNOW': '#FFFAFA',
        'SPRINGGREEN': '#00FF7F',
        'STEELBLUE': '#4682B4',
        'TAN': '#D2B48C',
        'TEAL': '#008080',
        'THISTLE': '#D8BFD8',
        'TOMATO': '#FF6347',
        'TURQUOISE': '#40E0D0',
        'VIOLET': '#EE82EE',
        'WHEAT': '#F5DEB3',
        'WHITE': '#FFFFFF',
        'WHITESMOKE': '#F5F5F5',
        'YELLOW': '#FFFF00',
        'YELLOWGREEN': '#9ACD32',
    };

    if (Object.prototype.hasOwnProperty.call(htmlColors, colorUPPERCASE)) {
        returnValue = htmlColors[colorUPPERCASE];
    } else {
        returnValue = color;
    }
    return returnValue;
}

/**
 * Strips the last directory part of an url. Example:  /this/is/url/with/part/ becomes /this/is/url/with/
 * @function
 * @param {string} url reddit API comment object.
 * @returns {string} url without the last directory part
 */
export function removeLastDirectoryPartOf (url) {
    const urlNoSlash = url.replace(/\/$/, '');
    const array = urlNoSlash.split('/');
    array.pop();
    const returnValue = `${array.join('/')}/`;
    return returnValue;
}

/**
 * Because there are a ton of ways how subreddits are written down and sometimes we just want the name.
 * @function
 * @param {string} dirtySub dirty dirty sub.
 * @returns {string} shiny sub!
 */
export function cleanSubredditName (dirtySub) {
    return dirtySub.replace('/r/', '').replace('r/', '').replace('/', '').replace('−', '').replace('+', '').trim();
}

/**
 * Replaces {tokens} for the respective value in given content
 * @function
 * @param {object} info object with token name keys and token content values.
 * @param {string} content dirty dirty sub.
 * @returns {string} token replaced text!
 */
export function replaceTokens (info, content) {
    for (const i of Object.keys(info)) {
        const pattern = new RegExp(`{${i}}`, 'mig');
        content = content.replace(pattern, info[i]);
    }

    return content;
}

/**
 * reddit HTML encodes all of their JSON responses, we need to HTMLdecode them before parsing.
 * @function
 * @param {object} info object with token name keys and token content values.
 * @param {string} content dirty dirty sub.
 * @returns {string} token replaced text!
 */
export function unescapeJSON (val) {
    if (typeof val === 'string') {
        val = val.replace(/&quot;/g, '"')
            .replace(/&gt;/g, '>').replace(/&lt;/g, '<')
            .replace(/&amp;/g, '&');
    }
    return val;
}

// Utility methods

/**
 * Removes ASCII single and double quotes from a string.
 * @param {string} string
 * @returns {string}
 */
export const removeQuotes = string => string.replace(/['"]/g, '');

/**
 * Generates a color corresponding to a given string (used to assign colors
 * to subreddits for post borders in shared queues)
 * @param {string} str The string to generate a color for
 */
// TODO: cache results?
export function stringToColor (str) {
    // str to hash
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
        hash = str.charCodeAt(i) + ((hash << 5) - hash);
    }

    // int/hash to hex
    let color = '#';
    for (let index = 0; index < 3; index++) {
        color += `00${(hash >> index * 8 & 0xFF).toString(16)}`.slice(-2);
    }

    return color;
}

/**
 * Escapes text for HTML.
 * @param {string} value The text to escape
 * @returns {string}
 */
// TODO: How is this different than escapeHTML() above?
export function htmlEncode (value) {
    // create a in-memory div, set it's inner text(which jQuery automatically encodes)
    // then grab the encoded contents back out.  The div never exists on the page.
    return $('<div/>').text(value).html();
}

/**
 * Gets the text content of an HTML string.
 * @param {string} value The HTML to read
 * @returns {string}
 */
export function htmlDecode (value) {
    return $('<div/>').html(value).text();
}

/**
 * Inflates a base64-encoded zlib-compressed data string into data.
 * @param {string} string The compressed string
 * @returns {any}
 */
export function zlibInflate (stringThing) {
    // Expand base64
    stringThing = atob(stringThing);
    // zlib time!
    const inflate = new pako.Inflate({to: 'string'});
    inflate.push(stringThing);
    return inflate.result;
}

/**
 * Deflates some data into a base64-encoded zlib-compressed data string.
 * @param {any} object The data to compress
 * @returns {string}
 */
export function zlibDeflate (objThing) {
    // zlib time!
    const deflate = new pako.Deflate({to: 'string'});
    deflate.push(objThing, true);
    objThing = deflate.result;
    // Collapse to base64
    return btoa(objThing);
}

/**
 * Provides an initialized SnuOwnd parser.
 */
export const parser = SnuOwnd.getParser(SnuOwnd.getRedditRenderer());

/**
 * Wraps each of an iterable's values with an object that indicates which item
 * is the last one in the sequence by always reading one item ahead in the
 * iterator to check for `{done: true}` before yielding the current item.
 * @template T
 * @param {AsyncIterable<T>} iterable
 * @returns {AsyncGenerator<{item: T, last: boolean}, void>}
 */
export async function* wrapWithLastValue (iterable) {
    // get the underlying iterator
    const iterator = iterable[Symbol.asyncIterator]?.() ?? iterable[Symbol.iterator]?.();
    if (!iterator) {
        throw new TypeError('argument is not an iterable');
    }

    // fetch the first item
    let current = await iterator.next();

    // yield nothing for empty sequences
    if (current.done) {
        return;
    }

    while (true) {
        // fetch the next item and see if it yields anything
        const next = await iterator.next();
        if (next.done) {
            // the iterator has returned; the previous result is the last, and
            // this result is the return value
            yield {item: current.value, last: true};
            return next.value;
        }
        // the iterator isn't done yet; yield previous item and keep going
        yield {item: current.value, last: false};
        current = next;
    }
}