move-on

2.1.9 • Public • Published

Description

move-on is a module that:

Any bugs found? Give me to know on GitHub.

Usage

Node

npm install move-on

const moveOn = require('move-on');

Browsers

Add the move-on.js library to the HTML file.
The library is located in ./dist/move-on.js directory.
It is a webpack&babel bundled cross-browser library version.
The library is accessible as moveOn variable in the global (window) scope.

<script src='move-on.js'></script>
<script>
  moveOn(list, config, onDone, onCatch);
</script> 

Tests

npm test

> git clone https://github.com/devrafalko/move-on.git
> cd move-on
> npm install
> npm test        //run tests in node
> npm test err    //run tests in node with failed specs shown
> npm test deep   //run tests in node with errors' descriptions shown

Simple sample

const moveOn = require('move-on'); 
/* [Function] moveOn(list, config, onDone, onCatch)
   [Function] moveOn.all(list, config, onDone, onCatch)
   [Function] moveOn.each(list, config, onDone, onCatch)
   [Function] moveOn.first(list, config, onDone, onCatch) */
 
const list = [retrieveData, computeData, displayData];
const config = { timeout: 5000 };
 
moveOn(list, config, onDone, onCatch);
 
function retrieveData(resolve, reject, context){
  setTimeout(resolve, 1000); //asynchronous resolve
}
 
function computeData(resolve, reject, context){
  resolve(); //synchronous resolve
}
 
function displayData(resolve, reject, context){
  resolve();
}
 
function onDone(reject, context){}
function onCatch(context){}

Methods short description

The module's methods expect the [Array] list of functions to be passed as the first argument. Each function in the chain has the resolve and reject parameter, that should be called when ready (or failed) in order to move the functions execution forward. When the functions chain is successfully executed, the done callback function is called finally, otherwise the catch callback function is called.

1. moveOn

The chained functions are executed sequentially (one after another). Each function is expected to be resolved , so that the next chained function was executed. The done function is called as the last one, when all previous chained functions resolved. The catch function is called instead of done function, when at least one chained function failed (rejected).
See the full description below.
See the samples: [sample] [sample] [sample] [sample] [sample] [sample] [sample]

2. moveOn.all

The all static method of move-on module executes all chosen functions at the same time (similarly to Promises' .all method). All chained functions are expected to be resolved so that the final done function was called. The catch function is called instead of done function, when at least one chained function failed (rejected).
See the full description below.
See the samples: [sample]

3. moveOn.each

The each static method of move-on module executes all chosen functions at the same time. Each chained function is expected to be either resolved or rejected , so that the final done function was called. The failed (rejected) function does not stop the further functions execution. It can be used eg. to log the warnings in the catch callback function.
See the full description below.
See the samples: [sample]

4. moveOn.first

The first static method of move-on module executes all chained functions at the same time. It expects the first (fastest) function to be resolved , so that the done function was called (similarly to Promises' .race method). When all functions failed (rejected), the catch function is called instead.
See the full description below.
See the samples: [sample]

Methods behaviour

moveOn( list , config , done , catch )

moveOn.all( list , config , done , catch )

moveOn.each( list , config , done , catch )

moveOn.first( list , config , done , catch )

Arguments

  1. list
  2. config
  3. done
  4. catch

list [Array: function | array]

The [Array] list stores the list of functions, that should be called. It can contain:

1. [Function] items

const retrieveData = function(){};
const computeData = ()=>{};
const displayData = { display:()=>{} };
const list = [retrieveData, computeData, displayData.display];

2. [Array: function] items for individual binding

  • All chained functions are bound by default to the config.context reference
  • You can set the individual this reference for the chosen functions (except arrow functions and already bound functions [read more])
  • In order to bind the chained functions individually, push [Array] item into the list :
    • The [0] item should indicate the object or value to be the this reference for the functions
    • The [1], [2] , etc... item(s) should indicate the function(s), that will be bound to the [0] object or value
  • The [Array] item's functions are bound to the given [0] object or value instead of the config.context
  • The config.bind setting does not affect the individual this reference setting
  • The [Array] item's functions still have the access to the config.context parameter
  • the list can still contain the [Function] items next to this [Array] item
const workers = {}, earnings = {}, tasks = {};
const config = {context: tasks}; //the default this reference
const list = [
  functionA, //this === tasks
  [workers, functionB], //this === workers
  [earnings, functionC] //this === earnings
];
moveOn(list, config, onDone, onCatch));

3. [Array: string] items for methods

The methods passed to the list loses their this reference to the object, they were declared in, what may be undesirable.
const workers = {
  addWorker: function(){},
  listEarnings: function(){}
};
const list = [
  workers.addWorker, //this !== workers
  workers.listEarnings //this !== workers
];
  • to retain the this reference to the object, that the methods are declared in, push [Array] item with methods' [String] names into the list :
    • The [0] item should indicate the object, that the methods are declared in
    • The [1], [2] , etc... item(s) should indicate the [String] name(s) of the method(s) declared in the [0] object
  • These methods retain the this reference to the [0] object and are not bound to the config.context
  • The config.bind setting does not affect the this reference
  • The [Array] item functions still have the access to the config.context parameter
  • the list can still contain the [Function] items or [Array] items with functions next to this [Array] item with [String] method's names
  • Samples: [sample]
const displayData = function(){};
const workers = {
  addWorker: function(){},
  listEarnings: function(){}
};
const list = [ [workers, 'addWorker', 'listEarnings'], displayData ];
 
moveOn(list, config, onDone, onCatch));

