Greasy Fork

EntryList

Common functions for working on lists of entries

目前为 2019-10-05 提交的版本。查看 最新版本

此脚本不应直接安装,它是一个供其他脚本使用的外部库。如果您需要使用该库,请在脚本元属性加入:// @require https://update.greasyfork.icu/scripts/390248/738286/EntryList.js

// EntryList library
// Common functions for modifying/hiding/etc. entries in page, based on
// entry features or presence in one or more lists.
// For instance: hide all YouTube videos that have been watched and highlight
// the ones that have been started but not finished, highlight Netflix movies
// based on IMDb lists, etc.
//
// https://greasyfork.org/scripts/390248-entrylist
// Copyright (C) 2019, Guido Villa
// Original idea and some of the code is taken from IMDb 'My Movies' enhancer:
// Copyright (C) 2008-2018, Ricardo Mendonça Ferreira ([email protected])
// Released under the GPL license - http://www.gnu.org/copyleft/gpl.html
//
// For instructions, see https://greasyfork.org/help/installing-user-scripts
//
// --------------------------------------------------------------------
//
// ==UserScript==
// @namespace       https://greasyfork.org/users/373199-guido-villa
// @exclude         *
//
// ==UserLibrary==
// @name            EntryList
// @description     Common functions for working on lists of entries
// @version         1.5
// @author          guidovilla
// @date            03.10.2019
// @copyright       2019, Guido Villa (https://greasyfork.org/users/373199-guido-villa)
// @license         GPL-3.0-or-later
// @homepageURL     https://greasyfork.org/scripts/390248-entrylist
// @supportURL      https://gitlab.com/gv-browser/userscripts/issues
// @contributionURL https://tinyurl.com/gv-donate-0c
//
// @downloadURL     https://greasyfork.org/scripts/390248-entrylist/code/EntryList.user.js
// @updateURL       https://greasyfork.org/scripts/390248-entrylist/code/EntryList.meta.js
// ==/UserScript==
//
// ==/UserLibrary==
//
// --------------------------------------------------------------------
//
// To-do (priority: [H]igh, [M]edium, [L]ow):
//   - [H] Extend library to work on all the scripts
//   - [M] Make private members actually private and not only undocumented
//         (only after understanding which ones really can be private)
//   - [M] main context as default context
//   - [M] do we need that the library is not cached? if so, how?
//   - [M] changes to a list aren't reflected in page till reload. Change?
//   - [M] Better handle case without lists (e.g. optimizations)
//   - [M] Add description of flow in usage documentation
//   - [M] Add indication of URL to use to @require library itself
//   - [M] List regeneration function doesn't handle case where lists are missing
//
// Changelog:
// ----------
// 2019.10.03  [1.5] Automatically handle case with only one list
//                   Better handling of list of lists
//                   Add possibility to permanently skip an entry
// 2019.10.02  [1.4] More generic: getUser and getIdFromEntry are now optional
//                   Add newContext utility function
// 2019.09.30  [1.3] Correct @namespace and other headers (for public use)
// 2019.09.27  [1.2] Refactoring and name changing: TitleList -> EntryList
// 2019.09.27  [1.1] Code cleanup (string literals, reorder functions)
//                   Check for validity of the context object
//                   Add usage documentation
// 2019.09.21  [1.0] First version
// 2019.09.18  [0.1] First test version, private use only
//

/* jshint esversion: 6, supernew: true */
/* exported EL, Library_Version_ENTRYLIST */

const Library_Version_ENTRYLIST = '1.5';

