Pocket/Pocket.selectors.js

/**
 * ### PocketSelectors
 * - Extends PocketModule using selectors for better access to Probes
 * - allow selection to reference by, example:  `taskName`, `::taskName` and `${projectID}::taskName`, thanks to `selectByTask()` method
 */

// TYPES
// eslint-disable-next-line no-unused-vars
const Probe = require("../Probe/Probe")
// MODELS AND TYPES
// eslint-disable-next-line no-unused-vars
const SetUpdateModel = require("../Models/SetUpdateModel")

const { copy, warn, isArray, onerror, objectSize, isString, uniq, isFunction } = require("x-utils-es/umd")
const { validProbe, validProjectID } = require("../utils")

const PocketArchitect = require("./Pocket.architect")

/**
 * @class
 */
class PocketSelectors extends PocketArchitect {
    constructor(opts, debug) {
        super(opts, debug)
    }

    /**
     *
     * more construct way of setting up a project and allowing few external assets to be used. This method uses $payload inheritance with access to `type` and `async`. Things to remember consecutive call to $architect thru  .$condition() method, can only update existing items.
    -   `cb(()=>({project:payloadData, type,async}))`: return callback must return {project:payload} as minimum requirement, when running in a loop or repetitive actions, it is best to set type='update' so that concurrent call to the same task wont wont be ignored, `type` is state base, so last setting is kept
    -  memberof PocketArchitect
     */
    $architect(cb, projectID) {
        return super.architect(cb, projectID)
    }

    /**
     * can access the asset declared in `$architect`
     *  - memberof PocketArchitect
     *
     */
    $asset(assetName, asCallback, projectID) {
        return super.asset(assetName, asCallback, projectID)
    }

    /**
     * check is probe exists on PocketModule
     * @param {*} probeID required, can specify `::taskName` or full id `{projectID}::{probeID}`
     * @returns boolean
     */
    $exists(probeID) {
        return !!this.selectByTask(probeID)
    }

    /**
     * @param projectID required
     * @returns boolean `true/false/null`, determined by project completion
     */
    $projectComplete(projectID) {
        if (!this.projectsCache[projectID]) return null
        return this.projectsCache[projectID] === "complete"
    }

    /**
     * use it to check if project already available, it is similar to `$projectSetAsync` but not a promise, returns current status, not in future
     * @param {*} projectID required
     * @returns {boolean}
     */
    $projectSet(projectID = "") {
        projectID = validProjectID(projectID)
        if (this.payloadData[projectID]) return true
        return false
    }

    /**
     * Callback for adding two numbers.
     *
     * @callback condition_cb
     * @param {PocketSelectors} pocket
     * @returns {any}
     */

    /**
     * REVIEW self is inconsistent here we may have to update this logic
     *
     * run conditional statement within callback, so we can keep chaining in the same block
     * @param {condition_cb} cb required, inside callback access to self for PocketModule, or for Probe{}, depending on `projectID/probeID` id specified
     * @param {string} id `projectID/probeID` optional, specify either `projectID` or `probeID`, defaults to last `projectID`
     * @returns {PocketSelectors} by default returns Pocket/self, or any true value passed inside callback
     */
    $condition(cb, id = undefined) {
        if (!isFunction(cb)) {
            if (this.debug) warn("[pocket]", `[$condition] must provide callback`)
            return this
        }
        id = !isString(id) ? "" : id

        let selfType = "PocketSelf" // `ProbeSelf`
        let self = null
        if (id.indexOf(`::`) === 0) {
            // if specified id is `probeID`
            id = this.selectByTask(id, true)
            id = this.lastProbeID(id)
            selfType = `ProbeSelf`
            if (!id) {
                if (this.debug) warn("[pocket]", `[$condition] probeID not found`)
                return this
            }
            // also updates last selector reference
        } else if (this.lastProjectID(id)) selfType = "PocketSelf"
        // if specified id is `projectID`
        else {
            if (this.debug) warn("[pocket]", `[$condition] projectID not found`)
            return this
        }

        if (selfType === "PocketSelf") self = this
        if (selfType === "ProbeSelf") self = this.$get(id)

        if (!self) {
            if (this.debug) warn("[pocket]", `[$condition] no valid self value`)
            return this
        }

        const cbDATA = cb.call(self, self) // when using arrow function pass `(self)=>` in callback as well
        if (cbDATA) return cbDATA
        // if callback has any true data return it,
        else return this // else return self
    }