config [Object | null]

  • the [Object] config argument allows to set the following config properties: timeout , bind , context , passContext
  • when the config is set to null or when it does not define the particular config property or when it defines the config property incorrectly, the default value is used for this config property instead [sample] [sample] [sample]
  • any error is thrown when any config property is defined incorrectly (the default value is used instead)

config.timeout

Type: [Number | null | Infinity]
Default: 10000
Description:
  • It must be a [Number] integer, equal or bigger than 0, that indicates the milliseconds
  • it behaves different for each method:
    1. moveOn :
      The config.timeout starts out counting down individually for each chained function immediately after it is called.
      It expects each function to be resolved or rejected before timeout pass,
      otherwise it calls the catch function with the timeout error argument passed
    2. moveOn.all :
      The config.timeout starts out counting down once for all chained functions when the module is fired.
      It expects all functions to be resolved or any function to be rejected before timeout pass,
      otherwise it calls the catch function with the timeout error argument passed
    3. moveOn.each :
      The config.timeout starts out counting down once for all chained functions when the module is fired.
      It expects all functions to be either resolved or rejected before timeout pass,
      otherwise it calls the catch function with the timeout error argument passed
    4. moveOn.first :
      The config.timeout starts out counting down once for all chained functions when the module is fired.
      It expects at least one function to be resolved or all functions to be rejected before timeout pass,
      otherwise it calls the catch function with the timeout error argument passed
  • All resolves s and reject s that are called after the config.timeout pass are ignored
  • When the config.timeout is set to null or Infinity, the timeout is not set at all. If any of the chained function does not resolve (or reject), anything happen then and the done or catch function is never called in the end
  • When the config.timeout is not defined, or if it is defined with incorrect value, the default value is set instead
  • Samples: [sample] [sample]
Timeout error
It is an [Error] object with the following properties, that allow to distinguish, that the timeout error has been passed:
  • message: eg. "Timeout. The chained function did not respond in the expected time of 10000 ms."
  • info: "timeout"
  • code: "ETIMEDOUT"

config.context

Type: [any]
Default: {}
Description:
  • The config.context refers to the object (or value), that will be used as the this reference in all list functions, done and catch
  • It is usefull to transmit data between functions; eg. the [Object] config.context's properties can be defined and got in any function
  • The config.context can be any value, as any value can be used as the this reference in Function.prototype.bind [read more]
  • The config.context is used as the this reference by default, unless you set config.bind to false
  • The config.context is also accessible as the parameter, unless you set config.passContext to false
  • Samples: [sample] [sample] [sample]

config.passContext

Type: [Boolean]
Default: true
Description:

config.bind

Type: [Boolean]
Default: true
Description:
  • By default, each list function, done and catch are bound to the config.context object (or value), thus the this keyword refers to the config.context
  • In order to retain the former this reference of all functions, set the config.bind to false
  • In order to set the individual this reference for chosen functions, see the list constructing options
  • keep in mind, that arrow functions are non-binding and that already bound functions cannot have the this reference changed anymore

done( reject , context ) [Function]

