Initial async work

Operations can now:
1) return their progress directly.
2) throw an error.
3) (ADDED) return a promise:
  + that resolves to their progress.
  + that rejects an error message (like throwing but asynchronous).
  For an example see the new operation "Wait" (Flow Control)

Added a flow control operation "Wait", which waits for the number of
milliseconds passed in as its argument. It is a fairly useless operation
but it does demonstrate how asynchronous operations now work.

A recipe like:
```
Fork
Wait (1000ms)
```
will only wait for 1000ms (each wait runs at the same time as each
other).

I have not looked into performance implications yet, also this code is
probably more complicated than it needs to be (would love help on this).
This commit is contained in:
toby 2017-02-14 14:46:35 -05:00
parent 11e972ff26
commit 343d350af8
8 changed files with 346 additions and 108 deletions

View file

@ -428,3 +428,35 @@ span.btn img {
border-top: none; border-top: none;
margin-top: 0; margin-top: 0;
} }
@-moz-keyframes spinner {
from { -moz-transform: rotate(0deg); }
to { -moz-transform: rotate(359deg); }
}
@-webkit-keyframes spinner {
from { -webkit-transform: rotate(0deg); }
to { -webkit-transform: rotate(359deg); }
}
@keyframes spinner {
from {transform:rotate(0deg);}
to {transform:rotate(359deg);}
}
.loading-icon::before {
content: "\21bb";
}
.loading-icon {
-webkit-animation-name: spinner;
-webkit-animation-duration: 1000ms;
-webkit-animation-iteration-count: infinite;
-webkit-animation-timing-function: linear;
-moz-animation-name: spinner;
-moz-animation-duration: 1000ms;
-moz-animation-iteration-count: infinite;
-moz-animation-timing-function: linear;
-ms-animation-name: spinner;
-ms-animation-duration: 1000ms;
-ms-animation-iteration-count: infinite;
-ms-animation-timing-function: linear;
}

View file

@ -97,6 +97,9 @@
<div id="input" class="split no-select"> <div id="input" class="split no-select">
<div class="title no-select"> <div class="title no-select">
Input Input
<div class="loading-icon"
style="display: none">
</div>
<div class="btn-group io-btn-group"> <div class="btn-group io-btn-group">
<button type="button" class="btn btn-default btn-sm" id="clr-io"><img src="images/recycle-16x16.png" /> Clear I/O</button> <button type="button" class="btn btn-default btn-sm" id="clr-io"><img src="images/recycle-16x16.png" /> Clear I/O</button>
<button type="button" class="btn btn-default btn-sm" id="reset-layout"><img src="images/layout-16x16.png" /> Reset layout</button> <button type="button" class="btn btn-default btn-sm" id="reset-layout"><img src="images/layout-16x16.png" /> Reset layout</button>
@ -113,6 +116,9 @@
<div id="output" class="split"> <div id="output" class="split">
<div class="title no-select"> <div class="title no-select">
Output Output
<div class="loading-icon"
style="display: none">
</div>
<div class="btn-group io-btn-group"> <div class="btn-group io-btn-group">
<button type="button" class="btn btn-default btn-sm" id="save-to-file" title="Save to file"><img src="images/save_as-16x16.png" /> Save to file</button> <button type="button" class="btn btn-default btn-sm" id="save-to-file" title="Save to file"><img src="images/save_as-16x16.png" /> Save to file</button>
<button type="button" class="btn btn-default btn-sm" id="switch" title="Move output to input"><img src="images/switch-16x16.png" /> Move output to input</button> <button type="button" class="btn btn-default btn-sm" id="switch" title="Move output to input"><img src="images/switch-16x16.png" /> Move output to input</button>

View file

@ -285,6 +285,7 @@ var Categories = [
"Jump", "Jump",
"Conditional Jump", "Conditional Jump",
"Return", "Return",
"Wait",
] ]
}, },
]; ];

View file

@ -3094,5 +3094,19 @@ var OperationConfig = {
outputType: "html", outputType: "html",
args: [ args: [
] ]
} },
"Wait": {
description: "Waits for a number of milliseconds.",
run: FlowControl.runWait,
inputType: "string",
outputType: "string",
flowControl: true,
args: [
{
name: "Sleep time in milliseconds",
type: "number",
value: FlowControl.SLEEP_TIME,
}
]
},
}; };