    /**
     * @returns last selected projectID
     */
    get $projectID() {
        return this._lastProjectID
    }

    /**
     * return last probe status, this is a dynamic Promise, creates new promise every time status is changed, so then it needs to bu called again to get latest update
     * @param {*} probeID
     */
    $probeStatusAsync(probeID = "") {
        // allow use of short ref names example: `::cocalola`
        probeID = this.selectByTask(probeID, true)
        let lastProbeID = this.lastProbeID(probeID)
        return super.probeStatusAsync(lastProbeID)
    }

    /**
     *`get probe by 'id::taskName'`
     * - `returns instance`
     *  methods:`{get,all}` props: `{id,data,tasks,status}`
     * @param {*} probeID required, example format: `${payload.id}::taskName`
     * @param {*} self = false optional, in case you want to chain, and access `Probe{}` through `...).d` otheriwe return Pocket
     * @returns {Probe} actually {Pcket | Probe}
     */
    $get(probeID, self) {
        // allow use of short ref names example: `::cocalola`
        probeID = this.selectByTask(probeID, true)
        let lastProbeID = this.lastProbeID(probeID)
        return super._get(lastProbeID, self)
    }

    /**
     * REVIEW this method must be updated
     * @deprecated
     * - return array of Probes matched by ref
     * @param {*} probeRef, required
     * @returns [Probe{},...] array
     */
    $getByRef(probeRef = "") {
        return Object.assign(this.pocket).filter(([id, probe], inx) => probe.ref === probeRef)
    }

    /**
     * as name suggest sets up new new data for Probe/task, it derives from `$update`
     * @param {SetUpdateModel} dataFrom required, must specify what to set on Probe{}, example: `dataFrom:{data:'coke',status:'complete',campaign:'cocacola'}`
     * - we should only use `$set` for initialization, this action also calls `clearStoreTransfers`
     * @param {*} probeID required, example format: `${payload.id}::taskName`
     */
    $set(dataFrom, probeID) {
        // allow use of short ref names example: `::cocalola`
        probeID = this.selectByTask(probeID, true)
        let lastProbeID = this.lastProbeID(probeID)
        return super._set(dataFrom, lastProbeID)
    }

    /**
     * return as Probe{}, similar as $get(...), although does additional check for instanceOf Probe{}
     * @param {*} probeID
     * @returns {Probe}
     */
    $probe(probeID = "") {
        // allow use of short ref names example: `::cocalola`
        probeID = this.selectByTask(probeID, true)
        let lastProbeID = this.lastProbeID(probeID)
        if (!this.pocket[lastProbeID]) {
            if (this.debug) warn("[pocket]", `[$probe] not found for probeID: ${probeID}`)
            return undefined
        }

        if (this.pocket[lastProbeID].constructor.name !== "Probe") {
            if (this.debug) onerror("[pocket]", `[$probe] probeID: ${probeID} is not an instance of Probe{}`)
            return undefined
        }

        return this.pocket[lastProbeID]
    }

    /**
     * update Probe/task, for convenience, so we dont have do this, example: `pc.$get('abc123::grab').status='complete'`
     * @param {SetUpdateModel} dataFrom required, must specify what to update on Probe{}, example: `dataFrom:{data:'coke',status:'complete',campaign:'cocacola'}`
     * @param {*} mergeData optional if `true` will merge: `Object.assing({},probe[id].data,mergeData['data'])`
     * @param {*} probeID required, example format: `${payload.id}::taskName`
     */
    $update(dataFrom, mergeData, probeID) {
        // allow use of short ref names example: `::cocalola`
        probeID = this.selectByTask(probeID, true)
        let lastProbeID = this.lastProbeID(probeID)
        return super.update(dataFrom, mergeData, lastProbeID)
    }

