diff --git a/assets/config/webpack.config.development.js b/assets/config/webpack.config.development.js --- a/assets/config/webpack.config.development.js +++ b/assets/config/webpack.config.development.js @@ -25,6 +25,7 @@ // are we running webpack-dev-server ? const isDevServer = process.argv.find(v => v.includes('serve')) !== undefined; // webpack-dev-server configuration +const host = '0.0.0.0'; const devServerPort = 3000; const devServerPublicPath = 'http://localhost:' + devServerPort + '/static/'; // set publicPath according if we are using webpack-dev-server to serve @@ -103,7 +104,7 @@ // webpack-dev-server configuration devServer: { clientLogLevel: 'warning', - host: '0.0.0.0', + host: host, port: devServerPort, publicPath: devServerPublicPath, // enable to serve static assets not managed by webpack @@ -145,6 +146,11 @@ alias: { 'pdfjs-dist': 'pdfjs-dist/build/pdf.min.js' }, + // for web-tree-sitter + fallback: { + 'path': false, + 'fs': false + }, // configure base paths for resolving modules with webpack modules: [ 'node_modules', @@ -267,6 +273,18 @@ } }] }, + // import .wasm files (for web-tree-sitter) + { + test: /\.wasm$/, + type: 'javascript/auto', + use: [{ + loader: 'file-loader', + options: { + name: '[name].[ext]', + outputPath: 'js/' + } + }] + }, // css import configuration: // - first process it with postcss // - then extract it to a dedicated file associated to each bundle diff --git a/assets/src/bundles/browse/origin-search.js b/assets/src/bundles/browse/origin-search.js --- a/assets/src/bundles/browse/origin-search.js +++ b/assets/src/bundles/browse/origin-search.js @@ -6,7 +6,7 @@ */ import {handleFetchError, isArchivedOrigin} from 'utils/functions'; - +import {initAutocomplete} from 'utils/search-ql-autocomplete'; const limit = 100; const linksPrev = []; let linkNext = null; @@ -193,6 +193,7 @@ export function initOriginSearch() { $(document).ready(() => { + initAutocomplete(document.querySelector('#swh-origins-url-patterns')); $('#swh-search-origins').submit(event => { event.preventDefault(); if (event.target.checkValidity()) { diff --git a/assets/src/utils/autocomplete.css b/assets/src/utils/autocomplete.css new file mode 100644 --- /dev/null +++ b/assets/src/utils/autocomplete.css @@ -0,0 +1,48 @@ +/* the container must be positioned relative: */ +.autocomplete { + position: relative; + display: inline-block; +} + +input.invalid { + outline: none !important; + border: 1px solid red; +} + +input[type=submit] { + background-color: dodgerblue; + color: #fff; + cursor: pointer; +} + +/* position the autocomplete items to be the same width as the container: */ +.autocomplete-items { + position: absolute; + border: 1px solid #d4d4d4; + border-bottom: none; + border-top: none; + z-index: 99999; + top: 100%; + left: 0; + right: 0; +} + +.autocomplete-items div { + padding: 2px; + padding-left: 3px; + font-size: 15px; + cursor: pointer; + background-color: #fff; + border-bottom: 1px solid #d4d4d4; +} + +/* when hovering an item: */ +.autocomplete-items div:hover { + background-color: #e9e9e9; +} + +/* when navigating through the items using the arrow keys: */ +.autocomplete-active { + background-color: dodgerblue !important; + color: #fff; +} diff --git a/assets/src/utils/autocomplete.js b/assets/src/utils/autocomplete.js new file mode 100644 --- /dev/null +++ b/assets/src/utils/autocomplete.js @@ -0,0 +1,92 @@ +import 'utils/autocomplete.css'; + +export class Autocomplete { + constructor(params) { + const {inputBox, suggestions} = params; + this.inputBox = inputBox; + this.suggestions = suggestions; + this.currentIndex = -1; + + this.autocompleteList = document.createElement('div'); + this.autocompleteList.setAttribute('class', 'autocomplete-items'); + this.inputBox.parentNode.appendChild(this.autocompleteList); + + this.initListeners(); + } + + initListeners() { + this.inputBox.addEventListener('focus', this.updateLists.bind(this)); + this.inputBox.addEventListener('input', this.updateLists.bind(this)); + + this.inputBox.addEventListener('keydown', (e) => { + if (e.keyCode === 40) { // down + this.currentIndex++; + this.addActive(this.autocompleteList); + } else if (e.keyCode === 38) { // up + this.currentIndex--; + this.addActive(this.autocompleteList); + } else if (e.keyCode === 13) { // enter + e.preventDefault(); + if (this.currentIndex > -1) { + // Simulate a click on the "active" item: + if (this.autocompleteList) this.autocompleteList.children[this.currentIndex].click(); + } + } + }); + + document.addEventListener('click', (e) => { this.removeAllItems(e.target); }); + } + + updateLists() { + const inputValue = this.inputBox.value; + + /* close any already open lists of autocompleted values */ + this.removeAllItems(); + + this.currentFocusIdx = -1; + + this.suggestions.forEach(suggestion => { + const itemDiv = document.createElement('div'); + itemDiv.innerHTML = `${suggestion.substr(0, inputValue.length)}`; + itemDiv.innerHTML += suggestion.substr(inputValue.length); + itemDiv.setAttribute('data-value', suggestion); + + const suggestionClick = (e) => { + this.inputBox.value += e.target.getAttribute('data-value'); + this.inputBox.focus(); + }; + + itemDiv.addEventListener('click', suggestionClick.bind(this)); + + this.autocompleteList.appendChild(itemDiv); + }); + } + + addActive() { + // a function to classify an item as "active": + if (!this.autocompleteList) return false; + // start by removing the "active" class on all items: + const n = this.autocompleteList.childElementCount; + this.removeActive(); + if (this.currentIndex >= n) this.currentIndex = 0; + if (this.currentIndex < 0) this.currentIndex = (n - 1); + // add class "autocomplete-active": + this.autocompleteList.children[this.currentIndex].classList.add('autocomplete-active'); + } + + removeActive() { + /* a function to remove the "active" class from all autocomplete items */ + Array.from(this.autocompleteList.children).forEach(autocompleteItem => { + autocompleteItem.classList.remove('autocomplete-active'); + }); + } + + removeAllItems(element) { + /* + close all autocomplete lists in the document, + except the one passed as an argument + */ + if (element !== this.inputBox && this.autocompleteList) { this.autocompleteList.innerHTML = ''; } + } + +} diff --git a/assets/src/utils/search-ql-autocomplete.js b/assets/src/utils/search-ql-autocomplete.js new file mode 100644 --- /dev/null +++ b/assets/src/utils/search-ql-autocomplete.js @@ -0,0 +1,152 @@ +import '../../../node_modules/web-tree-sitter/tree-sitter.wasm'; +import {Parser} from 'web-tree-sitter'; +import {Autocomplete} from 'utils/autocomplete.js'; + +const filterNames = [ + 'origin', 'metadata', // pattern + 'visited', // boolean + 'visits', // numeric + 'visit_type', // boundedList + 'language', 'license', 'keyword', // unboundedList + 'last_visit', 'last_eventful_visit', 'last_revision', 'last_release', 'created', 'modified', 'published', // date + 'sort_by', + 'limit' +]; + +/* eslint-disable */ +const sortByOptions = ['visits', + 'last_visit', + 'last_eventful_visit', + 'last_revision', + 'last_release', + 'created', + 'modified', + 'published' +]; + +const visitTypeOptions = [ + 'any', + 'cran', + 'deb', + 'deposit', + 'ftp', + 'hg', + 'git', + 'nixguix', + 'npm', + 'pypi', + 'svn', + 'tar' +]; + + +const equalOp = ['=']; +const rangeOp = ['<', '<=', '=', '!=', '>=', '>']; +const listOp = ['in', 'not in']; + +const filterFieldToOperatorMap = { + 'patternField': equalOp, + 'booleanField': equalOp, + 'numericField': rangeOp, + 'listField': listOp, + 'sortByField': equalOp, + 'dateField': rangeOp +}; + +const filterOperators = ['equalOp', 'listOp', 'rangeOp']; + +const filterValues = ['singleWord', 'number', 'isoDateTime']; + +const filterCategories = ['patternFilter', 'booleanFilter', 'numericFilter', 'boundedListFilter', 'unboundedListFilter', 'dateFilter']; + +/* eslint-enable */ + +const findMissingNode = (node) => { + if (node.isMissing()) { + return node; + } + if (node.children.length > 0) { + for (let i = 0; i < node.children.length; i++) { + const missingNode = findMissingNode(node.children[i]); + if (missingNode !== null) { return missingNode; } + } + } + + return null; +}; + +const isWrapperNode = (child, parent) => { + if (!child || !parent) return false; + if (parent.namedChildren.length === 1 && parent.type !== 'ERROR') return true; + return ( + (child.startPosition.column === parent.startPosition.column) && + (child.endPosition.column === parent.endPosition.column) + ); +}; + +const suggestNextNode = (tree, inputBox) => { + + const cursor = inputBox.selectionStart - 1; + + const query = inputBox.value; + + let lastTokenIndex = cursor; + while (query[lastTokenIndex] === ' ') { lastTokenIndex--; } + + const lastTokenPosition = {row: 0, column: lastTokenIndex}; + const lastTokenNode = tree.rootNode.descendantForPosition(lastTokenPosition, lastTokenPosition); + + const missingNode = findMissingNode(tree.rootNode); + if (missingNode && missingNode !== null) { + if (missingNode.type === ')') { + return [')']; + } else if (missingNode.type === ']') { + return [']']; + } + } + + let ancestorOfLastTokenNode = lastTokenNode; + while (isWrapperNode(ancestorOfLastTokenNode, ancestorOfLastTokenNode.parent)) { + ancestorOfLastTokenNode = ancestorOfLastTokenNode.parent; + } + + if (lastTokenNode.type === 'ERROR' || (lastTokenNode.type === '(') || (lastTokenNode.type === 'and' || lastTokenNode.type === 'or')) { + return ['('].concat(filterNames); + } else if (lastTokenNode.type in filterFieldToOperatorMap) { return filterFieldToOperatorMap[lastTokenNode.type]; } else if (filterOperators.indexOf(lastTokenNode.type) >= 0) { return ['Value']; } else if ((!tree.rootNode.hasError() && (ancestorOfLastTokenNode.type.indexOf('Val') > 0)) || lastTokenNode.type === ')') { + return ['and', 'or']; + } + + return []; +}; + +/* eslint-disable */ +export const initAutocomplete = (inputBox) => { + Parser.init().then(async() => { + console.log('Loaded Parser'); + const parser = new Parser(); + + const swhSearchQL = await Parser.Language.load(`${window.location.origin}/static/swh_ql.wasm`); + parser.setLanguage(swhSearchQL); + + const autocomplete = new Autocomplete( + {inputBox, suggestions: ['('].concat(filterNames)} + ); + + const getSuggestions = () => { + const tree = parser.parse(inputBox.value); + + if (tree.rootNode.hasError()) { + inputBox.classList.add('invalid'); + } else { inputBox.classList.remove('invalid'); } + + console.log(`input(${inputBox.value}) => ${tree.rootNode.toString()}`); + const suggestions = suggestNextNode(tree, inputBox); + + autocomplete.suggestions = suggestions.map(item => `${item} `); + }; + + inputBox.addEventListener('input', getSuggestions.bind(this)); + }); +}; + +/* eslint-enable */ diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "typeface-alegreya": "^1.1.13", "typeface-alegreya-sans": "^1.1.13", "waypoints": "^4.0.1", + "web-tree-sitter": "^0.19.4", "whatwg-fetch": "^3.6.2" }, "devDependencies": { diff --git a/swh/web/config.py b/swh/web/config.py --- a/swh/web/config.py +++ b/swh/web/config.py @@ -59,7 +59,10 @@ ), "search_config": ( "dict", - {"backend": "swh-indexer-storage", "enable_ql": False}, # or "swh-search" + { + "backend": "swh-indexer-storage", + "enable_ql": False, + }, # or {"backend" : "swh-search", ...} ), "log_dir": ("string", "/tmp/swh/log"), "debug": ("bool", False), diff --git a/swh/web/settings/common.py b/swh/web/settings/common.py --- a/swh/web/settings/common.py +++ b/swh/web/settings/common.py @@ -12,6 +12,7 @@ import sys from typing import Any, Dict +from swh import search from swh.web.auth.utils import OIDC_SWH_WEB_CLIENT_ID from swh.web.config import get_config @@ -135,7 +136,15 @@ if not os.path.exists(STATIC_DIR): # static folder location when developping swh-web STATIC_DIR = os.path.join(PROJECT_DIR, "../../../static") -STATICFILES_DIRS = [STATIC_DIR] + +SEARCH_DIR = os.path.dirname(search.__file__) +# static folder location when swh-search has been installed with pip +SEARCH_STATIC_DIR = os.path.join(SEARCH_DIR, "static") +if not os.path.exists(SEARCH_STATIC_DIR): + # static folder location when developping swh-search + SEARCH_STATIC_DIR = os.path.join(SEARCH_DIR, "../../static") +STATICFILES_DIRS = [STATIC_DIR, SEARCH_STATIC_DIR] + INTERNAL_IPS = ["127.0.0.1"] diff --git a/swh/web/templates/includes/origin-search-form.html b/swh/web/templates/includes/origin-search-form.html --- a/swh/web/templates/includes/origin-search-form.html +++ b/swh/web/templates/includes/origin-search-form.html @@ -10,7 +10,8 @@ + oninput="swh.webapp.validateSWHIDInput(this)" + autofocus required autocomplete="off">