View file

@ -34,7 +34,7 @@ Chef.prototype.bake = function(inputText, recipeConfig, options, progress, step)
var startTime = new Date().getTime(), var startTime = new Date().getTime(),
recipe = new Recipe(recipeConfig), recipe = new Recipe(recipeConfig),
containsFc = recipe.containsFlowControl(), containsFc = recipe.containsFlowControl(),
error = false; chef = this;
// Reset attemptHighlight flag // Reset attemptHighlight flag
if (options.hasOwnProperty("attemptHighlight")) { if (options.hasOwnProperty("attemptHighlight")) {
@ -64,28 +64,41 @@ Chef.prototype.bake = function(inputText, recipeConfig, options, progress, step)
// If starting from scratch, load data // If starting from scratch, load data
if (progress === 0) { if (progress === 0) {
this.dish.set(inputText, Dish.STRING); chef.dish.set(inputText, Dish.STRING);
} }
try { var ret = {
progress = recipe.execute(this.dish, progress);
} catch (err) {
// Return the error in the result so that everything else gets correctly updated
// rather than throwing it here and losing state info.
error = err;
progress = err.progress;
}
return {
result: this.dish.type === Dish.HTML ?
this.dish.get(Dish.HTML) :
this.dish.get(Dish.STRING),
type: Dish.enumLookup(this.dish.type),
progress: progress,
options: options, options: options,
duration: new Date().getTime() - startTime, error: false,
error: error
}; };
return new Promise(function(resolve) {
recipe.execute(chef.dish, progress)
.then(function(progress) {
ret.result = chef.dish.type === Dish.HTML ?
chef.dish.get(Dish.HTML) :
chef.dish.get(Dish.STRING);
ret.type = Dish.enumLookup(chef.dish.type);
ret.duration = new Date().getTime() - startTime;
ret.progress = progress;
resolve(ret);
})
.catch(function(err) {
ret.result = chef.dish.type === Dish.HTML ?
chef.dish.get(Dish.HTML) :
chef.dish.get(Dish.STRING);
ret.type = Dish.enumLookup(chef.dish.type);
ret.duration = new Date().getTime() - startTime;
ret.progress = err.progress;
ret.error = err;
// Resolve not reject: we are packaging the error as a value.
resolve(ret);
});
});
}; };

View file

@ -59,29 +59,53 @@ var FlowControl = {
} }
} }
var recipe = new Recipe(), var recipe = new Recipe();
output = "",
progress = 0;
recipe.addOperations(subOpList); recipe.addOperations(subOpList);
// Run recipe over each tranche return new Promise(function(resolve, reject) {
for (i = 0; i < inputs.length; i++) { var promises = inputs.map(function(input, i) {
var dish = new Dish(inputs[i], inputType); var forkDish = new Dish(input, inputType);
try {
progress = recipe.execute(dish, 0);
} catch (err) {
if (!ignoreErrors) {
throw err;
}
progress = err.progress + 1;
}
output += dish.get(outputType) + mergeDelim;
}
state.dish.set(output, outputType); return new Promise(function(resolve, reject) {
state.progress += progress; recipe.execute(forkDish, 0)
return state; .then(function(progress) {
resolve({
progress: progress,
dish: forkDish,
});
})
.catch(function(err) {
if (ignoreErrors) {
resolve({
progress: err.progress + 1,
dish: forkDish,
});
} else {
reject(err);
}
});
});
});
Promise.all(promises)
.then(function(values) {
var progress;
var output = values.map(function(value) {
progress = value.progress;
return value.dish.get(outputType);
}).join(mergeDelim);
state.progress += progress;
state.dish.set(output, outputType);
resolve(state);
})
.catch(function(err) {
reject(err);
});
});
}, },
@ -183,4 +207,32 @@ var FlowControl = {
return state; return state;
}, },
/**
* @constant
* @default
*/
SLEEP_TIME: 2500,
/**
* Wait operation.
*
* @param {Object} state - The current state of the recipe.
* @param {number} state.progress - The current position in the recipe.
* @param {Dish} state.dish - The Dish being operated on.
* @param {Operation[]} state.opList - The list of operations in the recipe.
* @returns {Object} The updated state of the recipe.
*/
runWait: function(state) {
var ings = state.opList[state.progress].getIngValues(),
sleepTime = ings[0];
return new Promise(function(resolve, reject) {
setTimeout(function() {
resolve(state);
}, sleepTime);
});
},
}; };