    /**
     * select current payloadID/project/job by id you are working on
     * @param {*} projectID optional/sensitive, selects new point of reference.
     */
    $select(projectID = undefined) {
        projectID = !isString(projectID) ? "" : projectID
        this.lastProjectID(projectID) // also updates last selector reference
        return this
    }

    /**
     *
     * @callback filter_cb
     * @param {Probe} probe
     * @returns {any}
     */

    /**
     * Filter works together with `$compute` or standalone when specified `.d` to return filtered `list`
     * memberof PocketSelectors
     * @param {filter_cb} cb
     * @param {string} projectID
     * @returns {PocketSelectors}
     */
    $filter(cb, projectID = undefined) {
        const self = this
        projectID = this.lastProjectID(projectID) // also updates last selector reference
        const returnAs = (val) => {
            this.d = (val || []).filter((z) => z.isNONE === undefined)
            return self
        }

        if (!isFunction(cb)) return returnAs([])

        if (!this.payloadData[projectID]) {
            if (this.debug) warn("[pocket]", `[$filter] no projectID found`)
            return returnAs(null)
        }
        // when narrowing down $filter.$filter process, lets remember last action
        let probeList = []
        if ((this._lastFilterList[projectID] || []).length) probeList = this._lastFilterList[projectID]
        else {
            this._lastFilterList[projectID] = []
            probeList = this.projectProbeList(projectID)
        }
        probeList.forEach((probe, inx) => {
            if (probe.isNONE) {
                return
            }
            const matchFound = cb.call(probe, probe)
            if (!matchFound) {
                this._lastFilterList[projectID] = this._lastFilterList[projectID].filter((z) => z.id !== probe.id)
                this._lastFilterList[projectID].push({ id: probe.id, isNONE: true })
                return
            }

            if (matchFound !== undefined && (matchFound === true || matchFound === 1)) {
                // dont re-add same probe
                const isNew = this._lastFilterList[projectID].filter((z, i) => (z || {}).id === probe.id).length

                if (!isNew) this._lastFilterList[projectID].push(probe)
            } else this._lastFilterList[projectID].push({ id: probe.id, isNONE: true })
        })
        return returnAs(this._lastFilterList[projectID])
    }

    /**
     *
     * @callback compute_cb
     * @param {Probe} probe
     * @returns {any}
     */

    /**
     * iterate thru each Probe{}/ instance in a callback, and make changes to it
     * - note: you can only compute thru items that are not `complete`
     * @param {compute_cb} cb cb callback to current Probe instance process
     * @param {string} projectID optional/sensitive, selects new point of reference.
     */
    $compute(cb, projectID = "") {
        projectID = this.lastProjectID(projectID)
        const returnAs = (val) => {
            // delete last filtered list after it was consumed
            if (val) delete this._lastFilterList[projectID]
            this.d = val
            return this
        }

        if (!isFunction(cb)) {
            if (this.debug) warn("[pocket]", `[$compute] cb must be a function`)
            return returnAs(null)
        }

        if (!this.payloadData[projectID]) {
            if (this.debug) warn("[pocket]", `[$compute] no project found fo your/last id projectID:${projectID}`)
            return returnAs(null)
        }

        let lastFilter = this._lastFilterList[projectID] || []
        if (lastFilter.length) {
            // uniq
            this._lastFilterList[projectID] = lastFilter = lastFilter
                .filter((z) => z.isNONE === undefined)
                .filter(({ id }, i, all) => {
                    return all.filter((_probe) => id === _probe.id).length === 1
                })
            lastFilter.forEach((probe) => {
                // compute method is designed to allow access to each Probe, but we do not want to allow looping thru assets that are already complete
                if (probe.status !== "complete" && probe.status !== "send") cb.call(probe, probe)
            })
            // finally only return not none list on probes, then clear _lastFilterList
            return returnAs(this._lastFilterList[projectID])
        } else {
            this.projectProbeList(projectID).forEach((probe) => {
                // compute method is designed to allow access to each Probe, but we do not want to allow looping thru assets that are already complete

                if (probe.status !== "complete" && probe.status !== "send") cb.call(probe, probe)
            })
            return returnAs(this.projectProbeList(projectID))
        }
    }

