From 79592f0a1dc63b0b575505f250ce795e0cdcbf5c Mon Sep 17 00:00:00 2001 From: Taly Date: Thu, 11 Aug 2022 18:28:15 +0300 Subject: [PATCH 01/13] search implementation --- nodemon.json | 2 +- package.json | 2 + src/backend/controllers/search.ts | 128 +++++++++++++++++++ src/backend/routes/api/index.ts | 3 + src/backend/routes/api/search.ts | 54 ++++++++ src/backend/views/layout.twig | 2 +- src/frontend/js/app.js | 3 + src/frontend/js/modules/page.js | 1 + src/frontend/js/modules/search.js | 137 +++++++++++++++++++++ src/frontend/styles/components/search.pcss | 78 ++++++++++++ src/frontend/styles/main.pcss | 1 + yarn.lock | 57 +++++---- 12 files changed, 440 insertions(+), 28 deletions(-) create mode 100644 src/backend/controllers/search.ts create mode 100644 src/backend/routes/api/search.ts create mode 100644 src/frontend/js/modules/search.js create mode 100644 src/frontend/styles/components/search.pcss diff --git a/nodemon.json b/nodemon.json index 063c303..6afb070 100644 --- a/nodemon.json +++ b/nodemon.json @@ -9,5 +9,5 @@ "watch": [ "**/*" ], - "ext": "js,twig" + "ext": "ts,js,twig" } diff --git a/package.json b/package.json index f71eb53..72fd2d9 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@babel/polyfill": "^7.12.1", "@babel/preset-env": "^7.16.11", "@codexteam/misprints": "^1.0.0", + "@codexteam/shortcuts": "^1.2.0", "@editorjs/checklist": "^1.3.0", "@editorjs/code": "^2.7.0", "@editorjs/delimiter": "^1.2.0", @@ -81,6 +82,7 @@ "@types/sinon": "^10.0.2", "@types/twig": "^1.12.6", "autoprefixer": "^10.4.2", + "axios": "^0.27.2", "babel": "^6.23.0", "babel-eslint": "^10.0.1", "babel-loader": "^8.2.3", diff --git a/src/backend/controllers/search.ts b/src/backend/controllers/search.ts new file mode 100644 index 0000000..b766280 --- /dev/null +++ b/src/backend/controllers/search.ts @@ -0,0 +1,128 @@ +import Page from '../models/page'; +import Pages from '../controllers/pages'; + +type SearchResponse = { + completions: string[]; + + pages: Page[]; +} + +class Search { + /** + * Prepare words database + */ + public async index() { + /** + * Prepare pages content for the search + * @todo - it should be done in the background + */ + const pages = await Pages.getAll(); + const pagesWords = pages.map(page => { + const pageWords: string[] = []; + + page.body.blocks.forEach((block: any) => { + let blockContent = ''; + + const validBlocks = ['header', 'paragraph']; + if (!validBlocks.includes(block.type)) { + return; + } + + switch (block.type) { + case 'header': + blockContent = block.data.text; + break; + + case 'paragraph': + blockContent = block.data.text + break; + } + + const blockWords: string[] = blockContent + // @todo get text from inline code elements and remove html tags + + // left only letters and numbers + .replace(/[^a-z0-9]/gi, ' ') + + // lowercase all words + .toLowerCase() + + // remove multiple spaces + .replace(/\s+/g, ' ') + + // split to words by spaces + .split(' '); + + pageWords.push(...blockWords); + }); + + const uniqueWords = [...new Set(pageWords)].sort(); + + return { + id: page._id, + words: uniqueWords + }; + }); + + return pagesWords; + } + + public async query(searchString: string): Promise { + const pages = await Pages.getAll(); + const pagesWords = await this.index(); + + /** + * Search itself + */ + const searchWords = searchString.toLowerCase().split(' '); + const goodPages = pagesWords.map(({ id, words}) => { + const foundWords = searchWords.filter( + word => { + return words.filter( + testWord => { + return testWord.indexOf(word) === 0 + } + ).length > 0; + } + ); + + const successRatio = foundWords.length / searchWords.length * 100; + + return { + id, + successRatio + } + }); + + const foundPages = goodPages + .filter(({ successRatio }) => successRatio > 50) + .sort((a, b) => b.successRatio - a.successRatio) + .slice(0, 10); + + + // --------- START test --------- + + const uniqWords = [...new Set(pagesWords.flatMap(page => page.words))].sort(); + + uniqWords.forEach(word => { + console.log(word); + }) + + // --------- END test --------- + + + + return { + completions: uniqWords.filter(word => word.indexOf(searchWords.slice(-1)[0]) === 0), + pages: pages.filter(page => foundPages.some(({ id }) => id === page._id)) + } + } + + private async search(searchString: string) { + const pages = await this.query(searchString); + + return pages; + } +} + +export default Search; diff --git a/src/backend/routes/api/index.ts b/src/backend/routes/api/index.ts index be225d8..d4de2c3 100644 --- a/src/backend/routes/api/index.ts +++ b/src/backend/routes/api/index.ts @@ -1,12 +1,15 @@ import express from 'express'; + import pagesAPI from './pages'; import transportAPI from './transport'; import linksAPI from './links'; +import searchAPI from './search'; const router = express.Router(); router.use('/', pagesAPI); router.use('/', transportAPI); router.use('/', linksAPI); +router.use('/', searchAPI); export default router; diff --git a/src/backend/routes/api/search.ts b/src/backend/routes/api/search.ts new file mode 100644 index 0000000..a0f8b17 --- /dev/null +++ b/src/backend/routes/api/search.ts @@ -0,0 +1,54 @@ +import express, { Request, Response } from 'express'; +import Search from '../../controllers/search'; + +const router = express.Router(); + +/** + * GET /search/:searchString + * + * Search given words in all documents + */ +router.get('/search', async (req: Request, res: Response) => { + try { + const searchString = req.query.text as string; + + /** Start measuring search time */ + const startTime = performance.now(); + + const search = new Search(); + const searchResponse = await search.query(searchString); + + /** End measuring search time */ + const endTime = performance.now(); + + /** Show search time */ + const searchItem = (endTime - startTime).toFixed(6); + console.log(`🔎 "${searchString}" ⏱ ${searchItem} ms`); + + const compactedPages = searchResponse.pages.map(page => { + return { + _id: page._id, + title: page.title, + uri: page.uri, + // body: page.body, + // parent: page.parent, + }; + }); + + res.json({ + success: true, + result: { + completions: searchResponse.completions, + pages: compactedPages, + time: searchItem, + }, + }); + } catch (err) { + res.status(400).json({ + success: false, + error: (err as Error).message, + }); + } +}); + +export default router; diff --git a/src/backend/views/layout.twig b/src/backend/views/layout.twig index c35b0e8..429c4f9 100644 --- a/src/backend/views/layout.twig +++ b/src/backend/views/layout.twig @@ -15,7 +15,7 @@ hawkClientToken:"{{ config.hawkClientToken }}", }; - + {% include "components/header.twig" with res.locals.isAuthorized %}
diff --git a/src/frontend/js/app.js b/src/frontend/js/app.js index 1bd665e..132d183 100644 --- a/src/frontend/js/app.js +++ b/src/frontend/js/app.js @@ -17,6 +17,7 @@ import Writing from './modules/writing'; import Page from './modules/page'; import Extensions from './modules/extensions'; import Sidebar from './modules/sidebar'; +import Search from './modules/search'; import HawkCatcher from '@hawk.so/javascript'; /** @@ -31,6 +32,8 @@ class Docs { this.page = new Page(); this.extensions = new Extensions(); this.sidebar = new Sidebar(); + this.search = new Search(); + if (window.config.hawkClientToken) { this.hawk = new HawkCatcher(window.config.hawkClientToken); } diff --git a/src/frontend/js/modules/page.js b/src/frontend/js/modules/page.js index 104928e..4b6a185 100644 --- a/src/frontend/js/modules/page.js +++ b/src/frontend/js/modules/page.js @@ -17,6 +17,7 @@ export default class Page { init() { this.codeStyler = this.createCodeStyling(); this.tableOfContent = this.createTableOfContent(); + this.search = this.createSearch(); } /** diff --git a/src/frontend/js/modules/search.js b/src/frontend/js/modules/search.js new file mode 100644 index 0000000..fe120f1 --- /dev/null +++ b/src/frontend/js/modules/search.js @@ -0,0 +1,137 @@ +import { debounce } from '../utils/decorators'; +import Shortcut from '@codexteam/shortcuts'; +import axios from 'axios'; + + +export default class Search { + constructor() { + this.nodes = { + overlay: null, + searchWrapper: null, + searchInput: null, + searchResultsWrapper: null + }; + + this.isVisible = false; + + this.shortcut = null; + this.TOGGLER_SHORTCUT = 'CMD+SHIFT+F'; + + this.debouncedSearch = null; + this.DEBOUNCE_TIME = 500; + } + + init(settings = {}, moduleEl) { + console.log('search init'); + + this.createSearchOverlay(); + this.createDebouncedSearch(); + this.enableShortcutListening(); + + // ! force open search overlay + // this.toggleSearchOverlay(true); + } + + createSearchOverlay() { + this.nodes.overlay = document.createElement('div'); + this.nodes.overlay.classList.add('search-overlay'); + this.nodes.overlay.addEventListener('click', this.searchOverlayClickProcessor.bind(this)); + + this.nodes.searchWrapper = document.createElement('div'); + this.nodes.searchWrapper.classList.add('search-wrapper'); + + this.nodes.searchInput = document.createElement('input'); + this.nodes.searchInput.classList.add('search-input'); + this.nodes.searchInput.setAttribute('type', 'search'); + this.nodes.searchInput.setAttribute('placeholder', 'Find in documents...'); + this.nodes.searchInput.setAttribute('autocomplete', 'off'); + this.nodes.searchInput.addEventListener('input', this.searchInputOnchangeProcessor.bind(this)); + this.nodes.searchWrapper.appendChild(this.nodes.searchInput); + + this.nodes.searchResultsWrapper = document.createElement('div'); + this.nodes.searchResultsWrapper.classList.add('search-results-wrapper'); + this.nodes.searchWrapper.appendChild(this.nodes.searchResultsWrapper); + + this.nodes.overlay.appendChild(this.nodes.searchWrapper); + document.body.appendChild(this.nodes.overlay); + } + + searchOverlayClickProcessor(event) { + if (event.target !== this.nodes.overlay) { + return; + } + + this.toggleSearchOverlay(false); + } + + searchInputOnchangeProcessor(event) { + // close search overlay if ESC key is pressed + if (event.keyCode === 27) { + this.toggleSearchOverlay(false); + event.preventDefault(); + } + + console.log(event.target.value); + + this.debouncedSearch(event.target.value); + } + + enableShortcutListening() { + this.shortcut = new Shortcut({ + name : this.TOGGLER_SHORTCUT, + on : document.body, + callback: (event) => { + this.toggleSearchOverlay(); + } + }); + } + + toggleSearchOverlay(force) { + this.isVisible = force || !this.isVisible; + + this.nodes.overlay.classList.toggle('search-overlay--visible', this.isVisible); + document.body.classList.toggle('noscroll', this.isVisible); + + this.nodes.searchInput.focus(); + } + + createDebouncedSearch() { + this.debouncedSearch = debounce(this.getSearchResults, this.DEBOUNCE_TIME); + } + + getSearchResults(text) { + // this.showSearchResult(text); + + // call api to get search results + axios.get('/api/search', { + params: { + text: text + } + }) + .then(this.showSearchResult.bind(this)); + } + + showSearchResult({ data }) { + console.log(data); + + this.nodes.searchResultsWrapper.innerHTML = ''; + + + data.result.pages.forEach(page => { + const result = document.createElement('a'); + result.classList.add('search-results-item'); + result.setAttribute('href', `/${page.uri}`); + + const title = document.createElement('div'); + title.classList.add('search-results-item__title'); + title.innerHTML = page.title; + result.appendChild(title); + + // const description = document.createElement('div'); + // description.classList.add('search-results-item__description'); + // result.appendChild(description); + + this.nodes.searchResultsWrapper.appendChild(result); + }); + } +} diff --git a/src/frontend/styles/components/search.pcss b/src/frontend/styles/components/search.pcss new file mode 100644 index 0000000..72eb856 --- /dev/null +++ b/src/frontend/styles/components/search.pcss @@ -0,0 +1,78 @@ +.search { + &-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + z-index: 999; + display: none; + overflow-y: scroll; + + cursor: pointer; + + &--visible { + display: block; + } + } + + &-wrapper { + background: #fff; + margin: 30vh auto 10vh; + max-width: var(--layout-width-main-col); + width: 100%; + + @apply --squircle; + } + + &-input { + width: 100%; + background: #bbb; + padding: 15px 25px; + + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + border: none; + outline: none; + background-color: transparent; + + &::placeholder { + font-weight: 500; + } + } + + &-results { + &-wrapper { + + } + + &-item { + padding: 15px 25px; + display: block; + + &:nth-child(odd) { + background: #f5f5f5; + } + + &:hover { + background: #ddd; + } + + &__title { + font-weight: 500; + } + + &__description { + } + } + } + +} + +.noscroll { + overflow: hidden; +} + + diff --git a/src/frontend/styles/main.pcss b/src/frontend/styles/main.pcss index 13fafb3..cebcb1c 100644 --- a/src/frontend/styles/main.pcss +++ b/src/frontend/styles/main.pcss @@ -12,6 +12,7 @@ @import './components/sidebar.pcss'; @import './components/navigator.pcss'; @import './components/table-of-content.pcss'; +@import './components/search.pcss'; body { font-family: system-ui, Helvetica, Arial, Verdana; diff --git a/yarn.lock b/yarn.lock index f25e47b..d304f4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -805,6 +805,11 @@ version "1.0.0" resolved "https://registry.yarnpkg.com/@codexteam/misprints/-/misprints-1.0.0.tgz#e5a7dec7389fe0f176cd51a040d6dc9bdc252086" +"@codexteam/shortcuts@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@codexteam/shortcuts/-/shortcuts-1.2.0.tgz#b8dd7396962b0bd845a5c8f8f19bc6119b520e19" + integrity sha512-Udb8lkwhXEiPoLm7krtUv2f8jYQTutHxsLecmsMvMbOxMJ49LA/EUUzn8Fo32mxOFrI7qozOovspLhHb+y60nQ== + "@cspotcode/source-map-consumer@0.8.0": version "0.8.0" resolved "https://registry.yarnpkg.com/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz#33bf4b7b39c178821606f669bbc447a6a629786b" @@ -1698,6 +1703,14 @@ axios@^0.21.1: dependencies: follow-redirects "^1.14.0" +axios@^0.27.2: + version "0.27.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" + integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== + dependencies: + follow-redirects "^1.14.9" + form-data "^4.0.0" + babel-eslint@^10.0.1: version "10.1.0" resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232" @@ -3057,7 +3070,7 @@ flatten@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b" -follow-redirects@^1.14.0: +follow-redirects@^1.14.0, follow-redirects@^1.14.9: version "1.15.1" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== @@ -3089,6 +3102,15 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + formidable@^1.2.0: version "1.2.6" resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.6.tgz#d2a51d60162bbc9b4a055d8457a7c75315d1a168" @@ -3247,18 +3269,6 @@ growl@1.10.5: version "1.10.5" resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" -handlebars@^4.1.0: - version "4.7.7" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" - integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== - dependencies: - minimist "^1.2.5" - neo-async "^2.6.0" - source-map "^0.6.1" - wordwrap "^1.0.0" - optionalDependencies: - uglify-js "^3.1.4" - has-bigints@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" @@ -3305,6 +3315,11 @@ hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + htmlparser2@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" @@ -3920,7 +3935,7 @@ make-dir@^1.3.0: dependencies: pify "^3.0.0" -make-dir@^2.0.0: +make-dir@^2.0.0, make-dir@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" dependencies: @@ -4147,7 +4162,7 @@ negotiator@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" -neo-async@^2.6.0, neo-async@^2.6.2: +neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== @@ -5597,7 +5612,7 @@ supports-color@^5.3.0, supports-color@^5.4.0: dependencies: has-flag "^3.0.0" -supports-color@^6.0.0: +supports-color@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== @@ -5829,11 +5844,6 @@ typescript@^4.3.5: version "4.6.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.3.tgz#eefeafa6afdd31d725584c67a0eaba80f6fc6c6c" -uglify-js@^3.1.4: - version "3.16.3" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.16.3.tgz#94c7a63337ee31227a18d03b8a3041c210fd1f1d" - integrity sha512-uVbFqx9vvLhQg0iBaau9Z75AxWJ8tqM9AV890dIZCLApF4rTcyHwmAvLeEdYRs+BzYWu8Iw81F79ah0EfTXbaw== - uid-safe@2.1.5: version "2.1.5" resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" @@ -6044,11 +6054,6 @@ word-wrap@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" -wordwrap@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" - integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== - wrap-ansi@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" From 794067c663e119437806ded0af601c303034d9f6 Mon Sep 17 00:00:00 2001 From: Taly Date: Thu, 11 Aug 2022 19:26:21 +0300 Subject: [PATCH 02/13] add blur --- src/frontend/js/modules/search.js | 17 ++++++++++++----- src/frontend/styles/components/search.pcss | 5 ++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/frontend/js/modules/search.js b/src/frontend/js/modules/search.js index fe120f1..748908b 100644 --- a/src/frontend/js/modules/search.js +++ b/src/frontend/js/modules/search.js @@ -92,6 +92,10 @@ export default class Search { this.nodes.overlay.classList.toggle('search-overlay--visible', this.isVisible); document.body.classList.toggle('noscroll', this.isVisible); + try { + document.getElementsByClassName('docs')[0].classList.toggle('blurred', this.isVisible); + } catch (e) {} + this.nodes.searchInput.focus(); } @@ -100,9 +104,11 @@ export default class Search { } getSearchResults(text) { - // this.showSearchResult(text); + if (!text) { + this.clearSearchResults(); + return; + } - // call api to get search results axios.get('/api/search', { params: { text: text @@ -111,11 +117,12 @@ export default class Search { .then(this.showSearchResult.bind(this)); } - showSearchResult({ data }) { - console.log(data); - + clearSearchResults() { this.nodes.searchResultsWrapper.innerHTML = ''; + } + showSearchResult({ data }) { + this.clearSearchResults(); data.result.pages.forEach(page => { const result = document.createElement('a'); diff --git a/src/frontend/styles/components/search.pcss b/src/frontend/styles/components/search.pcss index 72eb856..3035252 100644 --- a/src/frontend/styles/components/search.pcss +++ b/src/frontend/styles/components/search.pcss @@ -6,7 +6,6 @@ width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); - z-index: 999; display: none; overflow-y: scroll; @@ -75,4 +74,8 @@ overflow: hidden; } +.blurred { + filter: blur(2px); +} + From bab89c979f0acb6816c32a4408a6533e3b0beff6 Mon Sep 17 00:00:00 2001 From: Taly Date: Thu, 11 Aug 2022 19:33:27 +0300 Subject: [PATCH 03/13] update z-index --- src/frontend/js/modules/search.js | 1 + src/frontend/styles/components/search.pcss | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/frontend/js/modules/search.js b/src/frontend/js/modules/search.js index 748908b..2fd2ee9 100644 --- a/src/frontend/js/modules/search.js +++ b/src/frontend/js/modules/search.js @@ -93,6 +93,7 @@ export default class Search { document.body.classList.toggle('noscroll', this.isVisible); try { + document.getElementsByClassName('docs-header')[0].classList.toggle('blurred', this.isVisible); document.getElementsByClassName('docs')[0].classList.toggle('blurred', this.isVisible); } catch (e) {} diff --git a/src/frontend/styles/components/search.pcss b/src/frontend/styles/components/search.pcss index 3035252..1d47bc2 100644 --- a/src/frontend/styles/components/search.pcss +++ b/src/frontend/styles/components/search.pcss @@ -9,6 +9,8 @@ display: none; overflow-y: scroll; + z-index: 10; + cursor: pointer; &--visible { From cb877398a6d6d6da50182073d592440797a49f41 Mon Sep 17 00:00:00 2001 From: Taly Date: Mon, 15 Aug 2022 18:53:57 +0300 Subject: [PATCH 04/13] enable search for cyrillic letters --- src/backend/controllers/search.ts | 63 ++++++++--- src/backend/routes/api/search.ts | 3 +- src/frontend/js/modules/search.js | 125 +++++++++++++++------ src/frontend/styles/components/search.pcss | 23 +++- 4 files changed, 163 insertions(+), 51 deletions(-) diff --git a/src/backend/controllers/search.ts b/src/backend/controllers/search.ts index b766280..fdb169d 100644 --- a/src/backend/controllers/search.ts +++ b/src/backend/controllers/search.ts @@ -1,12 +1,6 @@ import Page from '../models/page'; import Pages from '../controllers/pages'; -type SearchResponse = { - completions: string[]; - - pages: Page[]; -} - class Search { /** * Prepare words database @@ -23,7 +17,7 @@ class Search { page.body.blocks.forEach((block: any) => { let blockContent = ''; - const validBlocks = ['header', 'paragraph']; + const validBlocks = ['header', 'paragraph', 'list']; if (!validBlocks.includes(block.type)) { return; } @@ -36,17 +30,21 @@ class Search { case 'paragraph': blockContent = block.data.text break; + + case 'list': + blockContent = block.data.items.join(' '); + break; } const blockWords: string[] = blockContent // @todo get text from inline code elements and remove html tags - // left only letters and numbers - .replace(/[^a-z0-9]/gi, ' ') - // lowercase all words .toLowerCase() + // left only letters (+cyrillic) and numbers + .replace(/[^a-zа-я0-9]/gi, ' ') + // remove multiple spaces .replace(/\s+/g, ' ') @@ -67,7 +65,7 @@ class Search { return pagesWords; } - public async query(searchString: string): Promise { + public async query(searchString: string) { const pages = await Pages.getAll(); const pagesWords = await this.index(); @@ -95,10 +93,47 @@ class Search { }); const foundPages = goodPages - .filter(({ successRatio }) => successRatio > 50) + .filter(({ successRatio }) => successRatio > 75) .sort((a, b) => b.successRatio - a.successRatio) .slice(0, 10); + const returnPages = pages.filter(page => foundPages.some(({ id }) => id === page._id)) + .map(page => { + let shortBody = '...'; + let score = 1; + + page.body.blocks.forEach((block: any) => { + let blockContent = ''; + + switch (block.type) { + case 'header': + blockContent = block.data.text; + break; + + // case 'paragraph': + // blockContent = block.data.text + // break; + // + // case 'list': + // blockContent = block.data.items.join(' '); + // break; + } + + searchWords.forEach(word => { + blockContent = blockContent.replace(word, `${word}`); + }) + + // shortBody += blockContent; + }); + + return { + ...page, + shortBody + }; + }); + + + // --------- START test --------- @@ -113,8 +148,8 @@ class Search { return { - completions: uniqWords.filter(word => word.indexOf(searchWords.slice(-1)[0]) === 0), - pages: pages.filter(page => foundPages.some(({ id }) => id === page._id)) + suggestions: uniqWords.filter(word => word.indexOf(searchWords.slice(-1)[0]) === 0), + pages: returnPages } } diff --git a/src/backend/routes/api/search.ts b/src/backend/routes/api/search.ts index a0f8b17..0ece01a 100644 --- a/src/backend/routes/api/search.ts +++ b/src/backend/routes/api/search.ts @@ -32,13 +32,14 @@ router.get('/search', async (req: Request, res: Response) => { uri: page.uri, // body: page.body, // parent: page.parent, + shortBody: page.shortBody, }; }); res.json({ success: true, result: { - completions: searchResponse.completions, + suggestions: searchResponse.suggestions, pages: compactedPages, time: searchItem, }, diff --git a/src/frontend/js/modules/search.js b/src/frontend/js/modules/search.js index 2fd2ee9..ddf9d93 100644 --- a/src/frontend/js/modules/search.js +++ b/src/frontend/js/modules/search.js @@ -2,23 +2,44 @@ import { debounce } from '../utils/decorators'; import Shortcut from '@codexteam/shortcuts'; import axios from 'axios'; - export default class Search { constructor() { this.nodes = { overlay: null, searchWrapper: null, searchInput: null, - searchResultsWrapper: null + searchResultWrapper: null }; this.isVisible = false; - this.shortcut = null; - this.TOGGLER_SHORTCUT = 'CMD+SHIFT+F'; + this.PLACEHOLDER = 'Find in documents...'; + this.TOGGLER_SHORTCUT = 'CMD+SHIFT+F'; + this.shortcut = null; + + this.DEBOUNCE_TIME = 300; this.debouncedSearch = null; - this.DEBOUNCE_TIME = 500; + + this.MIN_SEARCH_LENGTH = 1; + + this.CSS = { + overlay: 'search-overlay', + overlayVisible: 'search-overlay--visible', + searchWrapper: 'search-wrapper', + searchInput: 'search-input', + searchResultWrapper: 'search-result-wrapper', + + searchResultSuggestions: 'search-result-suggestions', + searchResultSuggestionItem: 'search-result-suggestions-item', + + searchResultItem: 'search-result-item', + searchResultItemTitle: 'search-result-item__title', + searchResultItemDescription: 'search-result-item__description', + + blur: 'blurred', + noscroll: 'noscroll' + }; } init(settings = {}, moduleEl) { @@ -30,27 +51,30 @@ export default class Search { // ! force open search overlay // this.toggleSearchOverlay(true); + // const testString = 'api'; + // this.nodes.searchInput.value = testString; + // this.debouncedSearch(testString); } createSearchOverlay() { this.nodes.overlay = document.createElement('div'); - this.nodes.overlay.classList.add('search-overlay'); + this.nodes.overlay.classList.add(this.CSS.overlay); this.nodes.overlay.addEventListener('click', this.searchOverlayClickProcessor.bind(this)); this.nodes.searchWrapper = document.createElement('div'); - this.nodes.searchWrapper.classList.add('search-wrapper'); + this.nodes.searchWrapper.classList.add(this.CSS.searchWrapper); this.nodes.searchInput = document.createElement('input'); - this.nodes.searchInput.classList.add('search-input'); + this.nodes.searchInput.classList.add(this.CSS.searchInput); this.nodes.searchInput.setAttribute('type', 'search'); - this.nodes.searchInput.setAttribute('placeholder', 'Find in documents...'); + this.nodes.searchInput.setAttribute('placeholder', this.PLACEHOLDER); this.nodes.searchInput.setAttribute('autocomplete', 'off'); this.nodes.searchInput.addEventListener('input', this.searchInputOnchangeProcessor.bind(this)); this.nodes.searchWrapper.appendChild(this.nodes.searchInput); - this.nodes.searchResultsWrapper = document.createElement('div'); - this.nodes.searchResultsWrapper.classList.add('search-results-wrapper'); - this.nodes.searchWrapper.appendChild(this.nodes.searchResultsWrapper); + this.nodes.searchResultWrapper = document.createElement('div'); + this.nodes.searchResultWrapper.classList.add(this.CSS.searchResultWrapper); + this.nodes.searchWrapper.appendChild(this.nodes.searchResultWrapper); this.nodes.overlay.appendChild(this.nodes.searchWrapper); document.body.appendChild(this.nodes.overlay); @@ -65,15 +89,14 @@ export default class Search { } searchInputOnchangeProcessor(event) { - // close search overlay if ESC key is pressed - if (event.keyCode === 27) { - this.toggleSearchOverlay(false); - event.preventDefault(); + const text = event.target.value; + + if (text.length < this.MIN_SEARCH_LENGTH) { + this.clearSearchResults(); + return; } - console.log(event.target.value); - - this.debouncedSearch(event.target.value); + this.debouncedSearch(text); } enableShortcutListening() { @@ -89,12 +112,13 @@ export default class Search { toggleSearchOverlay(force) { this.isVisible = force || !this.isVisible; - this.nodes.overlay.classList.toggle('search-overlay--visible', this.isVisible); - document.body.classList.toggle('noscroll', this.isVisible); + this.nodes.overlay.classList.toggle(this.CSS.overlayVisible, this.isVisible); + document.body.classList.toggle(this.CSS.noscroll, this.isVisible); + // blur everything except search overlay try { - document.getElementsByClassName('docs-header')[0].classList.toggle('blurred', this.isVisible); - document.getElementsByClassName('docs')[0].classList.toggle('blurred', this.isVisible); + document.getElementsByClassName('docs-header')[0].classList.toggle(this.CSS.blurred, this.isVisible); + document.getElementsByClassName('docs')[0].classList.toggle(this.CSS.blurred, this.isVisible); } catch (e) {} this.nodes.searchInput.focus(); @@ -105,41 +129,74 @@ export default class Search { } getSearchResults(text) { - if (!text) { - this.clearSearchResults(); - return; - } axios.get('/api/search', { params: { - text: text + text } }) .then(this.showSearchResult.bind(this)); } clearSearchResults() { - this.nodes.searchResultsWrapper.innerHTML = ''; + this.nodes.searchResultWrapper.innerHTML = ''; } showSearchResult({ data }) { this.clearSearchResults(); + // const suggestionsWrapper = this.generateSearchSuggestions(data.result.suggestions); + // + // this.nodes.searchResultWrapper.appendChild(suggestionsWrapper); + data.result.pages.forEach(page => { const result = document.createElement('a'); - result.classList.add('search-results-item'); + result.classList.add(this.CSS.searchResultItem); result.setAttribute('href', `/${page.uri}`); const title = document.createElement('div'); - title.classList.add('search-results-item__title'); + title.classList.add(this.CSS.searchResultItemTitle); title.innerHTML = page.title; result.appendChild(title); - // const description = document.createElement('div'); - // description.classList.add('search-results-item__description'); + const description = document.createElement('div'); + description.classList.add(this.CSS.searchResultItemDescription); + description.innerHTML = `${page.shortBody}`; // result.appendChild(description); - this.nodes.searchResultsWrapper.appendChild(result); + this.nodes.searchResultWrapper.appendChild(result); }); } + + // generateSearchSuggestions(suggestions = []) { + // const suggestionsWrapper = document.createElement('div'); + // + // suggestionsWrapper.classList.add(this.CSS.searchResultSuggestions); + // + // suggestions.forEach(suggestion => { + // const suggestionItem = document.createElement('span'); + // + // suggestionItem.classList.add(this.CSS.searchResultSuggestionItem); + // suggestionItem.innerHTML = suggestion; + // suggestionItem.addEventListener('click', this.searchSuggestionClickProcessor.bind(this)); + // + // suggestionsWrapper.appendChild(suggestionItem); + // }); + // + // return suggestionsWrapper; + // } + // + // searchSuggestionClickProcessor(event) { + // const word = event.target.innerHTML; + // + // const searchString = this.nodes.searchInput.value; + // const searchStringWords = searchString.split(' '); + // + // searchStringWords.pop(); + // searchStringWords.push(word); + // + // this.nodes.searchInput.value = searchStringWords.join(' '); + // + // this.debouncedSearch(this.nodes.searchInput.value); + // } } diff --git a/src/frontend/styles/components/search.pcss b/src/frontend/styles/components/search.pcss index 1d47bc2..5a71629 100644 --- a/src/frontend/styles/components/search.pcss +++ b/src/frontend/styles/components/search.pcss @@ -44,11 +44,29 @@ } } - &-results { + &-result { &-wrapper { } + &-suggestions { + padding: 5px 25px; + overflow-x: scroll; + + &-item { + margin-right: 12px; + + padding: 2px 8px; + background: #ccedf6; + border-radius: 50px; + border: 1px solid #b5d8e2; + + &:hover { + background: #aad4e0; + } + } + } + &-item { padding: 15px 25px; display: block; @@ -66,6 +84,7 @@ } &__description { + margin-top: 8px; } } } @@ -77,7 +96,7 @@ } .blurred { - filter: blur(2px); + filter: blur(3px); } From 03370dd06639694b83eac3e000f0df0e44fdc627 Mon Sep 17 00:00:00 2001 From: Taly Date: Tue, 23 Aug 2022 14:12:15 +0300 Subject: [PATCH 05/13] add short body --- src/backend/controllers/search.ts | 68 ++++++++++++++++------ src/backend/routes/api/search.ts | 2 + src/frontend/js/modules/search.js | 15 ++++- src/frontend/styles/components/search.pcss | 14 +++++ yarn.lock | 2 +- 5 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/backend/controllers/search.ts b/src/backend/controllers/search.ts index fdb169d..af7a998 100644 --- a/src/backend/controllers/search.ts +++ b/src/backend/controllers/search.ts @@ -34,10 +34,13 @@ class Search { case 'list': blockContent = block.data.items.join(' '); break; + + default: + return; } const blockWords: string[] = blockContent - // @todo get text from inline code elements and remove html tags + .replace(/<[^>]*>?/gm, '') // lowercase all words .toLowerCase() @@ -99,36 +102,59 @@ class Search { const returnPages = pages.filter(page => foundPages.some(({ id }) => id === page._id)) .map(page => { - let shortBody = '...'; - let score = 1; + let shortBody = ''; + let flag = false; + let section = ''; + let ratio = 0; page.body.blocks.forEach((block: any) => { + if (flag) return; + let blockContent = ''; switch (block.type) { case 'header': blockContent = block.data.text; + ratio = 1; + section = blockContent; break; - // case 'paragraph': - // blockContent = block.data.text - // break; - // - // case 'list': - // blockContent = block.data.items.join(' '); - // break; + case 'paragraph': + blockContent = block.data.text + ratio = 0.5; + break; + + case 'list': + blockContent = block.data.items.join(' '); + ratio = 0.5; + break; + + default: + return; } + blockContent = blockContent + .replace(/<[^>]*>?/gm, ''); + // .toLowerCase(); + searchWords.forEach(word => { - blockContent = blockContent.replace(word, `${word}`); + // blockContent = blockContent.replace(word, `${word}`); + if (flag) return; + + if (blockContent.toLowerCase().indexOf(word) !== -1) { + + shortBody = this.highlightSubstring(blockContent, word); + flag = true; + } }) - // shortBody += blockContent; }); return { ...page, - shortBody + shortBody, + anchor: section.replace(/\s+/g, '-').toLowerCase(), + section, }; }); @@ -136,12 +162,12 @@ class Search { // --------- START test --------- - + // const uniqWords = [...new Set(pagesWords.flatMap(page => page.words))].sort(); - - uniqWords.forEach(word => { - console.log(word); - }) + // + // uniqWords.forEach(word => { + // console.log(word); + // }) // --------- END test --------- @@ -158,6 +184,12 @@ class Search { return pages; } + + private highlightSubstring(text: string, word: string) { + const wordRegExp = new RegExp(word, "ig"); + + return text.replace(wordRegExp, '$&'); + } } export default Search; diff --git a/src/backend/routes/api/search.ts b/src/backend/routes/api/search.ts index 0ece01a..aea5303 100644 --- a/src/backend/routes/api/search.ts +++ b/src/backend/routes/api/search.ts @@ -32,6 +32,8 @@ router.get('/search', async (req: Request, res: Response) => { uri: page.uri, // body: page.body, // parent: page.parent, + section: page.section, + anchor: page.anchor, shortBody: page.shortBody, }; }); diff --git a/src/frontend/js/modules/search.js b/src/frontend/js/modules/search.js index ddf9d93..21fff76 100644 --- a/src/frontend/js/modules/search.js +++ b/src/frontend/js/modules/search.js @@ -35,6 +35,7 @@ export default class Search { searchResultItem: 'search-result-item', searchResultItemTitle: 'search-result-item__title', + searchResultItemSection: 'search-result-item__section', searchResultItemDescription: 'search-result-item__description', blur: 'blurred', @@ -122,6 +123,7 @@ export default class Search { } catch (e) {} this.nodes.searchInput.focus(); + this.nodes.searchInput.select(); } createDebouncedSearch() { @@ -150,19 +152,28 @@ export default class Search { // this.nodes.searchResultWrapper.appendChild(suggestionsWrapper); data.result.pages.forEach(page => { + const url = `/${page.uri}` + (page.section ? `#${page.anchor}` : ''); + const result = document.createElement('a'); result.classList.add(this.CSS.searchResultItem); - result.setAttribute('href', `/${page.uri}`); + result.setAttribute('href', url); const title = document.createElement('div'); title.classList.add(this.CSS.searchResultItemTitle); title.innerHTML = page.title; result.appendChild(title); + if (page.section !== page.title) { + const section = document.createElement('span'); + section.classList.add(this.CSS.searchResultItemSection); + section.innerHTML = `${page.section}`; + title.appendChild(section); + } + const description = document.createElement('div'); description.classList.add(this.CSS.searchResultItemDescription); description.innerHTML = `${page.shortBody}`; - // result.appendChild(description); + result.appendChild(description); this.nodes.searchResultWrapper.appendChild(result); }); diff --git a/src/frontend/styles/components/search.pcss b/src/frontend/styles/components/search.pcss index 5a71629..835528e 100644 --- a/src/frontend/styles/components/search.pcss +++ b/src/frontend/styles/components/search.pcss @@ -83,12 +83,26 @@ font-weight: 500; } + &__section { + opacity: 0.7; + + &:before { + content: '•'; + margin: 0 5px; + text-align: center; + } + } + &__description { margin-top: 8px; + line-height: 1.5; } } } + &-word { + color: var(--color-link-active); + } } .noscroll { diff --git a/yarn.lock b/yarn.lock index d304f4c..ebd51a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4183,7 +4183,7 @@ nise@^5.1.0: node-cache@^5.1.2: version "5.1.2" - resolved "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz" + resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== dependencies: clone "2.x" From 0cb69545dbb1b5a606c92665892e5a71ff4624ec Mon Sep 17 00:00:00 2001 From: Taly Date: Fri, 26 Aug 2022 15:08:36 +0300 Subject: [PATCH 06/13] update logic --- src/backend/controllers/search.ts | 407 ++++++++++++--------- src/backend/routes/api/search.ts | 3 +- src/backend/views/pages/page.twig | 2 +- src/frontend/js/modules/search.js | 17 +- src/frontend/styles/components/search.pcss | 9 +- 5 files changed, 258 insertions(+), 180 deletions(-) diff --git a/src/backend/controllers/search.ts b/src/backend/controllers/search.ts index af7a998..028019d 100644 --- a/src/backend/controllers/search.ts +++ b/src/backend/controllers/search.ts @@ -1,194 +1,263 @@ -import Page from '../models/page'; +import PageData from '../models/page'; import Pages from '../controllers/pages'; +import urlify from '../utils/urlify'; +import Page from '../models/page'; class Search { - /** - * Prepare words database - */ - public async index() { - /** - * Prepare pages content for the search - * @todo - it should be done in the background - */ - const pages = await Pages.getAll(); - const pagesWords = pages.map(page => { - const pageWords: string[] = []; + private words: { [key: string]: {[key: string]: number} } = Object.create(null); + private pages: PageData[] = []; - page.body.blocks.forEach((block: any) => { - let blockContent = ''; - - const validBlocks = ['header', 'paragraph', 'list']; - if (!validBlocks.includes(block.type)) { - return; - } - - switch (block.type) { - case 'header': - blockContent = block.data.text; - break; - - case 'paragraph': - blockContent = block.data.text - break; - - case 'list': - blockContent = block.data.items.join(' '); - break; - - default: - return; - } - - const blockWords: string[] = blockContent - .replace(/<[^>]*>?/gm, '') - - // lowercase all words - .toLowerCase() - - // left only letters (+cyrillic) and numbers - .replace(/[^a-zа-я0-9]/gi, ' ') - - // remove multiple spaces - .replace(/\s+/g, ' ') - - // split to words by spaces - .split(' '); - - pageWords.push(...blockWords); - }); - - const uniqueWords = [...new Set(pageWords)].sort(); - - return { - id: page._id, - words: uniqueWords - }; - }); - - return pagesWords; - } - - public async query(searchString: string) { - const pages = await Pages.getAll(); - const pagesWords = await this.index(); + public async init() { + this.pages = await this.getPages(); /** - * Search itself + * Process all pages */ - const searchWords = searchString.toLowerCase().split(' '); - const goodPages = pagesWords.map(({ id, words}) => { - const foundWords = searchWords.filter( - word => { - return words.filter( - testWord => { - return testWord.indexOf(word) === 0 - } - ).length > 0; - } - ); + for await (const page of this.pages) { - const successRatio = foundWords.length / searchWords.length * 100; + // if (page._id && !this.pages[page._id]) { + // this.pages[page._id] = []; + // } - return { - id, - successRatio - } - }); + /** + * Read content blocks from page + */ + for await (const block of page.body.blocks) { + const blockRatio = this.getBlockRatio(block); + const blockContent = this.getCleanTextFromBlock(block); + const blockWords: string[] = this.splitTextToWords(blockContent); - const foundPages = goodPages - .filter(({ successRatio }) => successRatio > 75) - .sort((a, b) => b.successRatio - a.successRatio) - .slice(0, 10); + // if (page._id) { + // this.pages[page._id].push(...blockWords); + // } - const returnPages = pages.filter(page => foundPages.some(({ id }) => id === page._id)) - .map(page => { - let shortBody = ''; - let flag = false; - let section = ''; - let ratio = 0; - - page.body.blocks.forEach((block: any) => { - if (flag) return; - - let blockContent = ''; - - switch (block.type) { - case 'header': - blockContent = block.data.text; - ratio = 1; - section = blockContent; - break; - - case 'paragraph': - blockContent = block.data.text - ratio = 0.5; - break; - - case 'list': - blockContent = block.data.items.join(' '); - ratio = 0.5; - break; - - default: - return; + /** + * Process list of words in a block + */ + for await (const word of blockWords) { + if (!this.words[word]) { + this.words[word] = Object.create(null); } - blockContent = blockContent - .replace(/<[^>]*>?/gm, ''); - // .toLowerCase(); - - searchWords.forEach(word => { - // blockContent = blockContent.replace(word, `${word}`); - if (flag) return; - - if (blockContent.toLowerCase().indexOf(word) !== -1) { - - shortBody = this.highlightSubstring(blockContent, word); - flag = true; + if (page._id) { + if (!this.words[word][page._id]) { + this.words[word][page._id] = 0; } - }) - }); - - return { - ...page, - shortBody, - anchor: section.replace(/\s+/g, '-').toLowerCase(), - section, - }; - }); - - - - - // --------- START test --------- - // - const uniqWords = [...new Set(pagesWords.flatMap(page => page.words))].sort(); - // - // uniqWords.forEach(word => { - // console.log(word); - // }) - - // --------- END test --------- - - - - return { - suggestions: uniqWords.filter(word => word.indexOf(searchWords.slice(-1)[0]) === 0), - pages: returnPages + /** + * Add page id to the list of pages with this word + */ + this.words[word][page._id] += blockRatio; + } + } + } } } - private async search(searchString: string) { - const pages = await this.query(searchString); + public async query(searchString: string) { + try { + await this.init(); + } catch (error) { + console.log(error); + throw error; + } - return pages; + const searchWords = searchString + .trim() + .toLowerCase() + .replace(/\s+/g, ' ') + .split(' '); + + const goodPages = (await this.getPagesByWords(searchWords)) + .slice(0, 10); + + const returnPages: {[key: string]: string|number, ratio: number}[] = []; + + goodPages.forEach(({ pageId, ratio }) => { + const page = this.pages.filter(page => page._id === pageId).pop(); + + if (!page) { + return; + } + + let section = ''; + + page.body.blocks.forEach((block: any) => { + let koef = 0; + + let blockContent = this.getCleanTextFromBlock(block); + + let shortBody = blockContent; + + if (block.type === 'header') { + section = blockContent; + } + + searchWords.forEach(word => { + if (blockContent.toLowerCase().indexOf(word) !== -1) { + koef += 1; + } + }) + + shortBody = this.highlightSubstring(shortBody, searchWords); + + if (koef > 0) { + returnPages.push({ + ...page, + shortBody, + anchor: urlify(section), + section, + ratio: ratio * koef, + }) + } + }); + }); + + // // --------- START test --------- + // // + // const uniqWords = [...new Set(pagesWords.flatMap(page => page.words))].sort(); + // // + // // uniqWords.forEach(word => { + // // console.log(word); + // // }) + // + // // --------- END test --------- + + // console.log('RESULT') + // returnPages.forEach(page => { + // console.log(page); + // }); + // + // return { + // suggestions: uniqWords.filter(word => word.indexOf(searchWords.slice(-1)[0]) === 0), + // pages: returnPages + // } + + + return { + suggestions: [], + pages: returnPages + .sort((a, b) => b.ratio - a.ratio) + .slice(0, 15) + } } - private highlightSubstring(text: string, word: string) { - const wordRegExp = new RegExp(word, "ig"); + private async getPages() { + return await Pages.getAll(); + } - return text.replace(wordRegExp, '$&'); + private async getPagesByWords(words: string[]) { + const pagesList: {[key: string]: number} = {}; + + Object.keys(this.words) + .filter(word => words.indexOf(word) !== -1) + .forEach(word => { + Object.keys(this.words[word]) + .forEach(pageId => { + if (!pagesList[pageId]) { + pagesList[pageId] = 0; + } + + pagesList[pageId] += this.words[word][pageId] + }) + }) + + const sortedPagesList = Object.keys(pagesList) + .map(pageId => { + return { + pageId, + ratio: pagesList[pageId] + } + }) + .sort((a, b) => b.ratio - a.ratio); + + return sortedPagesList; + } + + private getUnique(elements: string[]) { + return [...new Set(elements)].sort(); + } + + private getBlockRatio(block: any) { + switch (block.type) { + case 'header': + return 6; + case 'paragraph': + return 2; + case 'list': + return 1; + default: + return 0; + } + } + + private getCleanTextFromBlock(block: any): string { + let blockContent = ''; + + switch (block.type) { + case 'header': + blockContent = block.data.text; + break; + + case 'paragraph': + blockContent = block.data.text + break; + + case 'list': + blockContent = block.data.items.join(' '); + break; + + default: + return blockContent; + } + + blockContent = this.removeHTMLTags(blockContent); + blockContent = this.removeHTMLSpecialCharacters(blockContent); + + return blockContent; + } + + private removeHTMLTags(text: string) { + return text.replace(/<[^>]*>?/gm, ''); + } + + private removeHTMLSpecialCharacters(text: string) { + return text.replace(/&[^;]*;?/gm, ''); + } + + private splitTextToWords(text: string): string[] { + return text + // lowercase all words + .toLowerCase() + + // remove punctuation + .replace(/[.,;:]/gi, '') + + // left only letters (+cyrillic) and numbers + .replace(/[^a-zа-я0-9]/gi, ' ') + + // remove multiple spaces + .replace(/\s+/g, ' ') + + // split to words by spaces + .split(' ') + + // ignore words shorter than 3 chars + .filter(word => word.length >= 3); + } + + /** + * Highlight substring in string with a span wrapper + */ + private highlightSubstring(text: string, words: string|string[]) { + if (typeof words === 'string') { + words = [words]; + } + + const wordRegExp = new RegExp(words.join('|'), "ig"); + const CLASS_STYLE = 'search-word'; + + return text.replace(wordRegExp, `$&`); } } diff --git a/src/backend/routes/api/search.ts b/src/backend/routes/api/search.ts index aea5303..d9b50af 100644 --- a/src/backend/routes/api/search.ts +++ b/src/backend/routes/api/search.ts @@ -16,6 +16,7 @@ router.get('/search', async (req: Request, res: Response) => { const startTime = performance.now(); const search = new Search(); + const searchResponse = await search.query(searchString); /** End measuring search time */ @@ -30,8 +31,6 @@ router.get('/search', async (req: Request, res: Response) => { _id: page._id, title: page.title, uri: page.uri, - // body: page.body, - // parent: page.parent, section: page.section, anchor: page.anchor, shortBody: page.shortBody, diff --git a/src/backend/views/pages/page.twig b/src/backend/views/pages/page.twig index 58172d3..c5f9986 100644 --- a/src/backend/views/pages/page.twig +++ b/src/backend/views/pages/page.twig @@ -23,7 +23,7 @@ {% include 'components/button.twig' with {label: 'Edit', icon: 'pencil', size: 'small', url: '/page/edit/' ~ page._id, class: 'page__header-button'} %} {% endif %} -

+

{{ page.title }}

{% if (config.carbon and config.carbon.placement and config.carbon.serve) %} diff --git a/src/frontend/js/modules/search.js b/src/frontend/js/modules/search.js index 21fff76..034463a 100644 --- a/src/frontend/js/modules/search.js +++ b/src/frontend/js/modules/search.js @@ -13,7 +13,7 @@ export default class Search { this.isVisible = false; - this.PLACEHOLDER = 'Find in documents...'; + this.PLACEHOLDER = 'Search docs'; this.TOGGLER_SHORTCUT = 'CMD+SHIFT+F'; this.shortcut = null; @@ -38,7 +38,7 @@ export default class Search { searchResultItemSection: 'search-result-item__section', searchResultItemDescription: 'search-result-item__description', - blur: 'blurred', + blurred: 'blurred', noscroll: 'noscroll' }; } @@ -50,9 +50,15 @@ export default class Search { this.createDebouncedSearch(); this.enableShortcutListening(); - // ! force open search overlay + /** + * Only for development needs: + * + * - force open overlay + * - type a search string + * - fire search + */ + // const testString = 'api method'; // this.toggleSearchOverlay(true); - // const testString = 'api'; // this.nodes.searchInput.value = testString; // this.debouncedSearch(testString); } @@ -157,13 +163,14 @@ export default class Search { const result = document.createElement('a'); result.classList.add(this.CSS.searchResultItem); result.setAttribute('href', url); + result.addEventListener('click', this.toggleSearchOverlay.bind(this, false)); const title = document.createElement('div'); title.classList.add(this.CSS.searchResultItemTitle); title.innerHTML = page.title; result.appendChild(title); - if (page.section !== page.title) { + if (page.section && page.section !== page.title) { const section = document.createElement('span'); section.classList.add(this.CSS.searchResultItemSection); section.innerHTML = `${page.section}`; diff --git a/src/frontend/styles/components/search.pcss b/src/frontend/styles/components/search.pcss index 835528e..69239c6 100644 --- a/src/frontend/styles/components/search.pcss +++ b/src/frontend/styles/components/search.pcss @@ -5,7 +5,7 @@ left: 0; width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.5); + background: rgba(0, 0, 0, 0.7); display: none; overflow-y: scroll; @@ -20,11 +20,14 @@ &-wrapper { background: #fff; - margin: 30vh auto 10vh; + margin: 30vh auto 20vh; max-width: var(--layout-width-main-col); width: 100%; - @apply --squircle; + border-radius: 8px; + box-shadow: 0px 0px 15px rgba(0, 0, 0, 0.1); + + overflow: hidden; } &-input { From 80e950438603df3716b3629ddc67b4b8419543fa Mon Sep 17 00:00:00 2001 From: Taly Date: Fri, 26 Aug 2022 15:10:51 +0300 Subject: [PATCH 07/13] Update search.ts --- src/backend/controllers/search.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/backend/controllers/search.ts b/src/backend/controllers/search.ts index 028019d..5e87948 100644 --- a/src/backend/controllers/search.ts +++ b/src/backend/controllers/search.ts @@ -1,7 +1,6 @@ -import PageData from '../models/page'; -import Pages from '../controllers/pages'; -import urlify from '../utils/urlify'; -import Page from '../models/page'; +import PageData from '../models/page.js'; +import Pages from '../controllers/pages.js'; +import urlify from '../utils/urlify.js'; class Search { private words: { [key: string]: {[key: string]: number} } = Object.create(null); From e237f27eb857b347e50eadeffcab6724987e3e12 Mon Sep 17 00:00:00 2001 From: Taly Date: Fri, 26 Aug 2022 15:10:53 +0300 Subject: [PATCH 08/13] Update search.ts --- src/backend/routes/api/search.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/backend/routes/api/search.ts b/src/backend/routes/api/search.ts index d9b50af..77dc408 100644 --- a/src/backend/routes/api/search.ts +++ b/src/backend/routes/api/search.ts @@ -1,5 +1,5 @@ import express, { Request, Response } from 'express'; -import Search from '../../controllers/search'; +import Search from '../../controllers/search.js'; const router = express.Router(); @@ -13,18 +13,18 @@ router.get('/search', async (req: Request, res: Response) => { const searchString = req.query.text as string; /** Start measuring search time */ - const startTime = performance.now(); + // const startTime = performance.now(); const search = new Search(); const searchResponse = await search.query(searchString); /** End measuring search time */ - const endTime = performance.now(); + // const endTime = performance.now(); /** Show search time */ - const searchItem = (endTime - startTime).toFixed(6); - console.log(`🔎 "${searchString}" ⏱ ${searchItem} ms`); + // const searchItem = (endTime - startTime).toFixed(6); + // console.log(`🔎 "${searchString}" ⏱ ${searchItem} ms`); const compactedPages = searchResponse.pages.map(page => { return { @@ -42,7 +42,7 @@ router.get('/search', async (req: Request, res: Response) => { result: { suggestions: searchResponse.suggestions, pages: compactedPages, - time: searchItem, + // time: searchItem, }, }); } catch (err) { From 6db7fc876c2789e4e45d804eaab7c010d905ef22 Mon Sep 17 00:00:00 2001 From: Taly Date: Fri, 26 Aug 2022 17:27:41 +0300 Subject: [PATCH 09/13] Update search.ts --- src/backend/controllers/search.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/backend/controllers/search.ts b/src/backend/controllers/search.ts index 5e87948..adc56f5 100644 --- a/src/backend/controllers/search.ts +++ b/src/backend/controllers/search.ts @@ -149,7 +149,9 @@ class Search { const pagesList: {[key: string]: number} = {}; Object.keys(this.words) - .filter(word => words.indexOf(word) !== -1) + .filter(word => { + return !!words.filter(searchWord => word.indexOf(searchWord) !== -1).length + }) .forEach(word => { Object.keys(this.words[word]) .forEach(pageId => { From e15a5e8a85d7af6b968367ae5cfebbe1afd0ad3a Mon Sep 17 00:00:00 2001 From: Taly Date: Tue, 30 Aug 2022 18:52:15 +0300 Subject: [PATCH 10/13] update --- src/backend/controllers/search.ts | 12 +++++------- src/frontend/js/modules/search.js | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/backend/controllers/search.ts b/src/backend/controllers/search.ts index adc56f5..f2f6566 100644 --- a/src/backend/controllers/search.ts +++ b/src/backend/controllers/search.ts @@ -61,11 +61,7 @@ class Search { throw error; } - const searchWords = searchString - .trim() - .toLowerCase() - .replace(/\s+/g, ' ') - .split(' '); + const searchWords = this.splitTextToWords(searchString); const goodPages = (await this.getPagesByWords(searchWords)) .slice(0, 10); @@ -182,9 +178,9 @@ class Search { private getBlockRatio(block: any) { switch (block.type) { case 'header': - return 6; + return 8; case 'paragraph': - return 2; + return 1.1; case 'list': return 1; default: @@ -240,6 +236,8 @@ class Search { // remove multiple spaces .replace(/\s+/g, ' ') + .trim() + // split to words by spaces .split(' ') diff --git a/src/frontend/js/modules/search.js b/src/frontend/js/modules/search.js index 034463a..21df71c 100644 --- a/src/frontend/js/modules/search.js +++ b/src/frontend/js/modules/search.js @@ -57,7 +57,7 @@ export default class Search { * - type a search string * - fire search */ - // const testString = 'api method'; + // const testString = 'access editor'; // this.toggleSearchOverlay(true); // this.nodes.searchInput.value = testString; // this.debouncedSearch(testString); From a5d6f17cb57aac309942609acea7bbe2dce78c0f Mon Sep 17 00:00:00 2001 From: Taly Date: Tue, 30 Aug 2022 19:00:07 +0300 Subject: [PATCH 11/13] Update search.ts --- src/backend/controllers/search.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/controllers/search.ts b/src/backend/controllers/search.ts index f2f6566..5c757af 100644 --- a/src/backend/controllers/search.ts +++ b/src/backend/controllers/search.ts @@ -46,7 +46,7 @@ class Search { /** * Add page id to the list of pages with this word */ - this.words[word][page._id] += blockRatio; + this.words[word][page._id] *= blockRatio; } } } From a9dd65c964153aa579a41782df362c7eb7606a08 Mon Sep 17 00:00:00 2001 From: Taly Date: Wed, 7 Sep 2022 18:40:28 +0300 Subject: [PATCH 12/13] add cache --- src/backend/controllers/search.ts | 131 ++++++++++++++++-------------- src/backend/routes/api/search.ts | 6 +- src/frontend/js/modules/search.js | 2 +- 3 files changed, 73 insertions(+), 66 deletions(-) diff --git a/src/backend/controllers/search.ts b/src/backend/controllers/search.ts index 5c757af..807bb52 100644 --- a/src/backend/controllers/search.ts +++ b/src/backend/controllers/search.ts @@ -1,23 +1,28 @@ +import NodeCache from 'node-cache'; import PageData from '../models/page.js'; import Pages from '../controllers/pages.js'; import urlify from '../utils/urlify.js'; +import Page from '../models/page.js'; + +const globalWords: { [key: string]: {[key: string]: number} } = Object.create(null); +let globalPages: PageData[] = []; class Search { - private words: { [key: string]: {[key: string]: number} } = Object.create(null); - private pages: PageData[] = []; + // private words: { [key: string]: {[key: string]: number} } = Object.create(null); + // private pages: PageData[] = []; + // private cache: NodeCache = new NodeCache(); public async init() { - this.pages = await this.getPages(); + if (globalWords && Object.keys(globalWords).length) { + return Promise.resolve(); + } + + globalPages = await this.getPages(); /** * Process all pages */ - for await (const page of this.pages) { - - // if (page._id && !this.pages[page._id]) { - // this.pages[page._id] = []; - // } - + for await (const page of globalPages) { /** * Read content blocks from page */ @@ -26,40 +31,33 @@ class Search { const blockContent = this.getCleanTextFromBlock(block); const blockWords: string[] = this.splitTextToWords(blockContent); - // if (page._id) { - // this.pages[page._id].push(...blockWords); - // } - /** * Process list of words in a block */ for await (const word of blockWords) { - if (!this.words[word]) { - this.words[word] = Object.create(null); + if (!globalWords[word]) { + globalWords[word] = Object.create(null); } if (page._id) { - if (!this.words[word][page._id]) { - this.words[word][page._id] = 0; + if (!globalWords[word][page._id]) { + globalWords[word][page._id] = 0; } /** * Add page id to the list of pages with this word */ - this.words[word][page._id] *= blockRatio; + globalWords[word][page._id] += blockRatio; } } } } + + console.log('Done'); } public async query(searchString: string) { - try { - await this.init(); - } catch (error) { - console.log(error); - throw error; - } + await this.init(); const searchWords = this.splitTextToWords(searchString); @@ -69,7 +67,7 @@ class Search { const returnPages: {[key: string]: string|number, ratio: number}[] = []; goodPages.forEach(({ pageId, ratio }) => { - const page = this.pages.filter(page => page._id === pageId).pop(); + const page = globalPages.filter(page => page._id === pageId).pop(); if (!page) { return; @@ -78,7 +76,7 @@ class Search { let section = ''; page.body.blocks.forEach((block: any) => { - let koef = 0; + let koef = 1; let blockContent = this.getCleanTextFromBlock(block); @@ -90,7 +88,7 @@ class Search { searchWords.forEach(word => { if (blockContent.toLowerCase().indexOf(word) !== -1) { - koef += 1; + koef *= 10; } }) @@ -108,27 +106,6 @@ class Search { }); }); - // // --------- START test --------- - // // - // const uniqWords = [...new Set(pagesWords.flatMap(page => page.words))].sort(); - // // - // // uniqWords.forEach(word => { - // // console.log(word); - // // }) - // - // // --------- END test --------- - - // console.log('RESULT') - // returnPages.forEach(page => { - // console.log(page); - // }); - // - // return { - // suggestions: uniqWords.filter(word => word.indexOf(searchWords.slice(-1)[0]) === 0), - // pages: returnPages - // } - - return { suggestions: [], pages: returnPages @@ -137,27 +114,44 @@ class Search { } } - private async getPages() { - return await Pages.getAll(); + private async getPages(): Promise { + const pages = await Pages.getAll(); + + return pages; + + // let pages: Page[] | undefined = this.cache.get("SEARCH:PAGES"); + // + // if ( pages === undefined ) { + // console.log('cache for SEARCH:PAGES is missing') + // + // pages = await Pages.getAll(); + // + // this.cache.set("SEARCH:PAGES", pages); + // } else { + // console.log('wow SEARCH:PAGES is cached') + // } + // + // return pages; } private async getPagesByWords(words: string[]) { const pagesList: {[key: string]: number} = {}; - Object.keys(this.words) + const validWords = Object.keys(globalWords) .filter(word => { return !!words.filter(searchWord => word.indexOf(searchWord) !== -1).length - }) - .forEach(word => { - Object.keys(this.words[word]) - .forEach(pageId => { - if (!pagesList[pageId]) { - pagesList[pageId] = 0; - } + }); - pagesList[pageId] += this.words[word][pageId] - }) - }) + validWords.forEach(word => { + Object.keys(globalWords[word]) + .forEach(pageId => { + if (!pagesList[pageId]) { + pagesList[pageId] = 0; + } + + pagesList[pageId] += globalWords[word][pageId] + }) + }) const sortedPagesList = Object.keys(pagesList) .map(pageId => { @@ -178,11 +172,18 @@ class Search { private getBlockRatio(block: any) { switch (block.type) { case 'header': - return 8; + if (block.data.level === 1) { + return 16; + } else { + return 2; + } + case 'paragraph': return 1.1; + case 'list': return 1; + default: return 0; } @@ -260,4 +261,8 @@ class Search { } } -export default Search; +const search = new Search(); + +export default search; + +// export default Search; diff --git a/src/backend/routes/api/search.ts b/src/backend/routes/api/search.ts index 77dc408..30b4dba 100644 --- a/src/backend/routes/api/search.ts +++ b/src/backend/routes/api/search.ts @@ -15,9 +15,11 @@ router.get('/search', async (req: Request, res: Response) => { /** Start measuring search time */ // const startTime = performance.now(); - const search = new Search(); + // const search = new Search(); + // + // const searchResponse = await search.query(searchString); - const searchResponse = await search.query(searchString); + const searchResponse = await Search.query(searchString); /** End measuring search time */ // const endTime = performance.now(); diff --git a/src/frontend/js/modules/search.js b/src/frontend/js/modules/search.js index 21df71c..c1657f5 100644 --- a/src/frontend/js/modules/search.js +++ b/src/frontend/js/modules/search.js @@ -57,7 +57,7 @@ export default class Search { * - type a search string * - fire search */ - // const testString = 'access editor'; + // const testString = 'codex description'; // this.toggleSearchOverlay(true); // this.nodes.searchInput.value = testString; // this.debouncedSearch(testString); From 488d825c032d2c33440977a7e87217963ea44cb6 Mon Sep 17 00:00:00 2001 From: Taly Date: Fri, 9 Sep 2022 19:12:27 +0300 Subject: [PATCH 13/13] add comments --- src/backend/controllers/search.ts | 100 ++++++++++++++++++++---------- src/backend/routes/api/pages.ts | 11 ++++ src/backend/routes/api/search.ts | 40 +++++++----- src/frontend/js/modules/search.js | 20 +++++- yarn.lock | 6 +- 5 files changed, 125 insertions(+), 52 deletions(-) diff --git a/src/backend/controllers/search.ts b/src/backend/controllers/search.ts index 807bb52..12f50dc 100644 --- a/src/backend/controllers/search.ts +++ b/src/backend/controllers/search.ts @@ -1,22 +1,29 @@ -import NodeCache from 'node-cache'; import PageData from '../models/page.js'; import Pages from '../controllers/pages.js'; import urlify from '../utils/urlify.js'; import Page from '../models/page.js'; -const globalWords: { [key: string]: {[key: string]: number} } = Object.create(null); +let globalWords: { [key: string]: {[key: string]: number} } = Object.create(null); let globalPages: PageData[] = []; class Search { - // private words: { [key: string]: {[key: string]: number} } = Object.create(null); - // private pages: PageData[] = []; - // private cache: NodeCache = new NodeCache(); - + /** + * Initialize search + */ public async init() { if (globalWords && Object.keys(globalWords).length) { return Promise.resolve(); } + await this.syncDB(); + } + + /** + * Load all pages from DB and update globalWords + * Use this method when any page was updated + */ + public async syncDB() { + globalWords = Object.create(null); globalPages = await this.getPages(); /** @@ -56,6 +63,10 @@ class Search { console.log('Done'); } + /** + * Search for pages by given query + * @param searchString + */ public async query(searchString: string) { await this.init(); @@ -107,41 +118,40 @@ class Search { }); return { - suggestions: [], + suggestions: ['description', 'about', 'contact'], pages: returnPages .sort((a, b) => b.ratio - a.ratio) .slice(0, 15) } } + /** + * + * @private + */ private async getPages(): Promise { - const pages = await Pages.getAll(); - - return pages; - - // let pages: Page[] | undefined = this.cache.get("SEARCH:PAGES"); - // - // if ( pages === undefined ) { - // console.log('cache for SEARCH:PAGES is missing') - // - // pages = await Pages.getAll(); - // - // this.cache.set("SEARCH:PAGES", pages); - // } else { - // console.log('wow SEARCH:PAGES is cached') - // } - // - // return pages; + return await Pages.getAll(); } + /** + * Return list of pages with a given words + * @param words + * @private + */ private async getPagesByWords(words: string[]) { const pagesList: {[key: string]: number} = {}; + /** + * Get list of words starting with a words from the search query + */ const validWords = Object.keys(globalWords) .filter(word => { return !!words.filter(searchWord => word.indexOf(searchWord) !== -1).length }); + /** + * For each word get list of pages with this word + */ validWords.forEach(word => { Object.keys(globalWords[word]) .forEach(pageId => { @@ -153,6 +163,9 @@ class Search { }) }) + /** + * Sort pages by frequency of given words + */ const sortedPagesList = Object.keys(pagesList) .map(pageId => { return { @@ -165,10 +178,11 @@ class Search { return sortedPagesList; } - private getUnique(elements: string[]) { - return [...new Set(elements)].sort(); - } - + /** + * Get block's ratio. It is used to calculate the weight of the words in the block + * @param block + * @private + */ private getBlockRatio(block: any) { switch (block.type) { case 'header': @@ -189,6 +203,11 @@ class Search { } } + /** + * Return clear text content from block without HTML tags and special characters + * @param block + * @private + */ private getCleanTextFromBlock(block: any): string { let blockContent = ''; @@ -215,14 +234,29 @@ class Search { return blockContent; } + /** + * Remove HTML tags from string. Only content inside tags will be left + * @param text + * @private + */ private removeHTMLTags(text: string) { return text.replace(/<[^>]*>?/gm, ''); } + /** + * Remove special characters from text. For example:   & " < > + * @param text + * @private + */ private removeHTMLSpecialCharacters(text: string) { return text.replace(/&[^;]*;?/gm, ''); } + /** + * Split text to words + * @param text + * @private + */ private splitTextToWords(text: string): string[] { return text // lowercase all words @@ -237,6 +271,7 @@ class Search { // remove multiple spaces .replace(/\s+/g, ' ') + // remove spaces at the beginning and at the end .trim() // split to words by spaces @@ -261,8 +296,7 @@ class Search { } } -const search = new Search(); - -export default search; - -// export default Search; +/** + * Export initialized instance + */ +export default new Search(); diff --git a/src/backend/routes/api/pages.ts b/src/backend/routes/api/pages.ts index f4b1673..0b8b944 100644 --- a/src/backend/routes/api/pages.ts +++ b/src/backend/routes/api/pages.ts @@ -2,6 +2,7 @@ import express, { Request, Response } from 'express'; import multerFunc from 'multer'; import Pages from '../../controllers/pages.js'; import PagesOrder from '../../controllers/pagesOrder.js'; +import Search from '../../controllers/search.js'; const router = express.Router(); const multer = multerFunc(); @@ -70,6 +71,9 @@ router.put('/page', multer.none(), async (req: Request, res: Response) => { /** push to the orders array */ await PagesOrder.push(parent, page._id); + /** Update search index */ + await Search.syncDB(); + res.json({ success: true, result: page, @@ -127,6 +131,10 @@ router.post('/page/:id', multer.none(), async (req: Request, res: Response) => { parent, uri, }); + + /** Update search index */ + await Search.syncDB(); + res.json({ success: true, result: page, @@ -206,6 +214,9 @@ router.delete('/page/:id', async (req: Request, res: Response) => { parentPageOrder.remove(req.params.id); await parentPageOrder.save(); + /** Update search index */ + await Search.syncDB(); + res.json({ success: true, result: pageToRedirect, diff --git a/src/backend/routes/api/search.ts b/src/backend/routes/api/search.ts index 30b4dba..85700b2 100644 --- a/src/backend/routes/api/search.ts +++ b/src/backend/routes/api/search.ts @@ -10,31 +10,37 @@ const router = express.Router(); */ router.get('/search', async (req: Request, res: Response) => { try { + /** + * Get search string + */ const searchString = req.query.text as string; - /** Start measuring search time */ - // const startTime = performance.now(); - - // const search = new Search(); - // - // const searchResponse = await search.query(searchString); - + /** + * Get search results + */ const searchResponse = await Search.query(searchString); - /** End measuring search time */ - // const endTime = performance.now(); - - /** Show search time */ - // const searchItem = (endTime - startTime).toFixed(6); - // console.log(`🔎 "${searchString}" ⏱ ${searchItem} ms`); - + /** + * Compose response + */ const compactedPages = searchResponse.pages.map(page => { return { + /** Page id */ _id: page._id, + + /** Page title */ title: page.title, + + /** Page uri */ uri: page.uri, + + /** Section heading name for the found fragment */ section: page.section, + + /** Section's anchor */ anchor: page.anchor, + + /** Page fragment with searched items */ shortBody: page.shortBody, }; }); @@ -42,9 +48,11 @@ router.get('/search', async (req: Request, res: Response) => { res.json({ success: true, result: { - suggestions: searchResponse.suggestions, + /** Found pages */ pages: compactedPages, - // time: searchItem, + + /** Typing suggestions */ + suggestions: searchResponse.suggestions, }, }); } catch (err) { diff --git a/src/frontend/js/modules/search.js b/src/frontend/js/modules/search.js index c1657f5..d692967 100644 --- a/src/frontend/js/modules/search.js +++ b/src/frontend/js/modules/search.js @@ -57,7 +57,7 @@ export default class Search { * - type a search string * - fire search */ - // const testString = 'codex description'; + // const testString = 'codex descri'; // this.toggleSearchOverlay(true); // this.nodes.searchInput.value = testString; // this.debouncedSearch(testString); @@ -153,6 +153,10 @@ export default class Search { showSearchResult({ data }) { this.clearSearchResults(); + // if (data.result.suggestions.length) { + // this.showSuggestedWordCompletion(data.result.suggestions[0]); + // } + // const suggestionsWrapper = this.generateSearchSuggestions(data.result.suggestions); // // this.nodes.searchResultWrapper.appendChild(suggestionsWrapper); @@ -186,6 +190,20 @@ export default class Search { }); } + // showSuggestedWordCompletion(word) { + // const typedString = this.nodes.searchInput.value; + // const words = typedString.split(' '); + // + // words.pop(); + // words.push(word); + // + // this.nodes.searchInput.value = words.join(' '); + // + // this.nodes.searchInput.select(); + // this.nodes.searchInput.selectionStart = typedString.length; + // this.nodes.searchInput.selectionEnd = this.nodes.searchInput.value.length; + // } + // generateSearchSuggestions(suggestions = []) { // const suggestionsWrapper = document.createElement('div'); // diff --git a/yarn.lock b/yarn.lock index 1c2dd99..596e66b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2068,7 +2068,8 @@ clone-response@^1.0.2: clone@2.x: version "2.1.2" - resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz" + resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" + integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== code-point-at@^1.0.0: version "1.1.0" @@ -4262,7 +4263,8 @@ nise@^5.1.1: node-cache@^5.1.2: version "5.1.2" - resolved "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz" + resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" + integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== dependencies: clone "2.x"