The done is a callback function, that (in general) is called as the last one, when the list functions have been successfully executed. The done is called in a different way and time, depending on which method is called:
  1. moveOn The done is called, when the last function from the list collection is resolved.
    The arguments passed through done:
    [0] reject
    [1] config.context
    [2], [3] , etc... The arguments passed by the last resolved list function
  2. moveOn.all The done is called, when all list functions are resolved.
    The arguments passed through done:
    [0] reject
    [1] config.context
    [2] resolveMap
  3. moveOn.each The done is called, when all list functions are either resolved or rejected.
    The arguments passed through done:
    [0] reject
    [1] config.context
    [2] resolveMap
  4. moveOn.first The done is called, when the first (fastest) list function is resolved.
    The arguments passed through done:
    [0] reject
    [1] config.context
    [2], [3] , etc... The arguments passed by the first (fastest) resolved list function

resolveMap object

  • The resolveMap object is passed through done callback when the moveOn.all and moveOn.each method is executed. It stores all arguments that have been passed by each list function's resolve call.
  • The resolveMap contains all arguments objects at the indeces that correspond to the order of list functions calling; the third list function's arguments are accessible via resolveMap[2], and so on...
  • The resolveMap properties:
    • missing It returns the [Array] list of those list functions' indeces (due to the order of calling) that have not been resolved
  • The resolveMap methods:
    • forEach
      It loops through each arguments object.
      It expects the [0] parameter to be the [Function] callback.
      The [Function] callback is called for each arguments object.
      The callback parameters: {0: arguments, 1: argumentsIndex, 2: resolveMap}
      Usage: resolveMap.forEach((arguments, argumentsIndex, resolveMap) => {} );
    • forAll
      It loops through each item (argument) of each arguments object.
      It expects the [0] parameter to be the [Function] callback.
      The [Function] callback is called for each item (argument).
      The callback parameters: {0: argument, 1: argumentsIndex, 2: itemIndex, 3: resolveMap}
      Usage: resolveMap.forAll((argument, argumentsIndex, itemIndex, resolveMap) => {} );
  • Samples: [sample] [sample]

catch( context ) [Function]

The catch is a callback function, that (in general) is called as the last one, when the list function(s) have failed. The catch is called in a different way and time, depending on which method is called:
  1. moveOn The catch is called, when any list function rejects.
    The arguments passed through catch:
    [0] config.context
    [1], [2] , etc... The arguments passed by the rejected list function
  2. moveOn.all The catch is called, when any list function rejects.
    The arguments passed through catch:
    [0] config.context
    [1], [2] , etc... The arguments passed by the rejected list function
  3. moveOn.each The catch is called for each list function rejection.
    The arguments passed through catch:
    [0] config.context
    [1], [2] , etc... The arguments passed by the rejected list function
  4. moveOn.first The catch is called, when all list function rejected.
    The arguments passed through catch:
    [0] config.context
    [1] rejectMap

rejectMap object

  • The rejectMap object is passed through catch callback when the moveOn.first method is executed. It stores all arguments that have been passed by all list functions' reject calls
  • The rejectMap contains all arguments objects at the indeces that correspond to the order of list functions calling; the third list function's arguments are accessible via rejectMap[2], and so on...
  • The rejectMap methods:
    • forEach
      It loops through each arguments object.
      It expects the [0] parameter to be the [Function] callback.
      The [Function] callback is called for each arguments object.
      The callback parameters: {0: arguments, 1: argumentsIndex, 2: rejectMap}
      Usage: rejectMap.forEach((arguments, argumentsIndex, rejectMap) => {} );
    • forAll
      It loops through each item (argument) of each arguments object.
      It expects the [0] parameter to be the [Function] callback.
      The [Function] callback is called for each item (argument).
      The callback parameters: {0: argument, 1: argumentsIndex, 2: itemIndex, 3: rejectMap}
      Usage: rejectMap.forAll((argument, argumentsIndex, itemIndex, rejectMap) => {} );

Chained functions

function fetchData(resolve, reject, context){
  this.someAsyncAjaxHere((err, data) => {
    if(err) return reject(new Error('Could not read the data.'));
    this.data = data;
    return resolve();
  });
}

Multiple resolve | reject calls

inner move-on module

Code examples

1. The move-on chain of synchronous and asynchronous functions