    /**
     *
     * @callback list_cb
     * @param {object} probeCopy
     * @returns {{}}
     */

    /**
     * list active Probes{} by project id, should return all assigned probe/tasks regardless of status
     * - returns array[] of active Probe{}/tasks or []
     * @param {*} projectID optional/sensitive, selects new point of reference.
     * @param {list_cb} cb ((probe, probeID)=>) optional, when set will loop thru each Probe{} in callback
     * @param {*} type optional, set to `list`, will return latest Probes, that includes if initiated cb and made a few changes
     */
    $list(projectID = "", cb = null, type = "self") {
        projectID = this.lastProjectID(projectID)
        if (!this.payloadData[projectID]) return []
        const list = () => {
            return Object.entries(this.pocket).reduce((n, [key, val], inx) => {
                if (val.id.includes(projectID)) n.push(val)
                return n
            }, [])
        }
        if (isFunction(cb)) {
            this.projectProbeList(projectID).forEach((probe) => {
                cb(probe.all()) // no access to Probe/instance only copy
            })
            if (type === "self" || !type) {
                return this
            }
            if (type === "list") return list()
        } else {
            return list()
        }
    }

    /**
     * select data from `fromProbeID` and hold it in `_transferCache`, until `$to(probeID)` is called
     * - warning, action removes `Probe[fromProbeID].data` and overrides it on Probe[probeID].data, only when `$to(probeID)` is called, simple as that!
     * @param {*} fromProbeID optional/sensitive, selects new point of reference.
     */
    $transfer(fromProbeID = "") {
        // allow use of short ref names example: `::cocalola`
        fromProbeID = this.selectByTask(fromProbeID, true)
        fromProbeID = this.lastProbeID(fromProbeID)
        if (!this.pocket[fromProbeID]) {
            if (this.debug) warn("[pocket]", `[$transfer] no Probe{} found for this id fromProbeID:${fromProbeID}`)
            return this
        }
        this.storeTransfers(fromProbeID, copy(this.pocket[fromProbeID]["data"]))
        // NOTE needed for extra security to make sure it was called before we can update `$to()`
        this.$transfer_lastID = fromProbeID
        return this
    }

    /**
     * works together with `$transfer`, will transfer `data` from one Probe{} to another
     * if `_transferCache` is set, the cache is cleared.
     * @param {*} toProbeID optional/sensitive, points to Probe{} `data` will be packed, it is not previous reference pointer, but the next.
     * - will only work if `toProbeID` is not yet complete
     * @param {*} pointToThisProbe to stay on the current pointer reference
     * @param {*} maxDelay, keep at minimum! Time between transaction can take place, relates to `fromAverageTimeHasPast` found in `accessLastValidTransfer()`
     */
    $to(toProbeID = "", pointToThisProbe = true, maxDelay = 100) {
        // allow use of short ref names example: `::cocalola`
        toProbeID = this.selectByTask(toProbeID, pointToThisProbe)
        // if (!keepLastPointerReference) toProbeID = this.lastProbeID(toProbeID)
        if (pointToThisProbe) toProbeID = validProbe(toProbeID)
        if (!toProbeID) {
            if (this.debug) warn("[pocket]", `[$to] toProbeID is invalid`)
            return this
        }
        if (!this.pocket[toProbeID]) {
            if (this.debug) warn("[pocket]", `[$to] no Probe{} found for this id toProbeID:${toProbeID}`)
            return this
        }
        if (this.$transfer_lastID) {
            // please note because this can be a delayed transaction, if you send `status=complete`
            // the data will not be updated
            const lastValidTransfer = this.accessLastValidTransfer(maxDelay)
            if (objectSize(lastValidTransfer)) {
                const { fromProbeID, data } = lastValidTransfer
                if (this.$transfer_lastID === fromProbeID) {
                    if (this.pocket[toProbeID].status === "complete" || this.pocket[toProbeID].status === "send") {
                        if (this.debug) warn("[pocket]", `[$to] cannot transfer since toProbeID: ${toProbeID} is already complete`)
                        this.$transfer_lastID = ""
                        return this
                    }
                    this.pocket[fromProbeID]["data"] = null // from $transfer
                    this.pocket[toProbeID]["data"] = data // $to
                }
            } else {
                if (this.debug) warn("[pocket]", `[$to] no last valid transfer found`)
            }
            this.$transfer_lastID = ""
        }
        return this
    }

