From 8235bf5dad7c509c9590c5a29eed1dbae634ac0c Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Fri, 31 May 2019 20:07:12 -0700 Subject: [PATCH] Add search addon --- addons/xterm-addon-attach/src/tsconfig.json | 5 +- addons/xterm-addon-search/.gitignore | 2 + addons/xterm-addon-search/.npmignore | 5 + addons/xterm-addon-search/LICENSE | 19 + addons/xterm-addon-search/package.json | 17 + addons/xterm-addon-search/src/SearchAddon.ts | 392 ++++++++++++++++++ addons/xterm-addon-search/src/tsconfig.json | 18 + addons/xterm-addon-search/typings/search.d.ts | 71 ++++ addons/xterm-addon-search/yarn.lock | 13 + demo/client.ts | 2 + demo/tsconfig.json | 7 +- package.json | 3 +- tsconfig.all.json | 2 + yarn.lock | 5 - 14 files changed, 548 insertions(+), 13 deletions(-) create mode 100644 addons/xterm-addon-search/.gitignore create mode 100644 addons/xterm-addon-search/.npmignore create mode 100644 addons/xterm-addon-search/LICENSE create mode 100644 addons/xterm-addon-search/package.json create mode 100644 addons/xterm-addon-search/src/SearchAddon.ts create mode 100644 addons/xterm-addon-search/src/tsconfig.json create mode 100644 addons/xterm-addon-search/typings/search.d.ts create mode 100644 addons/xterm-addon-search/yarn.lock diff --git a/addons/xterm-addon-attach/src/tsconfig.json b/addons/xterm-addon-attach/src/tsconfig.json index 0a795e63..8acefce5 100644 --- a/addons/xterm-addon-attach/src/tsconfig.json +++ b/addons/xterm-addon-attach/src/tsconfig.json @@ -1,11 +1,10 @@ { "compilerOptions": { "module": "commonjs", - "target": "es5", + "target": "es2015", "lib": [ "dom", - "es5", - "es2015.promise" + "es2015" ], "rootDir": ".", "outDir": "../lib", diff --git a/addons/xterm-addon-search/.gitignore b/addons/xterm-addon-search/.gitignore new file mode 100644 index 00000000..a9f4ed54 --- /dev/null +++ b/addons/xterm-addon-search/.gitignore @@ -0,0 +1,2 @@ +lib +node_modules \ No newline at end of file diff --git a/addons/xterm-addon-search/.npmignore b/addons/xterm-addon-search/.npmignore new file mode 100644 index 00000000..4d1bd02b --- /dev/null +++ b/addons/xterm-addon-search/.npmignore @@ -0,0 +1,5 @@ +lib/**/*.js.map +src/ +node_modules/ +tsconfig.json +.editorconfig diff --git a/addons/xterm-addon-search/LICENSE b/addons/xterm-addon-search/LICENSE new file mode 100644 index 00000000..e597698c --- /dev/null +++ b/addons/xterm-addon-search/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2017, The xterm.js authors (https://github.com/xtermjs/xterm.js) + +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/addons/xterm-addon-search/package.json b/addons/xterm-addon-search/package.json new file mode 100644 index 00000000..66590b4d --- /dev/null +++ b/addons/xterm-addon-search/package.json @@ -0,0 +1,17 @@ +{ + "name": "xterm-addon-search", + "version": "0.1.0-beta4", + "author": { + "name": "The xterm.js authors", + "url": "https://xtermjs.org/" + }, + "main": "lib/SearchAddon.js", + "types": "typings/search.d.ts", + "license": "MIT", + "scripts": { + "prepublish": "tsc -p src" + }, + "peerDependencies": { + "xterm": "^3.13.0" + } +} diff --git a/addons/xterm-addon-search/src/SearchAddon.ts b/addons/xterm-addon-search/src/SearchAddon.ts new file mode 100644 index 00000000..faf0a262 --- /dev/null +++ b/addons/xterm-addon-search/src/SearchAddon.ts @@ -0,0 +1,392 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Terminal, IDisposable } from 'xterm'; + +// TODO: This is temporary, link to xterm when the new version is published +export interface ITerminalAddon extends IDisposable { + activate(terminal: Terminal): void; +} + +export interface ISearchOptions { + regex?: boolean; + wholeWord?: boolean; + caseSensitive?: boolean; + incremental?: boolean; +} + +export interface ISearchResult { + term: string; + col: number; + row: number; +} + +// TODO: This is temporary, link to xtem when new version is published +interface INewTerminal extends Terminal { + buffer: IBuffer; + select(column: number, row: number, length: number): void; + getSelectionPosition(): ISelectionPosition | undefined; +} +interface IBuffer { + readonly cursorY: number; + readonly cursorX: number; + readonly viewportY: number; + readonly baseY: number; + readonly length: number; + getLine(y: number): IBufferLine | undefined; +} +interface IBufferLine { + readonly isWrapped: boolean; + getCell(x: number): IBufferCell; + translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string; +} +interface IBufferCell { + readonly char: string; + readonly width: number; +} +interface ISelectionPosition { + startColumn: number; + startRow: number; + endColumn: number; + endRow: number; +} + +const NON_WORD_CHARACTERS = ' ~!@#$%^&*()+`-=[]{}|\;:"\',./<>?'; +const LINES_CACHE_TIME_TO_LIVE = 15 * 1000; // 15 secs + +export class SearchAddon implements ITerminalAddon { + private _terminal: INewTerminal; + + /** + * translateBufferLineToStringWithWrap is a fairly expensive call. + * We memoize the calls into an array that has a time based ttl. + * _linesCache is also invalidated when the terminal cursor moves. + */ + private _linesCache: string[] = null; + private _linesCacheTimeoutId = 0; + private _cursorMoveListener: IDisposable | undefined; + private _resizeListener: IDisposable | undefined; + + public activate(terminal: Terminal): void { + this._terminal = terminal; + } + + public dispose(): void {} + + /** + * Find the next instance of the term, then scroll to and select it. If it + * doesn't exist, do nothing. + * @param term The search term. + * @param searchOptions Search options. + * @return Whether a result was found. + */ + public findNext(term: string, searchOptions?: ISearchOptions): boolean { + const {incremental} = searchOptions; + let result: ISearchResult; + + if (!term || term.length === 0) { + this._terminal.clearSelection(); + return false; + } + + let startCol: number = 0; + let startRow = this._terminal.buffer.viewportY; + + if (this._terminal.hasSelection()) { + // Start from the selection end if there is a selection + // For incremental search, use existing row + const currentSelection = this._terminal.getSelectionPosition(); + startRow = incremental ? currentSelection.startRow : currentSelection.endRow; + startCol = incremental ? currentSelection.startColumn : currentSelection.endColumn; + } + + this._initLinesCache(); + + // A row that has isWrapped = false + let findingRow = startRow; + // index of beginning column that _findInLine need to scan. + let cumulativeCols = startCol; + // If startRow is wrapped row, scan for unwrapped row above. + // So we can start matching on wrapped line from long unwrapped line. + while (this._terminal.buffer.getLine(findingRow).isWrapped) { + findingRow--; + cumulativeCols += this._terminal.cols; + } + + // Search startRow + result = this._findInLine(term, findingRow, cumulativeCols, searchOptions); + + // Search from startRow + 1 to end + if (!result) { + + for (let y = startRow + 1; y < this._terminal.buffer.baseY + this._terminal.rows; y++) { + + // If the current line is wrapped line, increase index of column to ignore the previous scan + // Otherwise, reset beginning column index to zero with set new unwrapped line index + result = this._findInLine(term, y, 0, searchOptions); + if (result) { + break; + } + } + } + + // Search from the top to the startRow (search the whole startRow again in + // case startCol > 0) + if (!result) { + for (let y = 0; y < findingRow; y++) { + result = this._findInLine(term, y, 0, searchOptions); + if (result) { + break; + } + } + } + + // Set selection and scroll if a result was found + return this._selectResult(result); + } + + /** + * Find the previous instance of the term, then scroll to and select it. If it + * doesn't exist, do nothing. + * @param term The search term. + * @param searchOptions Search options. + * @return Whether a result was found. + */ + public findPrevious(term: string, searchOptions?: ISearchOptions): boolean { + let result: ISearchResult; + + if (!term || term.length === 0) { + this._terminal.clearSelection(); + return false; + } + + const isReverseSearch = true; + let startRow = this._terminal.buffer.viewportY + this._terminal.rows - 1; + let startCol = this._terminal.cols; + + if (this._terminal.hasSelection()) { + // Start from the selection start if there is a selection + const currentSelection = this._terminal.getSelectionPosition(); + startRow = currentSelection.startRow; + startCol = currentSelection.startColumn; + } + + this._initLinesCache(); + + // Search startRow + result = this._findInLine(term, startRow, startCol, searchOptions, isReverseSearch); + + // Search from startRow - 1 to top + if (!result) { + // If the line is wrapped line, increase number of columns that is needed to be scanned + // Se we can scan on wrapped line from unwrapped line + let cumulativeCols = this._terminal.cols; + if (this._terminal.buffer.getLine(startRow).isWrapped) { + cumulativeCols += startCol; + } + for (let y = startRow - 1; y >= 0; y--) { + result = this._findInLine(term, y, cumulativeCols, searchOptions, isReverseSearch); + if (result) { + break; + } + // If the current line is wrapped line, increase scanning range, + // preparing for scanning on unwrapped line + if (this._terminal.buffer.getLine(y).isWrapped) { + cumulativeCols += this._terminal.cols; + } else { + cumulativeCols = this._terminal.cols; + } + } + } + + // Search from the bottom to startRow (search the whole startRow again in + // case startCol > 0) + if (!result) { + const searchFrom = this._terminal.buffer.baseY + this._terminal.rows - 1; + let cumulativeCols = this._terminal.cols; + for (let y = searchFrom; y >= startRow; y--) { + result = this._findInLine(term, y, cumulativeCols, searchOptions, isReverseSearch); + if (result) { + break; + } + if (this._terminal.buffer.getLine(y).isWrapped) { + cumulativeCols += this._terminal.cols; + } else { + cumulativeCols = this._terminal.cols; + } + } + } + + // Set selection and scroll if a result was found + return this._selectResult(result); + } + + /** + * Sets up a line cache with a ttl + */ + private _initLinesCache(): void { + if (!this._linesCache) { + this._linesCache = new Array(this._terminal.buffer.length); + this._cursorMoveListener = this._terminal.onCursorMove(() => this._destroyLinesCache()); + this._resizeListener = this._terminal.onResize(() => this._destroyLinesCache()); + } + + window.clearTimeout(this._linesCacheTimeoutId); + this._linesCacheTimeoutId = window.setTimeout(() => this._destroyLinesCache(), LINES_CACHE_TIME_TO_LIVE); + } + + private _destroyLinesCache(): void { + this._linesCache = null; + if (this._cursorMoveListener) { + this._cursorMoveListener.dispose(); + this._cursorMoveListener = undefined; + } + if (this._resizeListener) { + this._resizeListener.dispose(); + this._resizeListener = undefined; + } + if (this._linesCacheTimeoutId) { + window.clearTimeout(this._linesCacheTimeoutId); + this._linesCacheTimeoutId = 0; + } + } + + /** + * A found substring is a whole word if it doesn't have an alphanumeric character directly adjacent to it. + * @param searchIndex starting indext of the potential whole word substring + * @param line entire string in which the potential whole word was found + * @param term the substring that starts at searchIndex + */ + private _isWholeWord(searchIndex: number, line: string, term: string): boolean { + return (((searchIndex === 0) || (NON_WORD_CHARACTERS.indexOf(line[searchIndex - 1]) !== -1)) && + (((searchIndex + term.length) === line.length) || (NON_WORD_CHARACTERS.indexOf(line[searchIndex + term.length]) !== -1))); + } + + /** + * Searches a line for a search term. Takes the provided terminal line and searches the text line, which may contain + * subsequent terminal lines if the text is wrapped. If the provided line number is part of a wrapped text line that + * started on an earlier line then it is skipped since it will be properly searched when the terminal line that the + * text starts on is searched. + * @param term The search term. + * @param row The line to start the search from. + * @param col The column to start the search from. + * @param searchOptions Search options. + * @return The search result if it was found. + */ + protected _findInLine(term: string, row: number, col: number, searchOptions: ISearchOptions = {}, isReverseSearch: boolean = false): ISearchResult { + + // Ignore wrapped lines, only consider on unwrapped line (first row of command string). + if (this._terminal.buffer.getLine(row).isWrapped) { + return; + } + let stringLine = this._linesCache ? this._linesCache[row] : void 0; + if (stringLine === void 0) { + stringLine = this.translateBufferLineToStringWithWrap(row, true); + if (this._linesCache) { + this._linesCache[row] = stringLine; + } + } + + const searchTerm = searchOptions.caseSensitive ? term : term.toLowerCase(); + const searchStringLine = searchOptions.caseSensitive ? stringLine : stringLine.toLowerCase(); + + let resultIndex = -1; + if (searchOptions.regex) { + const searchRegex = RegExp(searchTerm, 'g'); + let foundTerm: RegExpExecArray; + if (isReverseSearch) { + // This loop will get the resultIndex of the _last_ regex match in the range 0..col + while (foundTerm = searchRegex.exec(searchStringLine.slice(0, col))) { + resultIndex = searchRegex.lastIndex - foundTerm[0].length; + term = foundTerm[0]; + searchRegex.lastIndex -= (term.length - 1); + } + } else { + foundTerm = searchRegex.exec(searchStringLine.slice(col)); + if (foundTerm && foundTerm[0].length > 0) { + resultIndex = col + (searchRegex.lastIndex - foundTerm[0].length); + term = foundTerm[0]; + } + } + } else { + if (isReverseSearch) { + if (col - searchTerm.length >= 0) { + resultIndex = searchStringLine.lastIndexOf(searchTerm, col - searchTerm.length); + } + } else { + resultIndex = searchStringLine.indexOf(searchTerm, col); + } + } + + if (resultIndex >= 0) { + // Adjust the row number and search index if needed since a "line" of text can span multiple rows + if (resultIndex >= this._terminal.cols) { + row += Math.floor(resultIndex / this._terminal.cols); + resultIndex = resultIndex % this._terminal.cols; + } + if (searchOptions.wholeWord && !this._isWholeWord(resultIndex, searchStringLine, term)) { + return; + } + + const line = this._terminal.buffer.getLine(row); + + for (let i = 0; i < resultIndex; i++) { + const cell = line.getCell(i); + // Adjust the searchIndex to normalize emoji into single chars + const char = cell.char; + if (char.length > 1) { + resultIndex -= char.length - 1; + } + // Adjust the searchIndex for empty characters following wide unicode + // chars (eg. CJK) + const charWidth = cell.width; + if (charWidth === 0) { + resultIndex++; + } + } + return { + term, + col: resultIndex, + row + }; + } + } + /** + * Translates a buffer line to a string, including subsequent lines if they are wraps. + * Wide characters will count as two columns in the resulting string. This + * function is useful for getting the actual text underneath the raw selection + * position. + * @param line The line being translated. + * @param trimRight Whether to trim whitespace to the right. + */ + public translateBufferLineToStringWithWrap(lineIndex: number, trimRight: boolean): string { + let lineString = ''; + let lineWrapsToNext: boolean; + + do { + const nextLine = this._terminal.buffer.getLine(lineIndex + 1); + lineWrapsToNext = nextLine ? nextLine.isWrapped : false; + lineString += this._terminal.buffer.getLine(lineIndex).translateToString(!lineWrapsToNext && trimRight).substring(0, this._terminal.cols); + lineIndex++; + } while (lineWrapsToNext); + + return lineString; + } + + /** + * Selects and scrolls to a result. + * @param result The result to select. + * @return Whethera result was selected. + */ + private _selectResult(result: ISearchResult): boolean { + if (!result) { + this._terminal.clearSelection(); + return false; + } + this._terminal.select(result.col, result.row, result.term.length); + this._terminal.scrollLines(result.row - this._terminal.buffer.viewportY); + return true; + } +} diff --git a/addons/xterm-addon-search/src/tsconfig.json b/addons/xterm-addon-search/src/tsconfig.json new file mode 100644 index 00000000..fcf70ba9 --- /dev/null +++ b/addons/xterm-addon-search/src/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "lib": [ + "dom", + "es6", + ], + "rootDir": ".", + "outDir": "../lib", + "sourceMap": true, + "removeComments": true + }, + "include": [ + "./**/*", + "../../../typings/xterm.d.ts" + ] +} diff --git a/addons/xterm-addon-search/typings/search.d.ts b/addons/xterm-addon-search/typings/search.d.ts new file mode 100644 index 00000000..50c5b1cf --- /dev/null +++ b/addons/xterm-addon-search/typings/search.d.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { Terminal, ILinkMatcherOptions, IDisposable } from 'xterm'; + +// TODO: This is temporary, link to xterm when the new version is published +export interface ITerminalAddon extends IDisposable { + activate(terminal: Terminal): void; +} + +/** + * Options for a search. + */ +export interface ISearchOptions { + /** + * Whether the search term is a regex. + */ + regex?: boolean; + + /** + * Whether to search for a whole word, the result is only valid if it's + * suppounded in "non-word" characters such as `_`, `(`, `)` or space. + */ + wholeWord?: boolean; + + /** + * Whether the search is case sensitive. + */ + caseSensitive?: boolean; + + /** + * Whether to do an indcremental search, this will expand the selection if it + * still matches the term the user typed. Note that this only affects + * `findNext`, not `findPrevious`. + */ + incremental?: boolean; +} + +/** + * An xterm.js addon that provides search functionality. + */ +export class SearchAddon implements ITerminalAddon { + /** + * Activates the addon + * @param terminal The terminal the addon is being loaded in. + */ + public activate(terminal: Terminal): void; + + /** + * Disposes the addon. + */ + public dispose(): void; + + /** + * Search forwards for the next result that matches the search term and + * options. + * @param term The search term. + * @param searchOptions The options for the search. + */ + public findNext(term: string, searchOptions?: ISearchOptions): boolean; + + /** + * Search backwards for the previous result that matches the search term and + * options. + * @param term The search term. + * @param searchOptions The options for the search. + */ + public findPrevious(term: string, searchOptions?: ISearchOptions): boolean; +} diff --git a/addons/xterm-addon-search/yarn.lock b/addons/xterm-addon-search/yarn.lock new file mode 100644 index 00000000..1d4ce3e9 --- /dev/null +++ b/addons/xterm-addon-search/yarn.lock @@ -0,0 +1,13 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +typescript@^3.4.0: + version "3.4.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.4.5.tgz#2d2618d10bb566572b8d7aad5180d84257d70a99" + integrity sha512-YycBxUb49UUhdNMU5aJ7z5Ej2XGmaIBL0x34vZ82fn3hGvD+bgrMrVDpatgz2f7YxUMJxMkbWxJZeAvDxVe7Vw== + +xterm@^3.13.0: + version "3.13.0" + resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.13.0.tgz#d0e06c3cf4c1f079aa83f646948457db3b04220b" + integrity sha512-FZVmvkkbkky3zldJ2NNOZ9h8jirtbGTlF4sIKMDrejR4wPsVZ3o4F++DQVkdeZqjAwtNOMoR17PMSOTZ+h070g== diff --git a/demo/client.ts b/demo/client.ts index 43f6d8b4..da63ec0d 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -26,6 +26,7 @@ export interface IWindowWithTerminal extends Window { term: TerminalType; Terminal?: typeof TerminalType; AttachAddon?: typeof AttachAddon; + SearchAddon?: typeof SearchAddon; WebLinksAddon?: typeof WebLinksAddon; } declare let window: IWindowWithTerminal; @@ -77,6 +78,7 @@ const disposeRecreateButtonHandler = () => { if (document.location.pathname === '/test') { window.Terminal = Terminal; window.AttachAddon = AttachAddon; + window.SearchAddon = SearchAddon; window.WebLinksAddon = WebLinksAddon; } else { createTerminal(); diff --git a/demo/tsconfig.json b/demo/tsconfig.json index 76959c80..06797fc1 100644 --- a/demo/tsconfig.json +++ b/demo/tsconfig.json @@ -1,13 +1,14 @@ { "compilerOptions": { "module": "commonjs", - "target": "es5", + "target": "es2015", "rootDir": ".", "sourceMap": true, "baseUrl": ".", "paths": { - "xterm-addon-web-links": ["../addons/xterm-addon-web-links"], - "xterm-addon-attach": ["../addons/xterm-addon-attach"] + "xterm-addon-attach": ["../addons/xterm-addon-attach"], + "xterm-addon-search": ["../addons/xterm-addon-search"], + "xterm-addon-web-links": ["../addons/xterm-addon-web-links"] } }, "include": [ diff --git a/package.json b/package.json index 6a80b405..fd627d5a 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,7 @@ "utf8": "^3.0.0", "webpack": "^4.17.1", "webpack-cli": "^3.1.0", - "ws": "^7.0.0", - "xterm-addon-search": "0.1.0-beta4" + "ws": "^7.0.0" }, "scripts": { "prepackage": "npm run build", diff --git a/tsconfig.all.json b/tsconfig.all.json index 4c464292..e7e503b6 100644 --- a/tsconfig.all.json +++ b/tsconfig.all.json @@ -3,6 +3,8 @@ "include": [], "references": [ { "path": "./src" }, + { "path": "./addons/xterm-addon-attach/src" }, + { "path": "./addons/xterm-addon-search/src" }, { "path": "./addons/xterm-addon-web-links/src" }, { "path": "./src/addons/attach" }, { "path": "./src/addons/fit" }, diff --git a/yarn.lock b/yarn.lock index d0c6174c..61a70629 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4566,11 +4566,6 @@ xtend@^4.0.0, xtend@~4.0.1: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68= -xterm-addon-search@0.1.0-beta4: - version "0.1.0-beta4" - resolved "https://registry.yarnpkg.com/xterm-addon-search/-/xterm-addon-search-0.1.0-beta4.tgz#c73fe058c87f07eaae31baaa92976e927438a396" - integrity sha512-tJgZ1VTRd/DOFUhSFZzybRF8SR1LCEXRYkw/mHzGV5Ba3zhqVdSkN/0J9sjOpX6u21buee2OmTiCMZxq80zfJg== - "y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"