/* How to use the library

This library instantitates an EL object with a startup method.

Call EL.startup(ctx), passing a "context" object that is specific to the
website you are working on.

Other functions and variables:
- mainContext: the context saved with EL.startup

- addToggleEventOnClick(button, howToFindEntry[, toggleList[, toggleType]]):
  mainly used in ctx.modifyEntry(), adds an event listener that implements
  a toggle function:
  - button: the DOM object to attach the event listener to
  - howToFindEntry: how to go from evt.target to the entry object. It can be:
    - a number: # of node.parentNode to hop to get from evt.target to to entry
    - a CSS selector: used with evt.target.closest to get to entry
  - toggleList: the list where the entry is toggled when the button is pressed
                (can be omitted if a default list is to be used)
  - toggleType: the processing type that is toggled by the press of the button
                (can be omitted if only one processing type is used)
                It cannot be a false value (0, null, false, undefined, etc.)
- newContext(name):
  utility function that returns a new context, initialized with <name>
- markInvalid(entry):
  marks entry as invalid to skips it in subsequent passes
  This function returns false so it can be used in isValidEntry() in this way:
  return condition || EL.markInvalid(entry)
  This leaves the return value unchanged and marks the entry only if invalid


Mandatory callback functions and variables in context:

- name: identifier of the site

- getPageEntries():
  return (usually with querySelectorAll) an array of entries to be treated
- processItem(entry, tt, processingType):
  process the entry based on the processing type or other features of the entry


Conditionally mandatory callback functions and variables in context:

- getUser(): retrieve and return the username used on the website
  mandatory if data are to be stored on a per-user basis
- getIdFromEntry(entry): return a tt: { id, name } object from the entry
  mandatory if you want to save entries to lists
  NOTE: if id is not found, entry is skipped but it is not marked as invalid
  for subsequent passes (unless you use TL.markInvalid(), see above)
- unProcessItem(entry, tt, processingType):
  like processItem, but it should reverse the action
  mandatory for entries that have a toggle action added with
  EL.addToggleEventOnClick()


Optional callback functions and variables in context:

- interval: interval (in ms) to re-scan links in the DOM
            won't re-scan if < MIN_INTERVAL
            dafault: DEFAULT_INTERVAL

- isEntryPage():
  returns false if page must not be scanned for entries
  default is always true (all pages contain entries)
- isValidEntry(entry):
  return false if entry must be skipped
  NOTE: if entry is skipped, it is not however marked as invalid for subsequent
  passes (unless you use TL.markInvalid(), see above)
  default is always true (all entries returned by "getPageEntries" are valid)
- modifyEntry(entry):
  optionally modify entry when scanned for the first time (e.g. add a button)
  see also EL.addToggleEventOnClick() above
- determineType(lists, tt, entry):
  return the processing type for an entry, given the lists it appears in, or a
  false value (0, null, false, undefined, etc.) if no processing is required
  "lists" is an object with a true property for each list the entry appears in.
  The decision can also be taken using name, id and properties of the entry.
  If there is a single processing type, the function might as well return true/false
  Default: returns true if entry is in at least one list (especially useful in
  cases with only one list, so there is no need to tell different lists apart)

*/


