Browse Source

Merge branch 'feature-pretty-recipe-format'

n1474335 8 years ago
parent
commit
af311001cf

+ 133 - 0
src/core/Utils.js

@@ -843,6 +843,139 @@ const Utils = {
     },
 
 
+    /**
+     * Encodes a URI fragment (#) or query (?) using a minimal amount of percent-encoding.
+     *
+     * RFC 3986 defines legal characters for the fragment and query parts of a URL to be as follows:
+     *
+     * fragment      = *( pchar / "/" / "?" )
+     * query         = *( pchar / "/" / "?" )
+     * pchar         = unreserved / pct-encoded / sub-delims / ":" / "@" 
+     * unreserved    = ALPHA / DIGIT / "-" / "." / "_" / "~"
+     * pct-encoded   = "%" HEXDIG HEXDIG
+     * sub-delims    = "!" / "$" / "&" / "'" / "(" / ")"
+     *                  / "*" / "+" / "," / ";" / "="
+     *
+     * Meaning that the list of characters that need not be percent-encoded are alphanumeric plus:
+     * -._~!$&'()*+,;=:@/?
+     *
+     * & and = are still escaped as they are used to serialise the key-value pairs in CyberChef
+     * fragments. + is also escaped so as to prevent it being decoded to a space.
+     *
+     * @param {string} str
+     * @returns {string}
+     */
+    encodeURIFragment: function(str) {
+        const LEGAL_CHARS = {
+            "%2D": "-",
+            "%2E": ".",
+            "%5F": "_",
+            "%7E": "~",
+            "%21": "!",
+            "%24": "$",
+            //"%26": "&",
+            "%27": "'",
+            "%28": "(",
+            "%29": ")",
+            "%2A": "*",
+            //"%2B": "+",
+            "%2C": ",",
+            "%3B": ";",
+            //"%3D": "=",
+            "%3A": ":",
+            "%40": "@",
+            "%2F": "/",
+            "%3F": "?"
+        };
+        str = encodeURIComponent(str);
+
+        return str.replace(/%[0-9A-F]{2}/g, function (match) {
+            return LEGAL_CHARS[match] || match;
+        });
+    },
+
+
+    /**
+     * Generates a "pretty" recipe format from a recipeConfig object.
+     *
+     * "Pretty" CyberChef recipe formats are designed to be included in the fragment (#) or query (?)
+     * parts of the URL. They can also be loaded into CyberChef through the 'Load' interface. In order
+     * to make this format as readable as possible, various special characters are used unescaped. This
+     * reduces the amount of percent-encoding included in the URL which is typically difficult to read,
+     * as well as substantially increasing the overall length. These characteristics can be quite
+     * offputting for users.
+     *
+     * @param {Object[]} recipeConfig
+     * @param {boolean} newline - whether to add a newline after each operation
+     * @returns {string}
+     */
+    generatePrettyRecipe: function(recipeConfig, newline) {
+        let prettyConfig = "",
+            name = "",
+            args = "",
+            disabled = "",
+            bp = "";
+
+        recipeConfig.forEach(op => {
+            name = op.op.replace(/ /g, "_");
+            args = JSON.stringify(op.args)
+                .slice(1, -1) // Remove [ and ] as they are implied
+                // We now need to switch double-quoted (") strings to single-quotes (') as these do not
+                // need to be percent-encoded.
+                .replace(/'/g, "\\'") // Escape single quotes
+                .replace(/\\"/g, '"') // Unescape double quotes
+                .replace(/(^|,)"/g, "$1'") // Replace opening " with '
+                .replace(/"(,|$)/g, "'$1"); // Replace closing " with '
+
+            disabled = op.disabled ? "/disabled": "";
+            bp = op.breakpoint ? "/breakpoint" : "";
+            prettyConfig += `${name}(${args}${disabled}${bp})`;
+            if (newline) prettyConfig += "\n";
+        });
+        return prettyConfig;
+    },
+
+
+    /**
+     * Converts a recipe string to the JSON representation of the recipe.
+     * Accepts either stringified JSON or bespoke "pretty" recipe format.
+     *
+     * @param {string} recipe
+     * @returns {Object[]}
+     */
+    parseRecipeConfig: function(recipe) {
+        recipe = recipe.trim();
+        if (recipe.length === 0) return [];
+        if (recipe[0] === "[") return JSON.parse(recipe);
+
+        // Parse bespoke recipe format
+        recipe = recipe.replace(/\n/g, "");
+        let m,
+            recipeRegex = /([^(]+)\(((?:'[^'\\]*(?:\\.[^'\\]*)*'|[^)/])*)(\/[^)]+)?\)/g,
+            recipeConfig = [],
+            args;
+
+        while ((m = recipeRegex.exec(recipe))) {
+            // Translate strings in args back to double-quotes
+            args = m[2]
+                .replace(/"/g, '\\"') // Escape double quotes
+                .replace(/(^|,)'/g, '$1"') // Replace opening ' with "
+                .replace(/([^\\])'(,|$)/g, '$1"$2') // Replace closing ' with "
+                .replace(/\\'/g, "'"); // Unescape single quotes
+            args = "[" + args + "]";
+
+            let op = {
+                op: m[1].replace(/_/g, " "),
+                args: JSON.parse(args)
+            };
+            if (m[3] && m[3].indexOf("disabled") > 0) op.disabled = true;
+            if (m[3] && m[3].indexOf("breakpoint") > 0) op.breakpoint = true;
+            recipeConfig.push(op);
+        }
+        return recipeConfig;
+    },
+
+
     /**
      * Expresses a number of milliseconds in a human readable format.
      *

+ 2 - 2
src/core/operations/Extract.js

@@ -170,9 +170,9 @@ const Extract = {
             protocol = "[A-Z]+://",
             hostname = "[-\\w]+(?:\\.\\w[-\\w]*)+",
             port = ":\\d+",
-            path = "/[^.!,?;\"'<>()\\[\\]{}\\s\\x7F-\\xFF]*";
+            path = "/[^.!,?\"<>\\[\\]{}\\s\\x7F-\\xFF]*";
 
-        path += "(?:[.!,?]+[^.!,?;\"'<>()\\[\\]{}\\s\\x7F-\\xFF]+)*";
+        path += "(?:[.!,?]+[^.!,?\"<>\\[\\]{}\\s\\x7F-\\xFF]+)*";
         const regex = new RegExp(protocol + hostname + "(?:" + port +
             ")?(?:" + path + ")?", "ig");
         return Extract._search(input, regex, null, displayTotal);

+ 1 - 1
src/core/operations/StrUtils.js

@@ -36,7 +36,7 @@ const StrUtils = {
         },
         {
             name: "URL",
-            value: "([A-Za-z]+://)([-\\w]+(?:\\.\\w[-\\w]*)+)(:\\d+)?(/[^.!,?;\"\\x27<>()\\[\\]{}\\s\\x7F-\\xFF]*(?:[.!,?]+[^.!,?;\"\\x27<>()\\[\\]{}\\s\\x7F-\\xFF]+)*)?"
+            value: "([A-Za-z]+://)([-\\w]+(?:\\.\\w[-\\w]*)+)(:\\d+)?(/[^.!,?\"<>\\[\\]{}\\s\\x7F-\\xFF]*(?:[.!,?]+[^.!,?\"<>\\[\\]{}\\s\\x7F-\\xFF]+)*)?"
         },
         {
             name: "Domain",

+ 1 - 1
src/web/App.js

@@ -411,7 +411,7 @@ App.prototype.loadURIParams = function() {
     // Read in recipe from URI params
     if (this.uriParams.recipe) {
         try {
-            const recipeConfig = JSON.parse(this.uriParams.recipe);
+            const recipeConfig = Utils.parseRecipeConfig(this.uriParams.recipe);
             this.setRecipeConfig(recipeConfig);
         } catch (err) {}
     } else if (this.uriParams.op) {

+ 14 - 7
src/web/ControlsWaiter.js

@@ -170,7 +170,7 @@ ControlsWaiter.prototype.generateStateUrl = function(includeRecipe, includeInput
     const link = baseURL || window.location.protocol + "//" +
         window.location.host +
         window.location.pathname;
-    const recipeStr = JSON.stringify(recipeConfig);
+    const recipeStr = Utils.generatePrettyRecipe(recipeConfig);
     const inputStr = Utils.toBase64(this.app.getInput(), "A-Za-z0-9+/"); // B64 alphabet with no padding
 
     includeRecipe = includeRecipe && (recipeConfig.length > 0);
@@ -184,7 +184,7 @@ ControlsWaiter.prototype.generateStateUrl = function(includeRecipe, includeInput
 
     const hash = params
         .filter(v => v)
-        .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
+        .map(([key, value]) => `${key}=${Utils.encodeURIFragment(value)}`)
         .join("&");
 
     if (hash) {
@@ -198,9 +198,9 @@ ControlsWaiter.prototype.generateStateUrl = function(includeRecipe, includeInput
 /**
  * Handler for changes made to the save dialog text area. Re-initialises the save link.
  */
-ControlsWaiter.prototype.saveTextChange = function() {
+ControlsWaiter.prototype.saveTextChange = function(e) {
     try {
-        const recipeConfig = JSON.parse(document.getElementById("save-text").value);
+        const recipeConfig = Utils.parseRecipeConfig(e.target.value);
         this.initialiseSaveLink(recipeConfig);
     } catch (err) {}
 };
@@ -211,9 +211,16 @@ ControlsWaiter.prototype.saveTextChange = function() {
  */
 ControlsWaiter.prototype.saveClick = function() {
     const recipeConfig = this.app.getRecipeConfig();
-    const recipeStr = JSON.stringify(recipeConfig).replace(/},{/g, "},\n{");
+    const recipeStr = JSON.stringify(recipeConfig);
 
-    document.getElementById("save-text").value = recipeStr;
+    document.getElementById("save-text-chef").value = Utils.generatePrettyRecipe(recipeConfig, true);
+    document.getElementById("save-text-clean").value = JSON.stringify(recipeConfig, null, 2)
+        .replace(/{\n\s+"/g, "{ \"")
+        .replace(/\[\n\s{3,}/g, "[")
+        .replace(/\n\s{3,}]/g, "]")
+        .replace(/\s*\n\s*}/g, " }")
+        .replace(/\n\s{6,}/g, " ");
+    document.getElementById("save-text-compact").value = recipeStr;
 
     this.initialiseSaveLink(recipeConfig);
     $("#save-modal").modal();
@@ -339,7 +346,7 @@ ControlsWaiter.prototype.loadNameChange = function(e) {
  */
 ControlsWaiter.prototype.loadButtonClick = function() {
     try {
-        const recipeConfig = JSON.parse(document.getElementById("load-text").value);
+        const recipeConfig = Utils.parseRecipeConfig(document.getElementById("load-text").value);
         this.app.setRecipeConfig(recipeConfig);
 
         $("#rec-list [data-toggle=popover]").popover();

+ 1 - 1
src/web/Manager.js

@@ -102,7 +102,7 @@ Manager.prototype.initialiseEventListeners = function() {
     document.getElementById("load-name").addEventListener("change", this.controls.loadNameChange.bind(this.controls));
     document.getElementById("load-button").addEventListener("click", this.controls.loadButtonClick.bind(this.controls));
     document.getElementById("support").addEventListener("click", this.controls.supportButtonClick.bind(this.controls));
-    this.addMultiEventListener("#save-text", "keyup paste", this.controls.saveTextChange, this.controls);
+    this.addMultiEventListeners("#save-texts textarea", "keyup paste", this.controls.saveTextChange, this.controls);
 
     // Operations
     this.addMultiEventListener("#search", "keyup paste search", this.ops.searchOperations, this.ops);

+ 3 - 0
src/web/RecipeWaiter.js

@@ -295,6 +295,9 @@ RecipeWaiter.prototype.getConfig = function() {
                     option: ingList[j].previousSibling.children[0].textContent.slice(0, -1),
                     string: ingList[j].value
                 };
+            } else if (ingList[j].getAttribute("type") === "number") {
+                // number
+                ingredients[j] = parseFloat(ingList[j].value, 10);
             } else {
                 // all others
                 ingredients[j] = ingList[j].value;

+ 16 - 1
src/web/html/index.html

@@ -215,7 +215,22 @@
                     <div class="modal-body">
                         <div class="form-group">
                             <label for="save-text">Save your recipe to local storage or copy the following string to load later</label>
-                            <textarea class="form-control" id="save-text" rows="5"></textarea>
+                            <ul class="nav nav-tabs" role="tablist">
+                                <li role="presentation" class="active"><a href="#chef-format" role="tab" data-toggle="tab">Chef format</a></li>
+                                <li role="presentation"><a href="#clean-json" role="tab" data-toggle="tab">Clean JSON</a></li>
+                                <li role="presentation"><a href="#compact-json" role="tab" data-toggle="tab">Compact JSON</a></li>
+                            </ul>
+                            <div class="tab-content" id="save-texts">
+                                <div role="tabpanel" class="tab-pane active" id="chef-format">
+                                    <textarea class="form-control" id="save-text-chef" rows="5"></textarea>
+                                </div>
+                                <div role="tabpanel" class="tab-pane" id="clean-json">
+                                    <textarea class="form-control" id="save-text-clean" rows="5"></textarea>
+                                </div>
+                                <div role="tabpanel" class="tab-pane" id="compact-json">
+                                    <textarea class="form-control" id="save-text-compact" rows="5"></textarea>
+                                </div>
+                            </div>
                         </div>
                         <div class="form-group">
                             <label for="save-name">Recipe name</label>

+ 7 - 1
src/web/stylesheets/layout/_modals.css

@@ -78,7 +78,13 @@
     font-family: var(--primary-font-family);
 }
 
-#save-text,
+#save-texts textarea,
 #load-text {
     font-family: var(--fixed-width-font-family);
 }
+
+#save-texts textarea {
+    border-top: none;
+    box-shadow: none;
+    height: 200px;
+}