From 2a2cef918945408da39372d8e3f2499e51528017 Mon Sep 17 00:00:00 2001 From: James Muehlner Date: Mon, 15 May 2023 23:29:58 +0000 Subject: [PATCH] GUACAMOLE-1820: Create UI for viewing, searching, and navigating to key events in session recording player. --- doc/licenses/fuzzysort-2.0.4/LICENSE | 21 + doc/licenses/fuzzysort-2.0.4/README | 8 + .../fuzzysort-2.0.4/dep-coordinates.txt | 1 + .../webapp/modules/KeyEventInterpreter.js | 487 ++++++++++++++++++ .../main/webapp/modules/SessionRecording.js | 52 ++ guacamole/src/main/frontend/package-lock.json | 11 + guacamole/src/main/frontend/package.json | 1 + .../src/app/player/directives/player.js | 141 ++--- .../src/app/player/directives/textView.js | 137 +++++ .../app/player/services/playerTimeService.js | 82 +++ .../frontend/src/app/player/styles/player.css | 61 ++- .../src/app/player/styles/textView.css | 115 +++++ .../src/app/player/templates/player.html | 67 ++- .../src/app/player/templates/textView.html | 23 + .../src/app/player/types/TextBatch.js | 57 ++ .../app/settings/styles/history-player.css | 6 +- .../settingsConnectionHistoryPlayer.html | 9 +- .../main/frontend/src/images/fullscreen.svg | 1 + .../main/frontend/src/translations/en.json | 13 +- 19 files changed, 1197 insertions(+), 96 deletions(-) create mode 100644 doc/licenses/fuzzysort-2.0.4/LICENSE create mode 100644 doc/licenses/fuzzysort-2.0.4/README create mode 100644 doc/licenses/fuzzysort-2.0.4/dep-coordinates.txt create mode 100644 guacamole-common-js/src/main/webapp/modules/KeyEventInterpreter.js create mode 100644 guacamole/src/main/frontend/src/app/player/directives/textView.js create mode 100644 guacamole/src/main/frontend/src/app/player/services/playerTimeService.js create mode 100644 guacamole/src/main/frontend/src/app/player/styles/textView.css create mode 100644 guacamole/src/main/frontend/src/app/player/templates/textView.html create mode 100644 guacamole/src/main/frontend/src/app/player/types/TextBatch.js create mode 100644 guacamole/src/main/frontend/src/images/fullscreen.svg diff --git a/doc/licenses/fuzzysort-2.0.4/LICENSE b/doc/licenses/fuzzysort-2.0.4/LICENSE new file mode 100644 index 0000000000..a3b9d9d79f --- /dev/null +++ b/doc/licenses/fuzzysort-2.0.4/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Stephen Kamenar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/doc/licenses/fuzzysort-2.0.4/README b/doc/licenses/fuzzysort-2.0.4/README new file mode 100644 index 0000000000..add7ed1426 --- /dev/null +++ b/doc/licenses/fuzzysort-2.0.4/README @@ -0,0 +1,8 @@ +fuzzysort (https://github.com/farzher/fuzzysort/tree/master) +--------------------------------------------- + + Version: 2.0.4 + From: 'Stephen Kamenar' (https://github.com/farzher) + License(s): + MIT (bundled/fuzzysort-2.0.4/LICENSE) + diff --git a/doc/licenses/fuzzysort-2.0.4/dep-coordinates.txt b/doc/licenses/fuzzysort-2.0.4/dep-coordinates.txt new file mode 100644 index 0000000000..24be4c2412 --- /dev/null +++ b/doc/licenses/fuzzysort-2.0.4/dep-coordinates.txt @@ -0,0 +1 @@ +fuzzysort:2.0.4 diff --git a/guacamole-common-js/src/main/webapp/modules/KeyEventInterpreter.js b/guacamole-common-js/src/main/webapp/modules/KeyEventInterpreter.js new file mode 100644 index 0000000000..957a90c6b6 --- /dev/null +++ b/guacamole-common-js/src/main/webapp/modules/KeyEventInterpreter.js @@ -0,0 +1,487 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +var Guacamole = Guacamole || {}; + +/** + * An object that will accept raw key events and produce human readable text + * batches, seperated by at least `batchSeperation` milliseconds, which can be + * retrieved through the onBatch callback or by calling getCurrentBatch(). + * + * NOTE: The event processing logic and output format is based on the `guaclog` + * tool, with the addition of batching support. + * + * @constructor + * @param {number} [batchSeperation=5000] + * The minimum number of milliseconds that must elapse between subsequent + * batches of key-event-generated text. If 0 or negative, no splitting will + * occur, resulting in a single batch for all provided key events. + */ +Guacamole.KeyEventInterpreter = function KeyEventInterpreter(batchSeperation) { + + /** + * Reference to this Guacamole.KeyEventInterpreter. + * + * @private + * @type {!Guacamole.SessionRecording} + */ + var interpreter = this; + + // Default to 5 seconds if the batch seperation was not provided + if (batchSeperation === undefined || batchSeperation === null) + batchSeperation = 5000; + + /** + * A definition for a known key. + * + * @constructor + * @private + * @param {KEY_DEFINITION|object} [template={}] + * The object whose properties should be copied within the new + * KEY_DEFINITION. + */ + var KeyDefinition = function KeyDefinition(template) { + + /** + * The X11 keysym of the key. + * @type {!number} + */ + this.keysym = parseInt(template.keysym); + + /** + * A human-readable name for the key. + * @type {!String} + */ + this.name = template.name; + + /** + * The value which would be typed in a typical text editor, if any. If the + * key is not associated with any typable value, or if the typable value is + * not generally useful in an auditing context, this will be undefined. + * @type {String} + */ + this.value = template.value; + + /** + * Whether this key is a modifier which may affect the interpretation of + * other keys, and thus should be tracked as it is held down. + * @type {!boolean} + * @default false + */ + this.modifier = template.modifier || false; + + }; + + /** + * A precursor array to the KNOWN_KEYS map. The objects contained within + * will be constructed into full KeyDefinition objects. + * + * @constant + * @private + * @type {Object[]} + */ + var _KNOWN_KEYS = [ + {keysym: 0xFE03, name: 'AltGr', value: "", modifier: true }, + {keysym: 0xFF08, name: 'Backspace' }, + {keysym: 0xFF09, name: 'Tab' }, + {keysym: 0xFF0B, name: 'Clear' }, + {keysym: 0xFF0D, name: 'Return', value: "\n" }, + {keysym: 0xFF13, name: 'Pause' }, + {keysym: 0xFF14, name: 'Scroll' }, + {keysym: 0xFF15, name: 'SysReq' }, + {keysym: 0xFF1B, name: 'Escape' }, + {keysym: 0xFF50, name: 'Home' }, + {keysym: 0xFF51, name: 'Left' }, + {keysym: 0xFF52, name: 'Up' }, + {keysym: 0xFF53, name: 'Right' }, + {keysym: 0xFF54, name: 'Down' }, + {keysym: 0xFF55, name: 'Page Up' }, + {keysym: 0xFF56, name: 'Page Down' }, + {keysym: 0xFF57, name: 'End' }, + {keysym: 0xFF63, name: 'Insert' }, + {keysym: 0xFF65, name: 'Undo' }, + {keysym: 0xFF6A, name: 'Help' }, + {keysym: 0xFF7F, name: 'Num' }, + {keysym: 0xFF80, name: 'Space', value: " " }, + {keysym: 0xFF8D, name: 'Enter', value: "\n" }, + {keysym: 0xFF95, name: 'Home' }, + {keysym: 0xFF96, name: 'Left' }, + {keysym: 0xFF97, name: 'Up' }, + {keysym: 0xFF98, name: 'Right' }, + {keysym: 0xFF99, name: 'Down' }, + {keysym: 0xFF9A, name: 'Page Up' }, + {keysym: 0xFF9B, name: 'Page Down' }, + {keysym: 0xFF9C, name: 'End' }, + {keysym: 0xFF9E, name: 'Insert' }, + {keysym: 0xFFAA, name: '*', value: "*" }, + {keysym: 0xFFAB, name: '+', value: "+" }, + {keysym: 0xFFAD, name: '-', value: "-" }, + {keysym: 0xFFAE, name: '.', value: "." }, + {keysym: 0xFFAF, name: '/', value: "/" }, + {keysym: 0xFFB0, name: '0', value: "0" }, + {keysym: 0xFFB1, name: '1', value: "1" }, + {keysym: 0xFFB2, name: '2', value: "2" }, + {keysym: 0xFFB3, name: '3', value: "3" }, + {keysym: 0xFFB4, name: '4', value: "4" }, + {keysym: 0xFFB5, name: '5', value: "5" }, + {keysym: 0xFFB6, name: '6', value: "6" }, + {keysym: 0xFFB7, name: '7', value: "7" }, + {keysym: 0xFFB8, name: '8', value: "8" }, + {keysym: 0xFFB9, name: '9', value: "9" }, + {keysym: 0xFFBE, name: 'F1' }, + {keysym: 0xFFBF, name: 'F2' }, + {keysym: 0xFFC0, name: 'F3' }, + {keysym: 0xFFC1, name: 'F4' }, + {keysym: 0xFFC2, name: 'F5' }, + {keysym: 0xFFC3, name: 'F6' }, + {keysym: 0xFFC4, name: 'F7' }, + {keysym: 0xFFC5, name: 'F8' }, + {keysym: 0xFFC6, name: 'F9' }, + {keysym: 0xFFC7, name: 'F10' }, + {keysym: 0xFFC8, name: 'F11' }, + {keysym: 0xFFC9, name: 'F12' }, + {keysym: 0xFFCA, name: 'F13' }, + {keysym: 0xFFCB, name: 'F14' }, + {keysym: 0xFFCC, name: 'F15' }, + {keysym: 0xFFCD, name: 'F16' }, + {keysym: 0xFFCE, name: 'F17' }, + {keysym: 0xFFCF, name: 'F18' }, + {keysym: 0xFFD0, name: 'F19' }, + {keysym: 0xFFD1, name: 'F20' }, + {keysym: 0xFFD2, name: 'F21' }, + {keysym: 0xFFD3, name: 'F22' }, + {keysym: 0xFFD4, name: 'F23' }, + {keysym: 0xFFD5, name: 'F24' }, + {keysym: 0xFFE1, name: 'Shift', value: "", modifier: true }, + {keysym: 0xFFE2, name: 'Shift', value: "", modifier: true }, + {keysym: 0xFFE3, name: 'Ctrl', value: null, modifier: true }, + {keysym: 0xFFE4, name: 'Ctrl', value: null, modifier: true }, + {keysym: 0xFFE5, name: 'Caps' }, + {keysym: 0xFFE7, name: 'Meta', value: null, modifier: true }, + {keysym: 0xFFE8, name: 'Meta', value: null, modifier: true }, + {keysym: 0xFFE9, name: 'Alt', value: null, modifier: true }, + {keysym: 0xFFEA, name: 'Alt', value: null, modifier: true }, + {keysym: 0xFFEB, name: 'Super', value: null, modifier: true }, + {keysym: 0xFFEC, name: 'Super', value: null, modifier: true }, + {keysym: 0xFFED, name: 'Hyper', value: null, modifier: true }, + {keysym: 0xFFEE, name: 'Hyper', value: null, modifier: true }, + {keysym: 0xFFFF, name: 'Delete' } + ]; + + /** + * All known keys, as a map of X11 keysym to KeyDefinition. + * + * @constant + * @private + * @type {Object.} + */ + var KNOWN_KEYS = {}; + _KNOWN_KEYS.forEach(function createKeyDefinitionMap(keyDefinition) { + + // Construct a map of keysym to KeyDefinition object + KNOWN_KEYS[keyDefinition.keysym] = new KeyDefinition(keyDefinition) + + }); + + /** + * A map of X11 keysyms to a KeyDefinition object, if the corresponding + * key is currently pressed. If a keysym has no entry in this map at all, + * it means that the key is not being pressed. Note that not all keysyms + * are necessarily tracked within this map - only those that are explicitly + * tracked. + * + * @private + * @type {Object. } + */ + var pressedKeys = {}; + + /** + * A human-readable representation of all keys pressed since the last keyframe. + * + * @private + * @type {String} + */ + var currentTypedValue = ''; + + /** + * The timestamp of the key event that started the most recent batch of + * text content. If 0, no key events have been processed yet. + * + * @private + * @type {Number} + */ + var lastTextTimestamp = 0; + + /** + * The timestamp of the most recent key event processed. + * + * @private + * @type {Number} + */ + var lastKeyEvent = 0; + + /** + * Returns true if the currently-pressed keys are part of a shortcut, or + * false otherwise. + * + * @private + * @returns {!boolean} + * True if the currently-pressed keys are part of a shortcut, or false + * otherwise. + */ + function isShortcut() { + + // If one of the currently-pressed keys is non-printable, a shortcut + // is being typed + for (var keysym in pressedKeys) { + if (pressedKeys[keysym].value === null) + return true; + } + + return false; + } + + /** + * If the provided keysym corresponds to a valid UTF-8 character, return + * a KeyDefinition for that keysym. Otherwise, return null. + * + * @private + * @param {Number} keysym + * The keysym to produce a UTF-8 KeyDefinition for, if valid. + * + * @returns + * Return a KeyDefinition for the provided keysym, if it it's a valid + * UTF-8 keysym, or null otherwise. + */ + function getUnicodeKeyDefinition(keysym) { + + // Translate only if keysym maps to Unicode + if (keysym < 0x00 || (keysym > 0xFF && (keysym | 0xFFFF) != 0x0100FFFF)) + return null; + + var codepoint = keysym & 0xFFFF; + var mask; + var bytes; + + /* Determine size and initial byte mask */ + if (codepoint <= 0x007F) { + mask = 0x00; + bytes = 1; + } + else if (codepoint <= 0x7FF) { + mask = 0xC0; + bytes = 2; + } + else { + mask = 0xE0; + bytes = 3; + } + + var byteArray = new ArrayBuffer(bytes); + var byteView = new Int8Array(byteArray); + + // Add trailing bytes, if any + for (var i = 1; i < bytes; i++) { + byteView[bytes - i] = 0x80 | (codepoint & 0x3F); + codepoint >>= 6; + } + + // Set initial byte + byteView[0] = mask | codepoint; + + // Convert to UTF8 string + var name = new TextDecoder("utf-8").decode(byteArray); + + // Create and return the definition + return new KeyDefinition({keysym: keysym.toString(), name: name, value: name, modifier: false}); + + } + + /** + * Return a KeyDefinition corresponding to the provided keysym. + * + * @private + * @param {Number} keysym + * The keysym to return a KeyDefinition for. + * + * @returns + * A KeyDefinition corresponding to the provided keysym. + */ + function getKeyDefinitionByKeysym(keysym) { + + // If it's a known type, return the existing definition + if (keysym in KNOWN_KEYS) + return KNOWN_KEYS[keysym]; + + // Return a UTF-8 KeyDefinition, if valid + var definition = getUnicodeKeyDefinition(keysym); + if (definition != null) + return definition; + + // If it's not UTF-8, return an unknown definition, with the name + // just set to the hex value of the keysym + return new KeyDefinition({ + keysym: keysym, + name: '0x' + String(keysym.toString(16)) + }) + + } + + /** + * Fired whenever a new batch of typed text extracted from key events + * is available. A new batch will be provided every time a new key event + * is processed after more than batchSeperation milliseconds after the + * previous key event. + * + * @event + * @param {!String} text + * The typed text associated with the batch of text. + * + * @param {!number} timestamp + * The raw recording timestamp associated with the first key event + * that started this batch of text. + */ + interpreter.onBatch = null; + + /** + * Handles a raw key event, potentially appending typed text to the + * current batch, and calling onBatch with the current batch, if the + * callback is set and a new batch is about to be started. + * + * @param {!string[]} args + * The arguments of the key event. + */ + interpreter.handleKeyEvent = function handleKeyEvent(args) { + + // The X11 keysym + var keysym = parseInt(args[0]); + + // Either 1 or 0 for pressed or released, respectively + var pressed = parseInt(args[1]); + + // The timestamp when this key event occured + var timestamp = parseInt(args[2]); + + // If no current batch exists, start a new one now + if (!lastTextTimestamp) { + lastTextTimestamp = timestamp; + lastKeyEvent = timestamp; + } + + // Only switch to a new batch of text if sufficient time has passed + // since the last key event + var newBatch = (batchSeperation >= 0 + && (timestamp - lastKeyEvent) >= batchSeperation); + lastKeyEvent = timestamp; + + if (newBatch) { + + // Call the handler with the current batch of text and the timestamp + // at which the current batch started + if (currentTypedValue && interpreter.onBatch) + interpreter.onBatch(currentTypedValue, lastTextTimestamp); + + // Move on to the next batch of text + currentTypedValue = ''; + lastTextTimestamp = 0; + + } + + var keyDefinition = getKeyDefinitionByKeysym(keysym); + + // Mark down whether the key was pressed or released + if (keyDefinition.modifier) { + if (pressed) + pressedKeys[keysym] = keyDefinition; + else + delete pressedKeys[keysym]; + } + + // Append to the current typed value when a printable + // (non-modifier) key is pressed + else if (pressed) { + + if (isShortcut()) { + + currentTypedValue += '<'; + + var firstKey = true; + + // Compose entry by inspecting the state of each tracked key. + // At least one key must be pressed when in a shortcut. + for (var keysym in pressedKeys) { + + var pressedKeyDefinition = pressedKeys[keysym]; + + // Print name of key + if (firstKey) { + currentTypedValue += pressedKeyDefinition.name; + firstKey = false; + } + + else + currentTypedValue += ('+' + pressedKeyDefinition.name); + + } + + // Finally, append the printable key to close the shortcut + currentTypedValue += ('+' + keyDefinition.name + '>') + + } + + // Print the key itself + else { + + // Print the value if explicitly defined + if (keyDefinition.value != null) + currentTypedValue += keyDefinition.value; + + // Otherwise print the name + else + currentTypedValue += ('<' + keyDefinition.name + '>'); + } + + } + + } + + /** + * Return the current batch of typed text. Note that the batch may be + * incomplete, as more key events might be processed before the next + * batch starts. + * + * @returns + * The current batch of text. + */ + interpreter.getCurrentText = function getCurrentText() { + return currentTypedValue; + } + + /** + * Return the recording timestamp associated with the start of the + * current batch of typed text. + * + * @returns + * The recording timestamp at which the current batch started. + */ + interpreter.getCurrentTimestamp = function getCurrentTimestamp() { + return lastTextTimestamp; + } + +} diff --git a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js index 3a0dae1319..18db916d4e 100644 --- a/guacamole-common-js/src/main/webapp/modules/SessionRecording.js +++ b/guacamole-common-js/src/main/webapp/modules/SessionRecording.js @@ -104,6 +104,16 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) */ var KEYFRAME_TIME_INTERVAL = 5000; + /** + * The minimum number of milliseconds which must elapse between key events + * before text can be split across multiple frames. + * + * @private + * @constant + * @type {Number} + */ + var TYPED_TEXT_INTERVAL = 5000; + /** * All frames parsed from the provided blob. * @@ -375,6 +385,26 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) // Hide cursor unless mouse position is received playbackClient.getDisplay().showCursor(false); + /** + * A key event interpreter to split all key events in this recording into + * human-readable batches of text. + * + * @type {!Guacamole.KeyEventInterpreter} + */ + var keyEventInterpreter = new Guacamole.KeyEventInterpreter(); + + // Pass through any received batches to the recording ontext handler + keyEventInterpreter.onBatch = function onBatch(text, timestamp) { + + // Don't call the callback if it was never set + if (!recording.ontext) + return; + + // Convert to a recording-relative timestamp and pass through + recording.ontext(text, toRelativeTimestamp(timestamp)); + + }; + /** * Handles a newly-received instruction, whether from the main Blob or a * tunnel, adding new frames and keyframes as necessary. Load progress is @@ -421,6 +451,8 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) } + else if (opcode === 'key') + keyEventInterpreter.handleKeyEvent(args); }; /** @@ -487,6 +519,13 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) instructionBuffer = ''; } + // If there's any typed text that's yet to be sent to the ontext + // handler, send it now + var text = keyEventInterpreter.getCurrentText(); + var timestamp = keyEventInterpreter.getCurrentTimestamp(); + if (text && recording.ontext) + recording.ontext(text, toRelativeTimestamp(timestamp)); + // Consider recording loaded if tunnel has closed without errors if (!errorEncountered) notifyLoaded(); @@ -872,6 +911,19 @@ Guacamole.SessionRecording = function SessionRecording(source, refreshInterval) */ this.onpause = null; + /** + * Fired whenever a new batch of typed text extracted from key events + * is available. + * + * @event + * @param {!String} text + * The typed text associated with the batch of text. + * + * @param {!number} timestamp + * The relative timestamp associated with the batch of text. + */ + this.ontext = null; + /** * Fired whenever the playback position within the recording changes. * diff --git a/guacamole/src/main/frontend/package-lock.json b/guacamole/src/main/frontend/package-lock.json index 9ad15bdced..c9f0013559 100644 --- a/guacamole/src/main/frontend/package-lock.json +++ b/guacamole/src/main/frontend/package-lock.json @@ -16,6 +16,7 @@ "csv": "^6.2.5", "datalist-polyfill": "^1.25.1", "file-saver": "^2.0.5", + "fuzzysort": "^2.0.4", "jquery": "^3.6.4", "jstz": "^2.1.1", "lodash": "^4.17.21", @@ -5632,6 +5633,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuzzysort": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz", + "integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==" + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -15703,6 +15709,11 @@ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true }, + "fuzzysort": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-2.0.4.tgz", + "integrity": "sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==" + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/guacamole/src/main/frontend/package.json b/guacamole/src/main/frontend/package.json index 4b7f98630a..773d5f80d6 100644 --- a/guacamole/src/main/frontend/package.json +++ b/guacamole/src/main/frontend/package.json @@ -15,6 +15,7 @@ "csv": "^6.2.5", "datalist-polyfill": "^1.25.1", "file-saver": "^2.0.5", + "fuzzysort": "^2.0.4", "jquery": "^3.6.4", "jstz": "^2.1.1", "lodash": "^4.17.21", diff --git a/guacamole/src/main/frontend/src/app/player/directives/player.js b/guacamole/src/main/frontend/src/app/player/directives/player.js index 7b3e087928..43b1c8ec24 100644 --- a/guacamole/src/main/frontend/src/app/player/directives/player.js +++ b/guacamole/src/main/frontend/src/app/player/directives/player.js @@ -77,6 +77,12 @@ */ angular.module('player').directive('guacPlayer', ['$injector', function guacPlayer($injector) { + // Required types + const TextBatch = $injector.get('TextBatch'); + + // Required services + const playerTimeService = $injector.get('playerTimeService'); + const config = { restrict : 'E', templateUrl : 'app/player/templates/player.html' @@ -142,6 +148,21 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay */ $scope.seekPosition = null; + /** + * Any batches of text typed during the recording. + * + * @type {TextBatch[]} + */ + $scope.textBatches = []; + + /** + * Whether or not the key log viewer should be displayed. False by + * default unless explicitly enabled by user interaction. + * + * @type {boolean} + */ + $scope.showKeyLog = false; + /** * Whether a seek request is currently in progress. A seek request is * in progress if the user is attempting to change the current playback @@ -161,57 +182,29 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay var resumeAfterSeekRequest = false; /** - * Formats the given number as a decimal string, adding leading zeroes - * such that the string contains at least two digits. The given number - * MUST NOT be negative. - * - * @param {!number} value - * The number to format. + * Return true if any batches of key event logs are available for this + * recording, or false otherwise. * - * @returns {!string} - * The decimal string representation of the given value, padded - * with leading zeroes up to a minimum length of two digits. + * @return + * True if any batches of key event logs are avaiable for this + * recording, or false otherwise. */ - const zeroPad = function zeroPad(value) { - return value > 9 ? value : '0' + value; + $scope.hasTextBatches = function hasTextBatches () { + return $scope.textBatches.length >= 0; }; /** - * Formats the given quantity of milliseconds as days, hours, minutes, - * and whole seconds, separated by colons (DD:HH:MM:SS). Hours are - * included only if the quantity is at least one hour, and days are - * included only if the quantity is at least one day. All included - * groups are zero-padded to two digits with the exception of the - * left-most group. - * - * @param {!number} value - * The time to format, in milliseconds. - * - * @returns {!string} - * The given quantity of milliseconds formatted as "DD:HH:MM:SS". + * Toggle the visibility of the text key log viewer. */ - $scope.formatTime = function formatTime(value) { - - // Round provided value down to whole seconds - value = Math.floor((value || 0) / 1000); - - // Separate seconds into logical groups of seconds, minutes, - // hours, etc. - var groups = [ 1, 24, 60, 60 ]; - for (var i = groups.length - 1; i >= 0; i--) { - var placeValue = groups[i]; - groups[i] = zeroPad(value % placeValue); - value = Math.floor(value / placeValue); - } - - // Format groups separated by colons, stripping leading zeroes and - // groups which are entirely zeroes, leaving at least minutes and - // seconds - var formatted = groups.join(':'); - return /^[0:]*([0-9]{1,2}(?::[0-9]{2})+)$/.exec(formatted)[1]; - + $scope.toggleKeyLogView = function toggleKeyLogView() { + $scope.showKeyLog = !$scope.showKeyLog; }; + /** + * @borrows playerTimeService.formatTime + */ + $scope.formatTime = playerTimeService.formatTime; + /** * Pauses playback and decouples the position slider from current * playback position, allowing the user to manipulate the slider @@ -242,30 +235,51 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay // If a recording is present and there is an active seek request, // restore the playback state at the time that request began and // begin seeking to the requested position - if ($scope.recording && pendingSeekRequest) { + if ($scope.recording && pendingSeekRequest) + $scope.seekToPlaybackPosition(); - $scope.seekPosition = null; - $scope.operationMessage = 'PLAYER.INFO_SEEK_IN_PROGRESS'; - $scope.operationProgress = 0; + // Flag seek request as completed + pendingSeekRequest = false; - // Cancel seek when requested, updating playback position if - // that position changed - $scope.cancelOperation = function abortSeek() { - $scope.recording.cancel(); - $scope.playbackPosition = $scope.seekPosition || $scope.playbackPosition; - }; + }; - resumeAfterSeekRequest && $scope.recording.play(); - $scope.recording.seek($scope.playbackPosition, function seekComplete() { - $scope.operationMessage = null; - $scope.$evalAsync(); - }); + /** + * Seek the recording to the specified position within the recording, + * in milliseconds. + * + * @param {Number} timestamp + * The position to seek to within the current record, + * in milliseconds. + */ + $scope.seekToTimestamp = function seekToTimestamp(timestamp) { - } + // Set the timestamp and seek to it + $scope.playbackPosition = timestamp; + $scope.seekToPlaybackPosition(); - // Flag seek request as completed - pendingSeekRequest = false; + }; + /** + * Seek the recording to the current playback position value. + */ + $scope.seekToPlaybackPosition = function seekToPlaybackPosition() { + + $scope.seekPosition = null; + $scope.operationMessage = 'PLAYER.INFO_SEEK_IN_PROGRESS'; + $scope.operationProgress = 0; + + // Cancel seek when requested, updating playback position if + // that position changed + $scope.cancelOperation = function abortSeek() { + $scope.recording.cancel(); + $scope.playbackPosition = $scope.seekPosition || $scope.playbackPosition; + }; + + resumeAfterSeekRequest && $scope.recording.play(); + $scope.recording.seek($scope.playbackPosition, function seekComplete() { + $scope.operationMessage = null; + $scope.$evalAsync(); + }); }; /** @@ -342,6 +356,11 @@ angular.module('player').directive('guacPlayer', ['$injector', function guacPlay $scope.$evalAsync(); }; + // Append any extracted batches of typed text + $scope.recording.ontext = function appendTextBatch(text, timestamp) { + $scope.textBatches.push({text, timestamp}); + } + // Notify listeners when current position within the recording // has changed $scope.recording.onseek = function positionChanged(position, current, total) { diff --git a/guacamole/src/main/frontend/src/app/player/directives/textView.js b/guacamole/src/main/frontend/src/app/player/directives/textView.js new file mode 100644 index 0000000000..2a9fbb303d --- /dev/null +++ b/guacamole/src/main/frontend/src/app/player/directives/textView.js @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const fuzzysort = require('fuzzysort') + +/** + * Directive which plays back session recordings. + */ +angular.module('player').directive('guacPlayerTextView', + ['$injector', function guacPlayer($injector) { + + // Required types + const TextBatch = $injector.get('TextBatch'); + + // Required services + const playerTimeService = $injector.get('playerTimeService'); + + const config = { + restrict : 'E', + templateUrl : 'app/player/templates/textView.html' + }; + + config.scope = { + + /** + * All the batches of text extracted from this recording. + * + * @type {!TextBatch[]} + */ + textBatches : '=', + + /** + * A callback that accepts a timestamp, and seeks the recording to + * that provided timestamp. + * + * @type {!Function} + */ + seek: '&', + + /** + * The current position within the recording. + * + * @type {!Number} + */ + currentPosition: '=' + + }; + + config.controller = ['$scope', '$element', '$injector', + function guacPlayerController($scope, $element) { + + /** + * The phrase to search within the text batches in order to produce the + * filtered list for display. + * + * @type {String} + */ + $scope.searchPhrase = ''; + + /** + * The text batches that match the current search phrase, or all + * batches if no search phrase is set. + * + * @type {!TextBatch[]} + */ + $scope.filteredBatches = $scope.textBatches; + + /** + * Whether or not the key log viewer should be full-screen. False by + * default unless explicitly enabled by user interaction. + * + * @type {boolean} + */ + $scope.fullscreenKeyLog = false; + + /** + * Toggle whether the key log viewer should take up the whole screen. + */ + $scope.toggleKeyLogFullscreen = function toggleKeyLogFullscreen() { + $element.toggleClass("fullscreen"); + }; + + /** + * Filter the provided text batches using the provided search phrase to + * generate the list of filtered batches, or set to all provided + * batches if no search phrase is provided. + * + * @param {String} searchPhrase + * The phrase to search the text batches for. If no phrase is + * provided, the list of batches will not be filtered. + */ + const applyFilter = searchPhrase => { + + // If there's search phrase entered, search the text within the + // batches for it + if (searchPhrase) + $scope.filteredBatches = fuzzysort.go( + searchPhrase, $scope.textBatches, {key: 'text'}) + .map(result => result.obj); + + // Otherwise, do not filter the batches + else + $scope.filteredBatches = $scope.textBatches; + + }; + + // Reapply the filter to the updated text batches + $scope.$watch('textBatches', applyFilter); + + // Reapply the filter whenever the search phrase is updated + $scope.$watch('searchPhrase', applyFilter); + + /** + * @borrows playerTimeService.formatTime + */ + $scope.formatTime = playerTimeService.formatTime; + + }]; + + return config; +}]); diff --git a/guacamole/src/main/frontend/src/app/player/services/playerTimeService.js b/guacamole/src/main/frontend/src/app/player/services/playerTimeService.js new file mode 100644 index 0000000000..b005cbc209 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/player/services/playerTimeService.js @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * 'License'); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * A service for formatting time, specifically for the recording player. + */ +angular.module('player').factory('playerTimeService', + ['$injector', function playerTimeService($injector) { + + const service = {}; + + /** + * Formats the given number as a decimal string, adding leading zeroes + * such that the string contains at least two digits. The given number + * MUST NOT be negative. + * + * @param {!number} value + * The number to format. + * + * @returns {!string} + * The decimal string representation of the given value, padded + * with leading zeroes up to a minimum length of two digits. + */ + const zeroPad = function zeroPad(value) { + return value > 9 ? value : '0' + value; + }; + + /** + * Formats the given quantity of milliseconds as days, hours, minutes, + * and whole seconds, separated by colons (DD:HH:MM:SS). Hours are + * included only if the quantity is at least one hour, and days are + * included only if the quantity is at least one day. All included + * groups are zero-padded to two digits with the exception of the + * left-most group. + * + * @param {!number} value + * The time to format, in milliseconds. + * + * @returns {!string} + * The given quantity of milliseconds formatted as "DD:HH:MM:SS". + */ + service.formatTime = function formatTime(value) { + + // Round provided value down to whole seconds + value = Math.floor((value || 0) / 1000); + + // Separate seconds into logical groups of seconds, minutes, + // hours, etc. + var groups = [ 1, 24, 60, 60 ]; + for (var i = groups.length - 1; i >= 0; i--) { + var placeValue = groups[i]; + groups[i] = zeroPad(value % placeValue); + value = Math.floor(value / placeValue); + } + + // Format groups separated by colons, stripping leading zeroes and + // groups which are entirely zeroes, leaving at least minutes and + // seconds + var formatted = groups.join(':'); + return /^[0:]*([0-9]{1,2}(?::[0-9]{2})+)$/.exec(formatted)[1]; + + }; + + return service; + +}]); diff --git a/guacamole/src/main/frontend/src/app/player/styles/player.css b/guacamole/src/main/frontend/src/app/player/styles/player.css index 36a783eca1..a1566810a3 100644 --- a/guacamole/src/main/frontend/src/app/player/styles/player.css +++ b/guacamole/src/main/frontend/src/app/player/styles/player.css @@ -50,7 +50,6 @@ guac-player { } guac-player .guac-player-display { - position: absolute; top: 0; left: 0; width: 100%; @@ -60,6 +59,7 @@ guac-player .guac-player-display { guac-player .guac-player-controls { position: absolute; + padding-bottom: 0; left: 0; bottom: 0; width: 100%; @@ -107,6 +107,18 @@ guac-player .guac-player-controls { background-image: url('images/action-icons/guac-pause.svg'); } +.guac-player-controls .guac-player-buttons { + display: flex; + flex-direction: row; + align-items: center; +} + +.guac-player-controls .guac-player-keys { + margin-left: auto; + padding-right: 0.5em; + cursor: pointer; +} + guac-player .guac-player-status { position: fixed; @@ -145,3 +157,50 @@ guac-player .guac-player-status { flex-direction: column; } + +.guac-player-container { + + height: 100%; + + display: -webkit-box; + display: -webkit-flex; + display: -moz-box; + display: -ms-flexbox; + display: flex; + + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + + -ms-flex-pack: space-between; + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + justify-content: space-between; + +} + +guac-player-display { + + flex-grow: 5; + + /* Required for horizontal resizing to work */ + min-width: 0; + +} + +guac-player-text-view { + + min-width: 25em; + flex-basis: 0; + + /* Make room for the control bar at the bottom */ + height: calc(100% - 48px); + +} + +guac-player-text-view.fullscreen { + + min-width: 100%; + +} + diff --git a/guacamole/src/main/frontend/src/app/player/styles/textView.css b/guacamole/src/main/frontend/src/app/player/styles/textView.css new file mode 100644 index 0000000000..3a73c940e6 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/player/styles/textView.css @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* + * NOTE: This session recording player implementation is based on the Session + * Recording Player for Glyptodon Enterprise which is available at + * https://github.com/glyptodon/glyptodon-enterprise-player under the + * following license: + * + * Copyright (C) 2019 Glyptodon, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +.text-batches { + + display: flex; + flex-direction: column; + overflow-y: scroll; + +} + +.text-batches .text-batch { + + margin-bottom: 1em; + margin-left: 0.5em; + cursor: pointer; + +} + +.text-batches .text-batch .timestamp { + + white-space: pre-wrap; + color: blue; + +} + +.guac-player-text-container { + + height: 100%; + display: flex; + flex-direction: column; + background-color: white; + color: black; +} + +.guac-player-text-container .text-controls { + + display: flex; + flex-direction: row; + align-items: center; + +} + +.guac-player-text-container .text-controls .filter { + + flex-grow: 5; + +} + +.guac-player-text-container .text-controls .fullscreen-button { + + background-image: url('images/fullscreen.svg'); + background-size: contain; + cursor: pointer; + height: 22px; + width: 22px; + margin-right: 0.25em; + +} + +.guac-player-text-container .result-count { + + font-weight: bold; + margin: 0.5em; + +} + +.guac-player-text-container .filter { + + margin: 0.25em; + +} diff --git a/guacamole/src/main/frontend/src/app/player/templates/player.html b/guacamole/src/main/frontend/src/app/player/templates/player.html index 91594b40e6..eeb3744b54 100644 --- a/guacamole/src/main/frontend/src/app/player/templates/player.html +++ b/guacamole/src/main/frontend/src/app/player/templates/player.html @@ -1,9 +1,25 @@ - - +
+ + + + + + + +
-
+
- - - - - - - - - {{ formatTime(playbackPosition) }} / {{ formatTime(recording.getDuration()) }} - +
+ + + + + + + + + + {{ formatTime(playbackPosition) }} / {{ formatTime(recording.getDuration()) }} + + + + {{ 'PLAYER.ACTION_SHOW_KEY_LOG' | translate }} + + + + {{ 'PLAYER.INFO_NO_KEY_LOG' | translate }} + +
diff --git a/guacamole/src/main/frontend/src/app/player/templates/textView.html b/guacamole/src/main/frontend/src/app/player/templates/textView.html new file mode 100644 index 0000000000..1d30da58f1 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/player/templates/textView.html @@ -0,0 +1,23 @@ +
+ +
+
+ +
+ +
+ +
+ +
+
+
{{ formatTime(batch.timestamp) }}
+
{{ batch.text }}
+ +
+
diff --git a/guacamole/src/main/frontend/src/app/player/types/TextBatch.js b/guacamole/src/main/frontend/src/app/player/types/TextBatch.js new file mode 100644 index 0000000000..fbe92c7d23 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/player/types/TextBatch.js @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Service which defines the TextBatch class. + */ +angular.module('player').factory('TextBatch', [function defineTextBatch() { + + /** + * A batch of text associated with a recording. The batch consists of a + * string representation of the text that would be typed based on the key + * events in the recording, as well as a timestamp when the batch started. + * + * @constructor + * @param {TextBatch|Object} [template={}] + * The object whose properties should be copied within the new TextBatch. + */ + const TextBatch = function TextBatch(template) { + + // Use empty object by default + template = template || {}; + + /** + * The text that was typed in this batch. + * + * @type String + */ + this.text = template.text; + + /** + * The timestamp at which the batch of text was typed. + * + * @type Number + */ + this.timestamp = template.timestamp; + + }; + + return TextBatch; + +}]); diff --git a/guacamole/src/main/frontend/src/app/settings/styles/history-player.css b/guacamole/src/main/frontend/src/app/settings/styles/history-player.css index 03d562a539..7503cc8c30 100644 --- a/guacamole/src/main/frontend/src/app/settings/styles/history-player.css +++ b/guacamole/src/main/frontend/src/app/settings/styles/history-player.css @@ -96,7 +96,7 @@ background: rgba(0, 0, 0, 0.5); } -.settings.connectionHistoryPlayer.playing .guac-player-controls { +.settings.connectionHistoryPlayer .guac-player-controls.playing { opacity: 0; -webkit-transition: opacity 0.25s linear 0.25s; -moz-transition: opacity 0.25s linear 0.25s; @@ -104,8 +104,8 @@ transition: opacity 0.25s linear 0.25s; } -.settings.connectionHistoryPlayer.paused .guac-player-controls, -.settings.connectionHistoryPlayer.playing:hover .guac-player-controls { +.settings.connectionHistoryPlayer .guac-player-controls.paused, +.settings.connectionHistoryPlayer .guac-player-controls.playing:hover { opacity: 1; -webkit-transition-delay: 0s; -moz-transition-delay: 0s; diff --git a/guacamole/src/main/frontend/src/app/settings/templates/settingsConnectionHistoryPlayer.html b/guacamole/src/main/frontend/src/app/settings/templates/settingsConnectionHistoryPlayer.html index 41d6aa873a..d986864d1c 100644 --- a/guacamole/src/main/frontend/src/app/settings/templates/settingsConnectionHistoryPlayer.html +++ b/guacamole/src/main/frontend/src/app/settings/templates/settingsConnectionHistoryPlayer.html @@ -1,11 +1,6 @@ - + - \ No newline at end of file + diff --git a/guacamole/src/main/frontend/src/images/fullscreen.svg b/guacamole/src/main/frontend/src/images/fullscreen.svg new file mode 100644 index 0000000000..52e77e7da5 --- /dev/null +++ b/guacamole/src/main/frontend/src/images/fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 0826f33542..a2df4ff486 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -478,12 +478,17 @@ "PLAYER" : { - "ACTION_CANCEL" : "@:APP.ACTION_CANCEL", - "ACTION_PAUSE" : "@:APP.ACTION_PAUSE", - "ACTION_PLAY" : "@:APP.ACTION_PLAY", + "ACTION_CANCEL" : "@:APP.ACTION_CANCEL", + "ACTION_PAUSE" : "@:APP.ACTION_PAUSE", + "ACTION_PLAY" : "@:APP.ACTION_PLAY", + "ACTION_SHOW_KEY_LOG" : "Keystroke Log", "INFO_LOADING_RECORDING" : "Your recording is now being loaded. Please wait...", - "INFO_SEEK_IN_PROGRESS" : "Seeking to the requested position. Please wait..." + "INFO_NO_KEY_LOG" : "Keystroke Log Unavailable", + "INFO_NUMBER_OF_RESULTS" : "{RESULTS} {RESULTS, plural, one{Match} other{Matches}}", + "INFO_SEEK_IN_PROGRESS" : "Seeking to the requested position. Please wait...", + + "FIELD_PLACEHOLDER_TEXT_BATCH_FILTER" : "@:APP.FIELD_PLACEHOLDER_FILTER" },