var EL = new (function() {
    'use strict';
    const STORAGE_SEP      = '-';
    const FAKE_USER        = '_';
    const DEFAULT_TYPE     = '_DEF_';
    const MIN_INTERVAL     = 100;
    const DEFAULT_INTERVAL = 1000;

    var self = this;

    this.mainContext = null;


    /* PRIVATE members */

    // Check if "object" has "property" of "type"
    // used to test if object "implements" a specific interface
    function checkProperty(object, property, type, optional) {
        if (typeof object[property] !== type && (!optional || typeof object[property] !== 'undefined')) {
            console.error((optional ? 'Optionally, c' : 'C') + 'ontext must have a "' + property + '" property of type "' + type + '"');
            return false;
        }
        else return true;
    }


    // check if context has the correct variables and functions
    function isValidTargetContext(ctx) {
        var valid = true;

        valid &= checkProperty(ctx, 'name',           'string');
        valid &= checkProperty(ctx, 'getPageEntries', 'function');
        valid &= checkProperty(ctx, 'processItem',    'function');
        valid &= checkProperty(ctx, 'interval',       'number',   true);
        valid &= checkProperty(ctx, 'isEntryPage',    'function', true);
        valid &= checkProperty(ctx, 'isValidEntry',   'function', true);
        valid &= checkProperty(ctx, 'modifyEntry',    'function', true);
        valid &= checkProperty(ctx, 'determineType',  'function', true);
        valid &= checkProperty(ctx, 'getUser',        'function', true);
        valid &= checkProperty(ctx, 'getIdFromEntry', 'function', true);
        valid &= checkProperty(ctx, 'unProcessItem',  'function', true);

        return !!valid;
    }


    // standardized names for storage variables
    var storName = {
        'lastUser':    function(ctx)           { return ctx.name     + STORAGE_SEP + 'lastUser'; },
        'listOfLists': function(ctx)           { return 'EntryLists' + STORAGE_SEP + ctx.user; },
        'listPrefix':  function(ctx)           { return 'EntryList'  + STORAGE_SEP + ctx.user + STORAGE_SEP; },
        'listName':    function(ctx, listName) { return this.listPrefix(ctx) + listName; },
    };


    // Return name of user currently logged on <ctx> site
    // Return last saved value and log error if no user is found
    this.getLoggedUser = function(ctx) {
        if (!ctx.getUser) return FAKE_USER;

        var user = ctx.getUser();
        if (!user) {
            console.error(ctx.name + ": user not logged in (or couldn't get user info) on URL " + document.URL);
            user = GM_getValue(storName.lastUser(ctx), '');
            console.error('Using last user: ' + user);
        }
        GM_setValue(storName.lastUser(ctx), user);
        ctx.user = user;
        return user;
    };


    // Regenerate and save the list of lists stored object, even if empty
    // returns the new list
    function regenerateListOfLists(ctx) {
        var allVariables = GM_listValues();

        var listNames = allVariables.reduce(function(listNames, variable) {
            if (variable.startsWith(storName.listPrefix(ctx))) {
                listNames.push(variable.substring(storName.listPrefix(ctx).length));
            }
            return listNames;
        }, []);

        var userData = JSON.stringify(listNames);
        GM_setValue(storName.listOfLists(ctx), userData);
        return listNames;
    }


    // Load a single saved lists
    function loadSavedList(listName) {
        var list;
        var userData = GM_getValue(listName, null);
        if (userData) {
            try {
                list = JSON.parse(userData);
            } catch(err) {
                alert("Error loading saved list named '" + listName + "'!\n" + err.message);
            }
        }
        return list;
    }


    // Load the list of lists, regenerating it if necessary
    // always returns an array, possibly empty
    function loadListOfLists(ctx) {
        var listNames = loadSavedList(storName.listOfLists(ctx));

        if (!Array.isArray(listNames)) listNames = regenerateListOfLists(ctx);
        return listNames;
    }


    // Load lists for the current user
    this.loadSavedLists = function(ctx) {
        var listNames = loadListOfLists(ctx);
        var lists = {};
        var list;
        var mustRegenerateListOfLists = false;

        listNames.forEach(function(listName) {
            list = loadSavedList(storName.listName(ctx, listName));
            if (list) lists[listName] = list;
            else mustRegenerateListOfLists = true;
        });
        if (mustRegenerateListOfLists) regenerateListOfLists(ctx);
        return lists;
    };


    // Save single list for the current user
    this.saveList = function(ctx, list, name) {
        var listNames = loadListOfLists(ctx);

        if (listNames.indexOf(name) == -1) {
            listNames.push(name);
            var userData = JSON.stringify(listNames);
            GM_setValue(storName.listOfLists(ctx), userData);
        }

        userData = JSON.stringify(list);
        GM_setValue(storName.listName(ctx, name), userData);
    };


    // Receives an entry tt and finds all lists where tt.id appears
    this.inLists = function(ctx, tt) {
        var lists = {};

        for (var list in ctx.allLists) {
            if (ctx.allLists[list][tt.id]) lists[list] = true;
        }

        return lists;
    };


    // Wrap ctx.getIdFromEntry and add error logging
    function _wrap_getIdFromEntry(ctx, entry) {
        var tt = ctx.getIdFromEntry(entry);
        if (!tt) console.error('Could not determine id :-( - for entry', entry);
        return tt;
    }


    // Process all entries in current page
    this.processEntries = function(ctx) {
        var entries = ctx.getPageEntries();
        if (!entries) return;

        var entry, tt, lists, processingType;
        for (var i = 0; i < entries.length; i++) {
            entry = entries[i];

            // if entry has already been previously processed, skip it
            if (entry.ELProcessed || entry.ELInvalid) continue;

            // see if entry is valid
            if (ctx.isValidEntry && !ctx.isValidEntry(entry)) continue;

            tt = null;
            if (ctx.getIdFromEntry) {
                tt = _wrap_getIdFromEntry(ctx, entry);
                if (!tt) continue;
            }

            if (ctx.modifyEntry) ctx.modifyEntry(entry);
            lists = ( tt ? self.inLists(ctx, tt) : {} );

            processingType = (ctx.determineType
                ? ctx.determineType(lists, tt, entry)
                : Object.keys(lists).length > 0);

            if (processingType) {
                ctx.processItem(entry, tt, processingType);
                entry.ELProcessingType = processingType;
            }

            entry.ELProcessed = true; // set to "true" after processing (so we skip it on next pass)
        }
    };


    // handle the toggle event
    this.handleToggleButton = function(evt) {
        evt.stopPropagation();
        evt.preventDefault();
        var data = evt.target.dataset;
        var toggleList = (typeof data.toggleList === 'undefined' ? DEFAULT_TYPE : data.toggleList);
        var toggleType = (typeof data.toggleType === 'undefined' ? DEFAULT_TYPE : data.toggleType);

        // get corresponding entry
        var entry = evt.target;
        if (Number.isInteger(Number(data.howToFindEntry))) {
            for (var i = 0; i < Number(data.howToFindEntry); i++) entry = entry.parentNode;
        } else {
            entry = entry.closest(data.howToFindEntry);
        }

        self.toggleEntry(entry, toggleList, toggleType);
    };


    // add/remove entry from a list
    this.toggleEntry = function(entry, toggleList, toggleType) {
        var ctx  = self.mainContext;

        var tt = _wrap_getIdFromEntry(ctx, entry);
        if (!tt) return;

        // check if item is in list
        var list = ctx.allLists[toggleList];
        if (!list) list = ctx.allLists[toggleList] = {};
        if (list[tt.id]) {
            delete list[tt.id];
            ctx.unProcessItem(entry, tt, toggleType);
            entry.ELProcessingType = '-' + toggleType;
        } else {
            list[tt.id] = tt.name;
            ctx.processItem(entry, tt, toggleType);
            entry.ELProcessingType = toggleType;
        }
        self.saveList(ctx, list, toggleList);
    };



    /* PUBLIC members */

    // utility function to create a new context, initialized with <name>
    this.newContext = function(name) {
        return { 'name': name };
    };


    // startup function
    this.startup = function(ctx) {
        // check that passed context is good
        if (!isValidTargetContext(ctx)) {
            console.log('Invalid context, aborting');
            return;
        }

        self.mainContext = ctx;

        //TODO forse salvare una variabile we_are_in_an_entry_page nel contesto?
        //TODO per altri casi lo startup deve fare anche altro
        if (!( !ctx.isEntryPage || ctx.isEntryPage() )) return;

        // find current logged in user, or quit script
        if (!self.getLoggedUser(ctx)) {
            console.log('No user is defined, aborting');
            return;
        }

        // Load list data for this user from local storage
        ctx.allLists = self.loadSavedLists(ctx);

        // start the entry processing function
        self.processEntries(ctx);
        if (typeof ctx.interval === 'undefined' || ctx.interval >= MIN_INTERVAL) {
            // TODO we might consider using MutationObserver in the future, instead
            ctx.timer = setInterval(function() {self.processEntries(ctx);}, ctx.interval || DEFAULT_INTERVAL);
        }
    };


    this.addToggleEventOnClick = function(button, howToFindEntry, toggleList, toggleType) {
        button.dataset.howToFindEntry = howToFindEntry;
        if (typeof toggleList !== 'undefined') button.dataset.toggleList = toggleList;
        if (typeof toggleType !== 'undefined') button.dataset.toggleType = toggleType;
        button.addEventListener('click', self.handleToggleButton, false);
    };


    this.markInvalid = function(entry) {
        entry.ELInvalid = true;
        return false;
    }


})();