    /**
     * points to Probe{} be reference
     * @param {*} probeID optional/sensitive, select new point of reference
     */
    $of(probeID = "") {
        // allow use of short ref names example: `::cocalola`
        this.selectByTask(probeID, true)
        return this
    }

    /**
     * returns Object copy of `Probe['data']`
     * @param {*} dataProp optional, if you know what you are asking for example: `{assets:true}`,or `array['assets]`, it has catch error exception, so you wont receive any errors just `null`
     * will return all available matched within our `Probe{}['data]`. Multiples of `dataProp{}/([])/(',')` will return an object, if only one specified, only value will be returned
     * @param {*} probeID optional/sensitive, select new point of reference
     * @param {*} self optional,if you want to $cached() last data enquiry and return `self` to keep chaining, nice!
     */
    $data(dataProp = undefined /** {}||[] */, probeID = "", self = false) {
        // allow use of short ref names example: `::cocalola`
        probeID = this.selectByTask(probeID, true)
        if (!this.pocket[probeID]) return self ? this : undefined

        // NOTE can provide as an array
        if (isArray(dataProp) && (dataProp || []).length) {
            dataProp = uniq(dataProp).reduce((n, el) => {
                if (el !== undefined) n[el.trim()] = true
                return n
            }, {})
        }

        if (!dataProp || !objectSize(dataProp)) {
            this._$cached_data[probeID] = copy(this.pocket[probeID]["data"])
            return self ? this : this._$cached_data[probeID]
        }
        return this.dataPropSelector("data()", probeID, dataProp, self, copy(this.pocket[probeID]["data"]))
    }

    /**
     * grabs last cached `$data(...)` from Probe{}
     * @param {*} dataProp{}/String optional, know what you are asking for example: ` {assets:true}/ or > 'assets,values,somethingElse'`, it has catch error exception, so you wont receive any errors just `null`
     * will return all available matched within our `_$cached_data[probeID]`. Multiples of `dataProp{}/([])/(',')` will return an object, if only one specified, only value will be returned
     * @param {*} probeID
     */
    $cached(dataProp = {}, probeID = "") {
        probeID = this.selectByTask(probeID, true)
        if (!this.pocket[probeID]) return undefined
        const hasValue = this._$cached_data[probeID] !== undefined && this._$cached_data[probeID] !== null
        if (!hasValue) return undefined
        // if you provided a string make it an object
        if (isString(dataProp) && (dataProp || "").length) {
            dataProp = uniq(dataProp.trim().replace(/ /gi, "").split(",")).reduce((n, el) => {
                if (el !== undefined) n[el] = true
                return n
            }, {})
        }
        // return cached data if its not an object, or undefined
        if (!objectSize(this._$cached_data[probeID]) && hasValue) {
            if (objectSize(dataProp)) return undefined
            // our cache not an object, but we are asking for dataProp reference, so should return undefined
            else return this._$cached_data[probeID]
        }
        // no selection at all, so just return whats available
        if (!dataProp || !objectSize(dataProp)) {
            return this._$cached_data[probeID] === undefined ? undefined : this._$cached_data[probeID]
        } else return this.dataPropSelector("cached()", probeID, dataProp, false, this._$cached_data[probeID])
    }

    /**
     * returns Object copy of `Probe['campaign']`
     * @param {*} probeID optional/sensitive, select new point of reference
     */
    $campaign(probeID) {
        // allow use of short ref names example: `::cocalola`
        probeID = this.selectByTask(probeID, true)
        if (!this.pocket[probeID]) return null
        return copy(this.pocket[probeID]["campaign"])
    }