const moveOn = require('move-on');
const list = [requestData, parseData, displayData];
const config = {
  context:{
    table: document.getElementById('list') //accessible in all functions as this.table
  }
};
 
moveOn(list, config, onDone, onCatch);
 
//asynchronous
function requestData(resolve, reject, context) {
  getAjaxData((err, json) => {  
    if (err) return reject(err);
    this.json = json;
    resolve();
  });
}
 
//synchronous
function parseData(resolve, reject, context) {
  this.data = parseJSON(this.json);
  this.employees = getEmployeesList(this.data);
  this.earnings = getEarningsList(this.data);
  resolve();
}
 
function displayData(resolve, reject, context) {
  table.innerHTML = parseTableContent(this.employees, this.earnings);
  resolve();
}
 
function onDone(reject, context) {
  this.table.style.display = "table";
}
 
function onCatch(context, err) {
  throw new Error(`Could not get the data: ${err}`);
}

2. The move-on module with the user config.context and the arguments passed through the resolve and reject arrow callback functions

const moveOn = require('move-on');
 
function check(resolve, reject) {
  console.log(this.name); //Jessica
  console.log(this.age); //25
  //the [String] argument passed through the catch callback function
  if (!this.name || !this.age) return reject('The person was not defined.');
  return resolve();
}
 
const config = {
  context: {
    name: 'Jessica',
    age: 25
  }
};
 
moveOn([check], config, (reject, context) => {
  //the arrow function could not be bound to the context reference
  //but the context is still accessible as the parameter
  console.log(`New person added: ${context.name} (${context.age})yo.`);
}, (context, err) => {
  console.error(err); //The [String] argument passed through the reject callback function
});

3. The move-on module that rejects

Mind that the second rejected function ends up the execution of further chained functions.
const moveOn = require('move-on');
 
moveOn([a, b, c], null, onDone, onCatch);
 
function a(resolve, reject) {
  resolve();
}
function b(resolve, reject) {
  reject('oops!');
}
function c(resolve, reject) {
  //it's been never called!
  resolve();
}
 
function onDone(reject, context) {
  //it's been never called!
}
 
function onCatch(context, message) {
  console.log(message); //oops!
}

4. The move-on instructions after resolve call

In order to end up the chained function's execution, call return resolve();
const moveOn = require('move-on');
 
moveOn([a, b, c], null, onDone, onCatch);
 
/* logs order:
  A before
  B before
  C before
  Done!
  C after
  B after
  A after
*/
 
function a(resolve) {
  console.log('A before');
  resolve();
  console.log('A after');
}
function b(resolve) {
  console.log('B before');
  resolve();
  console.log('B after');
}
function c(resolve) {
  console.log('C before');
  resolve();
  console.log('C after');
}
 
function onDone(reject, context) {
  console.log('Done!');
}
 
function onCatch(context, msg) {
  console.log(msg); //oops!
}

5. The inner move-on module and multiple resolve and reject calls

Mind how X, Y and Z functions of the inner module execute between A, B and C chained functions.
Mind how the B and C chained functions are executed twice, by double resolve call in A chained function.
const moveOn = require('move-on');
moveOn([a, b, c], null, onDone, onCatch);
/* The order of functions execution:
   A, X, Y, Z, B, C, Done, Catch, B, C, Done, Catch */
 
function a(resolve, reject) {
  console.log('A');
  moveOn([x, y, z], null, () => resolve(), () => reject);
  resolve();
}
function b(resolve) {
  console.log('B');
  resolve();
}
function c(resolve, reject) {
  console.log('C');
  resolve();
  reject();
}
function x(resolve) {
  console.log('X');
  resolve();
}
function y(resolve) {
  console.log('Y');
  resolve();
}
function z(resolve) {
  console.log('Z');
  resolve();
}
function onDone() {
  console.log('Done');
}
function onCatch() {
  console.log('Catch');
}

6. The move-on.all chain

It calls done callback function right after all chained functions are resolved.
The user's shorter config.timeout is set.
The reject callback functions are not used. In case of error, the catch callback function will still be called with config.timeout error.
All retrieved data is passed through the resolve callback and accessible in the ResolveMap done callback function.
const moveOn = require('move-on');
 
const list = [getArticle, getTagList, getCommentSection];
 
moveOn.all(list, { timeout: 5000, passContext: false }, onDone, onCatch);
 