View file

@ -138,58 +138,112 @@ Recipe.prototype.lastOpIndex = function(startIndex) {
* Executes each operation in the recipe over the given Dish. * Executes each operation in the recipe over the given Dish.
* *
* @param {Dish} dish * @param {Dish} dish
* @param {number} [startFrom=0] - The index of the Operation to start executing from * @param {number} [currentStep=0] - The index of the Operation to start executing from
* @returns {number} - The final progress through the recipe * @returns {number} - The final progress through the recipe
*/ */
Recipe.prototype.execute = function(dish, startFrom) { Recipe.prototype.execute = function(dish, currentStep, state) {
startFrom = startFrom || 0; var recipe = this;
var op, input, output, numJumps = 0;
for (var i = startFrom; i < this.opList.length; i++) { var formatErrMsg = function(err, step, op) {
op = this.opList[i]; var e = typeof err == "string" ? { message: err } : err;
if (op.isDisabled()) {
continue; e.progress = step;
} if (e.fileName) {
if (op.isBreakpoint()) { e.displayStr = op.name + " - " + e.name + " in " +
return i; e.fileName + " on line " + e.lineNumber +
".<br><br>Message: " + (e.displayStr || e.message);
} else {
e.displayStr = op.name + " - " + (e.displayStr || e.message);
} }
try { return e;
};
// Operations can be asynchronous so we have to return a Promise to a
// future value.
return new Promise(function(resolve, reject) {
// Helper function to clean up recursing to the next recipe step.
// It is a closure to avoid having to pass in resolve and reject.
var execRecipe = function(recipe, dish, step, state) {
return recipe.execute(dish, step, state)
.then(function(progress) {
resolve(progress);
})
.catch(function(err) {
// Pass back the error to the previous caller.
// We don't want to handle the error here as the current
// operation did not cause the error, and so it should
// not appear in the error message.
reject(err);
});
};
currentStep = currentStep || 0;
if (currentStep === recipe.opList.length) {
resolve(currentStep);
return;
}
var op = recipe.opList[currentStep],
input = dish.get(op.inputType); input = dish.get(op.inputType);
if (op.isDisabled()) {
// Skip to next operation
var nextStep = currentStep + 1;
execRecipe(recipe, dish, nextStep, state);
} else if (op.isBreakpoint()) {
// We are at a breakpoint, we shouldn't recurse to the next op.
resolve(currentStep);
} else {
var operationResult;
// We must try/catch here because op.run can either return
// A) a value
// B) a promise
// Promise.resolve -> .catch will handle errors from promises
// try/catch will handle errors from values
try {
if (op.isFlowControl()) {
state = {
progress: currentStep,
dish: dish,
opList: recipe.opList,
numJumps: (state && state.numJumps) || 0,
};
operationResult = op.run(state);
} else {
operationResult = op.run(input, op.getIngValues());
}
} catch (err) {
reject(formatErrMsg(err, currentStep, op));
return;
}
if (op.isFlowControl()) { if (op.isFlowControl()) {
// Package up the current state Promise.resolve(operationResult)
var state = { .then(function(state) {
"progress" : i, return recipe.execute(state.dish, state.progress + 1);
"dish" : dish, })
"opList" : this.opList, .then(function(progress) {
"numJumps" : numJumps resolve(progress);
}; })
.catch(function(err) {
state = op.run(state); reject(formatErrMsg(err, currentStep, op));
i = state.progress; });
numJumps = state.numJumps;
} else { } else {
output = op.run(input, op.getIngValues()); Promise.resolve(operationResult)
dish.set(output, op.outputType); .then(function(output) {
dish.set(output, op.outputType);
var nextStep = currentStep + 1;
execRecipe(recipe, dish, nextStep, state);
})
.catch(function(err) {
reject(formatErrMsg(err, currentStep, op));
});
} }
} catch (err) {
var e = typeof err == "string" ? { message: err } : err;
e.progress = i;
if (e.fileName) {
e.displayStr = op.name + " - " + e.name + " in " +
e.fileName + " on line " + e.lineNumber +
".<br><br>Message: " + (e.displayStr || e.message);
} else {
e.displayStr = op.name + " - " + (e.displayStr || e.message);
}
throw e;
} }
} });
return this.opList.length;
}; };

View file

@ -28,6 +28,9 @@ var HTMLApp = function(categories, operations, defaultFavourites, defaultOptions
this.progress = 0; this.progress = 0;
this.ingId = 0; this.ingId = 0;
this.baking = false;
this.rebake = false;
window.chef = this.chef; window.chef = this.chef;
}; };
@ -61,6 +64,36 @@ HTMLApp.prototype.handleError = function(err) {
}; };
/**
* Updates the UI to show if baking is in process or not.
*
* @param {bakingStatus}
*/
HTMLApp.prototype.setBakingStatus = function(bakingStatus) {
var inputLoadingIcon = document.querySelector("#input .title .loading-icon");
var outputLoadingIcon = document.querySelector("#output .title .loading-icon");
var inputElement = document.querySelector("#input-text");
var outputElement = document.querySelector("#output-text");
if (bakingStatus) {
inputLoadingIcon.style.display = "inline-block";
outputLoadingIcon.style.display = "inline-block";
inputElement.classList.add("disabled");
outputElement.classList.add("disabled");
inputElement.disabled = true;
outputElement.disabled = true;
} else {
inputLoadingIcon.style.display = "none";
outputLoadingIcon.style.display = "none";
inputElement.classList.remove("disabled");
outputElement.classList.remove("disabled");
inputElement.disabled = false;
outputElement.disabled = false;
}
};
/** /**
* Calls the Chef to bake the current input using the current recipe. * Calls the Chef to bake the current input using the current recipe.
* *
@ -68,37 +101,70 @@ HTMLApp.prototype.handleError = function(err) {
* whole recipe. * whole recipe.
*/ */
HTMLApp.prototype.bake = function(step) { HTMLApp.prototype.bake = function(step) {
var response; var app = this;
if (app.baking) {
if (!app.rebake) {
// We do not want to keep autobaking
// Say that we will rebake and then try again later
app.rebake = true;
setTimeout(function() {
app.bake(step);
}, 500);
}
return;
}
app.rebake = false;
app.baking = true;
app.setBakingStatus(true);
try { try {
response = this.chef.bake( app.chef.bake(
this.getInput(), // The user's input app.getInput(), // The user's input
this.getRecipeConfig(), // The configuration of the recipe app.getRecipeConfig(), // The configuration of the recipe
this.options, // Options set by the user app.options, // Options set by the user
this.progress, // The current position in the recipe app.progress, // The current position in the recipe
step // Whether or not to take one step or execute the whole recipe step // Whether or not to take one step or execute the whole recipe
); )
.then(function(response) {
app.baking = false;
app.setBakingStatus(false);
if (!response) {
return;
}
if (response.error) {
app.handleError(response.error);
}
app.options = response.options;
if (response.type === "html") {
app.dishStr = Utils.stripHtmlTags(response.result, true);
} else {
app.dishStr = response.result;
}
app.progress = response.progress;
app.manager.recipe.updateBreakpointIndicator(response.progress);
app.manager.output.set(response.result, response.type, response.duration);
// If baking took too long, disable auto-bake
if (response.duration > app.options.autoBakeThreshold && app.autoBake_) {
app.manager.controls.setAutoBake(false);
app.alert("Baking took longer than " + app.options.autoBakeThreshold +
"ms, Auto Bake has been disabled.", "warning", 5000);
}
})
.catch(function(err) {
console.error("Chef's promise was rejected, should never occur");
});
} catch (err) { } catch (err) {
this.handleError(err); app.baking = false;
} app.setBakingStatus(false);
app.handleError(err);
if (!response) return;
if (response.error) {
this.handleError(response.error);
}
this.options = response.options;
this.dishStr = response.type === "html" ? Utils.stripHtmlTags(response.result, true) : response.result;
this.progress = response.progress;
this.manager.recipe.updateBreakpointIndicator(response.progress);
this.manager.output.set(response.result, response.type, response.duration);
// If baking took too long, disable auto-bake
if (response.duration > this.options.autoBakeThreshold && this.autoBake_) {
this.manager.controls.setAutoBake(false);
this.alert("Baking took longer than " + this.options.autoBakeThreshold +
"ms, Auto Bake has been disabled.", "warning", 5000);
} }
}; };