    /**
     * ### $ref
     * - returns Probe{}.ref
     * @param {*} probeID optional/sensitive, select new point of reference
     * @returns {string}
     */
    $ref(probeID) {
        // allow use of short ref names example: `::cocalola`
        probeID = this.selectByTask(probeID, true)
        if (!this.pocket[probeID]) return undefined
        return copy(this.pocket[probeID]["ref"])
    }

    /**
     * ### $status
     * - returns Object copy of `Probe['status']`
     * @param {*} probeID optional/sensitive, select new point of reference
     * @returns {string}
     */
    $status(probeID) {
        // allow use of short ref names example: `::cocalola`
        probeID = this.selectByTask(probeID, true)
        if (!this.pocket[probeID]) return undefined
        return copy(this.pocket[probeID]["status"])
    }

    /**
     * ### $task
     * - returns Object copy of `Probe['task']`
     * @param {*} probeID optional/sensitive, select new point of reference
     *  @returns {string}
     */
    $task(probeID) {
        // allow use of short ref names example: `::cocalola`
        probeID = this.selectByTask(probeID, true)
        if (!this.pocket[probeID]) return undefined
        return copy(this.pocket[probeID]["task"])
    }

    /**
     * ### $task
     * - returns Object copy of `Probe['task']`
     * @param {*} probeID optional/sensitive, select new point of reference
     * @returns {array}
     */
    $error(probeID) {
        // allow use of short ref names example: `::cocalola`
        probeID = this.selectByTask(probeID, true)
        if (!this.pocket[probeID]) return undefined
        return copy(this.pocket[probeID]["error"])
    }

    /**
     * ### $all
     * - return Object copy of all setters: `{id,status,campaign,task,data}`
     * @param {*} probeID optional/sensitive, select new point of reference
     * @returns {object}
     */
    $all(probeID) {
        // allow use of short ref names example: `::cocalola`
        probeID = this.selectByTask(probeID, true)
        if (!this.pocket[probeID]) return undefined
        return copy(this.pocket[probeID].all())
    }

    /**
     *
     * @callback onChange_cb
     * @param {Probe} probe
     * @param {string} id
     * @returns {any}
     */

    /**
     * - changes are observed for `[data,status,ref,error,campaign, status:complete]`
     * - when watchProp `status:complete` is selected all copy data is returned in callback
     * @param {onChange_cb} cb
     * @param watchProp specify what property to watch, defaults to `all`, except for `status:complete`
     * @param {*} probeID optional/sensitive, select new point of reference

     */
    $onChange(cb, watchProp, probeID) {
        if (!this._onChange) {
            if (this.debug) warn("[pocket]", `[$onChange] opts.onChange=true must be enabled to use this feature`)
            return this
        }
        probeID = this.selectByTask(probeID, true)
        if (!this.pocket[probeID]) return null
        this.pocket[probeID].onChange(cb, watchProp)
        return this
    }

    /**
     *
     * @callback onProbeComplete_cb
     * @param {Probe} probe
     * @param {string} id
     * @returns {any}
     */

    /**
     * callback initialled of any probe that was completed, unless specificity selected `probeID`
     * @param {onProbeComplete_cb} cb
     * @param {*} probeID optional if you only want to listen for changes to specific probe add the id
     */
    $onProbeComplete(cb, probeID) {
        if (!this._onChange) {
            if (this.debug) warn("[pocket]", `[$onChange] opts.onChange=true must be enabled to use this feature`)
            return this
        }
        // only run available onChange for selected probe by id
        if (probeID) {
            probeID = this.selectByTask(probeID, true)
            if (!this.pocket[probeID]) return undefined
            this.pocket[probeID].onChange(cb, "status:complete")
            return this
        } else {
            let projectID = this.lastProjectID()
            let probeList = this.projectProbeList(projectID)

            /**
             * callback any probe that completed due process
             */
            probeList.forEach((probe) => {
                probe.onChange(cb, "status:complete")
            })

            return this
        }
    }
}

module.exports = PocketSelectors