function getArticle(resolve) {
  var xhr = new XMLHttpRequest();
  xhr.onreadystatechange = () => {
    if (xhr.readyState == 4 && xhr.status == 200) return resolve(xhr.responseText);
  };
  xhr.open("GET", "article.js", true);
  xhr.send();
}
 
function getTagList(resolve) {
  var xhr = new XMLHttpRequest();
  xhr.onreadystatechange = () => {
    if (xhr.readyState == 4 && xhr.status == 200) return resolve(xhr.responseText);
  };
  xhr.open("GET", "tags.js", true);
  xhr.send();
}
 
function getCommentSection(resolve) {
  var xhr = new XMLHttpRequest();
  xhr.onreadystatechange = () => {
    if (xhr.readyState == 4 && xhr.status == 200) return resolve(xhr.responseText);
  };
  xhr.open("GET", "comments.js", true);
  xhr.send();
}
 
function onDone(reject, context, map) {
  let article = JSON.parse(map[0][0]);
  let tags = JSON.parse(map[1][0]);
  let comments = JSON.parse(map[2][0]);
}
 
function onCatch(err) {
  throw new Error(err);
}

7. The move-on.each inner module

The move-on is used to get the files list asynchronously and then to copy all files asynchronously.
The inner move-on.each module is injected in the second chained function in order to report the success | failure message for each copied file.
Each copying failure occurrence calls reject callback and logs the warning.
When all files are (un)successfully copied, the done callback function is called, that indicates the end of files copying action.
const moveOn = require('move-on');
const fs = require('fs');
const path = require('path');
 
const list = [getContentsList, copyFiles];
const config = {
  passContext: false,
  context: {
    copyFrom: './modules',
    copyTo: './prod/modules'
  }
};
 
moveOn(list, config, onDone, onCatch);
 
 
function getContentsList(resolve, reject) {
  fs.readdir(this.copyFrom, (err, files) => {
    if (err) return reject(`Could not get the access to the "${this.copyFrom}" path.`);
    resolve(files); //the files object will be passed through the second function
  });
}
 
function copyFiles(resolve, reject, files) {
  const list = [];
  //the moveOn.each will take the same user context to get the access to the paths
  const config = { context: this, passContext: false };
  //creating the list of chained functions for each files item:
  for (let file of files) list.push((resolve, reject) => {
    let fromPath = path.resolve(this.copyFrom, file);
    let toPath = path.resolve(this.copyTo, file);
    fs.copyFile(fromPath, toPath, (err) => {
      //the reject call does not abort the module execution in moveOn.each method
      if (err) return reject(`The file "${file}" could not be copied.`);
      resolve(file); //the file path is added to the [Resolved] map, accessible in the final done callback function
    });
  });
  //the inner moveOn.each module - as the done callback - calls the resolve callback of the second copyFiles function with [Resolved] map argument
  moveOn.each(list, config, (reject, map) => resolve(map), (err) => console.warn(err));
}
 
 
function onDone(reject, map) {
  //the [Resolved] map contains the collection of all passed files paths
  //the missing property contains the indeces of all moveOn.each chained functions that have been rejected
  let message = !map.missing.length ? 'All files have been successfully moved.' : 'The files have been moved with some errors.';
  console.log(message);
}
 
function onCatch(err) {
  throw new Error(err);
}

8. The move-on.first module

It sends the request for the three urls and waits for the first (fastest) response.
const moveOn = require('move-on');
 
loadLibrary();
 
function loadLibrary() {
  const list = [], urls = [ 'url-a', 'url-b', 'url-c' ];
  for (let url of urls) {
    list.push((resolve) => {
      let xhr = new XMLHttpRequest();
      xhr.onreadystatechange = function () {
        if (this.readyState == 4 && this.status == 200) return resolve(this.responseText);
      };
      xhr.open("GET", url, true);
      xhr.send();
    })
  }
  
  //all list functions are called simultaneously
  //the module expects the first function to be resolved in order to call the done callback
  //if any of chained functions resolves in 2000ms, the timeout error will be passed through the catch callback function
  moveOn.first(list, { timeout: 2000 }, onDone, onCatch);
 
  function onDone(reject, context, data){
    //the data has been passed by the fastest resolved chained function
    //the other chained functions are ignored
  }
 
  function onCatch(context, err){
    throw new Error(err); //timeout error
  }
}

9. The chain of class instance's methods

The config.context is set to the class instance's this reference, thus the chained methods still have the access to all instance's properties and methods.
const moveOn = require('move-on');
 
class Vegetables {
  constructor() {
    const list = [retrieveData, createTable, generateDiet];
    moveOn(list, { context: this }, () => { }, () => { });
  }
 
  retrieveData(resolve, reject) {
    this._ajax((err, json) => {
      if (err) return reject('We have got some problems. Try again later.');
      resolve(json);
    });
  }
 
  createTable(resolve, reject, context, json) {
    let data = this._parseJson(json);
    this.vegetables = this._generateTable(data);
    this._displayTable(this.vegetables);
    resolve();
  }
 
  generateDiet(resolve, reject) {
    this._userData((err, data) => {
      if (err) return reject('Register new account first!');
      this.diet = this._addIngredients(this.vegetables, data.caloriesDemand);
      resolve();
    });
 
  }
 
  _parseJson() { }
  _generateTable() { }
  _userData() { }
  _ajax() { }
  _displayTable() { }
  _addIngredients() { }
}

10. The email validation sample

Mind that chained functions are passed as [String] methods' names to retain the access to the this reference to the class instance.
The done and catch calls the same callback function. The reject is used to terminate the further chained functions and the resolve to continue execution. Mind that isString and isAvailable methods call reject to stop the module execution.
The [Array] errorMessages stores all validation error messages, that will be printed out for the user.
The isAvailable chained function is asynchronous and simulates the request sent to the server to check whether the email address is available.
const moveOn = require('move-on');
 
class EmailValidation {
  constructor({ email, callback }) {
    this.email = email;
    this.errorMessages = [];
    const list = [[this, 'isString', 'hasAtSign', 'hasMultipleAtSigns', 'hasSpecialChars',
      'hasCapitalLetters', 'hasSpaces', 'hasDoubleDots', 'isAvailable']];
    moveOn(list, null, () => callback(this.errorMessages), () => callback(this.errorMessages));
  }
 
  isString(resolve, reject) {
    if (typeof this.email !== 'string') {
      this.errorMessages.push('The value must be a String.');
      return reject();
    }
    resolve();
  }
  hasAtSign(resolve) {
    if (!(/@/).test(this.email)) this.errorMessages.push('The value must contain @ sign.');
    resolve();
  }
  hasMultipleAtSigns(resolve) {
    if ((/@.*@/).test(this.email)) this.errorMessages.push('The value can contain only one @ sign.');
    resolve();
  }
  hasSpecialChars(resolve) {
    if (!(/^[A-z.\-@_]+$/).test(this.email)) this.errorMessages.push('The value cannot contain special characters.');
    resolve();
  }
  hasCapitalLetters(resolve) {
    if ((/[A-Z]/).test(this.email)) this.errorMessages.push('The value cannot contain capital letters.');
    resolve();
  }
 
  hasSpaces(resolve) {
    if ((/\s/).test(this.email)) this.errorMessages.push('The value cannot contain spaces.');
    resolve();
  }
 
  hasDoubleDots(resolve) {
    if ((/\.{2,}/).test(this.email)) this.errorMessages.push('The value cannot contain double dots.');
    resolve();
  }
 
  isAvailable(resolve, reject) {
    if (this.errorMessages.length) return reject();
    setTimeout(() => {
      if (!Math.random() > .5) { //check the availability
        this.errorMessages.push(`The ${this.email} address is unavailable.`);
        return reject();
      }
      resolve();
    }, 500);
  }
}
 
const formInput = document.getElementById('email-input');
const formAlert = document.getElementById('form-alert');
formInput.addEventListener('input', (event) => {
  new EmailValidation({
    email: event.target.value,
    callback: (errorList) => {
      formAlert.innerHTML = ''; //clear before print new messages
      if (!errorList.length) formAlert.innerHTML = 'The address is OK';
      else errorList.forEach((err) => formAlert.innerHTML += `${err}<br/>`);
    }
  });
});

Dependencies (2)

Dev Dependencies (13)

Package Sidebar

Install

npm i move-on

Weekly Downloads

5,435

Version

2.1.9

License

MIT

Unpacked Size

85.7 kB

Total Files

4

Last publish

Collaborators

  • devrafalko